Add docker

This commit is contained in:
Evgenii Saenko
2025-12-17 11:52:18 +03:00
parent 2d5b329b36
commit ea390b1533
38 changed files with 1359 additions and 165 deletions

View File

@@ -9,9 +9,9 @@ fun main(args: Array<String>) {
fun Application.module() {
val jwtCfg = loadJwtConfig()
configureSerialization()
configureSecurity(jwtCfg)
configureDatabase()
configureHTTP()
configureSerialization()
configureSecurity(jwtCfg)
configureRouting(jwtCfg)
}

View File

@@ -1,13 +1,19 @@
package cc.essaenko.app
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.errors.ValidationException
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.compression.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.request.ContentTransformationException
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureHTTP() {
@@ -19,13 +25,43 @@ fun Application.configureHTTP() {
}
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
allowHeader("MyCustomHeader")
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Accept)
anyHost() // @TODO: Don't do this in production if possible. Try to limit it.
}
install(StatusPages) {
exception<ContentTransformationException> { call, cause ->
val msg = cause.message ?: "Invalid request payload"
call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to msg)
)
}
exception<BadRequestException> { call, cause ->
val msg = cause.message ?: "Bad request"
println(cause.message ?: "Bad request")
cause.stackTrace.forEach { println(it) }
call.respond(HttpStatusCode.BadRequest, mapOf("error" to msg))
}
exception<ValidationException> { call, cause ->
val message = cause.message ?: "Validation failed"
val status = if (message.equals("Invalid credentials", ignoreCase = true)) {
HttpStatusCode.Unauthorized
} else {
HttpStatusCode.BadRequest
}
call.respond(status, mapOf("error" to message))
}
exception<NotFoundException> { call, cause ->
call.respond(HttpStatusCode.NotFound, mapOf("error" to (cause.message ?: "Not found")))
}
}
install(Compression)
install(CachingHeaders) {
options { call, outgoingContent ->

View File

@@ -44,18 +44,20 @@ fun Application.configureRouting(jwtCfg: JwtConfig) {
val serviceSvc = ServiceService(serviceRepo, serviceCategoryRepo)
routing {
publicNewsRoutes(newsSvc)
publicLeadRoutes(leadSvc)
publicServiceCategoryRoutes(serviceCategorySvc)
publicServiceRoutes(serviceSvc)
publicAdminRoutes(adminSvc)
route("/api/v1") {
publicNewsRoutes(newsSvc)
publicLeadRoutes(leadSvc)
publicServiceCategoryRoutes(serviceCategorySvc)
publicServiceRoutes(serviceSvc)
publicAdminRoutes(adminSvc)
authenticate("admin-auth") {
adminNewsRoutes(newsSvc)
adminRoutes(adminSvc)
adminLeadRoutes(leadSvc)
adminServiceCategoryRoutes(serviceCategorySvc)
adminServiceRoutes(serviceSvc)
authenticate("admin-auth") {
adminNewsRoutes(newsSvc)
adminRoutes(adminSvc)
adminLeadRoutes(leadSvc)
adminServiceCategoryRoutes(serviceCategorySvc)
adminServiceRoutes(serviceSvc)
}
}
}
}

View File

@@ -3,9 +3,11 @@ package cc.essaenko.app
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.github.cdimascio.dotenv.dotenv
import io.ktor.http.HttpMethod
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.httpMethod
data class JwtConfig(
@@ -32,6 +34,7 @@ fun Application.configureSecurity(jwt: JwtConfig) {
install(Authentication) {
jwt("admin-auth") {
realm = jwt.realm
skipWhen { call -> call.request.httpMethod == HttpMethod.Options }
verifier(
JWT
.require(algorithm)

View File

@@ -0,0 +1,23 @@
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object JavaLocalDateTimeSerializer : KSerializer<LocalDateTime> {
private val fmt = DateTimeFormatter.ISO_LOCAL_DATE_TIME
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("JavaLocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(fmt.format(value))
}
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString(), fmt)
}
}

View File

@@ -1,9 +1,11 @@
package cc.essaenko.modules.admin
import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.http.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@kotlinx.serialization.Serializable
data class AdminRegisterRequest(val username: String, val password: String)
@@ -12,52 +14,70 @@ data class AdminRegisterRequest(val username: String, val password: String)
data class AdminLoginRequest(val username: String, val password: String)
@kotlinx.serialization.Serializable
data class ChangePasswordRequest(val password: String)
data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
@kotlinx.serialization.Serializable
data class AdminLoginResponse(
val id: Long,
val username: String,
val token: String,
val tokenType: String = "Bearer",
val expiresInMinutes: Long
)
@kotlinx.serialization.Serializable
data class AdminRegisterResponse(val id: Long, val username: 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(
AdminLoginResponse(
id = auth.id,
username = auth.username,
token = auth.token,
tokenType = "Bearer",
expiresInMinutes = auth.expiresInMinutes
)
)
}
get("/password_hash") {
val raw = call.request.queryParameters["password"] ?: "admin123";
call.respond(
mapOf(
"id" to auth.id,
"username" to auth.username,
"token" to auth.token,
"tokenType" to "Bearer",
"expiresInMinutes" to auth.expiresInMinutes
"pass" to svc.getPasswordHash(raw)
)
)
}
}
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))
val principal = call.principal<JWTPrincipal>() ?: return@get call.respond(HttpStatusCode.Unauthorized)
val adminId = principal.subject?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.Unauthorized)
call.respond(svc.current(adminId))
}
// Регистрация нового админа
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))
call.respond(HttpStatusCode.Created, AdminRegisterResponse(id, body.username))
}
// Смена пароля
put("{id}/password") {
val id = call.parameters["id"]?.toLongOrNull() ?: return@put call.respond(HttpStatusCode.BadRequest)
val id = call.parameters["id"]?.toLongOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid admin id"))
val body = call.receive<ChangePasswordRequest>()
svc.changePassword(id, body.password)
svc.changePassword(id, body.currentPassword, body.newPassword)
call.respond(mapOf("updated" to true))
}
// Удаление админа
delete("{id}") {
val id = call.parameters["id"]?.toLongOrNull() ?: return@delete call.respond(HttpStatusCode.BadRequest)
val id = call.parameters["id"]?.toLongOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, mapOf("error" to "invalid admin id"))
svc.remove(id)
call.respond(mapOf("deleted" to true))
}

View File

@@ -1,5 +1,6 @@
package cc.essaenko.modules.admin
import kotlinx.serialization.Serializable
import org.ktorm.entity.Entity
import org.ktorm.schema.*
import java.time.LocalDateTime
@@ -13,7 +14,15 @@ interface AdminEntity : Entity<AdminEntity> {
var lastLoginAt: LocalDateTime?
}
object AdminUsers : Table<AdminEntity>("admin_user") {
@Serializable
data class AdminDTO(
val id: Long,
val username: String,
@Serializable(with = JavaLocalDateTimeSerializer::class)
val createdAt: LocalDateTime,
)
object AdminUsers : Table<AdminEntity>("t_admins") {
val id = long("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val password = varchar("password_hash").bindTo { it.password }

View File

@@ -6,10 +6,9 @@ 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 list(limit: Int = 50, offset: Int = 0): List<AdminDTO>
fun findById(id: Long): AdminEntity?
fun findByUsername(username: String): AdminEntity?
fun create(cmd: AdminCreate): Long
@@ -22,9 +21,9 @@ class AdminRepositoryImpl(private val db: Database) : AdminRepository {
private val admins get() = db.sequenceOf(AdminUsers)
override fun list(limit: Int, offset: Int): List<AdminView> =
override fun list(limit: Int, offset: Int): List<AdminDTO> =
admins.sortedBy { it.id }.drop(offset).take(limit).toList()
.map { AdminView(it.id, it.username, it.createdAt, it.lastLoginAt) }
.map { AdminDTO(it.id, it.username, it.createdAt) }
override fun findById(id: Long): AdminEntity? =
admins.firstOrNull { it.id eq id }

View File

@@ -25,8 +25,16 @@ class AdminService(
private val hasher: PasswordHasher,
private val tokens: TokenService
) {
fun getPasswordHash(raw: String): String {
return hasher.hash(raw)
}
fun list(limit: Int = 50, offset: Int = 0) = repo.list(limit, offset)
fun current(id: Long): AdminDTO {
val admin = repo.findById(id) ?: throw NotFoundException("Admin not found")
return AdminDTO(admin.id, admin.username, admin.createdAt)
}
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" }
@@ -48,8 +56,12 @@ class AdminService(
return AuthResult(admin.id, admin.username, token, /*экспорт*/ 60)
}
fun changePassword(id: Long, newPassword: String) {
fun changePassword(id: Long, currentPassword: String, newPassword: String) {
require(newPassword.length >= 8) { "Password must be at least 8 characters" }
val admin = repo.findById(id) ?: throw NotFoundException("Admin not found")
if (!hasher.verify(currentPassword, admin.password)) {
throw ValidationException("Current password is invalid")
}
val ok = repo.updatePassword(id, hasher.hash(newPassword))
if (!ok) throw NotFoundException("Admin not found")
}
@@ -75,4 +87,4 @@ class TokenService(private val cfg: JwtConfig) {
.withExpiresAt(exp)
.sign(algorithm)
}
}
}

View File

@@ -1,9 +1,10 @@
package cc.essaenko.modules.lead
import io.ktor.server.routing.*
import cc.essaenko.shared.pagination.Page
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.routing.*
@kotlinx.serialization.Serializable
data class LeadCreateRequest(
@@ -12,7 +13,6 @@ data class LeadCreateRequest(
val phone: String? = null,
)
/** Публичный эндпоинт формы обратной связи */
fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
post {
val body = call.receive<LeadCreateRequest>()
@@ -27,15 +27,28 @@ fun Route.publicLeadRoutes(svc: LeadService) = route("/leads") {
}
}
/** Админские эндпоинты для просмотра/удаления лидов */
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 page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
if (page < 1) {
return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "page must be greater than 0")
)
}
val offset = (page - 1) * limit
val q = call.request.queryParameters["q"]
val page = svc.list(limit, offset, q)
call.respond(page)
val data = svc.list(limit, offset, q)
call.respond(
Page(
items = data.items,
total = data.total,
limit = data.limit,
offset = data.offset
)
)
}
get("{id}") {
@@ -43,12 +56,12 @@ fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
?: 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
LeadDTO(
id = lead.id,
createdAt = lead.createdAt,
email = lead.email,
fullName = lead.fullName,
phone = lead.phone,
)
)
}
@@ -59,4 +72,4 @@ fun Route.adminLeadRoutes(svc: LeadService) = route("/admin/leads") {
svc.delete(id)
call.respond(mapOf("deleted" to true))
}
}
}

View File

@@ -1,5 +1,6 @@
package cc.essaenko.modules.lead
import kotlinx.serialization.Serializable
import org.ktorm.entity.Entity
import org.ktorm.schema.*
import java.time.LocalDateTime
@@ -13,7 +14,17 @@ interface LeadEntity : Entity<LeadEntity> {
var createdAt: LocalDateTime
}
object Leads : Table<LeadEntity>("lead") {
@Serializable
data class LeadDTO(
val id: Long,
val fullName: String,
val email: String,
val phone: String?,
@Serializable(with = JavaLocalDateTimeSerializer::class)
val createdAt: LocalDateTime,
)
object Leads : Table<LeadEntity>("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 }

View File

@@ -11,17 +11,10 @@ data class LeadCreate(
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 list(limit: Int = 50, offset: Int = 0, q: String? = null): List<LeadDTO>
fun delete(id: Long): Boolean
fun count(q: String? = null): Int
}
@@ -44,7 +37,7 @@ class LeadRepositoryImpl(private val db: Database) : LeadRepository {
override fun getById(id: Long): LeadEntity? =
leads.firstOrNull { it.id eq id }
override fun list(limit: Int, offset: Int, q: String?): List<LeadView> {
override fun list(limit: Int, offset: Int, q: String?): List<LeadDTO> {
var seq: EntitySequence<LeadEntity, Leads> = leads
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
@@ -60,11 +53,12 @@ class LeadRepositoryImpl(private val db: Database) : LeadRepository {
.take(limit)
.toList()
.map {
LeadView(
LeadDTO(
id = it.id,
fullName = it.fullName,
email = it.email,
phone = it.phone,
createdAt = it.createdAt
)
}
}

View File

@@ -2,6 +2,7 @@ package cc.essaenko.modules.lead
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.errors.ValidationException
import cc.essaenko.shared.pagination.Page
class LeadService(private val repo: LeadRepository) {
fun create(cmd: LeadCreate): Long {
@@ -15,9 +16,7 @@ class LeadService(private val repo: LeadRepository) {
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> {
fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page<LeadDTO> {
val items = repo.list(limit, offset, q)
val total = repo.count(q)
return Page(items, total, limit, offset)
@@ -27,4 +26,4 @@ class LeadService(private val repo: LeadRepository) {
val ok = repo.delete(id)
if (!ok) throw NotFoundException("lead $id not found")
}
}
}

View File

@@ -1,10 +1,43 @@
package cc.essaenko.modules.news
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import cc.essaenko.shared.pagination.Page
fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
@Serializable
data class NewsUpdateRequest(
val title: String? = null,
val slug: String? = null,
val summary: String? = null,
val content: String? = null,
val status: String? = null,
val imageUrl: String? = null,
)
fun Route.adminNewsRoutes(svc: NewsService) = route("/admin/news") {
get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
if (page < 1) {
return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "page must be greater than 0")
)
}
val offset = (page - 1) * limit
val pageData = svc.listAdmin(limit, offset)
call.respond(
Page(
items = pageData.items.map { it.toDto() },
total = pageData.total,
limit = pageData.limit,
offset = pageData.offset
)
)
}
post {
val payload = call.receive<NewsCreate>()
val id = svc.create(payload)
@@ -12,8 +45,8 @@ fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
}
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())
val body = call.receive<NewsUpdateRequest>()
val ok = svc.update(slug, body.toDomain())
call.respond(mapOf("updated" to ok))
}
post("{slug}/publish") {
@@ -31,16 +64,48 @@ fun Route.adminNewsRoutes(svc: NewsService) = route("/news") {
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)
})
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
if (page < 1) {
return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "page must be greater than 0")
)
}
val offset = (page - 1) * limit
val pageData = svc.list(limit, offset)
call.respond(
Page(
items = pageData.items.map { it.toDto() },
total = pageData.total,
limit = pageData.limit,
offset = pageData.offset
)
)
}
get("{slug}") {
val slug = call.parameters["slug"]!!
val item = svc.get(slug)
call.respond(item)
call.respond(item.toDto())
}
}
}
private fun NewsUpdateRequest.toDomain() = NewsUpdate(
title = title,
slug = slug,
summary = summary,
content = content,
status = status,
imageUrl = imageUrl
)
private fun News.toDto() = NewsDTO(
id = id,
title = title,
content = content,
imageUrl = imageUrl,
slug = slug,
summary = summary,
publishedAt = publishedAt,
status = status,
)

View File

@@ -1,5 +1,6 @@
package cc.essaenko.modules.news
import kotlinx.serialization.Serializable
import org.ktorm.database.Database
import org.ktorm.entity.Entity
import org.ktorm.entity.sequenceOf
@@ -20,6 +21,19 @@ interface News : Entity<News> {
var updatedAt: LocalDateTime
}
@Serializable
data class NewsDTO(
val id: Long,
val title: String,
val slug: String,
val summary: String,
val content: String,
val status: String,
val imageUrl: String?,
@Serializable(with = JavaLocalDateTimeSerializer::class)
val publishedAt: LocalDateTime?,
)
object NewsT : Table<News>("t_news") {
val id = long("id").primaryKey().bindTo { it.id }
val title = varchar("title").bindTo { it.title }

View File

@@ -1,5 +1,6 @@
package cc.essaenko.modules.news
import kotlinx.serialization.Serializable
import org.ktorm.database.Database
import org.ktorm.dsl.eq
import org.ktorm.dsl.lessEq
@@ -8,20 +9,34 @@ import java.time.LocalDateTime
interface NewsRepository {
fun listPublished(limit: Int = 20, offset: Int = 0): List<News>
fun countPublished(): Int
fun listAll(limit: Int = 50, offset: Int = 0): List<News>
fun countAll(): Int
fun getBySlug(slug: String): News?
fun create(cmd: NewsCreate): Long
fun updateContent(slug: String, summary: String, content: String): Boolean
fun update(slug: String, patch: NewsUpdate): Boolean
fun publish(slug: String, at: LocalDateTime = LocalDateTime.now()): Boolean
fun delete(slug: String): Boolean
}
@Serializable
data class NewsCreate(
val title: String,
val slug: String,
val summary: String,
val content: String,
val status: String = "DRAFT",
val imageUrl: String?,
val status: String = "draft",
val imageUrl: String? = null,
)
@Serializable
data class NewsUpdate(
val title: String? = null,
val slug: String? = null,
val summary: String? = null,
val content: String? = null,
val status: String? = null,
val imageUrl: String? = null,
)
class NewsRepositoryImpl(private val db: Database) : NewsRepository {
@@ -29,13 +44,29 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
override fun listPublished(limit: Int, offset: Int): List<News> =
news
.filter { it.status eq "PUBLISHED" }
.filter { it.status eq "published" }
.filter { it.publishedAt lessEq LocalDateTime.now() }
.sortedByDescending { it.publishedAt }
.drop(offset)
.take(limit)
.toList()
override fun countPublished(): Int =
news
.filter { it.status eq "published" }
.filter { it.publishedAt lessEq LocalDateTime.now() }
.count()
override fun listAll(limit: Int, offset: Int): List<News> =
news
.sortedByDescending { it.createdAt }
.drop(offset)
.take(limit)
.toList()
override fun countAll(): Int =
news.count()
override fun getBySlug(slug: String): News? =
news.firstOrNull { it.slug eq slug }
@@ -47,28 +78,38 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
summary = cmd.summary
content = cmd.content
status = cmd.status
publishedAt = if (cmd.status == "PUBLISHED") now else null
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 {
override fun update(slug: String, patch: NewsUpdate): Boolean {
val e = getBySlug(slug) ?: return false
e.summary = summary
e.content = content
patch.title?.let { e.title = it }
patch.slug?.let { e.slug = it }
patch.summary?.let { e.summary = it }
patch.content?.let { e.content = it }
patch.status?.let {
e.status = it
if (it.equals("published", ignoreCase = true)) {
e.publishedAt = e.publishedAt ?: LocalDateTime.now()
} else {
e.publishedAt = null
}
}
patch.imageUrl?.let { e.imageUrl = it }
e.updatedAt = LocalDateTime.now()
e.flushChanges() // применит UPDATE по изменённым полям
e.flushChanges()
return true
}
override fun publish(slug: String, at: LocalDateTime): Boolean {
val e = getBySlug(slug) ?: return false
e.status = "PUBLISHED"
e.status = "published"
e.publishedAt = at
e.updatedAt = LocalDateTime.now()
e.flushChanges()
@@ -78,4 +119,3 @@ class NewsRepositoryImpl(private val db: Database) : NewsRepository {
override fun delete(slug: String): Boolean =
news.removeIf { it.slug eq slug } > 0
}

View File

@@ -1,22 +1,42 @@
package cc.essaenko.modules.news
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.pagination.Page
class NewsService (private val repo: NewsRepository) {
fun list(limit: Int = 20, offset: Int = 0) = repo.listPublished(limit, offset)
private val slugRegex = Regex("^[a-z0-9-]{3,}$")
fun list(limit: Int = 20, offset: Int = 0): Page<News> =
Page(
items = repo.listPublished(limit, offset),
total = repo.countPublished(),
limit = limit,
offset = offset
)
fun listAdmin(limit: Int = 50, offset: Int = 0): Page<News> =
Page(
items = repo.listAll(limit, offset),
total = repo.countAll(),
limit = limit,
offset = 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" }
validateTitle(cmd.title)
validateSlug(cmd.slug)
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 update(slug: String, patch: NewsUpdate): Boolean {
patch.slug?.let { validateSlug(it) }
patch.title?.let { validateTitle(it) }
val ok = repo.update(slug, patch)
require(ok) { "news '$slug' not found" }
return ok
}
fun publish(slug: String) =
repo.publish(slug).also {
@@ -27,4 +47,12 @@ class NewsService (private val repo: NewsRepository) {
repo.delete(slug).also {
require(it) { "news '$slug' not found" }
}
}
private fun validateTitle(title: String) {
require(title.isNotBlank()) { "title is required" }
}
private fun validateSlug(slug: String) {
require(slug.matches(slugRegex)) { "slug invalid" }
}
}

View File

@@ -1,9 +1,11 @@
package cc.essaenko.modules.service
import io.ktor.server.routing.*
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
import cc.essaenko.shared.pagination.Page
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.routing.*
import java.math.BigDecimal
@kotlinx.serialization.Serializable
@@ -36,41 +38,65 @@ private fun String?.toBigDecOrNull() = this?.let { runCatching { BigDecimal(it)
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 page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
if (page < 1) {
return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "page must be greater than 0")
)
}
val offset = (page - 1) * limit
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)
val res = svc.listPublic(limit, offset, q, categorySlug, minPrice, maxPrice)
call.respond(
Page(
items = res.items,
total = res.total,
limit = res.limit,
offset = res.offset
)
)
}
get("{slug}") {
val slug = call.parameters["slug"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val slug = call.parameters["slug"]
?: return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "slug parameter is required")
)
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
ServiceDTO(
id = item.id,
title = item.title,
slug = item.slug,
description = item.description,
priceFrom = item.priceFrom?.toFloat(),
imageUrl = item.imageUrl,
status = item.status,
createdAt = item.createdAt,
updatedAt = item.updatedAt,
category = item.category?.let { ServiceCategoryDTO(it.id, it.name, it.slug) },
)
)
}
}
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 page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
if (page < 1) {
return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "page must be greater than 0")
)
}
val offset = (page - 1) * limit
val q = call.request.queryParameters["q"]
val status = call.request.queryParameters["status"]
call.respond(svc.listAdmin(limit, offset, q, status))

View File

@@ -2,6 +2,8 @@ package cc.essaenko.modules.service
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
import cc.essaenko.modules.serviceCategory.ServiceCategories
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
import kotlinx.serialization.Serializable
import org.ktorm.entity.Entity
import org.ktorm.schema.*
import java.math.BigDecimal
@@ -9,6 +11,7 @@ import java.time.LocalDateTime
interface ServiceEntity : Entity<ServiceEntity> {
companion object : Entity.Factory<ServiceEntity>()
var id: Long
var title: String
var slug: String
@@ -21,17 +24,33 @@ interface ServiceEntity : Entity<ServiceEntity> {
var updatedAt: LocalDateTime
}
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 }
@Serializable
data class ServiceDTO(
var id: Long,
var title: String,
var slug: String,
var description: String,
var priceFrom: Float?,
var imageUrl: String?,
var status: String,
var category: ServiceCategoryDTO?,
@Serializable(with = JavaLocalDateTimeSerializer::class)
var createdAt: LocalDateTime,
@Serializable(with = JavaLocalDateTimeSerializer::class)
var updatedAt: LocalDateTime,
)
object Services : Table<ServiceEntity>("t_services") {
val id = long("id").primaryKey().bindTo { it.id }
val title = varchar("title").bindTo { it.title }
val slug = varchar("slug").bindTo { it.slug }
val description = text("description").bindTo { it.description }
val priceFrom = decimal("price_from").bindTo { it.priceFrom }
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
val status = varchar("status").bindTo { it.status }
val priceFrom = decimal("price_from").bindTo { it.priceFrom }
val imageUrl = varchar("image_url").bindTo { it.imageUrl }
val status = varchar("status").bindTo { it.status }
val category = long("category_id").references(ServiceCategories) { it.category }
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 createdAt = datetime("created_at").bindTo { it.createdAt }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
}

View File

@@ -1,5 +1,6 @@
package cc.essaenko.modules.service
import cc.essaenko.modules.serviceCategory.ServiceCategoryDTO
import cc.essaenko.modules.serviceCategory.ServiceCategoryEntity
import org.ktorm.database.Database
import org.ktorm.entity.*
@@ -27,19 +28,6 @@ data class ServiceUpdate(
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,
@@ -48,11 +36,12 @@ interface ServiceRepository {
categoryId: Long? = null,
minPrice: BigDecimal? = null,
maxPrice: BigDecimal? = null
): List<ServiceView>
): List<ServiceDTO>
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 listAdmin(limit: Int = 50, offset: Int = 0, q: String? = null, status: String? = null): List<ServiceDTO>
fun listAll(limit: Int = 50, offset: Int = 0, q: String? = null): List<ServiceDTO>
fun countAdmin(q: String? = null, status: String? = null): Int
fun getBySlug(slug: String): ServiceEntity?
@@ -68,22 +57,22 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
private val services get() = db.sequenceOf(Services)
private fun ServiceEntity.toView() = ServiceView(
private fun ServiceEntity.toView() = ServiceDTO(
id = id,
title = title,
slug = slug,
description = description,
priceFrom = priceFrom,
priceFrom = priceFrom?.toFloat(),
imageUrl = imageUrl,
status = status,
categoryId = category?.id,
category = category?.let { ServiceCategoryDTO(it.id, it.name, it.slug) },
createdAt = createdAt,
updatedAt = updatedAt
)
override fun listPublic(
limit: Int, offset: Int, q: String?, categoryId: Long?, minPrice: BigDecimal?, maxPrice: BigDecimal?
): List<ServiceView> {
): List<ServiceDTO> {
var seq: EntitySequence<ServiceEntity, Services> = services
.filter { it.status eq "PUBLISHED" }
@@ -105,7 +94,6 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
}
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" }
@@ -120,7 +108,7 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
return expr.totalRecordsInAllPages
}
override fun listAdmin(limit: Int, offset: Int, q: String?, status: String?): List<ServiceView> {
override fun listAdmin(limit: Int, offset: Int, q: String?, status: String?): List<ServiceDTO> {
var seq: EntitySequence<ServiceEntity, Services> = services
if (!q.isNullOrBlank()) {
@@ -134,8 +122,17 @@ class ServiceRepositoryImpl(private val db: Database) : ServiceRepository {
return seq.sortedBy { it.title }.drop(offset).take(limit).toList().map { it.toView() }
}
override fun listAll(limit: Int, offset: Int, q: String?): List<ServiceDTO> {
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) }
}
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())
var expr = services.query
if (!q.isNullOrBlank()) {
val like = "%${q.lowercase()}%"
expr = expr.where { (Services.title like like) or (Services.description like like) }

View File

@@ -4,18 +4,18 @@ package cc.essaenko.modules.service
import cc.essaenko.modules.serviceCategory.ServiceCategoryRepository
import cc.essaenko.shared.errors.NotFoundException
import cc.essaenko.shared.errors.ValidationException
import cc.essaenko.shared.pagination.Page
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> {
): Page<ServiceDTO> {
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)

View File

@@ -14,19 +14,30 @@ data class CategoryUpdateRequest(val name: String? = null, val slug: String? = n
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)
ServiceCategoryDTO(
id=c.id,
slug = c.slug,
name = c.name
)
}
call.respond(items)
}
get("{slug}") {
val slug = call.parameters["slug"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val slug = call.parameters["slug"]
?: return@get call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "slug parameter is required")
)
val c = svc.getBySlug(slug)
call.respond(mapOf("id" to c.id, "name" to c.name, "slug" to c.slug))
call.respond(ServiceCategoryDTO(
id=c.id,
slug = c.slug,
name = c.name
))
}
}
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

View File

@@ -1,5 +1,6 @@
package cc.essaenko.modules.serviceCategory
import kotlinx.serialization.Serializable
import org.ktorm.entity.Entity
import org.ktorm.schema.*
@@ -10,7 +11,14 @@ interface ServiceCategoryEntity : Entity<ServiceCategoryEntity> {
var slug: String
}
object ServiceCategories : Table<ServiceCategoryEntity>("service_category") {
@Serializable
data class ServiceCategoryDTO(
val id: Long,
val name: String,
val slug: String,
)
object ServiceCategories : Table<ServiceCategoryEntity>("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 }

View File

@@ -7,9 +7,14 @@ 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 listAdmin(limit: Int = 100, offset: Int = 0): List<ServiceCategoryDTO> =
repo.listAdmin(limit, offset).map {
ServiceCategoryDTO(
id = it.id,
slug = it.slug,
name = it.name
)
}
fun getBySlug(slug: String) = repo.findBySlug(slug)
?: throw NotFoundException("category '$slug' not found")

View File

@@ -0,0 +1,11 @@
package cc.essaenko.shared.pagination
import kotlinx.serialization.Serializable
@Serializable
data class Page<T>(
val items: List<T>,
val total: Int,
val limit: Int,
val offset: Int
)