From ea390b15338004efa01581c0a6998e65212a0257 Mon Sep 17 00:00:00 2001 From: Evgenii Saenko Date: Wed, 17 Dec 2025 11:52:18 +0300 Subject: [PATCH] Add docker --- .gitignore | 3 +- AGENTS.md | 23 ++ Dockerfile | 20 ++ build.gradle.kts | 2 + db/init-db.sh | 12 + db/seed.dump | Bin 0 -> 21420 bytes docker-compose.yml | 41 +++ docs/architecture.md | 102 ++++++ docs/crud-model.svg | 147 +++++++++ docs/db-structure.md | 119 +++++++ docs/interaction-diagram.svg | 69 ++++ docs/rest-api.md | 311 ++++++++++++++++++ gradle/libs.versions.toml | 2 + src/main/kotlin/app/Application.kt | 4 +- src/main/kotlin/app/HTTP.kt | 38 ++- src/main/kotlin/app/Routing.kt | 24 +- src/main/kotlin/app/Security.kt | 3 + .../kotlin/app/serialization/LocalDateTime.kt | 23 ++ src/main/kotlin/modules/admin/Controller.kt | 64 ++-- src/main/kotlin/modules/admin/Entity.kt | 11 +- src/main/kotlin/modules/admin/Repository.kt | 7 +- src/main/kotlin/modules/admin/Service.kt | 16 +- src/main/kotlin/modules/lead/Controller.kt | 41 ++- src/main/kotlin/modules/lead/Entity.kt | 13 +- src/main/kotlin/modules/lead/Reository.kt | 14 +- src/main/kotlin/modules/lead/Service.kt | 7 +- src/main/kotlin/modules/news/Controller.kt | 87 ++++- src/main/kotlin/modules/news/Entity.kt | 14 + src/main/kotlin/modules/news/Repository.kt | 64 +++- src/main/kotlin/modules/news/Service.kt | 44 ++- src/main/kotlin/modules/service/Controller.kt | 64 ++-- src/main/kotlin/modules/service/Entity.kt | 39 ++- src/main/kotlin/modules/service/Repository.kt | 41 ++- src/main/kotlin/modules/service/Service.kt | 4 +- .../modules/serviceCategory/Controller.kt | 19 +- .../kotlin/modules/serviceCategory/Entity.kt | 10 +- .../kotlin/modules/serviceCategory/Service.kt | 11 +- src/main/kotlin/shared/pagination/Page.kt | 11 + 38 files changed, 1359 insertions(+), 165 deletions(-) create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100755 db/init-db.sh create mode 100644 db/seed.dump create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 docs/crud-model.svg create mode 100644 docs/db-structure.md create mode 100644 docs/interaction-diagram.svg create mode 100644 docs/rest-api.md create mode 100644 src/main/kotlin/app/serialization/LocalDateTime.kt create mode 100644 src/main/kotlin/shared/pagination/Page.kt diff --git a/.gitignore b/.gitignore index 4c5c834..15f88a4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build .env .idea -.kotlin \ No newline at end of file +.kotlin +.DS_Store \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..901899f --- /dev/null +++ b/AGENTS.md @@ -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/` 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d934cf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/build.gradle.kts b/build.gradle.kts index 535584b..80574eb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) } diff --git a/db/init-db.sh b/db/init-db.sh new file mode 100755 index 0000000..9d3565f --- /dev/null +++ b/db/init-db.sh @@ -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" diff --git a/db/seed.dump b/db/seed.dump new file mode 100644 index 0000000000000000000000000000000000000000..2b7f715a803ef5a3850cb390e338fdfe50eaf164 GIT binary patch literal 21420 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. Репозиторий обрабатывает 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` перед коммитом, чтобы убедиться в корректной работе всего набора. diff --git a/docs/crud-model.svg b/docs/crud-model.svg new file mode 100644 index 0000000..82f07e2 --- /dev/null +++ b/docs/crud-model.svg @@ -0,0 +1,147 @@ + + + + + + + + + + + CRUD-модель сервера Diploma + Ktor обрабатывает HTTP-запросы через Controllers, Services, Repositories и таблицы PostgreSQL. + + + + Публичные каналы + Маркетинговый сайт + Лендинги и формы + + + Админ-консоль + Внутренний кабинет + CMS, отчеты + + + + Ktor HTTP-слой + Routing.kt · Serialization.kt · Security.kt + Проверка JWT, JSON-парсинг, + маршрутизация по модулям + + + + shared/* + Пагинация + Ошибки и DTO + + + + NewsController + GET /news и /news/{slug} + Админ: список и создание + обновление, публикация, удаление + + + ServiceController + Публичный список/просмотр + Фильтры: категория, цена + Админ: CRUD и статус + + + ServiceCategoryController + Публичный список и slug + Админ: список, создание + обновление, удаление + + + LeadController + POST /leads с сайта + Админ: список и чтение + Удаление заявок + + + + NewsService + Правила публикации, DTO + Страницы и статусы + + + ServiceService + Фильтры по статусу/категории + Контроль цены, связи + + + ServiceCategoryService + Таксономия и slug + CRUD категорий + + + LeadService + Валидация контактов + Поиск и пагинация + + + + NewsRepository + SQL-запросы для news + Админ и публичные выборки + + + ServiceRepository + Джоины услуг и категорий + Фильтры по цене + + + ServiceCategoryRepository + Метаданные категорий + Хранение slug + + + LeadRepository + Прием заявок + Админские запросы + + + + Схема PostgreSQL + news, services, service_categories, leads + Временные метки, статусы, связи + + + + + + + + + + + + + + + + + + + + + + + + + + + Каждый модуль следует паттерну Controller → Service → Repository и покрывает CRUD. + diff --git a/docs/db-structure.md b/docs/db-structure.md new file mode 100644 index 0000000..cdea378 --- /dev/null +++ b/docs/db-structure.md @@ -0,0 +1,119 @@ +# Структура БД и модель данных + +Документ описывает схемы PostgreSQL, которые используются приложением, а также слой абстракции на базе Ktorm ORM. + +## Технологический стек + +- **PostgreSQL** – основная СУБД. Подключение выполняется через HikariCP (см. `app/Database.kt`), параметры берутся из переменных окружения `DB_*`. +- **Ktorm ORM** – легковесная ORM, которая: + - предоставляет интерфейсы `Entity` для объектного представления строк; + - описывает таблицы через `object Table` (например, `object NewsT : Table("t_news")`); + - дает `Database.sequenceOf(Table)` для CRUD-операций и построения SQL через DSL. + +Каждая доменная сущность имеет: + +1. `interface : Entity<>` – декларация полей. +2. `object : Table<>("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("t_services") { + val title = varchar("title").bindTo { it.title } + val category = long("category_id").references(ServiceCategories) { it.category } + } + ``` + +- **Расширения Database** – в каждом модуле есть свой `val Database.` (например, `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`) дополняет данное описание визуальным представлением потока данных. diff --git a/docs/interaction-diagram.svg b/docs/interaction-diagram.svg new file mode 100644 index 0000000..8fb0387 --- /dev/null +++ b/docs/interaction-diagram.svg @@ -0,0 +1,69 @@ + + + + + + + + + + Общая схема взаимодействия: Клиент → Ktor Server → PostgreSQL + + + + Клиент (React SPA) + • UI Components + • MobX Stores + • fetch API + • Браузерное приложение + + + + Ktor Server (Kotlin) + • Routing + • Controllers + • Services + • Repositories + • Ktorm ORM + • JWT Authentication + • Запуск на Netty runtime + + + + + + + База данных PostgreSQL + • t_admins + • t_news + • t_services + • t_service_categories + • t_users (leads) + • Транзакционность (ACID) + + + + + REST API /api/v1/* + + + + SQL-запросы + через Ktorm + + + + JSON-ответы + + + JSON-ответы + diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 0000000..8726b16 --- /dev/null +++ b/docs/rest-api.md @@ -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 ` из `POST /admin/login`. +- **Ошибки**: при валидационных/авторизационных ошибках сервер возвращает код `4xx` с телом `{"error": "описание"}`. + +## Базовые схемы +| Схема | Описание | +| --- | --- | +| **Page** | Обёртка пагинации: `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": "" }`. + +### 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`. +Пример элемента: `{ "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`, где `items` отсортированы по `publishedAt` у опубликованных записей. + +### GET /api/v1/news/{slug} +Возвращает `NewsDTO` опубликованной новости (если slug неактивен — `404`). + +### GET /api/v1/admin/news +**Query-параметры**: `limit` (50), `page` (1, ≥1). +**Ответ**: `Page` (включая черновики). + +### 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": }`. + +### 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": }`. + +### 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`. +Пример элемента: +```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` (включая черновики). + +### 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": }`. + +### 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 +} +``` \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 683369d..d3ca3e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/src/main/kotlin/app/Application.kt b/src/main/kotlin/app/Application.kt index 5dce846..407fd49 100644 --- a/src/main/kotlin/app/Application.kt +++ b/src/main/kotlin/app/Application.kt @@ -9,9 +9,9 @@ fun main(args: Array) { fun Application.module() { val jwtCfg = loadJwtConfig() - configureSerialization() - configureSecurity(jwtCfg) configureDatabase() configureHTTP() + configureSerialization() + configureSecurity(jwtCfg) configureRouting(jwtCfg) } diff --git a/src/main/kotlin/app/HTTP.kt b/src/main/kotlin/app/HTTP.kt index 460365b..3255d87 100644 --- a/src/main/kotlin/app/HTTP.kt +++ b/src/main/kotlin/app/HTTP.kt @@ -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 { call, cause -> + val msg = cause.message ?: "Invalid request payload" + call.respond( + HttpStatusCode.BadRequest, + mapOf("error" to msg) + ) + } + exception { 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 { 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 { call, cause -> + call.respond(HttpStatusCode.NotFound, mapOf("error" to (cause.message ?: "Not found"))) + } + } install(Compression) install(CachingHeaders) { options { call, outgoingContent -> diff --git a/src/main/kotlin/app/Routing.kt b/src/main/kotlin/app/Routing.kt index 2668f9d..81f710c 100644 --- a/src/main/kotlin/app/Routing.kt +++ b/src/main/kotlin/app/Routing.kt @@ -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) + } } } } diff --git a/src/main/kotlin/app/Security.kt b/src/main/kotlin/app/Security.kt index cd087a4..06701ed 100644 --- a/src/main/kotlin/app/Security.kt +++ b/src/main/kotlin/app/Security.kt @@ -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) diff --git a/src/main/kotlin/app/serialization/LocalDateTime.kt b/src/main/kotlin/app/serialization/LocalDateTime.kt new file mode 100644 index 0000000..1e488ba --- /dev/null +++ b/src/main/kotlin/app/serialization/LocalDateTime.kt @@ -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 { + 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) + } +} diff --git a/src/main/kotlin/modules/admin/Controller.kt b/src/main/kotlin/modules/admin/Controller.kt index 23684ab..0434a99 100644 --- a/src/main/kotlin/modules/admin/Controller.kt +++ b/src/main/kotlin/modules/admin/Controller.kt @@ -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() 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() ?: 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() 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() - 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)) } diff --git a/src/main/kotlin/modules/admin/Entity.kt b/src/main/kotlin/modules/admin/Entity.kt index 111308b..5fef189 100644 --- a/src/main/kotlin/modules/admin/Entity.kt +++ b/src/main/kotlin/modules/admin/Entity.kt @@ -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 { var lastLoginAt: LocalDateTime? } -object AdminUsers : Table("admin_user") { +@Serializable +data class AdminDTO( + val id: Long, + val username: String, + @Serializable(with = JavaLocalDateTimeSerializer::class) + val createdAt: LocalDateTime, +) + +object AdminUsers : Table("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 } diff --git a/src/main/kotlin/modules/admin/Repository.kt b/src/main/kotlin/modules/admin/Repository.kt index 3e1d753..91ea73e 100644 --- a/src/main/kotlin/modules/admin/Repository.kt +++ b/src/main/kotlin/modules/admin/Repository.kt @@ -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 + fun list(limit: Int = 50, offset: Int = 0): List 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 = + override fun list(limit: Int, offset: Int): List = 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 } diff --git a/src/main/kotlin/modules/admin/Service.kt b/src/main/kotlin/modules/admin/Service.kt index 8f8ea39..f33a49d 100644 --- a/src/main/kotlin/modules/admin/Service.kt +++ b/src/main/kotlin/modules/admin/Service.kt @@ -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) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/modules/lead/Controller.kt b/src/main/kotlin/modules/lead/Controller.kt index 027ed27..12571f2 100644 --- a/src/main/kotlin/modules/lead/Controller.kt +++ b/src/main/kotlin/modules/lead/Controller.kt @@ -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() @@ -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)) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/modules/lead/Entity.kt b/src/main/kotlin/modules/lead/Entity.kt index 9a4ca93..678a45b 100644 --- a/src/main/kotlin/modules/lead/Entity.kt +++ b/src/main/kotlin/modules/lead/Entity.kt @@ -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 { var createdAt: LocalDateTime } -object Leads : Table("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("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 } diff --git a/src/main/kotlin/modules/lead/Reository.kt b/src/main/kotlin/modules/lead/Reository.kt index f437cf0..c365dbd 100644 --- a/src/main/kotlin/modules/lead/Reository.kt +++ b/src/main/kotlin/modules/lead/Reository.kt @@ -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 + fun list(limit: Int = 50, offset: Int = 0, q: String? = null): List 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 { + override fun list(limit: Int, offset: Int, q: String?): List { var seq: EntitySequence = 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 ) } } diff --git a/src/main/kotlin/modules/lead/Service.kt b/src/main/kotlin/modules/lead/Service.kt index 7d30b7a..c44624b 100644 --- a/src/main/kotlin/modules/lead/Service.kt +++ b/src/main/kotlin/modules/lead/Service.kt @@ -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(val items: List, val total: Int, val limit: Int, val offset: Int) - - fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page { + fun list(limit: Int = 50, offset: Int = 0, q: String? = null): Page { 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") } -} \ No newline at end of file +} diff --git a/src/main/kotlin/modules/news/Controller.kt b/src/main/kotlin/modules/news/Controller.kt index 03e8bae..a68196d 100644 --- a/src/main/kotlin/modules/news/Controller.kt +++ b/src/main/kotlin/modules/news/Controller.kt @@ -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() 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>() - val ok = svc.update(slug, body["summary"].orEmpty(), body["content"].orEmpty()) + val body = call.receive() + 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()) } -} \ No newline at end of file +} + +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, +) diff --git a/src/main/kotlin/modules/news/Entity.kt b/src/main/kotlin/modules/news/Entity.kt index e593b64..17aecf3 100644 --- a/src/main/kotlin/modules/news/Entity.kt +++ b/src/main/kotlin/modules/news/Entity.kt @@ -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 { 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("t_news") { val id = long("id").primaryKey().bindTo { it.id } val title = varchar("title").bindTo { it.title } diff --git a/src/main/kotlin/modules/news/Repository.kt b/src/main/kotlin/modules/news/Repository.kt index ecdb536..4813211 100644 --- a/src/main/kotlin/modules/news/Repository.kt +++ b/src/main/kotlin/modules/news/Repository.kt @@ -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 + fun countPublished(): Int + fun listAll(limit: Int = 50, offset: Int = 0): List + 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 - .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 + .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 } - diff --git a/src/main/kotlin/modules/news/Service.kt b/src/main/kotlin/modules/news/Service.kt index b5515f6..7a7e40c 100644 --- a/src/main/kotlin/modules/news/Service.kt +++ b/src/main/kotlin/modules/news/Service.kt @@ -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 = + Page( + items = repo.listPublished(limit, offset), + total = repo.countPublished(), + limit = limit, + offset = offset + ) + + fun listAdmin(limit: Int = 50, offset: Int = 0): Page = + 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" } } -} \ No newline at end of file + + private fun validateTitle(title: String) { + require(title.isNotBlank()) { "title is required" } + } + + private fun validateSlug(slug: String) { + require(slug.matches(slugRegex)) { "slug invalid" } + } +} diff --git a/src/main/kotlin/modules/service/Controller.kt b/src/main/kotlin/modules/service/Controller.kt index 2f09224..023ec76 100644 --- a/src/main/kotlin/modules/service/Controller.kt +++ b/src/main/kotlin/modules/service/Controller.kt @@ -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)) diff --git a/src/main/kotlin/modules/service/Entity.kt b/src/main/kotlin/modules/service/Entity.kt index b751103..946c6cf 100644 --- a/src/main/kotlin/modules/service/Entity.kt +++ b/src/main/kotlin/modules/service/Entity.kt @@ -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 { companion object : Entity.Factory() + var id: Long var title: String var slug: String @@ -21,17 +24,33 @@ interface ServiceEntity : Entity { var updatedAt: LocalDateTime } -object Services : Table("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("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 } } diff --git a/src/main/kotlin/modules/service/Repository.kt b/src/main/kotlin/modules/service/Repository.kt index 744fc97..60fd18d 100644 --- a/src/main/kotlin/modules/service/Repository.kt +++ b/src/main/kotlin/modules/service/Repository.kt @@ -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 + ): List 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 + fun listAdmin(limit: Int = 50, offset: Int = 0, q: String? = null, status: String? = null): List + fun listAll(limit: Int = 50, offset: Int = 0, q: String? = null): List 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 { + ): List { var seq: EntitySequence = 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 { + override fun listAdmin(limit: Int, offset: Int, q: String?, status: String?): List { var seq: EntitySequence = 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 { + var seq: EntitySequence = 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) } diff --git a/src/main/kotlin/modules/service/Service.kt b/src/main/kotlin/modules/service/Service.kt index 104b67f..c6d8b75 100644 --- a/src/main/kotlin/modules/service/Service.kt +++ b/src/main/kotlin/modules/service/Service.kt @@ -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(val items: List, 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 { + ): Page { 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) diff --git a/src/main/kotlin/modules/serviceCategory/Controller.kt b/src/main/kotlin/modules/serviceCategory/Controller.kt index 14a0ee1..f25a5fa 100644 --- a/src/main/kotlin/modules/serviceCategory/Controller.kt +++ b/src/main/kotlin/modules/serviceCategory/Controller.kt @@ -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 diff --git a/src/main/kotlin/modules/serviceCategory/Entity.kt b/src/main/kotlin/modules/serviceCategory/Entity.kt index 5346f8c..ba7c952 100644 --- a/src/main/kotlin/modules/serviceCategory/Entity.kt +++ b/src/main/kotlin/modules/serviceCategory/Entity.kt @@ -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 { var slug: String } -object ServiceCategories : Table("service_category") { +@Serializable +data class ServiceCategoryDTO( + val id: Long, + val name: String, + val slug: String, +) + +object ServiceCategories : Table("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 } diff --git a/src/main/kotlin/modules/serviceCategory/Service.kt b/src/main/kotlin/modules/serviceCategory/Service.kt index 752b0bf..58e9edd 100644 --- a/src/main/kotlin/modules/serviceCategory/Service.kt +++ b/src/main/kotlin/modules/serviceCategory/Service.kt @@ -7,9 +7,14 @@ class ServiceCategoryService(private val repo: ServiceCategoryRepository) { fun listPublic() = repo.listPublic() - data class Page(val items: List, 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 = + 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") diff --git a/src/main/kotlin/shared/pagination/Page.kt b/src/main/kotlin/shared/pagination/Page.kt new file mode 100644 index 0000000..0051047 --- /dev/null +++ b/src/main/kotlin/shared/pagination/Page.kt @@ -0,0 +1,11 @@ +package cc.essaenko.shared.pagination + +import kotlinx.serialization.Serializable + +@Serializable +data class Page( + val items: List, + val total: Int, + val limit: Int, + val offset: Int +)