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 0000000..2b7f715 Binary files /dev/null and b/db/seed.dump differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..126d0f3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d2c8f3e --- /dev/null +++ b/docs/architecture.md @@ -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`). Репозиторий обрабатывает 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 +)