Add docker
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
23
src/main/kotlin/app/serialization/LocalDateTime.kt
Normal file
23
src/main/kotlin/app/serialization/LocalDateTime.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
11
src/main/kotlin/shared/pagination/Page.kt
Normal file
11
src/main/kotlin/shared/pagination/Page.kt
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user