Diploma-1 Main CRUD operations

This commit is contained in:
Evgenii Saenko
2025-10-16 17:30:03 -07:00
parent c470f766e0
commit 2d5b329b36
38 changed files with 1354 additions and 213 deletions

View File

@@ -25,6 +25,7 @@ dependencies {
implementation(libs.ktor.server.netty)
implementation(libs.logback.classic)
implementation(libs.dotenv.kotlin)
implementation(libs.hcpool)
implementation(libs.postgresql)
implementation(libs.bcrypt)
implementation(libs.ktorm)

View File

@@ -6,6 +6,7 @@ ktorm = "4.1.1"
dotenv = "6.5.1"
psql = "42.7.3"
bcrypt="0.4"
hcp = "7.0.2"
[libraries]
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
@@ -27,6 +28,7 @@ ktorm = { module = "org.ktorm:ktorm-core", version.ref = "ktorm" }
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "psql" }
bcrypt = { module = "org.mindrot:jbcrypt", version.ref = "bcrypt"}
hcpool = { module = "com.zaxxer:HikariCP", version.ref = "hcp" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -1,15 +0,0 @@
package cc.essaenko
import io.ktor.server.application.*
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
configureSerialization()
configureSecurity()
configureHTTP()
configureRouting()
configureDatabase()
}

View File

@@ -1,38 +0,0 @@
package cc.essaenko
import io.github.cdimascio.dotenv.dotenv
import io.ktor.server.application.Application
import org.ktorm.database.Database
import io.ktor.server.application.*
import io.ktor.util.AttributeKey
val DatabaseKey = AttributeKey<Database>("Database")
val Application.database: Database
get() = attributes[DatabaseKey]
val ApplicationCall.database: Database
get() = application.database
fun Application.configureDatabase() {
val env = dotenv {
ignoreIfMissing = true
}
val host = env["DB_HOST"] ?: "localhost"
val port = env["DB_PORT"] ?: "5432"
val name = env["DB_NAME"] ?: "db"
val user = env["DB_USER"] ?: "user"
val password = env["DB_PASSWORD"] ?: "password"
val url = "jdbc:postgresql://$host:$port/$name"
val database = Database.connect(
url = url,
driver = "org.postgresql.Driver",
user = user,
password = password
)
attributes.put(DatabaseKey, database)
}

View File

@@ -1,14 +0,0 @@
package cc.essaenko
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}

View File

@@ -1,41 +0,0 @@
package cc.essaenko
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureSecurity() {
// Please read the jwt property from the config file if you are using EngineMain
val jwtAudience = "jwt-audience"
val jwtDomain = "https://jwt-provider-domain/"
val jwtRealm = "ktor sample app"
val jwtSecret = "secret"
authentication {
jwt {
realm = jwtRealm
verifier(
JWT
.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtDomain)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
}
}
}
}

View File

@@ -0,0 +1,17 @@
package cc.essaenko.app
import io.ktor.server.application.*
import io.ktor.server.netty.EngineMain
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
val jwtCfg = loadJwtConfig()
configureSerialization()
configureSecurity(jwtCfg)
configureDatabase()
configureHTTP()
configureRouting(jwtCfg)
}

View File

@@ -0,0 +1,61 @@
package cc.essaenko.app
import io.github.cdimascio.dotenv.dotenv
import io.ktor.server.application.Application
import org.ktorm.database.Database
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.server.application.*
import io.ktor.util.AttributeKey
val DatabaseKey = AttributeKey<Database>("Database")
val Application.database: Database
get() = attributes[DatabaseKey]
val ApplicationCall.database: Database
get() = application.database
fun Application.configureDatabase() {
val env = dotenv {
ignoreIfMissing = true
}
val host = env["DB_HOST"] ?: "localhost"
val port = env["DB_PORT"] ?: "5432"
val name = env["DB_NAME"] ?: "db"
val user = env["DB_USER"] ?: "user"
val pass = env["DB_PASSWORD"] ?: "password"
val maxPool = (env["DB_POOL_MAX"] ?: "10").toInt()
val minIdle = (env["DB_POOL_MIN_IDLE"] ?: "2").toInt()
val connTimeout = (env["DB_POOL_CONN_TIMEOUT_MS"] ?: "5000").toLong()
val idleTo = (env["DB_POOL_IDLE_TIMEOUT_MS"] ?: "60000").toLong()
val url = "jdbc:postgresql://$host:$port/$name"
val cfg = HikariConfig().apply {
this.jdbcUrl = url
username = user
password = pass
driverClassName = "org.postgresql.Driver"
maximumPoolSize = maxPool
minimumIdle = minIdle
connectionTimeout = connTimeout
idleTimeout = idleTo
addDataSourceProperty("reWriteBatchedInserts", "true")
addDataSourceProperty("tcpKeepAlive", "true")
}
val source = HikariDataSource(cfg)
val database = Database.connect(source)
attributes.put(DatabaseKey, database)
monitor.subscribe(ApplicationStopped) {
source.close()
}
}

View File

@@ -1,20 +1,13 @@
package cc.essaenko
package cc.essaenko.app
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureHTTP() {

View File

@@ -0,0 +1,61 @@
package cc.essaenko.app
import cc.essaenko.modules.admin.AdminRepositoryImpl
import cc.essaenko.modules.admin.AdminService
import cc.essaenko.modules.admin.BcryptPasswordHasher
import cc.essaenko.modules.admin.TokenService
import cc.essaenko.modules.admin.publicAdminRoutes
import cc.essaenko.modules.admin.adminRoutes
import cc.essaenko.modules.lead.LeadRepositoryImpl
import cc.essaenko.modules.lead.LeadService
import cc.essaenko.modules.lead.adminLeadRoutes
import cc.essaenko.modules.lead.publicLeadRoutes
import cc.essaenko.modules.news.NewsRepositoryImpl
import cc.essaenko.modules.news.NewsService
import cc.essaenko.modules.news.adminNewsRoutes
import cc.essaenko.modules.news.publicNewsRoutes
import cc.essaenko.modules.service.ServiceRepositoryImpl
import cc.essaenko.modules.service.ServiceService
import cc.essaenko.modules.service.adminServiceRoutes
import cc.essaenko.modules.service.publicServiceRoutes
import cc.essaenko.modules.serviceCategory.ServiceCategoryRepositoryImpl
import cc.essaenko.modules.serviceCategory.ServiceCategoryService
import cc.essaenko.modules.serviceCategory.adminServiceCategoryRoutes
import cc.essaenko.modules.serviceCategory.publicServiceCategoryRoutes
import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.routing.*
fun Application.configureRouting(jwtCfg: JwtConfig) {
val newsRepo = NewsRepositoryImpl(database)
val newsSvc = NewsService(newsRepo)
val adminRepo = AdminRepositoryImpl(database)
val adminSvc = AdminService(adminRepo, BcryptPasswordHasher(), TokenService(jwtCfg))
val leadRepo = LeadRepositoryImpl(database)
val leadSvc = LeadService(leadRepo)
val serviceCategoryRepo = ServiceCategoryRepositoryImpl(database)
val serviceCategorySvc = ServiceCategoryService(serviceCategoryRepo)
val serviceRepo = ServiceRepositoryImpl(database)
val serviceSvc = ServiceService(serviceRepo, serviceCategoryRepo)
routing {
publicNewsRoutes(newsSvc)
publicLeadRoutes(leadSvc)
publicServiceCategoryRoutes(serviceCategorySvc)
publicServiceRoutes(serviceSvc)
publicAdminRoutes(adminSvc)
authenticate("admin-auth") {
adminNewsRoutes(newsSvc)
adminRoutes(adminSvc)
adminLeadRoutes(leadSvc)
adminServiceCategoryRoutes(serviceCategorySvc)
adminServiceRoutes(serviceSvc)
}
}
}

View File

@@ -0,0 +1,52 @@
package cc.essaenko.app
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.github.cdimascio.dotenv.dotenv
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
data class JwtConfig(
val issuer: String,
val audience: String,
val realm: String = "admin",
val secret: String,
val expiresMinutes: Long
)
fun Application.loadJwtConfig(): JwtConfig {
val env = dotenv {
ignoreIfMissing = true
}
val secret = env["JWT_SECRET"] ?: error("JWT_SECRET not set")
val issuer = env["JWT_ISSUER"] ?: "cc.essaenko"
val audience = env["JWT_AUDIENCE"] ?: "cc.essaenko.admin"
val expires = (env["JWT_EXPIRES_MIN"] ?: "60").toLong()
return JwtConfig(issuer, audience, "admin", secret, expires)
}
fun Application.configureSecurity(jwt: JwtConfig) {
val algorithm = Algorithm.HMAC256(jwt.secret)
install(Authentication) {
jwt("admin-auth") {
realm = jwt.realm
verifier(
JWT
.require(algorithm)
.withIssuer(jwt.issuer)
.withAudience(jwt.audience)
.acceptLeeway(3) // секунды допуска рассинхронизации часов
.build()
)
validate { cred ->
val subj = cred.payload.subject // admin id
val username = cred.payload.getClaim("username").asString()
if (!subj.isNullOrBlank() && !username.isNullOrBlank()) {
JWTPrincipal(cred.payload)
} else null
}
}
}
}

View File

@@ -1,19 +1,8 @@
package cc.essaenko
package cc.essaenko.app
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

View File

@@ -1,25 +0,0 @@
package cc.essaenko.model
import org.ktorm.database.Database
import org.ktorm.entity.Entity
import org.ktorm.entity.sequenceOf
import org.ktorm.schema.*
interface ServiceCategory : Entity<ServiceCategory> {
companion object : Entity.Factory<ServiceCategory>()
var id: Long
var name: String
var slug: String
var sortOrder: Int
}
object ServiceCategories : Table<ServiceCategory>("t_service_categories") {
val id = long("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val slug = varchar("slug").bindTo { it.slug }
val sortOrder = int("sort_order").bindTo { it.sortOrder }
}
val Database.serviceCategories get() = this.sequenceOf(ServiceCategories)

View File

@@ -1,31 +0,0 @@
package cc.essaenko.model
import org.ktorm.database.Database
import org.ktorm.entity.*
import java.time.LocalDateTime
import org.ktorm.schema.*
// Холодная база (все заявки)
interface User : Entity<User> {
companion object : Entity.Factory<User>()
var id: Long
var fullName: String
var email: String
var phone: String?
var message: String
var sourcePage: String?
var createdAt: LocalDateTime
}
object Users : Table<User>("t_users") {
val id = long("id").primaryKey().bindTo { it.id }
val fullName = varchar("full_name").bindTo { it.fullName }
val email = varchar("email").bindTo { it.email }
val phone = varchar("phone").bindTo { it.phone }
val message = text("message").bindTo { it.message }
val sourcePage = varchar("source_page").bindTo { it.sourcePage }
val createdAt = datetime("created_at").bindTo { it.createdAt }
}
val Database.users get() = this.sequenceOf(Users);

View File

@@ -0,0 +1,64 @@
package cc.essaenko.modules.admin
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.http.*
@kotlinx.serialization.Serializable
data class AdminRegisterRequest(val username: String, val password: String)
@kotlinx.serialization.Serializable
data class AdminLoginRequest(val username: String, val password: String)
@kotlinx.serialization.Serializable
data class ChangePasswordRequest(val password: String)
fun Route.publicAdminRoutes(svc: AdminService) = route("/admin") {
// Логин
post("/login") {
val body = call.receive<AdminLoginRequest>()
val auth = svc.login(body.username, body.password)
call.respond(
mapOf(
"id" to auth.id,
"username" to auth.username,
"token" to auth.token,
"tokenType" to "Bearer",
"expiresInMinutes" to auth.expiresInMinutes
)
)
}
}
fun Route.adminRoutes(svc: AdminService) = route("/admin") {
// Список админов (id, username, createdAt, lastLoginAt)
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
call.respond(svc.list(limit, offset))
}
// Регистрация нового админа
post {
val body = call.receive<AdminRegisterRequest>()
val id = svc.register(body.username, body.password)
call.respond(HttpStatusCode.Created, mapOf("id" to id, "username" to body.username))
}
// Смена пароля
put("{id}/password") {
val id = call.parameters["id"]?.toLongOrNull() ?: return@put call.respond(HttpStatusCode.BadRequest)
val body = call.receive<ChangePasswordRequest>()
svc.changePassword(id, body.password)
call.respond(mapOf("updated" to true))
}
// Удаление админа
delete("{id}") {
val id = call.parameters["id"]?.toLongOrNull() ?: return@delete call.respond(HttpStatusCode.BadRequest)
svc.remove(id)
call.respond(mapOf("deleted" to true))
}
}

View File

@@ -1,27 +1,22 @@
package cc.essaenko.model
package cc.essaenko.modules.admin
import org.ktorm.database.Database
import org.ktorm.entity.Entity
import org.ktorm.entity.sequenceOf
import java.time.LocalDateTime
import org.ktorm.schema.*
import java.time.LocalDateTime
interface Admin : Entity<Admin> {
companion object : Entity.Factory<Admin>()
interface AdminEntity : Entity<AdminEntity> {
companion object : Entity.Factory<AdminEntity>()
var id: Long
var username: String
var passwordHash: String
var password: String
var createdAt: LocalDateTime
var lastLoginAt: LocalDateTime?
}
object Admins : Table<Admin>("t_admins") {
object AdminUsers : Table<AdminEntity>("admin_user") {
val id = long("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val passwordHash = varchar("password_hash").bindTo { it.passwordHash }
val password = varchar("password_hash").bindTo { it.password }
val createdAt = datetime("created_at").bindTo { it.createdAt }
val lastLoginAt = datetime("last_login_at").bindTo { it.lastLoginAt }
}
val Database.admins get() = this.sequenceOf(Admins)
}

View File

@@ -0,0 +1,64 @@
package cc.essaenko.modules.admin
import org.ktorm.database.Database
import org.ktorm.dsl.eq
import org.ktorm.entity.*
import java.time.LocalDateTime
data class AdminCreate(val username: String, val password: String)
data class AdminView(val id: Long, val username: String, val createdAt: LocalDateTime, val lastLoginAt: LocalDateTime?)
interface AdminRepository {
fun list(limit: Int = 50, offset: Int = 0): List<AdminView>
fun findById(id: Long): AdminEntity?
fun findByUsername(username: String): AdminEntity?
fun create(cmd: AdminCreate): Long
fun updatePassword(id: Long, password: String): Boolean
fun touchLastLogin(id: Long, at: LocalDateTime = LocalDateTime.now()): Boolean
fun delete(id: Long): Boolean
}
class AdminRepositoryImpl(private val db: Database) : AdminRepository {
private val admins get() = db.sequenceOf(AdminUsers)
override fun list(limit: Int, offset: Int): List<AdminView> =
admins.sortedBy { it.id }.drop(offset).take(limit).toList()
.map { AdminView(it.id, it.username, it.createdAt, it.lastLoginAt) }
override fun findById(id: Long): AdminEntity? =
admins.firstOrNull { it.id eq id }
override fun findByUsername(username: String): AdminEntity? =
admins.firstOrNull { it.username eq username }
override fun create(cmd: AdminCreate): Long {
val now = LocalDateTime.now()
val e = AdminEntity {
username = cmd.username
password = cmd.password
createdAt = now
lastLoginAt = null
}
admins.add(e)
return e.id
}
override fun updatePassword(id: Long, password: String): Boolean {
val e = findById(id) ?: return false
e.password = password
e.lastLoginAt = e.lastLoginAt // no-op, просто чтобы было очевидно что мы правим только пароль
e.flushChanges()
return true
}
override fun touchLastLogin(id: Long, at: LocalDateTime): Boolean {
val e = findById(id) ?: return false
e.lastLoginAt = at
e.flushChanges()
return true
}
override fun delete(id: Long): Boolean =
admins.removeIf { it.id eq id } > 0
}

View File

@@ -0,0 +1,78 @@
package cc.essaenko.modules.admin
import cc.essaenko.app.JwtConfig
import org.mindrot.jbcrypt.BCrypt
import cc.essaenko.shared.errors.ValidationException
import cc.essaenko.shared.errors.NotFoundException
import com.auth0.jwt.algorithms.Algorithm
import java.util.Date
import com.auth0.jwt.JWT
interface PasswordHasher {
fun hash(raw: String): String
fun verify(raw: String, hash: String): Boolean
}
class BcryptPasswordHasher(private val rounds: Int = 10) : PasswordHasher {
override fun hash(raw: String): String = BCrypt.hashpw(raw, BCrypt.gensalt(rounds))
override fun verify(raw: String, hash: String): Boolean = BCrypt.checkpw(raw, hash)
}
data class AuthResult(val id: Long, val username: String, val token: String, val expiresInMinutes: Long)
class AdminService(
private val repo: AdminRepository,
private val hasher: PasswordHasher,
private val tokens: TokenService
) {
fun list(limit: Int = 50, offset: Int = 0) = repo.list(limit, offset)
fun register(username: String, rawPassword: String): Long {
require(username.matches(Regex("^[a-zA-Z0-9_.-]{3,}$"))) { "Invalid username" }
require(rawPassword.length >= 8) { "Password must be at least 8 characters" }
if (repo.findByUsername(username) != null) {
throw ValidationException("Username already exists")
}
val id = repo.create(AdminCreate(username, hasher.hash(rawPassword)))
return id
}
fun login(username: String, rawPassword: String): AuthResult {
val admin = repo.findByUsername(username) ?: throw ValidationException("Invalid credentials")
if (!hasher.verify(rawPassword, admin.password)) {
throw ValidationException("Invalid credentials")
}
repo.touchLastLogin(admin.id)
val token = tokens.issue(admin.id, admin.username)
return AuthResult(admin.id, admin.username, token, /*экспорт*/ 60)
}
fun changePassword(id: Long, newPassword: String) {
require(newPassword.length >= 8) { "Password must be at least 8 characters" }
val ok = repo.updatePassword(id, hasher.hash(newPassword))
if (!ok) throw NotFoundException("Admin not found")
}
fun remove(id: Long) {
val ok = repo.delete(id)
if (!ok) throw NotFoundException("Admin not found")
}
}
class TokenService(private val cfg: JwtConfig) {
private val algorithm = Algorithm.HMAC256(cfg.secret)
fun issue(id: Long, username: String): String {
val now = System.currentTimeMillis()
val exp = Date(now + cfg.expiresMinutes * 60_000)
return JWT.create()
.withIssuer(cfg.issuer)
.withAudience(cfg.audience)
.withSubject(id.toString())
.withClaim("username", username)
.withIssuedAt(Date(now))
.withExpiresAt(exp)
.sign(algorithm)
}
}

View File

@@ -0,0 +1,62 @@
package cc.essaenko.modules.lead
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.http.*
@kotlinx.serialization.Serializable
data class LeadCreateRequest(
val fullName: String,
val email: String,
val phone: String? = null,
)
/** Публичный эндпоинт формы обратной связи */
fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
post {
val body = call.receive<LeadCreateRequest>()
val id = svc.create(
LeadCreate(
fullName = body.fullName,
email = body.email,
phone = body.phone,
)
)
call.respond(HttpStatusCode.Created, mapOf("id" to id))
}
}
/** Админские эндпоинты для просмотра/удаления лидов */
fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val q = call.request.queryParameters["q"]
val page = svc.list(limit, offset, q)
call.respond(page)
}
get("{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
val lead = svc.get(id)
call.respond(
mapOf(
"id" to lead.id,
"fullName" to lead.fullName,
"email" to lead.email,
"phone" to lead.phone,
"createdAt" to lead.createdAt
)
)
}
delete("{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
svc.delete(id)
call.respond(mapOf("deleted" to true))
}
}

View File

@@ -0,0 +1,22 @@
package cc.essaenko.modules.lead
import org.ktorm.entity.Entity
import org.ktorm.schema.*
import java.time.LocalDateTime
interface LeadEntity : Entity<LeadEntity> {
companion object : Entity.Factory<LeadEntity>()
var id: Long
var fullName: String
var email: String
var phone: String?
var createdAt: LocalDateTime
}
object Leads : Table<LeadEntity>("lead") {
val id = long("id").primaryKey().bindTo { it.id }
val fullName = varchar("full_name").bindTo { it.fullName }
val email = varchar("email").bindTo { it.email }
val phone = varchar("phone").bindTo { it.phone }
val createdAt = datetime("created_at").bindTo { it.createdAt }
}

View File

@@ -0,0 +1,84 @@
package cc.essaenko.modules.lead
import org.ktorm.database.Database
import org.ktorm.entity.*
import org.ktorm.dsl.*
import java.time.LocalDateTime
data class LeadCreate(
val fullName: String,
val email: String,
val phone: String? = null,
)
data class LeadView(
val id: Long,
val fullName: String,
val email: String,
val phone: String?,
)
interface LeadRepository {
fun create(cmd: LeadCreate): Long
fun getById(id: Long): LeadEntity?
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): List<LeadView>
fun delete(id: Long): Boolean
fun count(q: String? = null): Int
}
class LeadRepositoryImpl(private val db: Database) : LeadRepository {
private val leads get() = db.sequenceOf(Leads)
override fun create(cmd: LeadCreate): Long {
val e = LeadEntity {
fullName = cmd.fullName
email = cmd.email
phone = cmd.phone
createdAt = LocalDateTime.now()
}
leads.add(e)
return e.id
}
override fun getById(id: Long): LeadEntity? =
leads.firstOrNull { it.id eq id }
override fun list(limit: Int, offset: Int, q: String?): List<LeadView> {
var seq: EntitySequence<LeadEntity, Leads> = leads
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
seq = seq.filter {
(it.fullName like like) or
(it.email like like) or
(it.phone like like)
}
}
return seq
.sortedByDescending { it.createdAt }
.drop(offset)
.take(limit)
.toList()
.map {
LeadView(
id = it.id,
fullName = it.fullName,
email = it.email,
phone = it.phone,
)
}
}
override fun delete(id: Long): Boolean =
leads.removeIf { it.id eq id } > 0
override fun count(q: String?): Int {
if (q.isNullOrBlank()) return db.from(Leads).select().totalRecordsInAllPages
val like = "%${q.lowercase()}%"
return db
.from(Leads)
.select()
.where { (Leads.fullName like like) or (Leads.email like like) or (Leads.phone like like) }
.totalRecordsInAllPages
}
}

View File

@@ -0,0 +1,30 @@
package cc.essaenko.modules.lead
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.errors.ValidationException
class LeadService(private val repo: LeadRepository) {
fun create(cmd: LeadCreate): Long {
// простая валидация
require(cmd.fullName.isNotBlank()) { "fullName is required" }
if (!cmd.email.matches(Regex("^[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}$"))) {
throw ValidationException("email is invalid")
}
return repo.create(cmd)
}
fun get(id: Long) = repo.getById(id) ?: throw NotFoundException("lead $id not found")
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page<LeadView> {
val items = repo.list(limit, offset, q)
val total = repo.count(q)
return Page(items, total, limit, offset)
}
fun delete(id: Long) {
val ok = repo.delete(id)
if (!ok) throw NotFoundException("lead $id not found")
}
}

View File

@@ -0,0 +1,46 @@
package cc.essaenko.modules.news
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
post {
val payload = call.receive<NewsCreate>()
val id = svc.create(payload)
call.respond(mapOf("id" to id))
}
put("{slug}") {
val slug = call.parameters["slug"]!!
val body = call.receive<Map<String, String>>()
val ok = svc.update(slug, body["summary"].orEmpty(), body["content"].orEmpty())
call.respond(mapOf("updated" to ok))
}
post("{slug}/publish") {
val slug = call.parameters["slug"]!!
val ok = svc.publish(slug)
call.respond(mapOf("published" to ok))
}
delete("{slug}") {
val slug = call.parameters["slug"]!!
val ok = svc.delete(slug)
call.respond(mapOf("deleted" to ok))
}
}
fun Route.publicNewsRoutes(svc: NewsService) = route("/news") {
get {
val items = svc.list()
call.respond(items.map {
// можно вернуть Entity напрямую (оно сериализуемо, если поля простые),
// но лучше собрать DTO — показываю минимально:
mapOf("id" to it.id, "title" to it.title, "slug" to it.slug, "summary" to it.summary)
})
}
get("{slug}") {
val slug = call.parameters["slug"]!!
val item = svc.get(slug)
call.respond(item)
}
}

View File

@@ -1,4 +1,4 @@
package cc.essaenko.model
package cc.essaenko.modules.news
import org.ktorm.database.Database
import org.ktorm.entity.Entity

View File

@@ -0,0 +1,81 @@
package cc.essaenko.modules.news
import org.ktorm.database.Database
import org.ktorm.dsl.eq
import org.ktorm.dsl.lessEq
import org.ktorm.entity.*
import java.time.LocalDateTime
interface NewsRepository {
fun listPublished(limit: Int = 20, offset: Int = 0): List<News>
fun getBySlug(slug: String): News?
fun create(cmd: NewsCreate): Long
fun updateContent(slug: String, summary: String, content: String): Boolean
fun publish(slug: String, at: LocalDateTime = LocalDateTime.now()): Boolean
fun delete(slug: String): Boolean
}
data class NewsCreate(
val title: String,
val slug: String,
val summary: String,
val content: String,
val status: String = "DRAFT",
val imageUrl: String?,
)
class NewsRepositoryImpl(private val db: Database) : NewsRepository {
private val news get() = db.sequenceOf(NewsT)
override fun listPublished(limit: Int, offset: Int): List<News> =
news
.filter { it.status eq "PUBLISHED" }
.filter { it.publishedAt lessEq LocalDateTime.now() }
.sortedByDescending { it.publishedAt }
.drop(offset)
.take(limit)
.toList()
override fun getBySlug(slug: String): News? =
news.firstOrNull { it.slug eq slug }
override fun create(cmd: NewsCreate): Long {
val now = LocalDateTime.now()
val entity = News {
title = cmd.title
slug = cmd.slug
summary = cmd.summary
content = cmd.content
status = cmd.status
publishedAt = if (cmd.status == "PUBLISHED") now else null
imageUrl = cmd.imageUrl
createdAt = now
updatedAt = now
}
// add(...) вернёт количество затронутых строк, ключ читаем через свойство после вставки
news.add(entity)
return entity.id
}
override fun updateContent(slug: String, summary: String, content: String): Boolean {
val e = getBySlug(slug) ?: return false
e.summary = summary
e.content = content
e.updatedAt = LocalDateTime.now()
e.flushChanges() // применит UPDATE по изменённым полям
return true
}
override fun publish(slug: String, at: LocalDateTime): Boolean {
val e = getBySlug(slug) ?: return false
e.status = "PUBLISHED"
e.publishedAt = at
e.updatedAt = LocalDateTime.now()
e.flushChanges()
return true
}
override fun delete(slug: String): Boolean =
news.removeIf { it.slug eq slug } > 0
}

View File

@@ -0,0 +1,30 @@
package cc.essaenko.modules.news
import cc.essaenko.shared.errors.NotFoundException
class NewsService (private val repo: NewsRepository) {
fun list(limit: Int = 20, offset: Int = 0) = repo.listPublished(limit, offset)
fun get(slug: String) = repo.getBySlug(slug) ?: throw NotFoundException("news '$slug' not found")
fun create(cmd: NewsCreate): Long {
require(cmd.title.isNotBlank()) { "title is required" }
require(cmd.slug.matches(Regex("^[a-z0-9-]{3,}$"))) { "slug invalid" }
return repo.create(cmd)
}
fun update(slug: String, summary: String, content: String) =
repo.updateContent(slug, summary, content).also {
require(it) { "news '$slug' not found" }
}
fun publish(slug: String) =
repo.publish(slug).also {
require(it) { "news '$slug' not found" }
}
fun delete(slug: String) =
repo.delete(slug).also {
require(it) { "news '$slug' not found" }
}
}

View File

@@ -0,0 +1,128 @@
package cc.essaenko.modules.service
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.http.*
import java.math.BigDecimal
@kotlinx.serialization.Serializable
data class ServiceCreateRequest(
val title: String,
val slug: String,
val description: String,
val priceFrom: String? = null,
val imageUrl: String? = null,
val status: String? = null,
val categoryId: Long? = null
)
@kotlinx.serialization.Serializable
data class ServiceUpdateRequest(
val title: String? = null,
val slug: String? = null,
val description: String? = null,
val priceFrom: String? = null,
val imageUrl: String? = null,
val status: String? = null,
val categoryId: Long? = null
)
@kotlinx.serialization.Serializable
data class StatusRequest(val status: String)
private fun String?.toBigDecOrNull() = this?.let { runCatching { BigDecimal(it) }.getOrNull() }
fun Route.publicServiceRoutes(svc: ServiceService) = route("/services") {
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val q = call.request.queryParameters["q"]
val categorySlug = call.request.queryParameters["category"]
val minPrice = call.request.queryParameters["minPrice"].toBigDecOrNull()
val maxPrice = call.request.queryParameters["maxPrice"].toBigDecOrNull()
val page = svc.listPublic(limit, offset, q, categorySlug, minPrice, maxPrice)
call.respond(page)
}
get("{slug}") {
val slug = call.parameters["slug"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val item = svc.getBySlug(slug)
call.respond(
mapOf(
"id" to item.id,
"title" to item.title,
"slug" to item.slug,
"description" to item.description,
"priceFrom" to item.priceFrom,
"imageUrl" to item.imageUrl,
"status" to item.status,
"categoryId" to item.category?.id,
"createdAt" to item.createdAt,
"updatedAt" to item.updatedAt
)
)
}
}
fun Route.adminServiceRoutes(svc: ServiceService) = route("/admin/services") {
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val q = call.request.queryParameters["q"]
val status = call.request.queryParameters["status"]
call.respond(svc.listAdmin(limit, offset, q, status))
}
post {
val body = call.receive<ServiceCreateRequest>()
val id = svc.create(
ServiceCreate(
title = body.title,
slug = body.slug,
description = body.description,
priceFrom = body.priceFrom.toBigDecOrNull(),
imageUrl = body.imageUrl,
status = body.status ?: "PUBLISHED",
categoryId = body.categoryId
)
)
call.respond(HttpStatusCode.Created, mapOf("id" to id))
}
put("{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
val body = call.receive<ServiceUpdateRequest>()
svc.update(
id,
ServiceUpdate(
title = body.title,
slug = body.slug,
description = body.description,
priceFrom = body.priceFrom.toBigDecOrNull(),
imageUrl = body.imageUrl,
status = body.status,
categoryId = body.categoryId
)
)
call.respond(mapOf("updated" to true))
}
put("{id}/status") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
val body = call.receive<StatusRequest>()
svc.setStatus(id, body.status)
call.respond(mapOf("updated" to true))
}
delete("{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
svc.delete(id)
call.respond(mapOf("deleted" to true))
}
}

View File

@@ -1,28 +1,27 @@
package cc.essaenko.model
package cc.essaenko.modules.service
import org.ktorm.database.Database
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
import cc.essaenko.modules.serviceCategory.ServiceCategories
import org.ktorm.entity.Entity
import org.ktorm.entity.sequenceOf
import java.time.LocalDateTime
import java.math.BigDecimal
import org.ktorm.schema.*
import java.math.BigDecimal
import java.time.LocalDateTime
interface Service : Entity<Service> {
companion object : Entity.Factory<Service>()
interface ServiceEntity : Entity<ServiceEntity> {
companion object : Entity.Factory<ServiceEntity>()
var id: Long
var title: String
var slug: String
var description: String
var priceFrom: BigDecimal?
var imageUrl: String?
var status: String // "PUBLISHED" | "DRAFT" | "ARCHIVED"
var categoryId: Long? // связь по FK
var status: String // "PUBLISHED" | "DRAFT" | "ARCHIVED"
var category: ServiceCategoryEntity?
var createdAt: LocalDateTime
var updatedAt: LocalDateTime
}
object Services : Table<Service>("t_services") {
object Services : Table<ServiceEntity>("service") {
val id = long("id").primaryKey().bindTo { it.id }
val title = varchar("title").bindTo { it.title }
val slug = varchar("slug").bindTo { it.slug }
@@ -30,9 +29,9 @@ object Services : Table<Service>("t_services") {
val priceFrom = decimal("price_from").bindTo { it.priceFrom }
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
val status = varchar("status").bindTo { it.status }
val categoryId = long("category_id").bindTo { it.categoryId }
val category = long("category_id").references(ServiceCategories) { it.category }
val createdAt = datetime("created_at").bindTo { it.createdAt }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
}
val Database.service get() = this.sequenceOf(Services)

View File

@@ -0,0 +1,196 @@
package cc.essaenko.modules.service
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
import org.ktorm.database.Database
import org.ktorm.entity.*
import org.ktorm.dsl.*
import java.math.BigDecimal
import java.time.LocalDateTime
data class ServiceCreate(
val title: String,
val slug: String,
val description: String,
val priceFrom: BigDecimal? = null,
val imageUrl: String? = null,
val status: String = "PUBLISHED",
val categoryId: Long? = null
)
data class ServiceUpdate(
val title: String? = null,
val slug: String? = null,
val description: String? = null,
val priceFrom: BigDecimal? = null,
val imageUrl: String? = null,
val status: String? = null,
val categoryId: Long? = null
)
data class ServiceView(
val id: Long,
val title: String,
val slug: String,
val description: String,
val priceFrom: BigDecimal?,
val imageUrl: String?,
val status: String,
val categoryId: Long?,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
)
interface ServiceRepository {
fun listPublic(
limit: Int = 20,
offset: Int = 0,
q: String? = null,
categoryId: Long? = null,
minPrice: BigDecimal? = null,
maxPrice: BigDecimal? = null
): List<ServiceView>
fun countPublic(q: String? = null, categoryId: Long? = null, minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null): Int
fun listAdmin(limit: Int = 50, offset: Int = 0, q: String? = null, status: String? = null): List<ServiceView>
fun countAdmin(q: String? = null, status: String? = null): Int
fun getBySlug(slug: String): ServiceEntity?
fun getById(id: Long): ServiceEntity?
fun create(cmd: ServiceCreate): Long
fun update(id: Long, patch: ServiceUpdate): Boolean
fun updateStatus(id: Long, status: String): Boolean
fun delete(id: Long): Boolean
}
class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
private val services get() = db.sequenceOf(Services)
private fun ServiceEntity.toView() = ServiceView(
id = id,
title = title,
slug = slug,
description = description,
priceFrom = priceFrom,
imageUrl = imageUrl,
status = status,
categoryId = category?.id,
createdAt = createdAt,
updatedAt = updatedAt
)
override fun listPublic(
limit: Int, offset: Int, q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?
): List<ServiceView> {
var seq: EntitySequence<ServiceEntity, Services> = services
.filter { it.status eq "PUBLISHED" }
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
seq = seq.filter { (it.title like like) or (it.description like like) }
}
if (categoryId != null) {
seq = seq.filter { it.category eq categoryId }
}
if (minPrice != null) {
seq = seq.filter { it.priceFrom greaterEq minPrice }
}
if (maxPrice != null) {
seq = seq.filter { it.priceFrom lessEq maxPrice }
}
return seq.sortedBy { it.title }.drop(offset).take(limit).toList().map { it.toView() }
}
override fun countPublic(q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?): Int {
// Для подсчёта используем DSL, чтобы не тащить сущности
var expr = db.from(Services).select(count())
.where { Services.status eq "PUBLISHED" }
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
expr = expr.where { (Services.title like like) or (Services.description like like) }
}
if (categoryId != null) expr = expr.where { Services.category eq categoryId }
if (minPrice != null) expr = expr.where { Services.priceFrom greaterEq minPrice }
if (maxPrice != null) expr = expr.where { Services.priceFrom lessEq maxPrice }
return expr.totalRecordsInAllPages
}
override fun listAdmin(limit: Int, offset: Int, q: String?, status: String?): List<ServiceView> {
var seq: EntitySequence<ServiceEntity, Services> = services
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
seq = seq.filter { (it.title like like) or (it.description like like) }
}
if (!status.isNullOrBlank()) {
seq = seq.filter { it.status eq status }
}
return seq.sortedBy { it.title }.drop(offset).take(limit).toList().map { it.toView() }
}
override fun countAdmin(q: String?, status: String?): Int {
var expr = db.from(Services).select(count())
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
expr = expr.where { (Services.title like like) or (Services.description like like) }
}
if (!status.isNullOrBlank()) expr = expr.where { Services.status eq status }
return expr.totalRecordsInAllPages
}
override fun getBySlug(slug: String): ServiceEntity? =
services.firstOrNull { it.slug eq slug }
override fun getById(id: Long): ServiceEntity? =
services.firstOrNull { it.id eq id }
override fun create(cmd: ServiceCreate): Long {
val now = LocalDateTime.now()
val e = ServiceEntity {
title = cmd.title
slug = cmd.slug
description = cmd.description
priceFrom = cmd.priceFrom
imageUrl = cmd.imageUrl
status = cmd.status
category = cmd.categoryId?.let { ServiceCategoryEntity { this.id = it } }
createdAt = now
updatedAt = now
}
services.add(e)
return e.id
}
override fun update(id: Long, patch: ServiceUpdate): Boolean {
val e = getById(id) ?: return false
patch.title?.let { e.title = it }
patch.slug?.let { e.slug = it }
patch.description?.let { e.description = it }
if (patch.priceFrom != null) e.priceFrom = patch.priceFrom
patch.imageUrl?.let { e.imageUrl = it }
patch.status?.let { e.status = it }
if (patch.categoryId != null) {
e.category = ServiceCategoryEntity { this.id = patch.categoryId }
}
e.updatedAt = LocalDateTime.now()
e.flushChanges()
return true
}
override fun updateStatus(id: Long, status: String): Boolean {
val e = getById(id) ?: return false
e.status = status
e.updatedAt = LocalDateTime.now()
e.flushChanges()
return true
}
override fun delete(id: Long): Boolean =
services.removeIf { it.id eq id } > 0
}

View File

@@ -0,0 +1,61 @@
// modules/service/service/ServiceService.kt
package cc.essaenko.modules.service
import cc.essaenko.modules.serviceCategory.ServiceCategoryRepository
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.errors.ValidationException
import java.math.BigDecimal
class ServiceService(
private val repo: ServiceRepository,
private val categoryRepo: ServiceCategoryRepository
) {
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
fun listPublic(
limit: Int = 20, offset: Int = 0, q: String? = null, categorySlug: String? = null,
minPrice: BigDecimal? = null, maxPrice: BigDecimal? = null
): Page<ServiceView> {
val catId = categorySlug?.let { categoryRepo.findBySlug(it)?.id }
val items = repo.listPublic(limit, offset, q, catId, minPrice, maxPrice)
val total = repo.countPublic(q, catId, minPrice, maxPrice)
return Page(items, total, limit, offset)
}
fun listAdmin(limit: Int = 50, offset: Int = 0, q: String? = null, status: String? = null) =
Page(repo.listAdmin(limit, offset, q, status), repo.countAdmin(q, status), limit, offset)
fun getBySlug(slug: String) = repo.getBySlug(slug) ?: throw NotFoundException("service '$slug' not found")
fun create(cmd: ServiceCreate): Long {
validateTitle(cmd.title)
validateSlug(cmd.slug)
cmd.categoryId?.let { if (categoryRepo.findById(it) == null) throw ValidationException("category not found") }
return repo.create(cmd.copy(status = cmd.status.ifBlank { "PUBLISHED" }))
}
fun update(id: Long, patch: ServiceUpdate) {
patch.slug?.let { validateSlug(it) }
patch.categoryId?.let { if (categoryRepo.findById(it) == null) throw ValidationException("category not found") }
val ok = repo.update(id, patch)
if (!ok) throw NotFoundException("service $id not found")
}
fun setStatus(id: Long, status: String) {
require(status in setOf("PUBLISHED", "DRAFT", "ARCHIVED")) { "invalid status" }
val ok = repo.updateStatus(id, status)
if (!ok) throw NotFoundException("service $id not found")
}
fun delete(id: Long) {
val ok = repo.delete(id)
if (!ok) throw NotFoundException("service $id not found")
}
private fun validateTitle(title: String) {
require(title.isNotBlank()) { "title is required" }
}
private fun validateSlug(slug: String) {
require(slug.matches(Regex("^[a-z0-9-]{3,}$"))) { "slug is invalid" }
}
}

View File

@@ -0,0 +1,56 @@
package cc.essaenko.modules.serviceCategory
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.http.*
@kotlinx.serialization.Serializable
data class CategoryCreateRequest(val name: String, val slug: String)
@kotlinx.serialization.Serializable
data class CategoryUpdateRequest(val name: String? = null, val slug: String? = null)
fun Route.publicServiceCategoryRoutes(svc: ServiceCategoryService) = route("/service-categories") {
get {
val items = svc.listPublic().map { c ->
mapOf("id" to c.id, "name" to c.name, "slug" to c.slug)
}
call.respond(items)
}
get("{slug}") {
val slug = call.parameters["slug"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val c = svc.getBySlug(slug)
call.respond(mapOf("id" to c.id, "name" to c.name, "slug" to c.slug))
}
}
fun Route.adminServiceCategoryRoutes(svc: ServiceCategoryService) = route("/admin/service-categories") {
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
call.respond(svc.listAdmin(limit, offset))
}
post {
val body = call.receive<CategoryCreateRequest>()
val id = svc.create(CategoryCreate(body.name, body.slug))
call.respond(HttpStatusCode.Created, mapOf("id" to id))
}
put("{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
val body = call.receive<CategoryUpdateRequest>()
svc.update(id, CategoryUpdate(body.name, body.slug))
call.respond(mapOf("updated" to true))
}
delete("{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid id"))
svc.delete(id)
call.respond(mapOf("deleted" to true))
}
}

View File

@@ -0,0 +1,17 @@
package cc.essaenko.modules.serviceCategory
import org.ktorm.entity.Entity
import org.ktorm.schema.*
interface ServiceCategoryEntity : Entity<ServiceCategoryEntity> {
companion object : Entity.Factory<ServiceCategoryEntity>()
var id: Long
var name: String
var slug: String
}
object ServiceCategories : Table<ServiceCategoryEntity>("service_category") {
val id = long("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val slug = varchar("slug").bindTo { it.slug }
}

View File

@@ -0,0 +1,60 @@
package cc.essaenko.modules.serviceCategory
import org.ktorm.database.Database
import org.ktorm.entity.*
import org.ktorm.dsl.*
data class CategoryCreate(val name: String, val slug: String)
data class CategoryUpdate(val name: String? = null, val slug: String? = null)
interface ServiceCategoryRepository {
fun listPublic(): List<ServiceCategoryEntity>
fun listAdmin(limit: Int = 100, offset: Int = 0): List<ServiceCategoryEntity>
fun count(): Int
fun findById(id: Long): ServiceCategoryEntity?
fun findBySlug(slug: String): ServiceCategoryEntity?
fun create(cmd: CategoryCreate): Long
fun update(id: Long, patch: CategoryUpdate): Boolean
fun delete(id: Long): Boolean
}
class ServiceCategoryRepositoryImpl(private val db: Database) : ServiceCategoryRepository {
private val categories get() = db.sequenceOf(ServiceCategories)
override fun listPublic(): List<ServiceCategoryEntity> =
categories.toList()
override fun listAdmin(limit: Int, offset: Int): List<ServiceCategoryEntity> =
categories.toList().drop(offset).take(limit).toList()
override fun count(): Int = db.from(ServiceCategories).select().totalRecordsInAllPages
override fun findById(id: Long): ServiceCategoryEntity? =
categories.firstOrNull { it.id eq id }
override fun findBySlug(slug: String): ServiceCategoryEntity? =
categories.firstOrNull { it.slug eq slug }
override fun create(cmd: CategoryCreate): Long {
val e = ServiceCategoryEntity {
name = cmd.name
slug = cmd.slug
}
categories.add(e)
return e.id
}
override fun update(id: Long, patch: CategoryUpdate): Boolean {
val e = findById(id) ?: return false
patch.name?.let { e.name = it }
patch.slug?.let { e.slug = it }
e.flushChanges()
return true
}
override fun delete(id: Long): Boolean =
categories.removeIf { it.id eq id } > 0
}

View File

@@ -0,0 +1,47 @@
package cc.essaenko.modules.serviceCategory
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.errors.ValidationException
class ServiceCategoryService(private val repo: ServiceCategoryRepository) {
fun listPublic() = repo.listPublic()
data class Page<T>(val items: List<T>, val total: Int, val limit: Int, val offset: Int)
fun listAdmin(limit: Int = 100, offset: Int = 0) =
Page(repo.listAdmin(limit, offset), repo.count(), limit, offset)
fun getBySlug(slug: String) = repo.findBySlug(slug)
?: throw NotFoundException("category '$slug' not found")
fun create(cmd: CategoryCreate): Long {
validateName(cmd.name)
validateSlug(cmd.slug)
if (repo.findBySlug(cmd.slug) != null) throw ValidationException("slug already exists")
return repo.create(cmd)
}
fun update(id: Long, patch: CategoryUpdate) {
patch.slug?.let {
validateSlug(it)
repo.findBySlug(it)?.let { existing ->
if (existing.id != id) throw ValidationException("slug already exists")
}
}
val ok = repo.update(id, patch)
if (!ok) throw NotFoundException("category $id not found")
}
fun delete(id: Long) {
val ok = repo.delete(id)
if (!ok) throw NotFoundException("category $id not found")
}
private fun validateName(name: String) {
require(name.isNotBlank()) { "name is required" }
}
private fun validateSlug(slug: String) {
require(slug.matches(Regex("^[a-z0-9-]{3,}$"))) { "slug is invalid" }
}
}

View File

@@ -0,0 +1,4 @@
package cc.essaenko.shared.errors
class NotFoundException(private val error: String): RuntimeException(error) {
}

View File

@@ -0,0 +1,4 @@
package cc.essaenko.shared.errors
class ValidationException(private val error: String) : RuntimeException(error) {
}

View File

@@ -1,7 +1,7 @@
ktor:
application:
modules:
- cc.essaenko.ApplicationKt.module
- cc.essaenko.app.ApplicationKt.module
deployment:
port: 8080
jwt:

View File

@@ -1,5 +1,6 @@
package cc.essaenko
import cc.essaenko.app.module
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*