Diploma-1 Main CRUD operations
This commit is contained in:
@@ -25,6 +25,7 @@ dependencies {
|
|||||||
implementation(libs.ktor.server.netty)
|
implementation(libs.ktor.server.netty)
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
implementation(libs.dotenv.kotlin)
|
implementation(libs.dotenv.kotlin)
|
||||||
|
implementation(libs.hcpool)
|
||||||
implementation(libs.postgresql)
|
implementation(libs.postgresql)
|
||||||
implementation(libs.bcrypt)
|
implementation(libs.bcrypt)
|
||||||
implementation(libs.ktorm)
|
implementation(libs.ktorm)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ ktorm = "4.1.1"
|
|||||||
dotenv = "6.5.1"
|
dotenv = "6.5.1"
|
||||||
psql = "42.7.3"
|
psql = "42.7.3"
|
||||||
bcrypt="0.4"
|
bcrypt="0.4"
|
||||||
|
hcp = "7.0.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
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" }
|
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
|
||||||
postgresql = { module = "org.postgresql:postgresql", version.ref = "psql" }
|
postgresql = { module = "org.postgresql:postgresql", version.ref = "psql" }
|
||||||
bcrypt = { module = "org.mindrot:jbcrypt", version.ref = "bcrypt"}
|
bcrypt = { module = "org.mindrot:jbcrypt", version.ref = "bcrypt"}
|
||||||
|
hcpool = { module = "com.zaxxer:HikariCP", version.ref = "hcp" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
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.*
|
||||||
import io.ktor.http.content.*
|
import io.ktor.http.content.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import io.ktor.server.application.*
|
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.cachingheaders.*
|
||||||
import io.ktor.server.plugins.compression.*
|
import io.ktor.server.plugins.compression.*
|
||||||
import io.ktor.server.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.server.plugins.cors.routing.*
|
import io.ktor.server.plugins.cors.routing.*
|
||||||
import io.ktor.server.plugins.defaultheaders.*
|
import io.ktor.server.plugins.defaultheaders.*
|
||||||
import io.ktor.server.plugins.swagger.*
|
import io.ktor.server.plugins.swagger.*
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
fun Application.configureHTTP() {
|
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.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
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.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.response.*
|
||||||
import io.ktor.server.routing.*
|
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.Entity
|
||||||
import org.ktorm.entity.sequenceOf
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import org.ktorm.schema.*
|
import org.ktorm.schema.*
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface AdminEntity : Entity<AdminEntity> {
|
||||||
interface Admin : Entity<Admin> {
|
companion object : Entity.Factory<AdminEntity>()
|
||||||
companion object : Entity.Factory<Admin>()
|
|
||||||
var id: Long
|
var id: Long
|
||||||
var username: String
|
var username: String
|
||||||
var passwordHash: String
|
var password: String
|
||||||
var createdAt: LocalDateTime
|
var createdAt: LocalDateTime
|
||||||
var lastLoginAt: 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 id = long("id").primaryKey().bindTo { it.id }
|
||||||
val username = varchar("username").bindTo { it.username }
|
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 createdAt = datetime("created_at").bindTo { it.createdAt }
|
||||||
val lastLoginAt = datetime("last_login_at").bindTo { it.lastLoginAt }
|
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.database.Database
|
||||||
import org.ktorm.entity.Entity
|
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.Entity
|
||||||
import org.ktorm.entity.sequenceOf
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import org.ktorm.schema.*
|
import org.ktorm.schema.*
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface ServiceEntity : Entity<ServiceEntity> {
|
||||||
interface Service : Entity<Service> {
|
companion object : Entity.Factory<ServiceEntity>()
|
||||||
companion object : Entity.Factory<Service>()
|
|
||||||
var id: Long
|
var id: Long
|
||||||
var title: String
|
var title: String
|
||||||
var slug: String
|
var slug: String
|
||||||
var description: String
|
var description: String
|
||||||
var priceFrom: BigDecimal?
|
var priceFrom: BigDecimal?
|
||||||
var imageUrl: String?
|
var imageUrl: String?
|
||||||
var status: String // "PUBLISHED" | "DRAFT" | "ARCHIVED"
|
var status: String // "PUBLISHED" | "DRAFT" | "ARCHIVED"
|
||||||
var categoryId: Long? // связь по FK
|
var category: ServiceCategoryEntity?
|
||||||
var createdAt: LocalDateTime
|
var createdAt: LocalDateTime
|
||||||
var updatedAt: LocalDateTime
|
var updatedAt: LocalDateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
object Services : Table<Service>("t_services") {
|
object Services : Table<ServiceEntity>("service") {
|
||||||
val id = long("id").primaryKey().bindTo { it.id }
|
val id = long("id").primaryKey().bindTo { it.id }
|
||||||
val title = varchar("title").bindTo { it.title }
|
val title = varchar("title").bindTo { it.title }
|
||||||
val slug = varchar("slug").bindTo { it.slug }
|
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 priceFrom = decimal("price_from").bindTo { it.priceFrom }
|
||||||
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
|
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
|
||||||
val status = varchar("status").bindTo { it.status }
|
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 createdAt = datetime("created_at").bindTo { it.createdAt }
|
||||||
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
|
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:
|
ktor:
|
||||||
application:
|
application:
|
||||||
modules:
|
modules:
|
||||||
- cc.essaenko.ApplicationKt.module
|
- cc.essaenko.app.ApplicationKt.module
|
||||||
deployment:
|
deployment:
|
||||||
port: 8080
|
port: 8080
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cc.essaenko
|
package cc.essaenko
|
||||||
|
|
||||||
|
import cc.essaenko.app.module
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.testing.*
|
import io.ktor.server.testing.*
|
||||||
|
|||||||
Reference in New Issue
Block a user