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

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ build
.env
.idea
.kotlin
.DS_Store

23
AGENTS.md Normal file
View File

@@ -0,0 +1,23 @@
# Repository Guidelines
## Project Structure & Module Organization
The server is a Kotlin/Ktor app. Bootstrap code (`Application.kt`, `Routing.kt`, `Security.kt`, `Serialization.kt`) lives in `src/main/kotlin/app`. Business features sit inside `src/main/kotlin/modules/<domain>` and follow the repeating `Controller → Service → Repository → Entity` flow, reusing utilities from `src/main/kotlin/shared`. Configuration (`application.yaml`, `logback.xml`) and OpenAPI specs (`resources/openapi`) live in `src/main/resources`. Tests mirror the production tree under `src/test/kotlin` (see `ApplicationTest.kt` for the pattern). Generated artifacts stay inside `build/`.
## Build, Test, and Development Commands
- `./gradlew run` — start the server for local iteration.
- `./gradlew test` — run the full unit/integration suite; required before every push.
- `./gradlew build` — compile sources, execute tests, and assemble deployable jars.
- `./gradlew buildFatJar` — emit `build/libs/*-all.jar` for standalone deployment.
- `./gradlew buildImage` / `publishImageToLocalRegistry` / `runDocker` — build, publish, and exercise the Docker image for parity checks.
## Coding Style & Naming Conventions
Stick to Kotlin defaults: four-space indentation, braces on the same line, `camelCase` members, `PascalCase` types, lowercase packages. Keep controllers focused on HTTP + validation, services on business rules, and repositories on persistence. Prefer immutable DTOs/data classes, throw the shared exceptions instead of string literals, and wire routes declaratively inside `app/Routing.kt`. Name files after the primary type (`NewsController`, `LeadService`) for clarity.
## Testing Guidelines
Tests rely on the Ktor test engine and should shadow the production package path (e.g., `modules/news/ControllerTest`). Name tests with behavior phrases such as `shouldReturn401WhenTokenMissing`, covering success, validation, and authorization branches. Every feature or bug fix must ship with tests plus a local `./gradlew test` run; target coverage on every public service method.
## Commit & Pull Request Guidelines
Use short, imperative commit subjects consistent with history (`Init commit`, `Diploma-1 Main CRUD operations`). Reference tickets when relevant (`Diploma-42 Add news search`) and keep each commit scoped to one concern. Pull requests need a summary, linked issues, API evidence (screenshots or curl output), and confirmation that `./gradlew test` succeeded. Request review only after CI is green and OpenAPI docs reflect any contract changes.
## Security & Configuration Tips
Secrets such as JWT keys or database URIs must come from environment variables or an untracked overlay—never hard-code them in `application.yaml`. When touching authentication, revisit `Security.kt` so routes opt into the correct provider. Update `resources/openapi/documentation.yaml` whenever request/response schemas evolve so downstream clients stay synchronized.

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM gradle:8.7-jdk17 AS builder
WORKDIR /app
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle ./gradle
RUN chmod +x gradlew
COPY src ./src
RUN ./gradlew --no-daemon clean buildFatJar
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /app/build/libs/*-all.jar /app/app.jar
EXPOSE 8080
ENV JVM_OPTS=""
CMD ["sh", "-c", "java $JVM_OPTS -jar /app/app.jar"]

View File

@@ -20,6 +20,7 @@ dependencies {
implementation(libs.ktor.server.swagger)
implementation(libs.ktor.server.default.headers)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.status.pages)
implementation(libs.ktor.server.compression)
implementation(libs.ktor.server.caching.headers)
implementation(libs.ktor.server.netty)
@@ -30,6 +31,7 @@ dependencies {
implementation(libs.bcrypt)
implementation(libs.ktorm)
implementation(libs.ktor.server.config.yaml)
implementation(libs.psql.support)
testImplementation(libs.ktor.server.test.host)
testImplementation(libs.kotlin.test.junit)
}

12
db/init-db.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -euo pipefail
echo "Restoring database from seed.dump..."
pg_restore \
--clean \
--if-exists \
--no-acl \
--no-owner \
-U "${POSTGRES_USER}" \
-d "${POSTGRES_DB}" \
"/docker-entrypoint-initdb.d/seed.dump"

BIN
db/seed.dump Normal file

Binary file not shown.

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: "3.9"
services:
db:
image: postgres:15-alpine
container_name: diploma-db
environment:
POSTGRES_DB: ${DB_NAME:-diploma_db}
POSTGRES_USER: ${DB_USER:-diploma_admin}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
volumes:
- pg_data:/var/lib/postgresql/data
- ./db/seed.dump:/docker-entrypoint-initdb.d/seed.dump:ro
- ./db/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-diploma_admin} -d ${DB_NAME:-diploma_db}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports:
- "5432:5432"
app:
build:
context: .
dockerfile: Dockerfile
container_name: diploma-app
depends_on:
db:
condition: service_healthy
env_file:
- .env
environment:
DB_HOST: db
DB_PORT: 5432
ports:
- "8080:8080"
volumes:
pg_data:

102
docs/architecture.md Normal file
View File

@@ -0,0 +1,102 @@
# Архитектура и структура сервиса
Документ описывает как организован сервер дипломного проекта: какие слои присутствуют, как они взаимодействуют и где искать ключевые модули.
## Общий обзор
Сервис написан на Kotlin с использованием Ktor и разворачивается как Netty-приложение (`Application.kt`). Приложение поднимает инфраструктурные плагины (HTTP, сериализация, безопасность) и регистрирует доменные маршруты. Все функции сгруппированы в повторяющийся паттерн `Controller → Service → Repository → Entity`, что делает поддержку CRUD-операций симметричной во всех доменных областях.
Высокоуровневый поток запросов:
1. HTTP-вызов попадает в `Routing.kt`, где маршруты разделены на публичную и админскую зоны (`/api/v1/...`).
2. Контроллер модуля занимается десериализацией запроса, базовой валидацией и формированием ответа.
3. Сервис инкапсулирует бизнес-правила: публикация материалов, фильтрация, проверка прав, подготовка DTO.
4. Репозиторий обращается к PostgreSQL через Ktorm, используя HikariCP-пул соединений.
5. Исключения (`ValidationException`, `NotFoundException`) перехватывает `StatusPages`, что обеспечивает единый контракт ошибок.
Графическая схема слоев расположена в `docs/crud-model.svg`.
## Структура каталогов
```
src/main/kotlin
├── app # Bootstrap: Application, Routing, Security, HTTP, Database
├── shared # Переиспользуемые DTO, пагинация, ошибки
└── modules # Доменные модули (admin, news, service, serviceCategory, lead)
src/main/resources
├── application.yaml # Конфигурация Ktor
└── openapi # Спецификации API
src/test/kotlin # Зеркало production-пакетов для тестов
docs/
├── crud-model.svg # Диаграмма последовательности CRUD
└── architecture.md # Текущий документ
```
## Инфраструктурный слой (`app/*`)
- **Application.module** точка входа, выстраивает последовательность `configureDatabase → configureHTTP → configureSerialization → configureSecurity → configureRouting`.
- **Database.kt** инициализация пула HikariCP (настройки берутся из `.env`), регистрация `Database` в `Application.attributes`.
- **Http.kt** подключает плагины Ktor (CORS, Compression, CachingHeaders, StatusPages, Swagger UI). Здесь же централизованно обрабатываются исключения.
- **Serialization.kt** `ContentNegotiation` с `kotlinx.serialization`.
- **Security.kt** загрузка параметров JWT из окружения, настройка схемы `admin-auth`, валидация `JWTPrincipal`.
- **Routing.kt** собирает зависимости модулей, подключает публичные маршруты и оборачивает админские роуты в `authenticate("admin-auth")`.
## Доменные модули (`modules/*`)
Каждый модуль состоит из четырех файлов:
- `Controller.kt` набор Ktor-маршрутов для публичной и/или админской зоны.
- `Service.kt` бизнес-логика. Например, `NewsService` отвечает за публикацию, пагинацию и преобразование сущностей в DTO, а `ServiceService` поддерживает фильтры по статусу, категории и ценовому диапазону.
- `Repository.kt` слой доступа к данным на Ktorm: построение SQL, пагинация, CRUD-операции, трансформации в `Entity`.
- `Entity.kt` / DTO описания таблиц, модели для сериализации и транспортировки данных.
### Admin
Отвечает за регистрацию, авторизацию и управление администраторами. Использует `BcryptPasswordHasher` для хранения паролей и `TokenService` для выпуска JWT. Публичная часть (`/admin/login`) возвращает токен; защищенная (`/admin/*`) требует `admin-auth`.
### News
Два набора маршрутов: публичные (`/news`, `/news/{slug}`) и админские (`/admin/news`). Сервис применяет правила публикации и формирует страницы (`Page<T>`). Репозиторий обрабатывает SQL для списков и выборок по `slug`.
### Services и Service Categories
`ServiceController` поддерживает сложные фильтры (категория, диапазон цен, статус). `ServiceService` связывает услуги с категориями, обеспечивая получение вложенных DTO. `ServiceCategory*` модули обслуживают справочник категорий и проверяют уникальность `slug`.
### Leads
`publicLeadRoutes` принимает заявки с сайта (`POST /leads`), `adminLeadRoutes` предоставляет CRUD для операторов. Сервис выполняет базовую валидацию и поиск по строке `q`.
## Общие компоненты (`shared/*`)
- `shared.pagination.Page` универсальный ответ для списков.
- `shared.errors.ValidationException/NotFoundException` типы, которые перехватываются `StatusPages` и автоматически конвертируются в HTTP-ответ.
## Работа с БД
- **База** PostgreSQL, подключение через Ktorm + HikariCP (`jdbc:postgresql://...`).
- **Пулы** настраиваются через переменные окружения (`DB_HOST`, `DB_POOL_MAX` и т.д.).
- **Репозитории** реализованы вручную на SQL Builder, что дает контроль над запросами и связями между таблицами (`services``service_categories`, `news`, `leads`, `admins`).
## Безопасность
- Единственная схемa аутентификации JWT `admin-auth`.
- Конфигурация (`JWT_SECRET`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_EXPIRES_MIN`) читается из `.env` или переменных окружения.
- Public маршруты доступны без токена, все `admin*Routes` подключены внутри `authenticate`.
## Сборка и эксплуатация
- Основные команды Gradle:
- `./gradlew run` локальный сервер.
- `./gradlew test` прогон тестов (обязателен перед пушем).
- `./gradlew buildFatJar` сборка standalone JAR.
- `./gradlew buildImage` / `runDocker` сборка и запуск docker-образа.
- Конфигурация HTTP-порта, логирования и OpenAPI лежит в `src/main/resources/application.yaml` и `resources/openapi`.
- Для обновления документации добавляйте артефакты в `docs/` (например, текущую архитектурную схему и диаграмму CRUD).
## Тестирование
Тесты зеркалируют production-пакеты (`src/test/kotlin/...`). При добавлении новой функциональности рекомендуется:
1. Класть тесты в соответствующий пакет модуля (например, `modules/news/NewsServiceTest.kt`).
2. Проверять ветки успеха, валидации и авторизации.
3. Запускать `./gradlew test` перед коммитом, чтобы убедиться в корректной работе всего набора.

147
docs/crud-model.svg Normal file
View File

@@ -0,0 +1,147 @@
<svg width="1200" height="950" viewBox="0 0 1200 950" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.bg { fill: #f8fafc; }
.card { fill: #ffffff; stroke: #0f172a; stroke-width: 1.5; }
.title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 32px; font-weight: 600; fill: #0f172a; }
.subtitle { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 16px; fill: #475569; }
.card-title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 17px; font-weight: 600; fill: #0f172a; }
.card-body { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 13px; fill: #334155; }
.note { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 13px; fill: #475569; }
.arrow { stroke: #0f172a; stroke-width: 1.8; fill: none; }
.dashed { stroke-dasharray: 6 6; }
</style>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0f172a" />
</marker>
</defs>
<rect class="bg" x="0" y="0" width="1200" height="950" rx="32" />
<text x="600" y="45" text-anchor="middle" class="title">CRUD-модель сервера Diploma</text>
<text x="600" y="75" text-anchor="middle" class="subtitle">Ktor обрабатывает HTTP-запросы через Controllers, Services, Repositories и таблицы PostgreSQL.</text>
<!-- Client surfaces -->
<rect class="card" x="150" y="90" width="220" height="100" rx="14" />
<text x="260" y="120" text-anchor="middle" class="card-title">Публичные каналы</text>
<text x="260" y="150" text-anchor="middle" class="card-body">Маркетинговый сайт</text>
<text x="260" y="170" text-anchor="middle" class="card-body">Лендинги и формы</text>
<rect class="card" x="830" y="90" width="220" height="100" rx="14" />
<text x="940" y="120" text-anchor="middle" class="card-title">Админ-консоль</text>
<text x="940" y="150" text-anchor="middle" class="card-body">Внутренний кабинет</text>
<text x="940" y="170" text-anchor="middle" class="card-body">CMS, отчеты</text>
<!-- Ktor layer -->
<rect class="card" x="420" y="200" width="360" height="140" rx="18" />
<text x="600" y="235" text-anchor="middle" class="card-title">Ktor HTTP-слой</text>
<text x="600" y="260" text-anchor="middle" class="card-body">Routing.kt · Serialization.kt · Security.kt</text>
<text x="600" y="285" text-anchor="middle" class="card-body">Проверка JWT, JSON-парсинг,</text>
<text x="600" y="305" text-anchor="middle" class="card-body">маршрутизация по модулям</text>
<!-- Shared utilities -->
<rect class="card" x="1000" y="230" width="150" height="120" rx="14" />
<text x="1075" y="260" text-anchor="middle" class="card-title">shared/*</text>
<text x="1075" y="285" text-anchor="middle" class="card-body">Пагинация</text>
<text x="1075" y="305" text-anchor="middle" class="card-body">Ошибки и DTO</text>
<!-- Controllers row -->
<rect class="card" x="80" y="360" width="220" height="150" rx="16" />
<text x="190" y="390" text-anchor="middle" class="card-title">NewsController</text>
<text x="190" y="415" text-anchor="middle" class="card-body">GET /news и /news/{slug}</text>
<text x="190" y="435" text-anchor="middle" class="card-body">Админ: список и создание</text>
<text x="190" y="455" text-anchor="middle" class="card-body">обновление, публикация, удаление</text>
<rect class="card" x="340" y="360" width="220" height="150" rx="16" />
<text x="450" y="390" text-anchor="middle" class="card-title">ServiceController</text>
<text x="450" y="415" text-anchor="middle" class="card-body">Публичный список/просмотр</text>
<text x="450" y="435" text-anchor="middle" class="card-body">Фильтры: категория, цена</text>
<text x="450" y="455" text-anchor="middle" class="card-body">Админ: CRUD и статус</text>
<rect class="card" x="600" y="360" width="220" height="150" rx="16" />
<text x="710" y="390" text-anchor="middle" class="card-title">ServiceCategoryController</text>
<text x="710" y="415" text-anchor="middle" class="card-body">Публичный список и slug</text>
<text x="710" y="435" text-anchor="middle" class="card-body">Админ: список, создание</text>
<text x="710" y="455" text-anchor="middle" class="card-body">обновление, удаление</text>
<rect class="card" x="860" y="360" width="220" height="150" rx="16" />
<text x="970" y="390" text-anchor="middle" class="card-title">LeadController</text>
<text x="970" y="415" text-anchor="middle" class="card-body">POST /leads с сайта</text>
<text x="970" y="435" text-anchor="middle" class="card-body">Админ: список и чтение</text>
<text x="970" y="455" text-anchor="middle" class="card-body">Удаление заявок</text>
<!-- Services row -->
<rect class="card" x="80" y="530" width="220" height="120" rx="16" />
<text x="190" y="560" text-anchor="middle" class="card-title">NewsService</text>
<text x="190" y="585" text-anchor="middle" class="card-body">Правила публикации, DTO</text>
<text x="190" y="605" text-anchor="middle" class="card-body">Страницы и статусы</text>
<rect class="card" x="340" y="530" width="220" height="120" rx="16" />
<text x="450" y="560" text-anchor="middle" class="card-title">ServiceService</text>
<text x="450" y="585" text-anchor="middle" class="card-body">Фильтры по статусу/категории</text>
<text x="450" y="605" text-anchor="middle" class="card-body">Контроль цены, связи</text>
<rect class="card" x="600" y="530" width="220" height="120" rx="16" />
<text x="710" y="560" text-anchor="middle" class="card-title">ServiceCategoryService</text>
<text x="710" y="585" text-anchor="middle" class="card-body">Таксономия и slug</text>
<text x="710" y="605" text-anchor="middle" class="card-body">CRUD категорий</text>
<rect class="card" x="860" y="530" width="220" height="120" rx="16" />
<text x="970" y="560" text-anchor="middle" class="card-title">LeadService</text>
<text x="970" y="585" text-anchor="middle" class="card-body">Валидация контактов</text>
<text x="970" y="605" text-anchor="middle" class="card-body">Поиск и пагинация</text>
<!-- Repository row -->
<rect class="card" x="80" y="680" width="220" height="120" rx="16" />
<text x="190" y="710" text-anchor="middle" class="card-title">NewsRepository</text>
<text x="190" y="735" text-anchor="middle" class="card-body">SQL-запросы для news</text>
<text x="190" y="755" text-anchor="middle" class="card-body">Админ и публичные выборки</text>
<rect class="card" x="340" y="680" width="220" height="120" rx="16" />
<text x="450" y="710" text-anchor="middle" class="card-title">ServiceRepository</text>
<text x="450" y="735" text-anchor="middle" class="card-body">Джоины услуг и категорий</text>
<text x="450" y="755" text-anchor="middle" class="card-body">Фильтры по цене</text>
<rect class="card" x="600" y="680" width="220" height="120" rx="16" />
<text x="710" y="710" text-anchor="middle" class="card-title">ServiceCategoryRepository</text>
<text x="710" y="735" text-anchor="middle" class="card-body">Метаданные категорий</text>
<text x="710" y="755" text-anchor="middle" class="card-body">Хранение slug</text>
<rect class="card" x="860" y="680" width="220" height="120" rx="16" />
<text x="970" y="710" text-anchor="middle" class="card-title">LeadRepository</text>
<text x="970" y="735" text-anchor="middle" class="card-body">Прием заявок</text>
<text x="970" y="755" text-anchor="middle" class="card-body">Админские запросы</text>
<!-- Database layer -->
<rect class="card" x="100" y="830" width="1000" height="110" rx="20" />
<text x="600" y="860" text-anchor="middle" class="card-title">Схема PostgreSQL</text>
<text x="600" y="885" text-anchor="middle" class="card-body">news, services, service_categories, leads</text>
<text x="600" y="905" text-anchor="middle" class="card-body">Временные метки, статусы, связи</text>
<!-- Arrows -->
<path class="arrow" marker-end="url(#arrow)" d="M 260 190 L 600 200" />
<path class="arrow" marker-end="url(#arrow)" d="M 940 190 L 600 200" />
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 190 360" />
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 450 360" />
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 710 360" />
<path class="arrow" marker-end="url(#arrow)" d="M 600 340 L 970 360" />
<!-- Vertical flows -->
<path class="arrow" marker-end="url(#arrow)" d="M 190 510 L 190 530" />
<path class="arrow" marker-end="url(#arrow)" d="M 450 510 L 450 530" />
<path class="arrow" marker-end="url(#arrow)" d="M 710 510 L 710 530" />
<path class="arrow" marker-end="url(#arrow)" d="M 970 510 L 970 530" />
<path class="arrow" marker-end="url(#arrow)" d="M 190 650 L 190 680" />
<path class="arrow" marker-end="url(#arrow)" d="M 450 650 L 450 680" />
<path class="arrow" marker-end="url(#arrow)" d="M 710 650 L 710 680" />
<path class="arrow" marker-end="url(#arrow)" d="M 970 650 L 970 680" />
<path class="arrow" marker-end="url(#arrow)" d="M 190 800 L 190 830" />
<path class="arrow" marker-end="url(#arrow)" d="M 450 800 L 450 830" />
<path class="arrow" marker-end="url(#arrow)" d="M 710 800 L 710 830" />
<path class="arrow" marker-end="url(#arrow)" d="M 970 800 L 970 830" />
<text x="600" y="925" text-anchor="middle" class="note">Каждый модуль следует паттерну Controller → Service → Repository и покрывает CRUD.</text>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

119
docs/db-structure.md Normal file
View File

@@ -0,0 +1,119 @@
# Структура БД и модель данных
Документ описывает схемы PostgreSQL, которые используются приложением, а также слой абстракции на базе Ktorm ORM.
## Технологический стек
- **PostgreSQL** основная СУБД. Подключение выполняется через HikariCP (см. `app/Database.kt`), параметры берутся из переменных окружения `DB_*`.
- **Ktorm ORM** легковесная ORM, которая:
- предоставляет интерфейсы `Entity` для объектного представления строк;
- описывает таблицы через `object Table<T>` (например, `object NewsT : Table<News>("t_news")`);
- дает `Database.sequenceOf(Table)` для CRUD-операций и построения SQL через DSL.
Каждая доменная сущность имеет:
1. `interface <Entity> : Entity<<Entity>>` декларация полей.
2. `object <Table> : Table<<Entity>>("table_name")` описание колонок/ключей и связей через `bindTo`/`references`.
3. DTO для сериализации в API.
## Таблицы
### `t_admins`
| Колонка | Тип | Описание |
|----------------|------------------|---------------------------------------|
| `id` | `bigint` (PK) | Идентификатор администратора |
| `username` | `varchar` | Уникальное имя пользователя |
| `password_hash`| `varchar` | Хеш пароля (bcrypt) |
| `created_at` | `timestamp` | Дата регистрации |
| `last_login_at`| `timestamp` nul. | Время последней авторизации |
Сущность: `AdminEntity`, таблица: `AdminUsers`. Используется модулем `admin` для регистрации, логина, смены паролей и удаления аккаунтов.
### `t_news`
| Колонка | Тип | Описание |
|----------------|------------------|--------------------------------------------------|
| `id` | `bigint` (PK) | Идентификатор новости |
| `title` | `varchar` | Заголовок |
| `slug` | `varchar` | Уникальный slug для ссылок |
| `summary` | `varchar` | Краткое описание |
| `content` | `text` | Основной текст |
| `status` | `varchar` | `DRAFT` \| `PUBLISHED` \| `ARCHIVED` |
| `published_at` | `timestamp` nul. | Дата публикации |
| `image_url` | `varchar` nul. | Ссылка на изображение |
| `created_at` | `timestamp` | Дата создания |
| `updated_at` | `timestamp` | Дата последнего обновления |
Сущность: `News`, таблица: `NewsT`. Репозиторий использует фильтры по статусу и slug для публичных и админских запросов.
### `t_service_categories`
| Колонка | Тип | Описание |
|---------|---------------|-------------------------|
| `id` | `bigint` (PK) | Идентификатор категории |
| `name` | `varchar` | Название |
| `slug` | `varchar` | Уникальный slug |
Сущность: `ServiceCategoryEntity`, таблица: `ServiceCategories`. Используется как справочник категорий услуг.
### `t_services`
| Колонка | Тип | Описание |
|--------------|-------------------|----------------------------------------------------|
| `id` | `bigint` (PK) | Идентификатор услуги |
| `title` | `varchar` | Название |
| `slug` | `varchar` | Уникальный slug |
| `description`| `text` | Подробное описание |
| `price_from` | `decimal` nul. | Нижняя граница стоимости |
| `image_url` | `varchar` nul. | Изображение |
| `status` | `varchar` | `PUBLISHED` \| `DRAFT` \| `ARCHIVED` |
| `category_id`| `bigint` FK | Ссылка на `t_service_categories.id` (может быть `NULL`) |
| `created_at` | `timestamp` | Дата создания |
| `updated_at` | `timestamp` | Дата обновления |
Сущность: `ServiceEntity`, таблица: `Services`. Поле `category` смоделировано через `references(ServiceCategories)` и возвращает `ServiceCategoryEntity?`. Сервис объединяет услуги и категории для публичного и админского API.
### `t_users` (лиды)
| Колонка | Тип | Описание |
|-------------|-----------------|------------------------------------------|
| `id` | `bigint` (PK) | Идентификатор лида |
| `full_name` | `varchar` | Имя и фамилия |
| `email` | `varchar` | Контактный email |
| `phone` | `varchar` nul. | Телефон |
| `created_at`| `timestamp` | Дата поступления заявки |
Сущность: `LeadEntity`, таблица: `Leads`. Несмотря на название таблицы `t_users`, фактически хранит только заявки. Админский модуль использует пагинацию и поиск по `full_name`/`email`.
## Связи между сущностями
- **Service → ServiceCategory (многие к одному)**: `Services.category_id` ссылается на `ServiceCategories.id`. Ktorm позволяет навигировать через `ServiceEntity.category`. При выборе услуг можно жадно загружать категорию и формировать вложенный DTO.
- **Admin, News, Leads** независимые сущности без внешних ключей на другие таблицы (в текущей версии).
- **Status/enum поля** находятся в бизнес-логике: нет отдельных таблиц для статусов, их значения валидируются сервисами.
## Использование Ktorm
- **Entity интерфейсы** объявляют свойства и их типы. Экземпляры создаются через `Entity.Factory`.
- **Table объекты** задают имя таблицы, колонки и связи. Пример:
```kotlin
object Services : Table<ServiceEntity>("t_services") {
val title = varchar("title").bindTo { it.title }
val category = long("category_id").references(ServiceCategories) { it.category }
}
```
- **Расширения Database** в каждом модуле есть свой `val Database.<entities>` (например, `Database.news`) для получения `sequenceOf(Table)`:
```kotlin
val Database.news get() = this.sequenceOf(NewsT)
```
Это скрывает детали доступа к данным и дает типобезопасные операции (`filter`, `sortedBy`, `take`, `drop`, `insert`, `update` и т.д.).
- **DTO слой** отделяет Ktorm-entity от сериализуемых объектов (например, `NewsDTO`, `ServiceDTO`). Преобразование выполняется в сервисах.
## Итог
База данных состоит из пяти основных таблиц, объединенных единым пулом соединений и общими практиками (таймстемпы, статусы, slug). Слабое связывание между сущностями делает схему гибкой, а Ktorm обеспечивает лаконичный и типобезопасный доступ к данным без тяжелых ORM-схем. Диаграмма CRUD-слоя (`docs/crud-model.svg`) дополняет данное описание визуальным представлением потока данных.

View File

@@ -0,0 +1,69 @@
<svg width="1200" height="800" viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.bg { fill: #ffffff; }
.title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 26px; font-weight: 600; fill: #1f2937; }
.node { fill: #f8fafc; stroke: #1f2937; stroke-width: 2; rx: 16; }
.node-title { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 20px; font-weight: 600; fill: #111827; }
.node-body { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 15px; fill: #374151; }
.arrow { stroke: #1f2937; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
.arrow-label { font-family: 'Inter', 'Segoe UI', sans-serif; font-size: 15px; fill: #1f2937; }
.db { fill: #f8fafc; stroke: #1f2937; stroke-width: 2; }
</style>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#1f2937" />
</marker>
</defs>
<rect class="bg" x="0" y="0" width="1200" height="800" />
<text x="600" y="60" text-anchor="middle" class="title">Общая схема взаимодействия: Клиент → Ktor Server → PostgreSQL</text>
<!-- Client node -->
<rect class="node" x="20" y="180" width="300" height="420" />
<text x="180" y="220" text-anchor="middle" class="node-title">Клиент (React SPA)</text>
<text x="60" y="270" class="node-body">• UI Components</text>
<text x="60" y="305" class="node-body">• MobX Stores</text>
<text x="60" y="340" class="node-body">• fetch API</text>
<text x="60" y="375" class="node-body">• Браузерное приложение</text>
<!-- Server node -->
<rect class="node" x="460" y="140" width="320" height="500" />
<text x="620" y="180" text-anchor="middle" class="node-title">Ktor Server (Kotlin)</text>
<text x="490" y="230" class="node-body">• Routing</text>
<text x="490" y="265" class="node-body">• Controllers</text>
<text x="490" y="300" class="node-body">• Services</text>
<text x="490" y="335" class="node-body">• Repositories</text>
<text x="490" y="370" class="node-body">• Ktorm ORM</text>
<text x="490" y="405" class="node-body">• JWT Authentication</text>
<text x="490" y="440" class="node-body">• Запуск на Netty runtime</text>
<!-- Database cylinder -->
<g transform="translate(900,200)">
<ellipse class="db" cx="155" cy="0" rx="140" ry="25" />
<rect class="db" x="15" y="0" width="280" height="360" ry="0" />
<ellipse class="db" cx="155" cy="360" rx="140" ry="25" />
<text x="155" y="40" text-anchor="middle" class="node-title">База данных PostgreSQL</text>
<text x="50" y="90" class="node-body">• t_admins</text>
<text x="50" y="125" class="node-body">• t_news</text>
<text x="50" y="160" class="node-body">• t_services</text>
<text x="50" y="195" class="node-body">• t_service_categories</text>
<text x="50" y="230" class="node-body">• t_users (leads)</text>
<text x="50" y="265" class="node-body">• Транзакционность (ACID)</text>
</g>
<!-- Arrows -->
<path class="arrow" d="M 320 320 L 460 320" />
<text x="390" y="300" text-anchor="middle" class="arrow-label">REST API /api/v1/*</text>
<path class="arrow" d="M 780 320 L 915 320" />
<text x="850" y="290" text-anchor="middle" class="arrow-label">
<tspan>SQL-запросы</tspan>
<tspan x="845" dy="1.2em">через Ktorm</tspan>
</text>
<path class="arrow" d="M 915 400 L 780 400" />
<text x="850" y="385" text-anchor="middle" class="arrow-label">JSON-ответы</text>
<path class="arrow" d="M 460 400 L 320 400" />
<text x="395" y="385" text-anchor="middle" class="arrow-label">JSON-ответы</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

311
docs/rest-api.md Normal file
View File

@@ -0,0 +1,311 @@
# REST API (Swagger-Style)
## Общая информация
- **Базовый URL**: `http://0.0.0.0:8080/api/v1`
- **Форматы**: все конечные точки принимают и возвращают `application/json`, если не указан иной `Content-Type`.
- **Часовой формат**: даты/время сериализуются в `ISO_LOCAL_DATE_TIME`, например `2024-05-12T13:45:00`.
- **JWT-аутентификация**: приватные маршруты располагаются под `/api/v1/admin/**` и требуют заголовок `Authorization: Bearer <token>` из `POST /admin/login`.
- **Ошибки**: при валидационных/авторизационных ошибках сервер возвращает код `4xx` с телом `{"error": "описание"}`.
## Базовые схемы
| Схема | Описание |
| --- | --- |
| **Page<T>** | Обёртка пагинации: `items` (список сущностей `T`), `total` (общее количество), `limit` (число элементов на странице), `offset` (смещение). |
| **ServiceCategoryDTO** | `{ id: number, name: string, slug: string }`. |
| **ServiceDTO** | `{ id, title, slug, description, priceFrom: number\|null, imageUrl: string\|null, status: "PUBLISHED"\|"DRAFT"\|"ARCHIVED", category: ServiceCategoryDTO\|null, createdAt, updatedAt }`. |
| **NewsDTO** | `{ id, title, slug, summary, content, status: "draft"\|"published"\|"archived", imageUrl: string\|null, publishedAt: string\|null }`. |
| **LeadDTO** | `{ id, fullName, email, phone: string\|null, createdAt }`. |
| **AdminDTO** | `{ id, username, createdAt }`. |
---
## Администраторы и аутентификация
| Метод | Путь | Требуется JWT | Описание |
| --- | --- | --- | --- |
| POST | `/admin/login` | Нет | Получение JWT токена. |
| GET | `/admin/password_hash` | Нет | Вспомогательный эндпойнт для генерации bcrypt-хэша. |
| GET | `/admin` | Да | Получить профиль текущего администратора. |
| POST | `/admin` | Да | Создать нового администратора. |
| PUT | `/admin/{id}/password` | Да | Сменить пароль администратора. |
| DELETE | `/admin/{id}` | Да | Удалить администратора. |
### POST /api/v1/admin/login
**Тело запроса**
| Поле | Тип | Обязательно | Примечание |
| --- | --- | --- | --- |
| `username` | string | да | Мин. 3 символа (`[A-Za-z0-9_.-]`). |
| `password` | string | да | Мин. 8 символов. |
**Ответ 200**
| Поле | Тип | Примечание |
| --- | --- | --- |
| `id` | number | Идентификатор администратора. |
| `username` | string | Введённое имя. |
| `token` | string | JWT access token. |
| `tokenType` | string | Всегда `Bearer`. |
| `expiresInMinutes` | number | Время жизни токена (минуты). |
Пример:
```json
{
"id": 1,
"username": "admin",
"token": "eyJhbGciOi...",
"tokenType": "Bearer",
"expiresInMinutes": 60
}
```
### GET /api/v1/admin/password_hash
**Query-параметры**
| Параметр | Тип | Обязательно | Описание |
| --- | --- | --- | --- |
| `password` | string | нет | Исходный пароль. По умолчанию `admin123`. |
**Ответ 200**: `{ "pass": "<bcrypt-hash>" }`.
### GET /api/v1/admin
Возвращает `AdminDTO` текущего пользователя по subject токена.
### POST /api/v1/admin
**Тело запроса** `AdminRegisterRequest`:
| Поле | Тип | Обязательно | Примечание |
| --- | --- | --- | --- |
| `username` | string | да | Уникальное имя (регулярное выражение как при логине). |
| `password` | string | да | Мин. 8 символов. |
**Ответ 201** `AdminRegisterResponse`:
| Поле | Тип |
| --- | --- |
| `id` | number |
| `username` | string |
### PUT /api/v1/admin/{id}/password
**Параметры пути**: `id` numeric ID.
**Тело запроса** `ChangePasswordRequest`:
| Поле | Тип | Обязательно | Примечание |
| --- | --- | --- | --- |
| `currentPassword` | string | да | Текущий пароль (проверяется через bcrypt). |
| `newPassword` | string | да | Новый пароль, минимум 8 символов. |
**Ответ 200**: `{ "updated": true }`.
### DELETE /api/v1/admin/{id}
Удаляет администратора. Ответ `200 OK`: `{ "deleted": true }`.
---
## Лиды (заявки)
| Метод | Путь | JWT | Описание |
| --- | --- | --- | --- |
| POST | `/leads` | Нет | Оставить заявку. |
| GET | `/admin/leads` | Да | Список лидов с фильтром. |
| GET | `/admin/leads/{id}` | Да | Получить лид по ID. |
| DELETE | `/admin/leads/{id}` | Да | Удалить лид. |
### POST /api/v1/leads
**Тело запроса** `LeadCreateRequest`:
| Поле | Тип | Обязательно | Примечание |
| --- | --- | --- | --- |
| `fullName` | string | да | Имя клиента. |
| `email` | string | да | Проверяется регуляркой `^[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}$`. |
| `phone` | string | нет | Любой формат, сохраняется как строка. |
**Ответ 201**: `{ "id": 42 }`.
### GET /api/v1/admin/leads
**Query-параметры**
| Имя | Тип | По умолчанию | Описание |
| --- | --- | --- | --- |
| `limit` | integer | 50 | Размер страницы. |
| `page` | integer | 1 | Должна быть ≥ 1, иначе `400`. |
| `q` | string | null | Поиск по `fullName`, `email`, `phone`. |
**Ответ 200**: `Page<LeadDTO>`.
Пример элемента: `{ "id": 7, "fullName": "Иван Иванов", "email": "ivan@example.com", "phone": "+79001234567", "createdAt": "2024-05-05T10:34:00" }`.
### GET /api/v1/admin/leads/{id}
Параметр `id` (long). Ответ `LeadDTO`.
### DELETE /api/v1/admin/leads/{id}
Ответ `{ "deleted": true }`.
---
## Новости
| Метод | Путь | JWT | Описание |
| --- | --- | --- | --- |
| GET | `/news` | Нет | Пагинированный список опубликованных новостей. |
| GET | `/news/{slug}` | Нет | Получение новости по `slug`. |
| GET | `/admin/news` | Да | Список всех новостей. |
| POST | `/admin/news` | Да | Создать новость. |
| PUT | `/admin/news/{slug}` | Да | Обновить новость. |
| POST | `/admin/news/{slug}/publish` | Да | Публикация новости. |
| DELETE | `/admin/news/{slug}` | Да | Удалить новость. |
### GET /api/v1/news
**Query-параметры**: `limit` (default 20), `page` (default 1, ≥1).
**Ответ**: `Page<NewsDTO>`, где `items` отсортированы по `publishedAt` у опубликованных записей.
### GET /api/v1/news/{slug}
Возвращает `NewsDTO` опубликованной новости (если slug неактивен — `404`).
### GET /api/v1/admin/news
**Query-параметры**: `limit` (50), `page` (1, ≥1).
**Ответ**: `Page<NewsDTO>` (включая черновики).
### POST /api/v1/admin/news
**Тело запроса** `NewsCreate`:
| Поле | Тип | Обязательно | Примечание |
| --- | --- | --- | --- |
| `title` | string | да | Заголовок. |
| `slug` | string | да | Уникальный slug (валидация на уровне базы). |
| `summary` | string | да | Краткое описание. |
| `content` | string | да | Полный текст (HTML/Markdown). |
| `status` | string | нет | `draft` (по умолчанию) \| `published` \| `archived`. |
| `imageUrl` | string | нет | Ссылка на обложку. |
**Ответ**: `{ "id": <number> }`.
### PUT /api/v1/admin/news/{slug}
**Тело запроса** `NewsUpdateRequest` (все поля опциональны, как в `NewsCreate`). Ответ `{ "updated": true }`.
### POST /api/v1/admin/news/{slug}/publish
Без тела. Устанавливает `status = "published"`, `publishedAt = now`. Ответ `{ "published": true }`.
### DELETE /api/v1/admin/news/{slug}
Ответ `{ "deleted": true }`.
---
## Категории услуг
| Метод | Путь | JWT | Описание |
| --- | --- | --- | --- |
| GET | `/service-categories` | Нет | Публичный список категорий. |
| GET | `/service-categories/{slug}` | Нет | Получить категорию по slug. |
| GET | `/admin/service-categories` | Да | Пагинация категорий. |
| POST | `/admin/service-categories` | Да | Создать категорию. |
| PUT | `/admin/service-categories/{id}` | Да | Обновить категорию. |
| DELETE | `/admin/service-categories/{id}` | Да | Удалить категорию. |
### GET /api/v1/service-categories
Возвращает массив `ServiceCategoryDTO`.
### GET /api/v1/service-categories/{slug}
Ответ `ServiceCategoryDTO`.
### GET /api/v1/admin/service-categories
**Query-параметры**: `limit` (100), `offset` (0).
Ответ: список `ServiceCategoryDTO` (без обёртки).
### POST /api/v1/admin/service-categories
**Тело запроса** `CategoryCreateRequest` (`name`, `slug` обязательные). Ответ `{ "id": <number> }`.
### PUT /api/v1/admin/service-categories/{id}
Тело `CategoryUpdateRequest` (оба поля опциональны). Ответ `{ "updated": true }`.
### DELETE /api/v1/admin/service-categories/{id}
Ответ `{ "deleted": true }`.
---
## Услуги
| Метод | Путь | JWT | Описание |
| --- | --- | --- | --- |
| GET | `/services` | Нет | Список опубликованных услуг с фильтрами. |
| GET | `/services/{slug}` | Нет | Одна услуга по slug. |
| GET | `/admin/services` | Да | Список всех услуг. |
| POST | `/admin/services` | Да | Создать услугу. |
| PUT | `/admin/services/{id}` | Да | Обновить услугу. |
| PUT | `/admin/services/{id}/status` | Да | Изменить статус. |
| DELETE | `/admin/services/{id}` | Да | Удалить услугу. |
### GET /api/v1/services
**Query-параметры**
| Имя | Тип | По умолчанию | Описание |
| --- | --- | --- | --- |
| `limit` | integer | 20 | Размер страницы. |
| `page` | integer | 1 | ≥ 1. |
| `q` | string | null | Поиск по `title` и `description` (case-insensitive). |
| `category` | string | null | `slug` категории. |
| `minPrice` | number | null | Минимальная цена (decimal). |
| `maxPrice` | number | null | Максимальная цена (decimal). |
**Ответ**: `Page<ServiceDTO>`.
Пример элемента:
```json
{
"id": 10,
"title": "Разработка мобильного приложения",
"slug": "mobile-dev",
"description": "Полный цикл разработки",
"priceFrom": 150000.0,
"imageUrl": "https://cdn.example/app.jpg",
"status": "PUBLISHED",
"category": { "id": 2, "name": "Разработка", "slug": "development" },
"createdAt": "2024-04-01T09:15:00",
"updatedAt": "2024-05-05T12:00:00"
}
```
### GET /api/v1/services/{slug}
Ответ `ServiceDTO` (включая `category`). Ошибка `404`, если slug не найден или услуга скрыта.
### GET /api/v1/admin/services
**Query-параметры**
| Имя | Тип | По умолчанию | Описание |
| --- | --- | --- | --- |
| `limit` | integer | 50 | Размер страницы. |
| `page` | integer | 1 | ≥ 1. |
| `q` | string | null | Фильтр по названию/описанию. |
| `status` | string | null | `PUBLISHED` \| `DRAFT` \| `ARCHIVED`. |
Ответ: `Page<ServiceDTO>` (включая черновики).
### POST /api/v1/admin/services
**Тело запроса** `ServiceCreateRequest`:
| Поле | Тип | Обязательно | Примечание |
| --- | --- | --- | --- |
| `title` | string | да | Название услуги. |
| `slug` | string | да | Уникальный slug (`^[a-z0-9-]{3,}$`). |
| `description` | string | да | Детальное описание. |
| `priceFrom` | string | нет | Десятичное число, например `"1499.99"`. |
| `imageUrl` | string | нет | URL изображения. |
| `status` | string | нет | По умолчанию `PUBLISHED`. |
| `categoryId` | number | нет | ID существующей категории. |
**Ответ 201**: `{ "id": <number> }`.
### PUT /api/v1/admin/services/{id}
Тело `ServiceUpdateRequest` (все поля опциональны, формат как в `ServiceCreateRequest`). Ответ `{ "updated": true }`.
### PUT /api/v1/admin/services/{id}/status
**Тело запроса** `StatusRequest` с полем `status` (`PUBLISHED`\|`DRAFT`\|`ARCHIVED`). Ответ `{ "updated": true }`.
### DELETE /api/v1/admin/services/{id}
Ответ `{ "deleted": true }`.
---
## Пример структуры Page
```json
{
"items": [ /* сущности */ ],
"total": 125,
"limit": 20,
"offset": 0
}
```

View File

@@ -19,6 +19,7 @@ ktor-server-default-headers = { module = "io.ktor:ktor-server-default-headers",
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-compression = { module = "io.ktor:ktor-server-compression", version.ref = "ktor" }
ktor-server-caching-headers = { module = "io.ktor:ktor-server-caching-headers", version.ref = "ktor" }
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" }
@@ -27,6 +28,7 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version
ktorm = { module = "org.ktorm:ktorm-core", version.ref = "ktorm" }
dotenv-kotlin = { module = "io.github.cdimascio:dotenv-kotlin", version.ref = "dotenv" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "psql" }
psql-support = { module = "org.ktorm:ktorm-support-postgresql", version.ref = "ktorm" }
bcrypt = { module = "org.mindrot:jbcrypt", version.ref = "bcrypt"}
hcpool = { module = "com.zaxxer:HikariCP", version.ref = "hcp" }

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,6 +44,7 @@ fun Application.configureRouting(jwtCfg: JwtConfig) {
val serviceSvc = ServiceService(serviceRepo, serviceCategoryRepo)
routing {
route("/api/v1") {
publicNewsRoutes(newsSvc)
publicLeadRoutes(leadSvc)
publicServiceCategoryRoutes(serviceCategorySvc)
@@ -59,3 +60,4 @@ fun Application.configureRouting(jwtCfg: JwtConfig) {
}
}
}
}

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")
}

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,
)
)
}

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)

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,21 +1,41 @@
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) =
@@ -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,7 +24,23 @@ interface ServiceEntity : Entity<ServiceEntity> {
var updatedAt: LocalDateTime
}
object Services : Table<ServiceEntity>("service") {
@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 }

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
)