Diploma-1 Main CRUD operations
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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!")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/main/kotlin/app/Application.kt
Normal file
17
src/main/kotlin/app/Application.kt
Normal 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)
|
||||
}
|
||||
61
src/main/kotlin/app/Database.kt
Normal file
61
src/main/kotlin/app/Database.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
61
src/main/kotlin/app/Routing.kt
Normal file
61
src/main/kotlin/app/Routing.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/main/kotlin/app/Security.kt
Normal file
52
src/main/kotlin/app/Security.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.*
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
64
src/main/kotlin/modules/admin/Controller.kt
Normal file
64
src/main/kotlin/modules/admin/Controller.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
64
src/main/kotlin/modules/admin/Repository.kt
Normal file
64
src/main/kotlin/modules/admin/Repository.kt
Normal 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
|
||||
}
|
||||
78
src/main/kotlin/modules/admin/Service.kt
Normal file
78
src/main/kotlin/modules/admin/Service.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
62
src/main/kotlin/modules/lead/Controller.kt
Normal file
62
src/main/kotlin/modules/lead/Controller.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
22
src/main/kotlin/modules/lead/Entity.kt
Normal file
22
src/main/kotlin/modules/lead/Entity.kt
Normal 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 }
|
||||
}
|
||||
84
src/main/kotlin/modules/lead/Reository.kt
Normal file
84
src/main/kotlin/modules/lead/Reository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
30
src/main/kotlin/modules/lead/Service.kt
Normal file
30
src/main/kotlin/modules/lead/Service.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
46
src/main/kotlin/modules/news/Controller.kt
Normal file
46
src/main/kotlin/modules/news/Controller.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cc.essaenko.model
|
||||
package cc.essaenko.modules.news
|
||||
|
||||
import org.ktorm.database.Database
|
||||
import org.ktorm.entity.Entity
|
||||
81
src/main/kotlin/modules/news/Repository.kt
Normal file
81
src/main/kotlin/modules/news/Repository.kt
Normal 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
|
||||
}
|
||||
|
||||
30
src/main/kotlin/modules/news/Service.kt
Normal file
30
src/main/kotlin/modules/news/Service.kt
Normal 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" }
|
||||
}
|
||||
}
|
||||
128
src/main/kotlin/modules/service/Controller.kt
Normal file
128
src/main/kotlin/modules/service/Controller.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
196
src/main/kotlin/modules/service/Repository.kt
Normal file
196
src/main/kotlin/modules/service/Repository.kt
Normal 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
|
||||
}
|
||||
61
src/main/kotlin/modules/service/Service.kt
Normal file
61
src/main/kotlin/modules/service/Service.kt
Normal 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" }
|
||||
}
|
||||
}
|
||||
56
src/main/kotlin/modules/serviceCategory/Controller.kt
Normal file
56
src/main/kotlin/modules/serviceCategory/Controller.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
17
src/main/kotlin/modules/serviceCategory/Entity.kt
Normal file
17
src/main/kotlin/modules/serviceCategory/Entity.kt
Normal 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 }
|
||||
}
|
||||
60
src/main/kotlin/modules/serviceCategory/Repository.kt
Normal file
60
src/main/kotlin/modules/serviceCategory/Repository.kt
Normal 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
|
||||
}
|
||||
47
src/main/kotlin/modules/serviceCategory/Service.kt
Normal file
47
src/main/kotlin/modules/serviceCategory/Service.kt
Normal 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" }
|
||||
}
|
||||
}
|
||||
4
src/main/kotlin/shared/errors/NotFoundException.kt
Normal file
4
src/main/kotlin/shared/errors/NotFoundException.kt
Normal file
@@ -0,0 +1,4 @@
|
||||
package cc.essaenko.shared.errors
|
||||
|
||||
class NotFoundException(private val error: String): RuntimeException(error) {
|
||||
}
|
||||
4
src/main/kotlin/shared/errors/ValidationException.kt
Normal file
4
src/main/kotlin/shared/errors/ValidationException.kt
Normal file
@@ -0,0 +1,4 @@
|
||||
package cc.essaenko.shared.errors
|
||||
|
||||
class ValidationException(private val error: String) : RuntimeException(error) {
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
ktor:
|
||||
application:
|
||||
modules:
|
||||
- cc.essaenko.ApplicationKt.module
|
||||
- cc.essaenko.app.ApplicationKt.module
|
||||
deployment:
|
||||
port: 8080
|
||||
jwt:
|
||||
|
||||
@@ -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.*
|
||||
|
||||
Reference in New Issue
Block a user