From 519903b9301bce2f0f49dd9672178ece5a9c2dee Mon Sep 17 00:00:00 2001 From: edadmin Date: Sun, 18 Oct 2026 22:07:25 +0300 Subject: [PATCH] Initial commit: backend structure --- .dockerignore | 16 + .drone.yml | 67 + .env.example | 124 + .env.prod.example | 95 + .gitignore | 3 + .golangci.yml | 39 + Dockerfile | 39 + Makefile | 46 + README.md | 1158 ++++ cmd/api/main.go | 736 +++ cmd/migrate/main.go | 53 + cmd/seed/main.go | 343 ++ compose.prod.yaml | 160 + compose.yaml | 74 + deploy/drone/compose.yaml | 70 + deploy/minio/Dockerfile | 26 + docs/docs.go | 4787 +++++++++++++++++ docs/swagger.json | 4766 ++++++++++++++++ docs/swagger.yaml | 3343 ++++++++++++ go.mod | 82 + go.sum | 230 + internal/auth/jwt.go | 85 + internal/auth/jwt_test.go | 104 + internal/auth/middleware.go | 43 + internal/auth/password.go | 68 + internal/auth/password_test.go | 34 + internal/auth/tokens.go | 28 + internal/crypt/cipher.go | 114 + internal/crypt/cipher_test.go | 128 + internal/db/db.go | 77 + internal/db/db_test.go | 62 + internal/db/migrate.go | 169 + internal/db/migrations/0001_init.sql | 129 + .../db/migrations/0002_ticket_resolution.sql | 44 + internal/db/migrations/0003_hardening.sql | 30 + internal/db/migrations/0004_projects.sql | 104 + .../migrations/0005_project_invitations.sql | 60 + internal/db/migrations/0006_ticket_events.sql | 55 + .../db/migrations/0007_password_reset.sql | 42 + internal/db/migrations/0008_ticket_photos.sql | 65 + internal/db/migrations/0009_rate_limits.sql | 32 + .../db/migrations/0010_user_confirmation.sql | 31 + .../0011_telegram_notifications.sql | 48 + .../db/migrations/0012_user_bot_tokens.sql | 25 + .../0013_project_delete_cascade.sql | 30 + .../db/migrations/0014_recurring_rules.sql | 81 + .../db/migrations/0015_recurring_annual.sql | 35 + .../db/migrations/0016_recurring_period.sql | 35 + internal/handlers/auth.go | 459 ++ internal/handlers/auth_test.go | 213 + internal/handlers/binding.go | 46 + internal/handlers/filters.go | 56 + internal/handlers/httperr.go | 42 + internal/handlers/list_test.go | 167 + internal/handlers/pagination.go | 53 + internal/handlers/photos.go | 374 ++ internal/handlers/photos_test.go | 84 + internal/handlers/projects.go | 785 +++ internal/handlers/recurring.go | 474 ++ internal/handlers/telegram.go | 430 ++ internal/handlers/telegram_test.go | 380 ++ internal/handlers/tickets.go | 740 +++ internal/handlers/types.go | 144 + internal/handlers/users.go | 517 ++ internal/handlers/users_test.go | 367 ++ internal/logging/logging.go | 44 + internal/mailer/mailer.go | 444 ++ internal/mailer/mailer_test.go | 227 + internal/middleware/accesslog.go | 46 + internal/middleware/cors.go | 66 + internal/middleware/cors_test.go | 103 + internal/middleware/recovery.go | 31 + internal/middleware/requestid.go | 37 + internal/models/models.go | 254 + internal/notify/notify.go | 197 + internal/notify/notify_test.go | 482 ++ internal/notify/poll.go | 146 + internal/notify/registry.go | 110 + internal/notify/registry_test.go | 158 + internal/ratelimit/db.go | 88 + internal/ratelimit/db_test.go | 205 + internal/ratelimit/ratelimit.go | 97 + internal/ratelimit/ratelimit_test.go | 102 + internal/repository/db.go | 30 + internal/repository/errors.go | 112 + internal/repository/page.go | 142 + internal/repository/page_test.go | 305 ++ internal/repository/password_reset.go | 124 + internal/repository/password_reset_test.go | 148 + internal/repository/photo.go | 257 + internal/repository/photo_test.go | 292 + internal/repository/profile.go | 472 ++ internal/repository/profile_test.go | 684 +++ internal/repository/project.go | 586 ++ internal/repository/project_test.go | 584 ++ internal/repository/recurring.go | 579 ++ internal/repository/recurring_test.go | 685 +++ internal/repository/refresh_token.go | 92 + internal/repository/telegram.go | 275 + internal/repository/telegram_test.go | 335 ++ internal/repository/ticket.go | 659 +++ internal/repository/ticket_test.go | 1013 ++++ internal/storage/s3.go | 193 + internal/telegram/telegram.go | 307 ++ internal/telegram/telegram_test.go | 268 + 105 files changed, 34325 insertions(+) create mode 100644 .dockerignore create mode 100644 .drone.yml create mode 100644 .env.example create mode 100644 .env.prod.example create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/api/main.go create mode 100644 cmd/migrate/main.go create mode 100644 cmd/seed/main.go create mode 100644 compose.prod.yaml create mode 100644 compose.yaml create mode 100644 deploy/drone/compose.yaml create mode 100644 deploy/minio/Dockerfile create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/jwt_test.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/tokens.go create mode 100644 internal/crypt/cipher.go create mode 100644 internal/crypt/cipher_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/migrate.go create mode 100644 internal/db/migrations/0001_init.sql create mode 100644 internal/db/migrations/0002_ticket_resolution.sql create mode 100644 internal/db/migrations/0003_hardening.sql create mode 100644 internal/db/migrations/0004_projects.sql create mode 100644 internal/db/migrations/0005_project_invitations.sql create mode 100644 internal/db/migrations/0006_ticket_events.sql create mode 100644 internal/db/migrations/0007_password_reset.sql create mode 100644 internal/db/migrations/0008_ticket_photos.sql create mode 100644 internal/db/migrations/0009_rate_limits.sql create mode 100644 internal/db/migrations/0010_user_confirmation.sql create mode 100644 internal/db/migrations/0011_telegram_notifications.sql create mode 100644 internal/db/migrations/0012_user_bot_tokens.sql create mode 100644 internal/db/migrations/0013_project_delete_cascade.sql create mode 100644 internal/db/migrations/0014_recurring_rules.sql create mode 100644 internal/db/migrations/0015_recurring_annual.sql create mode 100644 internal/db/migrations/0016_recurring_period.sql create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/auth_test.go create mode 100644 internal/handlers/binding.go create mode 100644 internal/handlers/filters.go create mode 100644 internal/handlers/httperr.go create mode 100644 internal/handlers/list_test.go create mode 100644 internal/handlers/pagination.go create mode 100644 internal/handlers/photos.go create mode 100644 internal/handlers/photos_test.go create mode 100644 internal/handlers/projects.go create mode 100644 internal/handlers/recurring.go create mode 100644 internal/handlers/telegram.go create mode 100644 internal/handlers/telegram_test.go create mode 100644 internal/handlers/tickets.go create mode 100644 internal/handlers/types.go create mode 100644 internal/handlers/users.go create mode 100644 internal/handlers/users_test.go create mode 100644 internal/logging/logging.go create mode 100644 internal/mailer/mailer.go create mode 100644 internal/mailer/mailer_test.go create mode 100644 internal/middleware/accesslog.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/cors_test.go create mode 100644 internal/middleware/recovery.go create mode 100644 internal/middleware/requestid.go create mode 100644 internal/models/models.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 internal/notify/poll.go create mode 100644 internal/notify/registry.go create mode 100644 internal/notify/registry_test.go create mode 100644 internal/ratelimit/db.go create mode 100644 internal/ratelimit/db_test.go create mode 100644 internal/ratelimit/ratelimit.go create mode 100644 internal/ratelimit/ratelimit_test.go create mode 100644 internal/repository/db.go create mode 100644 internal/repository/errors.go create mode 100644 internal/repository/page.go create mode 100644 internal/repository/page_test.go create mode 100644 internal/repository/password_reset.go create mode 100644 internal/repository/password_reset_test.go create mode 100644 internal/repository/photo.go create mode 100644 internal/repository/photo_test.go create mode 100644 internal/repository/profile.go create mode 100644 internal/repository/profile_test.go create mode 100644 internal/repository/project.go create mode 100644 internal/repository/project_test.go create mode 100644 internal/repository/recurring.go create mode 100644 internal/repository/recurring_test.go create mode 100644 internal/repository/refresh_token.go create mode 100644 internal/repository/telegram.go create mode 100644 internal/repository/telegram_test.go create mode 100644 internal/repository/ticket.go create mode 100644 internal/repository/ticket_test.go create mode 100644 internal/storage/s3.go create mode 100644 internal/telegram/telegram.go create mode 100644 internal/telegram/telegram_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07116d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Что НЕ попадает в контекст сборки образа api. +# Внимание: internal/db/migrations/*.sql исключать нельзя — +# они эмбедятся в бинарь (//go:embed), и без них сервер +# не сможет накатить схему. +.git +.github +.gitignore +.golangci.yml +.env +.env.example +.env.prod.example +*.md +compose*.yaml +deploy +Makefile +*_test.go diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..f27f37e --- /dev/null +++ b/.drone.yml @@ -0,0 +1,67 @@ +# Drone CI/CD бэкенда (сервер подключён к Gitea; см. README, «CI/CD»). +# +# Push/PR — только проверки и сборка образа. Деплой — ТОЛЬКО ручным +# запуском пайплайна из UI Drone (событие custom, кнопка Run Pipeline): +# шаг deploy выполняется исключительно при нём. +kind: pipeline +type: docker +name: ci-cd + +clone: + depth: 1 + +# Сервисная база для интеграционных тестов: схему накатывает сам шаг +# test (cmd/migrate), тесты выполняют сценарии в транзакции и откатывают +# их — данные в базе не остаются. +services: + - name: postgres + image: postgres:16-alpine + environment: + POSTGRES_USER: building + POSTGRES_PASSWORD: building + POSTGRES_DB: building_mgmt_test + +steps: + - name: test + # Полный golang-образ, не alpine: -race требует CGO и C-компилятора. + image: golang:1.27 + environment: + DATABASE_URL: postgres://building:building@postgres:5432/building_mgmt_test?sslmode=disable + commands: + - go vet ./... + # Postgres в сервисе поднимается параллельно со сборкой шага: + # повторяем накат, пока база не готова (30 попыток по 2 секунды). + - ok=0; for i in $(seq 1 30); do if go run ./cmd/migrate; then ok=1; break; fi; sleep 2; done; test "$ok" = 1 + - go test -race -count=1 ./... + - go build ./... + + - name: lint + image: ghcr.io/golangci/golangci-lint:v2.14.0 + commands: + - golangci-lint run ./... + + - name: build-api + # Образ и два тега: latest (его поднимает compose.prod.yaml) и SHA + # (для отката: собрать нужный коммит и перетегнуть latest вручную). + image: plugins/docker:27 + settings: + repo: building-mgmt-api + tags: + - latest + - ${DRONE_COMMIT_SHA} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + + - name: deploy + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + # -p закрепляет имя проекта: у Drone каталог клонирования свой, + # без -p имя сети стека разъехалось бы между запусками, и web + # перестал бы находить api. env-файл — постоянный файл на сервере. + - docker compose version + - docker compose -p building-mgmt --env-file /opt/building-mgmt/.env -f compose.prod.yaml up -d --wait + when: + event: + - custom diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8a9db8 --- /dev/null +++ b/.env.example @@ -0,0 +1,124 @@ +# Скопировать в .env и заполнить. Файл .env не попадает в git (.gitignore). + +# Строка подключения к PostgreSQL. Два взаимозаменяемых варианта: +# внешняя база через туннель → localhost:5432 (запуск: make up) +DATABASE_URL=postgres://user:pass@localhost:5432/building_mgmt?sslmode=disable +# локальная база в docker → localhost:5433 +# DATABASE_URL=postgres://building_app:building_app@localhost:5433/building_mgmt?sslmode=disable + +# Пул соединений PostgreSQL (необязательно; пусто — дефолты pgxpool). +# В проде рекомендуется задать явно (см. .env.prod.example): +# DB_MAX_CONNS=20 DB_MIN_CONNS=2 +# DB_MAX_CONN_LIFETIME=1h DB_MAX_CONN_IDLE_TIME=30m +DB_MAX_CONNS= +DB_MIN_CONNS= +DB_MAX_CONN_LIFETIME= +DB_MAX_CONN_IDLE_TIME= + +# Секрет подписи JWT: минимум 32 случайных символа. +# Сгенерировать: openssl rand -base64 48 +JWT_SECRET= + +# Режим окружения: пусто (dev) или production. В production +# команда seed (демо-аккаунты) отказывается работать. +APP_ENV= + +# Порт HTTP-сервера. По умолчанию 8080. +PORT=8080 + +# Swagger UI на /swagger/index.html. По умолчанию выключен — на проде +# открытая схема не нужна; в локальной разработке ставить true. +SWAGGER=false + +# Secure-флаг на httpOnly-куке refresh-токена. Локально сайт идёт по +# HTTP, а браузер Secure-куку по HTTP отвергает — оставлять false. +# В проде, когда сайт за TLS, ставить true. +COOKIE_SECURE=false + +# Доверенные reverse proxy для X-Forwarded-For (через запятую, CIDR/IP). +# пусто — доверен только loopback (127.0.0.1/32, ::1/32): дефолт +# за proxy — подсеть proxy: TRUSTED_PROXIES=172.0.0.0/12 +# Сам proxy обязан ПЕРЕЗАПИСЫВАТЬ X-Forwarded-For ($remote_addr), а не +# дополнять: gin берёт левый элемент, оставленный клиентом. +TRUSTED_PROXIES= + +# CORS: источники фронта через запятую (scheme + host + port). +# пусто — заголовки не отдаются вовсе (дефолт; фронту на том же +# домене CORS не нужен, чужому домену — и не должно быть) +# несколько — CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +CORS_ORIGINS= + +# Логирование (необязательно). +# LOG_LEVEL = debug | info | warn | error (по умолчанию info) +# LOG_FORMAT = text | json (по умолчанию text; +# json — когда логи собирает агрегатор) +LOG_LEVEL=info +LOG_FORMAT=text + +# Режим gin: debug (по умолчанию) выводит баннер и цветной access-log. +# Пусто = release — только структурные логи. +GIN_MODE= + +# S3-совместимое хранилище для фотоотчёта (фейл-фаст: без него сервер +# не стартует — бакет создаётся сам при старте). Локально — MinIO в +# compose: S3 на http://localhost:5434, консоль http://localhost:5435 +# (вход/пароль minioadmin/minioadmin). +# В проде — любой S3: у AWS endpoint пустой, ключи берутся из IAM-ролей; +# у R2/Wasabi/своего MinIO — endpoint + ключи. +S3_ENDPOINT=http://localhost:5434 +S3_REGION=us-east-1 +S3_BUCKET=building-mgmt +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +# path-style («/бакет/ключ») обязателен для локального MinIO: поддомен +# «бакет.localhost:5434» не резолвится. Для штатного AWS — false/пусто. +S3_USE_PATH_STYLE=true + +# Фотоотчёт: потолок одного файла (в байтах) и число файлов на заявку. +# Пусто — дефолты 10485760 (10 МБ) и 20. +PHOTO_MAX_BYTES=10485760 +PHOTO_MAX_PER_TICKET=20 + +# Автоочистка старых заявок: удалять закрытые заявки, чей closed_at +# старше N дней (прогон при старте и раз в сутки). 0 — чистка выключена. +# Незакрытые заявки не удаляются никогда, независимо от возраста. +TICKET_RETENTION_DAYS=60 + +# --- SMTP писем подтверждения (POST /users/:id/confirm) --- +# Пустой SMTP_HOST — письма не шлются. SMTP_TLS: tls | starttls | none; +# пусто = tls для порта 465, иначе starttls. SMTP_FROM пуст → берётся SMTP_USER. +# Локальная разработка — Mailpit вместо боевого SMTP: +# docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit +# SMTP_HOST=localhost SMTP_PORT=1025 SMTP_TLS=none SMTP_USER= SMTP_PASSWORD= SMTP_FROM=dev@localhost +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_TLS= +# База ссылок на страницу входа внутри письма. +APP_BASE_URL=http://localhost:3000 + +# --- Telegram: уведомления о заявках, не взятых в работу за 24 часа --- +# Два источника бота (подробности — README, раздел «Уведомления в Telegram»): +# 1. личный токен пользователя — вводится в разделе «Настройки», +# шифруется DATA_ENCRYPTION_KEY и хранится в БД; +# 2. глобальный бот сервера — запасной для тех, кто токен не ввёл. +# Пусто и то, и другое — уведомления выключены: GET /auth/telegram +# отдаёт enabled:false, выдача кода — 400 telegram not configured. +# Бот заводится через @BotFather; для общей цели его нужно добавить +# в группу или канал. +# +# Ключ шифрования токенов пользователей (32 байта, base64 или hex; +# генерация: openssl rand -base64 32 либо openssl rand -hex 32). +# Пусто — ввод токена в вебе выключен (POST /auth/telegram/bot → 400), +# а сохранённые ранее не расшифровываются. +DATA_ENCRYPTION_KEY= +# Токен глобального бота — секрет, задаётся только здесь (в код и в git +# не попадает); его отсутствие не ломает фичу, если есть личные токены. +TELEGRAM_BOT_TOKEN= +# База Bot API; пусто = https://api.telegram.org. +TELEGRAM_API_URL= +# Порог напоминания (duration: 24h, 30m) и период фонового тика. +TELEGRAM_NOTIFY_AFTER=24h +TELEGRAM_TICK_EVERY=1m diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..8eeda8e --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,95 @@ +# Прод-окружение стека compose.prod.yaml. Скопировать на сервер: +# +# mkdir -p /opt/building-mgmt && cp .env.prod.example /opt/building-mgmt/.env +# chmod 600 /opt/building-mgmt/.env # секреты, только root +# +# Заполняется один раз и правится осознанно: стек и Drone читают его +# по пути /opt/building-mgmt/.env. Пароли и секреты в git не входят. + +# --- Обязательные переменные (compose упадёт без них: :? фейл-фаст) --- + +# Роль и база postgres внутри стека. Пароль: openssl rand -base64 32 +POSTGRES_USER=building +POSTGRES_PASSWORD= +POSTGRES_DB=building_mgmt + +# S3-ключи (они же MINIO_ROOT_* — одна пара секретов на всё). +# Access key: минимум 3 символа (требование MinIO), пароль — 8+. +S3_ACCESS_KEY= +S3_SECRET_KEY= + +# Имя бакета фотоотчёта: создаётся сам при старте api. S3_REGION +# опционален (пустой = us-east-1 у aws sdk). +S3_BUCKET=building-mgmt + +# Секрет подписи JWT: минимум 32 случайных символа. +# Сгенерировать: openssl rand -base64 48 +JWT_SECRET= + +# Ключ шифрования токенов Telegram-ботов (AES-256-GCM). +# Сгенерировать: openssl rand -base64 32 +# Пусто — ввод личного токена бота в вебе выключен; для уведомлений +# достаточно TELEGRAM_BOT_TOKEN. +DATA_ENCRYPTION_KEY= + +# --- Режим и безопасность --- + +# Прод: включает lockdown (seed отказывается работать) и подсказывает +# агрегатору логов, что это боевой запуск. +APP_ENV=production + +# Secure-флаг на refresh-куке: сайт за TLS — только true. +COOKIE_SECURE=true + +# Swagger UI выключен: открытая схема API наружу не нужна. +SWAGGER=false + +# Режим gin: release убирает баннер и цветной access-log. +GIN_MODE=release + +# Доверенные reverse proxy для X-Forwarded-For. web-контейнер стоит в +# сети bridge compose (172.16.0.0/12) и ПЕРЕЗАПИСЫВАЕТ XFF ($remote_addr), +# поэтому доверяем подсети docker. Без этого все запросы придут с IP +# nginx-контейнера и лимиты логина сложатся в один общий ключ. +TRUSTED_PROXIES=172.16.0.0/12 + +# CORS не нужен: страница и API с одного домена (nginx проксирует). +CORS_ORIGINS= + +# Логи: json — когда собирает агрегатор; иначе text. +LOG_LEVEL=info +LOG_FORMAT=json + +# --- Ссылки и лимиты --- + +# Публичный адрес приложения (в письмах и Telegram-уведомлениях). +APP_BASE_URL= + +# Пул PostgreSQL (см. .env.example): на прод-машине держим потолок. +DB_MAX_CONNS=20 +DB_MIN_CONNS=2 +DB_MAX_CONN_LIFETIME=1h +DB_MAX_CONN_IDLE_TIME=30m + +# Фотоотчёт и автоочистка закрытых заявок (дни; 0 — выключено). +PHOTO_MAX_BYTES=10485760 +PHOTO_MAX_PER_TICKET=20 +TICKET_RETENTION_DAYS=60 + +# --- SMTP писем подтверждения и сброса пароля --- +# Пустой SMTP_HOST — письма не шлются (в проде обычно настроен). +# SMTP_TLS: tls | starttls | none; пусто = tls для 465, иначе starttls. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_TLS= + +# --- Telegram (уведомления о заявках, не взятых в работу) --- +# Один инстанс API на сервер обязателен: очередь getUpdates у бота +# одна на процесс (см. README, «Известные ограничения»). +TELEGRAM_BOT_TOKEN= +TELEGRAM_API_URL= +TELEGRAM_NOTIFY_AFTER=24h +TELEGRAM_TICK_EVERY=1m diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ffbf2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.log +/tmp/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..02e195f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,39 @@ +# Конфигурация golangci-lint v2. +# Запуск: `golangci-lint run ./...` (или `make lint`). +# Формат v2: линтеры живут в `linters`, форматтеры (gofmt/goimports) — +# в отдельной секции `formatters`. + +version: "2" + +run: + timeout: 5m + +linters: + # standard = errcheck, govet, ineffassign, staticcheck, unused. + default: standard + enable: + - gocritic # подсвечивает сомнительные конструкции, в т.ч. if-цепочки вместо switch + - misspell # опечатки в комментариях и строках + - revive # замена устаревшему golint: стиль и именование + exclusions: + rules: + # В тестах часть ошибок осознанно игнорируется (напр. откат tx в Cleanup). + - path: _test\.go + linters: + - errcheck + # Тестовые хелперы: t идёт первым — так заведено в testing-пакете + # (t.Helper()), а ctx создаётся один раз на весь тест и подставляется + # вторым. Перестановка местами ничего не даёт, но ломает читаемость. + - path: _test\.go + linters: + - revive + text: "context-as-argument" + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + # Локальные импорты не разбиваются на отдельную группу. + local-prefixes: [building-mgmt] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79961fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Прод-образ API. Сборка: docker build -t building-mgmt-api:latest . +# +# Две стадии: в builder — полный golang-образ со всеми инструментами, +# в рантайме — только альпайн с бинарниками и ca-certificates +# (нужен для TLS к S3, SMTP и Telegram). Миграции НЕ исключаются из +# контекста: они эмбедятся в бинарь через //go:embed. + +FROM golang:1.27-alpine AS builder +WORKDIR /src + +# Слой зависимостей отдельно: правка кода не сбрасывает кэш модулей. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# -trimpath убирает пути сборки из бинарней, -s -w — таблицы символов +# и DWARF: минус несколько мегабайт, отладка в проде идёт по логам. +# Три бинарника в одном образе: api — сервер, migrate и seed — +# разовые инструменты (seed заблокирован при APP_ENV=production). +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/migrate ./cmd/migrate && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/seed ./cmd/seed + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates && \ + addgroup -S app && adduser -S -G app app + +COPY --from=builder /out/ /usr/local/bin/ + +# От root'а не отдаём ни запросов, ни файловой системы контейнера. +USER app +EXPOSE 8080 + +# busybox wget входит в альпайн; /ping — открытый хеллер без авторизации. +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD wget -qO- http://127.0.0.1:8080/ping || exit 1 + +CMD ["api"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..82ee54b --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# Команды разработки — одинаковые на любой машине. +# На Windows без make те же пункты продублированы в README как голые go-команды. +# Проверить, что цели синхронны с README, можно глазами: список ниже — канон. + +.PHONY: run build test race vet lint fmt swag migrate seed up down tidy + +run: ## запустить сервер (собирает и поднимает cmd/api) + go run ./cmd/api + +build: ## собрать все пакеты + go build ./... + +test: ## тесты; без DATABASE_URL интеграционные скипаются — не обманывайтесь + go test ./... + +# race требует CGO и C-компилятор: в CI (ubuntu) они есть, на Windows +# без MinGW команда упадёт с «-race requires cgo» — там гонки ищет CI. +race: ## тесты с детектором гонок (то же, что делает CI) + go test -race ./... + +vet: ## быстрая статическая проверка + go vet ./... + +lint: ## полный линтер (конфиг — .golangci.yml) + golangci-lint run ./... + +fmt: ## отформатировать код + gofmt -w . + +swag: ## перегенерировать Swagger из аннотаций (docs/) + swag init -g cmd/api/main.go -o docs --parseInternal + +migrate: ## накатить миграции без запуска API (нужно и CI, и перед тестами) + go run ./cmd/migrate + +seed: ## демо-данные для ручной проверки (ТОЛЬКО локальная/демо-база) + go run ./cmd/seed --yes + +up: ## поднять локальную БД в docker (порт 5433, см. compose.yaml) + docker compose up -d --wait + +down: ## остановить локальную БД; данные остаются в томе pgdata + docker compose down + +tidy: ## привести go.mod/go.sum в порядок + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b5c6f3 --- /dev/null +++ b/README.md @@ -0,0 +1,1158 @@ +# building-mgmt-backend + +Backend для сервиса управления заявками в здании: админ создаёт клиента, +клиент заводит **проект**, объекты в нём (здания с адресами) и приглашает +мастеров, назначает заявку принятому мастеру, мастер ведёт работу до +завершения — каждый переход остаётся в журнале событий заявки. + +**Стек:** Go · Gin · PostgreSQL (pgx v5) · JWT (golang-jwt) · bcrypt + +--- + +## Быстрый старт + +1. Инфраструктура. Один `docker compose up` поднимает базу **и MinIO** — + локальный S3 для фотоотчёта (в `.env` подставляется `DATABASE_URL` + соответствующего вида): + + ```bash + docker compose up -d --wait # или: make up + ``` + + - **PostgreSQL** — локальная в docker, не зависит от чужих серверов и + туннеля. Порт наружу **5433**, чтобы не конфликтовать с пробросом + рабочей базы на 5432: + `postgres://building_app:building_app@localhost:5433/building_mgmt?sslmode=disable`. + Контейнер ограничен по ресурсам (`mem_limit: 512m`, `cpus: 1`): + dev-база не должна сожрать машину; `max_connections` дефолтный (100), + пул приложения и так маленький. + + - **MinIO** — S3-совместимое хранилище фотоотчёта: API на **5434**, + веб-консоль на **5435** (вход/пароль `minioadmin/minioadmin`), бакет + создаётся сам при старте сервера. Образ собирается из официального + бинарника релиза `RELEASE.2024-07-26T20-48-21Z` (см. + `deploy/minio/Dockerfile`): на Docker Hub `minio/minio` архивирован, + анонимный pull недоступен. В проде подойдёт любой S3-совместимый + (AWS, R2, Wasabi) — ключи задаются в `.env`. + + Остановить — `docker compose down` (`make down`), данные остаются в томах. + + - **внешняя база через туннель** — как раньше: `localhost:5432` + (MinIO поднимается тем же compose). + +2. Положить рядом `.env` (пример ключей — в `.env.example`): + + ```dotenv + DATABASE_URL=postgres://user:pass@localhost:5432/building_mgmt?sslmode=disable + JWT_SECRET=минимум-32-случайных-символа + PORT=8080 + SWAGGER=true # включить Swagger UI (в проде не ставить) + # TRUSTED_PROXIES=172.0.0.0/12 # за reverse proxy — его подсеть + # (по умолчанию доверен только loopback) + S3_ENDPOINT=http://localhost:5434 # локальный MinIO; у штатного AWS — пусто + S3_BUCKET=building-mgmt # обязателен, иначе сервер не стартует + # LOG_FORMAT=json|text и LOG_LEVEL=debug|info|warn|error — необязательно, + # все ключи с пояснениями перечислены в .env.example + ``` + +3. Запустить: + + ```bash + go run ./cmd/api # или: make run + ``` + +Миграции применяются **автоматически при старте** — отдельная команда не нужна. +Но если нужно накатить схему без запуска сервера (CI, релизный шаг): + +```bash +go run ./cmd/migrate # или: make migrate +``` + +Проверка: `GET /ping` — приложение, `GET /db-ping` — база (нужен токен админа), +`GET /swagger/index.html` — интерактивная документация API (нужен +`SWAGGER=true` в `.env` — включено в примере выше). + +4. *(по желанию)* Демо-данные, чтобы не собирать сценарий руками: + + ```bash + go run ./cmd/seed --yes # или: make seed + ``` + + В базе появятся клиент, два мастера, админ, проект «Демо-проект» + с двумя объектами и четыре заявки (все ветки статусной машины). + Пароль у демо-аккаунтов один и печатается командой. Повторный запуск + идемпотентен, `--force` пересоздаёт. **Только локальная/демо-база** — + пароли фиксированные. + +### Первый админ + +Самый быстрый способ — `make seed` (флаг `--yes` создаёт +`demo-admin@example.com`). Ручной способ описан ниже, он нужен, когда +демо-аккаунт не нужен, а админ есть: + +Аккаунты создаются **только изнутри системы**: админ — админов, +клиентов и мастеров (`POST /users`), клиент — исключительно мастеров +(роль там разрешена только `worker`), для себя в вебе, раздел +«Клиенты». Саморегистрации нет — отдельного эндпоинта и кнопки на +экране входа. При этом мастер, созданный клиентом, входит лишь после +его подтверждения (раздел «Подтверждение аккаунта (SMTP)»). +То есть для самого первого админа нужно +завести запись руками, например: + +```sql +INSERT INTO profiles (id, email, password_hash, name, role, is_active, confirmed_at) +VALUES (gen_random_uuid(), 'admin@example.com', + '', 'Admin', 'admin', true, now()); +``` + +Сгенерировать хеш: + +```bash +go run - <<'EOF' +package main + +import ( + "fmt" + "golang.org/x/crypto/bcrypt" +) + +func main() { + h, _ := bcrypt.GenerateFromPassword([]byte("ваш-пароль"), 12) + fmt.Println(string(h)) +} +EOF +``` + +--- + +## Роли и эндпоинты + +| Метод | Путь | Доступ | +|---|---|---| +| POST | `/auth/login` | открытый (лимит 10/мин на IP); неподтверждённый аккаунт → `403 account not confirmed`, отключённый → `403 account disabled` | +| POST | `/auth/refresh` | открытый, без тела: refresh-токен в httpOnly-куке `refresh_token` (лимит 30/мин) | +| POST | `/auth/logout` | открытый, без тела: гасит сессию из куки и саму куку | +| POST | `/auth/logout-all` | любой авторизованный | +| POST | `/auth/forgot-password` | открытый, тело `{email}`; ответ всегда 200 (ящик не «светится»), письмо — только подтверждённым активным; лимит 10/мин на IP и **3 письма/сутки на ящик** → `429` | +| POST | `/auth/reset-password` | открытый, тело `{token, password}` (лимит 30/мин) | +| GET | `/auth/me` | любой авторизованный | +| GET | `/auth/telegram` | любой авторизованный — свой статус уведомлений `{enabled, bound, target, bot_username, has_bot_token, token_configurable}` (токен и другим полям чужим не отдаётся) | +| POST | `/auth/telegram/code` | любой авторизованный — одноразовый код привязки + ссылка `t.me/?start=` (лимит 5 кодов за 5 минут на пользователя) | +| PUT | `/auth/telegram` | любой авторизованный, тело `{target}` — `@группа`/`chat_id` либо пусто (отключить); неверный формат или чат, которого бот не видит → `400 invalid telegram target` | +| DELETE | `/auth/telegram` | любой авторизованный — отключить уведомления | +| POST | `/auth/telegram/bot` | любой авторизованный, тело `{token}` — подключить своего бота: формат → `getMe` → шифрование → поллер (лимит 5 попыток за 5 минут); нет `DATA_ENCRYPTION_KEY` → `400 telegram bot token not configured`, не тот токен → `400 bot token rejected` | +| DELETE | `/auth/telegram/bot` | любой авторизованный — отключить своего бота: стирает токен и гасит его поллер (повтор безопасен) | +| GET | `/projects` | `client` (свои) / `admin` (все, `?client_id=`) | +| POST | `/projects` | `client` (себе) / `admin` (для клиента, тело `{name, client_id}`) | +| GET | `/projects/:id` | `client` (свой) / `admin` | +| PATCH | `/projects/:id` | `client` (свой) / `admin`, тело `{name?, is_active?}` | +| DELETE | `/projects/:id` | **только `admin`** — удалить проект вместе с объектами, участиями и всеми заявками (каскад `0013`, события/фото заявок уходят каскадом `0006`/`0008`); объекты фото из хранилища убираются после коммита (сбой — в лог) | +| GET | `/projects/:id/objects` | `client` (свой) / `admin` | +| POST | `/projects/:id/objects` | `client` (свой) / `admin`, тело `{name, address}` | +| PATCH | `/objects/:id` | `client` (свой) / `admin`, тело `{name?, address?, is_active?}` | +| GET | `/projects/:id/workers` | `client` (свой) / `admin` — состав со статусами | +| POST | `/projects/:id/workers` | `client` (свой) / `admin`, тело `{worker_id}` — **приглашение** | +| DELETE | `/projects/:id/workers/:worker_id` | `client` (свой) / `admin` — отзыв, в том числе непринятого приглашения | +| GET | `/invitations` | `worker` (только свои приглашения) | +| POST | `/projects/:id/invitation/accept` | `worker` (принять своё приглашение) | +| POST | `/projects/:id/invitation/decline` | `worker` (отклонить своё приглашение) | +| POST | `/tickets` | `client` (тело `{object_id, floor?, category, description, priority?, recurring?}`; `recurring = {anchor_at, period_days}` — той же операцией создаётся связанное правило повторения, в ответе заявки поле `recurring`) | +| GET | `/tickets` | любой (список режется по роли) | +| GET | `/tickets/:id` | любой (проверка владельца внутри) | +| GET | `/tickets/:id/events` | любой (кто видит заявку) — журнал событий | +| GET | `/tickets/:id/photos` | любой (кто видит заявку) — фотоотчёт, `url` во временной подписи | +| POST | `/tickets/:id/photos` | `worker` (заявка `in_progress` — после «Работа выполнена» отчёт заморожен, активный мастер проекта) / `admin`; multipart-поле `file`, jpeg/png/webp | +| DELETE | `/tickets/:id/photos/:photo_id` | `worker` (своё, пока заявка `in_progress`) / `admin` (любое) | +| PATCH | `/tickets/:id/assign` | `admin` / `client` (владелец), тело `{worker_id}` | +| PATCH | `/tickets/:id/take` | `worker` (свободная `new` из проектов, где он активный участник) — назначает себя | +| PATCH | `/tickets/:id/start` | `worker` (только назначенный ему) | +| PATCH | `/tickets/:id/done` | `worker` (только назначенный ему) | +| PATCH | `/tickets/:id/confirm` | `client` (владелец, только из `done`) | +| PATCH | `/tickets/:id/reject` | `client` (владелец, только из `done`, тело `{reason}`) | +| PATCH | `/tickets/:id/cancel` | `client` (своя) / `admin` (любая), тело `{reason}` | +| GET | `/recurring` | `client` (свои) / `admin` (все), `&project_id=` — правила повторяющихся заявок, обычная страница | +| POST | `/recurring` | `client` (объект своего проекта) / `admin` (любой — владелец берётся из проекта), тело `{object_id, floor?, category, description, priority?, anchor_at, period_days}` (`anchor_at` — дата-время первого запуска в RFC3339, `period_days` 1..3650 — «каждые N дней/недель») | +| GET | `/recurring/:id` | `client` (своё) / `admin` — чужое неотличимо от несуществующего (`404`) | +| PATCH | `/recurring/:id` | `client` (своё) / `admin`, тело — любые из `{floor, category, description, priority, anchor_at, period_days, is_active}` (минимум одно поле, `object_id` не меняется); выключенное правило планировщик пропускает, изменение расписания пересчитывает срок | +| DELETE | `/recurring/:id` | `client` (своё) / `admin` — правило удаляется вместе с историей срабатываний (каскад `0014`), созданные заявки не трогаются | +| GET | `/recurring/:id/runs` | `client` (своё) / `admin` — история срабатываний: какие заявки создавались по правилу и когда | +| POST | `/recurring/:id/run` | `client` (своё) / `admin` — ручной запуск: заявка создаётся сразу, не дожидаясь срока; `409`, если правило/проект/объект выключены или есть незакрытая заявка (дубль не создаётся); ответ `201 {ok, ticket_id}` | +| POST | `/users` | `admin` (мастеры, админы и клиенты; созданные админом подтверждаются сразу) / `client` (только мастера; созданные ждут его подтверждения) | +| GET | `/users?role=worker` | `admin` (любая роль; `&include_inactive=1` — и отключённые) / `client` (свои мастера: активные участники его проектов плюс созданные им, с `&include_inactive=1` — и отключённые); `&q=` — поиск по имени/email (мин. 2 символа), у клиента всегда только активные | +| POST | `/users/:id/confirm` | `admin` (любой неподтверждённый) / `client` (только созданный им мастер); чужой и несуществующий → `404`, повтор → `409 already confirmed`; после успеха пользователю шлётся письмо | +| PATCH | `/users/:id` | `admin` (любой) / `client` (свой мастер: активный участник его проекта либо создан им), тело `{is_active}` — включить/выключить аккаунт | +| DELETE | `/users/:id` | `admin` — удалить «чистый» аккаунт; `client` — только своих мастеров (участники его проектов либо созданные им; членства в проектах клиента удаляются в той же транзакции); если есть заявки/проекты/чужие участия → `409` (вместо удаления — отключение через PATCH) | +| POST | `/users/:id/password-reset` | `admin` — выпустить токен смены пароля | +| POST | `/users/:id/password-set` | `admin` — задать пароль напрямую, тело `{password}` (политика та же: 10..72 символа, буквы и цифры); успех гасит все refresh-сессии пользователя и его неиспользованные токены сброса; `404` на несуществующий id | +| GET | `/db-ping` | `admin` | + +Ответы `login` и `refresh` одинаковы: + +```json +{"token": "", "expires_in": 3600, + "user": {"id": "…", "email": "…", "role": "client"}} +``` + +Создание аккаунта — только `POST /users`: ответ сразу профиль +(см. таблицу выше), никакого отдельного статуса ожидания нет. + +Refresh-токен в JSON не входит: он уходит только в **httpOnly-куку** +`refresh_token` (`Path=/auth`, `SameSite=Lax`, `HttpOnly`; `Secure` +включается в проде флагом `COOKIE_SECURE=true` — по HTTP браузер +Secure-куку отвергает). Фронт в `localStorage` хранит лишь access-токен, +профиль и срок — XSS из куки ничего не вытащит, а в cross-site POST +кука не уходит вовсе (CSRF закрыт на стороне браузера). + +**Парольная политика** (`auth.ValidatePassword`, только на установку — +вход со старыми паролями работает): 10–72 символа (72 — предел bcrypt), +обязательны буквы и цифры. Применяется при создании +пользователя и смене пароля; ошибка — `400` с текстом вида +`password must be at least 10 characters`. + +### Подтверждение аккаунта (SMTP) + +Неподтверждённый аккаунт — это в первую очередь мастер, созданный +клиентом: токенов нет, вход до подтверждения — `403 account not +confirmed` (не путать с `account disabled`). Письмо «Аккаунт подтверждён» +уходит пользователю **после** подтверждения: multipart `text/plain` + +`text/html` — email аккаунта, роль, дата подтверждения и кнопка входа +(`${APP_BASE_URL}/#/login`), имя/email в HTML экранируются. + +Кто подтверждает — правило целиком в SQL (колонки `confirmed_at` / +`confirmed_by` / `created_by`, миграция 0010): + +- **админ** — любой неподтверждённый аккаунт (`POST /users/:id/confirm`); +- **клиент** — только созданного им мастера: чужой и несуществующий + неразличимы (`404`), повторное подтверждение — `409 already confirmed`; +- **создание админом = подтверждение сразу** («создатель и есть + подтверждающий»): админские `POST /users` не требуют второго клика; +- созданный клиентом мастер ждёт подтверждения этим же клиентом — + в вебе у неподтверждённых бейдж «не подтверждён» и кнопка + «Подтвердить» (раздел «Мастера»); +- существующие на момент обновления аккаунты подтверждены бэкфиллом + миграции. + +Неподтверждённого мастера нельзя пригласить в проект +(`POST /projects/:id/workers` → `400 user is not confirmed`) и назначить +на заявку — обе проверки в SQL. + +SMTP настраивается env-переменными: `SMTP_HOST` (пусто — письма не +шлются, dev), `SMTP_PORT` (по умолчанию 587), `SMTP_USER` (пусто — без +аутентификации), `SMTP_PASSWORD`, `SMTP_FROM` (пусто — берётся +`SMTP_USER`), `SMTP_TLS` (`tls` | `starttls` | `none`; пусто — `tls` +для порта 465, иначе `starttls`) и `APP_BASE_URL` (ссылка на вход в +письме). Некорректные порт/режим — ошибка на старте. Отправка +**fail-open**: недоступный SMTP не откатывает подтверждение, в лог +уходит `warn`. Локальная замена боевому SMTP — Mailpit: +`docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit` +и `SMTP_HOST=localhost SMTP_PORT=1025 SMTP_TLS=none`. + +### Сброс пароля (по письму) + +Пользователь жмёт «Забыли пароль?» на экране входа и вводит email — +`POST /auth/forgot-password` шлёт письмо со ссылкой +(`${APP_BASE_URL}/#/reset-password?token=…`). Ответ **всегда одинаковый** +(200 `{"ok": true}`): эндпоинт не перечисляет аккаунты — неизвестный, +неподтверждённый и выключенный ящик неотличимы от существующего, но +письмо в этих случаях не уходит (получают только **подтверждённые +активные** аккаунты). + +Лимиты: **10 запросов в минуту с IP**; **3 письма в сутки на один +ящик** — счётчик тикает при любом запросе (и для несуществующих ящиков +тоже), при превышении `429`. Счётчики — в БД (`rate_limits`), переживают +рестарт. + +Свойства токена: **одноразовый**, живёт 24 часа (`repository.ResetTTL`), +выпуск нового гасит прежние неиспользованные токены пользователя +(включая свежие из писем); в БД хранится только **sha256** — утечка базы +не даёт применить выпущенные токены. Пользователь меняет пароль: +`POST /auth/reset-password` с телом +`{"token": "…", "password": "…"}` — политика та же, что и при создании +пользователя. +Вместе со сменой пароля закрываются **все refresh-сессии** (прежний +access-токен живёт максимум час). Применение лимитировано 30/мин с IP; +неизвестный, использованный и истёкший токен дают одно и то же +`400 invalid token` — причина не раскрывается. + +Альтернатива письму — админские способы (оба только для роли `admin`): + +- `POST /users/:id/password-reset` возвращает + `{"ok": true, "token": "…", "expires_at": "…"}`, и админ передаёт + ссылку (`${APP_BASE_URL}/#/reset-password?token=…`) пользователю сам + любым каналом — пароль выбирает тот; +- `POST /users/:id/password-set` с телом `{"password": "…"}` задаёт + пароль сразу, без участия пользователя: политика та же (10..72 + символа, буквы и цифры). Успех закрывает **все refresh-сессии** + пользователя и гасит его неиспользованные токены сброса — со старым + паролем в системе остаться нельзя, старые ссылки перестают работать. + Слабый пароль и кривой id — `400`, несуществующий пользователь — + `404`, не-админ — `403`. + +Оба способа вынесены в одно окно разделов «Клиенты» и «Мастера» +веб-интерфейса. + +Фильтры `GET /tickets`: + +- `?status=new` — по статусу, плюс `?category=plumbing` и `?priority=high`; +- `?date_from=2026-10-01&date_to=2026-10-05` — диапазон `created_at`; + даты трактуются в **UTC**: `date_from` — с полуночи UTC, `date_to` — + включая весь день до полуночи UTC следующего дня (сервер парсит + `YYYY-MM-DD` через `time.Parse`, у которого часовой пояс всегда UTC) + в формате `YYYY-MM-DD`, обе границы включительно; +- клиент видит только свои заявки, мастер — назначенные ему плюс свободные + (`new`) в проектах, где он активный участник (такие он вправе взять сам + через `PATCH /tickets/:id/take`); +- админ дополнительно может сузить: `?client_id=…`, `?worker_id=…`; +- всем ролям доступны структурные фильтры `?project_id=…` и `?object_id=…` + (они накладываются поверх выборки по роли — чужое через них не подсмотреть). + +Мусор в фильтре — это `400` с именем параметра (`invalid status`, +`invalid category`, `invalid date_from`), а не пустой список: «введено +не то» и «таких записей нет» — разные вещи. + +### Уведомления в Telegram + +Если заявка пролежала в статусе `new` дольше **порога** (по умолчанию +24 часа), её **клиенту** уходит сообщение в Telegram со ссылкой на заявку +(`${APP_BASE_URL}/#/tickets/:id`). Пока заявку не взяли — напоминание +повторяется **раз в сутки**, после `PATCH /tickets/:id/assign|take` +условие гаснет само (`status = 'new'` в выборке кандидатов). + +**Чём слать** — два источника бота, и работает это так: + +- **свой бот пользователя** — токен от @BotFather вводится в разделе + «Настройки» (`POST /auth/telegram/bot`): сначала формат, потом `getMe` + (неверный токен не сохраняется), затем **шифрование** AES-256-GCM + (ключ `DATA_ENCRYPTION_KEY`) и запись в `profiles.telegram_bot_token`. + Как только токен сохранён, поднимается поллер этого бота, а нотификатор + шлёт уведомления через него. **Токен наружу не отдаётся никогда** — + в статусе только `has_bot_token` и имя бота; +- **глобальный бот сервера** `TELEGRAM_BOT_TOKEN` — запасной вариант для + тех, кто токен не вводил. + +Ни того, ни другого нет — `GET /auth/telegram` отдаёт `enabled:false`, +выдача кода `400 telegram not configured`, а заявки **не помечаются +отправленными**: уведомление уйдёт, как только пользователь подключит +бота, а не потеряется. + +Фоновые задачи живут в `cmd/api` (`internal/notify`), все на контексте, +как чистка лимитов: + +- **notifier** — тик каждые `TELEGRAM_TICK_EVERY` (по умолчанию `1m`): + кандидаты — `repository.StaleTickets` (частичный индекс + `tickets_new_created_idx`); для каждой заявки бот выбирается **по её + клиенту** (`notify.Registry`: личный токен, иначе глобальный). Отправка + **fail-open**: недоступный Telegram не роняет API, а заявка остаётся + кандидатом на следующий тик; «чат недоступен» (`chat not found` / бот + заблокирован) повтора не ждёт — сообщение помечается отправленным, + чтобы не писать в лог каждую минуту; +- **poller** — длинный `getUpdates` для привязки чата: на ошибке + экспоненциальный backoff 1–30 с, `offset` сдвигается всегда (иначе одно + необработанное сообщение встало бы в очередь навечно). Поллер **на каждый + бот свой**: у личного токена — свой экземпляр, у глобального — свой + (`notify.Registry` держит дочерние контексты, остановка процесса гасит + их разом). Очередь `getUpdates` одна на бота, поэтому инстансов API + должно быть один (см. «Известные ограничения»). + +Привязка — пункт меню **«Настройки»** (виден всем ролям): + +0. При желании — **подключить своего бота**: поле токена → + `POST /auth/telegram/bot` → `getMe` → шифрование → поллер. Отключить — + `DELETE /auth/telegram/bot`. Без `DATA_ENCRYPTION_KEY` поле на сервере + не работает (записать токен некуда) — остаётся глобальный бот. +1. «Получить код привязки» → `POST /auth/telegram/code` отдаёт + одноразовый код и ссылку `https://t.me/?start=`. Код живёт + 15 минут, выпуск нового гасит прежние, в БД лежит только **sha256** + (как токены сброса пароля); лимит — 5 выдач за 5 минут на пользователя. + Бот для ссылки — свой, а если токена нет — глобальный. +2. Человек отправляет код боту → poller гасит код одним `UPDATE` + (одноразовость и срок — в самом `WHERE`), пишет числовой `chat_id` в + `profiles.telegram_target` и отвечает «Готово — уведомления будут + приходить в этот чат». +3. Либо вручную: `PUT /auth/telegram` с телом `{"target": "@mygroup"}` + — группа или канал, куда бот добавлен участником. Формат + (`^@[A-Za-z0-9_]{5,32}$` либо `^-?[0-9]{5,20}$`) проверяется до базы, + затем бот сверяет чат через `getChat`. **Личный `@ник` здесь не + работает намеренно**: Telegram не даёт писать человеку по одному + никну, пока он сам боту не напишет, — для этого и нужен код. +4. `DELETE /auth/telegram` (или `PUT` с пустым `target`) — отключить. + +Поле `telegram_target` отдаётся **только владельцу** (`GET /auth/telegram` +и сама цель): в списках `GET /users` его нет. Токен бота не отдаётся +**никому** — в статусе только факт его наличия (`has_bot_token`) и имя +бота, а в логи он не попадает (там только `getMe`-результаты). + +Настройка env-переменными: + +| Переменная | Назначение | +|---|---| +| `DATA_ENCRYPTION_KEY` | ключ шифрования токенов ботов (AES-256-GCM, 32 байта в base64 или hex: `openssl rand -base64 32`); **пусто — ввод токена выключен**: `POST /auth/telegram/bot` → `400 telegram bot token not configured`, а сохранённые ранее токены не расшифровываются (warn при старте). Неверный формат — фейл-фаст до подключения к БД | +| `TELEGRAM_BOT_TOKEN` | токен **глобального** бота (секрет, задаётся env-ом); пусто — не страшно: уведомления пойдут через личные токены пользователей, а если не будет ни того, ни другого, `GET /auth/telegram` отдаёт `enabled:false` и выдача кода — `400 telegram not configured` | +| `TELEGRAM_API_URL` | база Bot API; по умолчанию `https://api.telegram.org` | +| `TELEGRAM_NOTIFY_AFTER` | порог напоминания, duration; по умолчанию `24h` | +| `TELEGRAM_TICK_EVERY` | период тика notifier'а; по умолчанию `1m` | + +Бот заводится через @BotFather и должен быть добавлен в группу или канал, +если цель — общая. Отказ бота в `getChat` и неверный формат дают одно и +то же `400 invalid telegram target` — причина уходит в лог, наружу не +попадает. + +## Пагинация и сортировка + +Все списки (`/tickets`, `/projects`, `/projects/:id/objects`, +`/projects/:id/workers`, `/users`, `/invitations`, +`/tickets/:id/events`, `/tickets/:id/photos`) — страницы одинаковой формы: + +```json +{"items": [ … ], "total": 137, "limit": 20, "offset": 40} +``` + +- `limit` — `1…100`, по умолчанию `20`; больше потолка → `400 invalid limit`; +- `offset` — сдвиг от начала выборки, по умолчанию `0`; сдвиг за концом + не ошибка: `items: []`, а `total` остаётся прежним; +- `sort` — ключ из белого списка у каждого списка, неизвестный → `400 invalid sort`; +- `order` — `asc` | `desc`; направление по умолчанию зависит от поля. + +| Список | `sort=` | по умолчанию | +|---|---|---| +| `/tickets` | `created_at`, `priority` (сначала важные), `id` | `created_at` desc | +| `/projects` | `created_at`, `name`, `id` | `created_at` desc | +| `/projects/:id/objects` | `name`, `created_at`, `id` | `name` asc | +| `/projects/:id/workers`, `/users` | `name`, `created_at`, `email`, `id` | `name` asc | +| `/invitations` | `invited_at`, `expires_at`, `project_name` | `invited_at` desc | +| `/tickets/:id/events` | `id`, `created_at` | `id` asc (хронология) | +| `/tickets/:id/photos` | `id`, `created_at` | `id` asc (хронология) | + +Сортировка всегда дополняется `id`, чтобы одинаковые значения не +«перетасовывались» между страницами. Колонка в `ORDER BY` берётся из +словаря в коде — пользовательская строка в SQL не попадает никогда. + +`total` считается **до** обрезки страницы (`COUNT(*) OVER()` одним +запросом; для пустого окна — отдельным `COUNT` с тем же `WHERE`), +поэтому число страниц стабильно при листании. + +> Ломающий формат: раньше списки отдавали голый массив. Потребителям +> списка нужно читать `items` (Swagger и README обновлены). + +--- + +## Структура: проект → объект → мастер → заявка + +``` +admin ──создаёт──> client ──создаёт──> project ──┬──> project_objects (адреса) + └──> project_workers (мастера, M2M) + │ + заявка ──object_id──> объект проекта +``` + +- **Проект** — контур клиента: что и для кого делаем. Уникальность — + `(client_id, name)`, у админа при создании обязателен `client_id`. +- **Объект** — здание/корпус внутри проекта с адресом. Адрес заявки + **копируется из объекта на момент создания** (история показывает адрес, + который был тогда; правка объекта старые заявки не меняет). +- **Мастера** — M2M `project_workers` со статусами (миграция `0005`): + `invited → active | declined`. Мастер может состоять в нескольких + проектах; «свои мастера» клиента — это `GET /users?role=worker`, то есть + только принявшие приглашение. Состав проекта + (`GET /projects/:id/workers`) отдаёт профили вместе со статусом и + сроком ответа. Существующего мастера, ещё не состоящего в проектах, + клиент находит поиском `GET /users?role=worker&q=<имя или email>` + (минимум 2 символа; так клиент узнаёт только того, чей адрес ввёл сам, + не видя полного списка) — этим же запросом пользуется поиск в вебе. +- **Заявка** всегда привязана к объекту (`object_id` обязателен) и проекту; + адрес в теле `POST /tickets` больше не передаётся. + +Принадлежность проверяется **на уровне БД** (составные FK, миграция `0004`): + +- `tickets (object_id, project_id) → project_objects (id, project_id)` — + объект входит именно в этот проект; +- `tickets (project_id, client_id) → projects (id, client_id)` — проект + принадлежит клиенту заявки; +- членство мастера — проверка внутри самого `UPDATE` назначения (FK отдалял бы + уход мастера из проекта навсегда: ссылки остаются от закрытых заявок); +- чужой проект/объект неотличим от несуществующего: `404`, а не `403`. + +Правила жизни структуры: + +- выключенный проект (`is_active=false`) не принимает новые объекты и заявки — + `409 project is not active`; +- мастера нельзя убрать из проекта, пока за ним есть **незакрытые** заявки + этого проекта — `409 worker has open tickets in this project`; после + закрытия отзыв проходит, история заявок при этом сохраняется; +- назначить заявку можно только **принявшему приглашение** мастеру её + проекта: посторонний — `400 worker is not in the project of this ticket`, + приглашённый, но не ответивший — `400 invitation is pending: the worker + has not accepted it`, отказавшийся — `400 invitation was declined`. + Рубеж стоит в самом `UPDATE` назначения (`status = 'active'`). + +### Приглашения мастеров + +`POST /projects/:id/workers` с 0005 — это **приглашение, а не членство**: + +```json +{"ok": true, "status": "invited", "expires_at": "2026-10-09T…Z"} +``` + +- приглашение живёт **7 дней** (`repository.InvitationTTL`); после этого + мастеру — `409 invitation expired`, а клиент может отозвать строку + обычным `DELETE /projects/:id/workers/:worker_id`; +- мастер смотрит свои приглашения в `GET /invitations` (просроченные там + не показываются) и решает: `POST /projects/:id/invitation/accept` — + `invited → active`, и только тогда проект начинает назначать ему + заявки; либо `…/decline` — `invited → declined`; +- принудительного членства (`force`) **нет**: единственный путь стать + участником — принять приглашение самому; +- повторное приглашение после отказа перезапускает ту же строку; + дубликаты — `409 worker is already in the project` (уже активен) и + `409 invitation is already pending` (ждём ответа); вторичный + accept/decline — `409 invitation is not pending`; +- `DELETE` отзывает строку любого статуса, непринятое приглашение — тоже. + +--- + +## Статусная машина заявки + +``` +new ──assign(admin/client) или take(worker)──> assigned ──start(worker)──> in_progress ──done(worker)──> done + │ │ │ + │ confirm(client) reject(client, причина) + │ │ │ + │ ▼ ▼ + └──cancel(client/admin, причина)──────────┐ closed rejected (доработка) + │ confirmed_at │ + │ + closed_at start(worker) → in_progress + └───────────────────── (второй круг, rework_count+1) + └── cancel(…) ──> closed + cancel_reason +``` + +| Переход | Кто | Условие в SQL | +|---|---|---| +| `new → assigned` | админ, клиент-владелец или сам мастер (`take`) | `status = 'new'`, мастер принят в проекте заявки (`status = 'active'`) | +| `assigned → in_progress` | назначенный мастер | `worker_id = current` | +| `in_progress → done` | назначенный мастер | `worker_id = current` | +| `done → closed` (принято) | клиент-владелец | `client_id = current`, ставит `confirmed_at` + `closed_at` | +| `done → rejected` (доработка) | клиент-владелец | `client_id = current`, причина обязательна, `rework_count+1` | +| `rejected → in_progress` | назначенный мастер | второй круг, `started_at` не затирается | +| `new/assigned/done/rejected → closed` | клиент (свой) / админ (любой, кроме `closed`) | ставит `cancel_reason` + `closed_by` | + +Ключевое: **`rejected` — это доработка, а не финал**. Финал у заявки один — +`closed`; исход различают `confirmed_at` (работа принята) и `cancel_reason` +(отменено/отказ). Клиенту нельзя отменить заявку в `in_progress` — мастер уже +выехал; там вправе только админ. + +Переходы защищены **на уровне БД**, а не только в коде: + +- `tickets_needs_worker` — мастер обязателен во всех статусах, кроме `new` + и закрытых (отменённая заявка может остаться без мастера); +- `tickets_assigned_time`, `tickets_started_time`, `tickets_done_time`, + `tickets_confirmed_time`, `tickets_closed_time`, `tickets_rejected_time` — + достигнутый статус обязан иметь свой таймстамп; +- `tickets_rejected_reason` — отклонение только из `done` и только с причиной; +- `tickets_closed_reason` — закрыта либо принята клиентом, либо отменена с причиной; +- `tickets_status_check`, `tickets_category_check`, `tickets_priority_check` — + белые списки значений. + +**Журнал событий** (`0006`, `GET /tickets/:id/events`): каждый переход +оставляет запись — `created`, `assigned`, `started`, `done`, `confirmed`, +`rejected`, `canceled` — с автором, парой `from_status → to_status`, +причиной (для `rejected`/`canceled`) и временем. Запись создаётся **в той же +SQL-операции, что и переход** (CTE `UPDATE … RETURNING → INSERT` в журнал): +«заявка перешла, а истории нет» невозможно — либо обе части, либо ни одной. +У события `created` нет `from_status`, при удалении аккаунта `actor_id` +обнуляется, но сама запись остаётся. Список пагинирован, по умолчанию — +хронология (`id` asc), `total` — все события заявки. + +Пул свободных заявок (`scope=free`) **удалён**: очередь не нужна — назначает +админ или клиент-владелец заявки. Мастер видит свободные (`new`) заявки +только своих проектов и может взять их сам (`PATCH /tickets/:id/take`). + +--- + +## Фотоотчёт (S3) + +Мастер показывает ход работ фото: только фото `jpeg`/`png`/`webp` (без видео), +формат определяется **по сигнатуре байтов**, а не по заголовку `Content-Type`. +Байты живут в S3-совместимом хранилище (локально MinIO в compose, в проде — +любой S3), в БД — только метаданные (`ticket_photos`, миграция `0008`). + +| Метод | Путь | Доступ | +|---|---|---| +| `POST` | `/tickets/:id/photos` | `worker` / `admin`, multipart-поле `file` → **201** + `url` (временная ссылка) | +| `GET` | `/tickets/:id/photos` | все, кто видит заявку; страница как у остальных списков | +| `DELETE` | `/tickets/:id/photos/:photo_id` | `worker` — своё, пока заявка `in_progress`; `admin` — любое | + +Правила живут **в SQL** (та же CTE-схема, что и переходы статусов): + +- загрузка разрешена только в статусе `in_progress` — и мастеру, и админу: + после «Работа выполнена» (`done`) отчёт заморожен, а после отклонения + клиентом (`rejected`) откроется снова вместе со стартом доработки; + мастер должен быть **активным участником проекта** заявки; +- лимиты из окружения: `PHOTO_MAX_BYTES` (по умолчанию 10 МБ на файл) и + `PHOTO_MAX_PER_TICKET` (20 файлов на заявку); превышение — + `400 photo too large` и `409 too many photos`; +- формат вне белого списка — `400 unsupported photo type`; +- каждая загрузка и удаление оставляет событие `photo_added`/`photo_removed` + в журнале **той же SQL-операцией** (comment — имя файла); у фото-событий + статус не меняется: `from_status = to_status`. + +Порядок в `POST`: валидация → заливка в S3 → `INSERT` строки и события; +если вставка не прошла (гонка за лимит) — объект компенсаторно удаляется, +«сирот» в хранилище не остаётся. При удалении наоборот: сначала строка и +событие, затем объект (сбой чистки уходит в лог, журнал не разъезжается +со строкой). + +Бакет **приватный**: наружу уходят только подписанные ссылки (SigV4, 15 минут), +ключ объекта клиенту не виден — его генерирует сервер +(`tickets//.`). Файл переживает удаление аккаунта +загрузившего (`uploaded_by → SET NULL`). + +Как и `JWT_SECRET`, конфигурация S3 проверяется на старте: без `S3_BUCKET` +(и без ключей при своём `S3_ENDPOINT`) сервер не стартует — ошибка видна +сразу, а не на первой загрузке. + +--- + +## Очистка старых заявок + +Фоновая задача удаляет **закрытые** заявки, чей `closed_at` старше +`TICKET_RETENTION_DAYS` (по умолчанию 60 дней; `0` — чистка выключена, +кривое значение — сервер не стартует, как у `PHOTO_MAX_*`). Прогон — +при старте сервера и дальше раз в сутки. + +- удаляются только заявки в `closed`: незакрытые (новые, в работе, + на доработке) не трогаются **никогда** — иначе терялась бы + незакрытая работа; возраст считается от закрытия, а не от создания: + заявка, закрытая вчера, но созданная два месяца назад, остаётся; +- события журнала (`ticket_events`) и строки фото (`ticket_photos`) + уходят каскадом (`ON DELETE CASCADE`) той же операцией; строка + истории повторяющейся заявки (`recurring_runs`) удаляется отдельным + шагом в конце — у удалённой заявки она осталась бы сиротой + (`ticket_id → SET NULL`), жить без заявки ей не на что; +- ключи объектов фото снимаются **до** удаления строк, затем файлы + удаляются из хранилища best-effort: сбой уходит в лог + (`photo object cleanup failed`), строка в БД уже удалена — тот же + порядок «сначала строка, потом объект», что в чистке фотоотчёта; +- удалённая заявка исчезает из списков, `GET /tickets/:id` отвечает 404; +- если что-то удалилось, в лог пишется + `tickets cleanup deleted=N photo_objects=M`. + +--- + +## Повторяющиеся заявки + +Периодические работы («осмотр вентиляции каждые 14 дней») заводятся +один раз **правилом** — шаблоном будущей заявки (объект, категория, +описание, приоритет) плюс расписанием `anchor_at` (дата-время первого +запуска) и `period_days` (период в днях): заявка создаётся **каждые +N дней/недель** от точки отсчёта (`recurring_rules`, миграции +`0014`/`0016`). + +Обычный путь — галочка «Повторяющаяся заявка» в `POST /tickets`: заявка, +правило и связь создаются одной операцией, в ответе заявки — поле +`recurring` (расписание, состояние, ближайший запуск). Управление на +странице заявки: `PATCH /recurring/:id` вкл/выкл и история +`GET /recurring/:id/runs`. Отдельный CRUD `GET/POST /recurring`, +`GET/PATCH/DELETE /recurring/:id` сохранён для API-клиентов (роли — +как у проектов): `PATCH` правит шаблон и расписание частично — меняются +только присланные поля, объект не переносится (это удаление и +создание). `POST /recurring/:id/run` — ручной запуск: заявка создаётся +сразу, не дожидаясь срока, но рубежи остаются — выключенное +правило/проект/объект или незакрытая прошлая заявка дают `409` +(дубль не создаём). После ручного запуска `last_run_at` сдвигается, +поэтому следующий срок отсчитывается уже от него. + +Как считается срок (считается Go-функцией `nextDue`, не SQL): + +- срок — сетка точек `anchor_at + k·period_days` (k ≥ 0), ближайшая + **строго после** отправной точки `COALESCE(last_run_at, created_at)`: + первая заявка — первая точка сетки **после создания** правила + (`anchor_at` в будущем — это сам `anchor_at`: пользователь выбрал + первый запуск), дальше — точка после каждой созданной заявки + (и после ручного запуска — тоже); +- сетка не «плывёт» после каждого срабатывания: опоздание на тик не + накапливается, дата отсчёта не сдвигается; +- **пока прошлая заявка по правилу не закрыта, новая не создаётся** + (ждём закрытия, без дублей); пропуск не сдвигает `last_run_at` — + после закрытия следующий тик создаст заявку сразу; +- выключенное правило, проект или объект — пропуск без сдвига срока: + когда включат, заявка создастся (возможно, с опозданием); +- точность расписания — **до суток**: тик планировщика раз в сутки, + выбранное в `anchor_at` время ориентировочное (заявка появится + ближайшим тиком после срока). + +Сам планировщик (`runRecurring` в `cmd/api`) — та же схема, что у чистки +заявок: прогон при старте сервера и дальше раз в сутки (срок считается +в днях, суточной точности хватает). Каждое правило обрабатывается в +**своей транзакции** с `FOR UPDATE` строки правила — параллельный прогон +(второй инстанс) не создаст вторую заявку. Заявка, её событие `created` +и строка `recurring_runs` со сдвигом `last_run_at` — одной операцией: +«заявка есть, а правило думает, что не запускалось», невозможно. + +Клиенту при авто-создании уходит уведомление в Telegram — как при +напоминаниях, через бота (`BotFor(clientID)`: собственный токен +пользователя, иначе глобальный) в `telegram_target`; ручной запуск +шлёт тем же текстом общим кодом. Отправка fail-open: недоступный +Telegram не роняет ни планировщик, ни запрос, ошибка уходит в warn, +заявка остаётся созданной. + +Удаление правила (или каскад проекта/объекта — `0014`) не трогает уже +созданные заявки: они живут обычным порядком. + +--- + +## Миграции + +Лежат в `internal/db/migrations/` и встраиваются в бинарник через `embed`. + +- имя файла — `_.sql`, например `0001_init.sql`; +- применяются по возрастанию версии, каждая в своей транзакции; +- применённые версии пишутся в таблицу `schema_migrations` вместе с + **checksum файла**. + +Сейчас в наборе шестнадцать миграций: + +| Версия | Что делает | +|---|---| +| `0001_init` | таблицы `profiles` и `tickets`, базовые ограничения и индексы | +| `0002_ticket_resolution` | колонки `reject_reason`, `cancel_reason`, `rejected_at`, `closed_by`, `rework_count`; ограничения закрытия цикла | +| `0003_hardening` | индекс `profiles_email_lower_key` (email без учёта регистра), таблица `refresh_tokens` | +| `0004_projects` | таблицы `projects`, `project_objects`, `project_workers`; у `tickets` — обязательные `project_id`/`object_id` и составные FK (перед этим удаляет тестовые заявки, сделанные до объектов) | +| `0005_project_invitations` | у `project_workers` — статусы `invited/active/declined`, `invited_by`, `invited_at`, `responded_at`, `expires_at` и индексы списков; существующие строки становятся `active` | +| `0006_ticket_events` | таблица `ticket_events` (журнал переходов заявки) и индекс `(ticket_id, id)` | +| `0007_password_reset` | таблица `password_reset_tokens` (sha256 одноразового токена, срок, использование) и индекс по пользователю | +| `0008_ticket_photos` | таблица `ticket_photos` (метаданные фотоотчёта; `uploaded_by` → `SET NULL`, CHECK на тип/размер) и расширение `CHECK` журнала на действия `photo_added`/`photo_removed` | +| `0009_rate_limits` | таблица `rate_limits` — счётчики брутфорс-лимитов в БД вместо памяти (переживают рестарт и общие для нескольких инстансов) | +| `0010_user_confirmation` | колонки `confirmed_at` / `confirmed_by` у `profiles` — подтверждение аккаунта админом или клиентом; правило доступа целиком в SQL | +| `0011_telegram_notifications` | `profiles.telegram_target` (куда слать уведомления), `tickets.tg_notified_at` (дедуп «раз в сутки»), таблица `telegram_bind_codes` (sha256 одноразового кода привязки, TTL 15 минут) и частичный индекс `tickets_new_created_idx` | +| `0012_user_bot_tokens` | `profiles.telegram_bot_token` (зашифрованный токен собственного бота пользователя, `bytea`) и `telegram_bot_username` (его имя из `getMe`) | +| `0013_project_delete_cascade` | два FK в `tickets` (`object_project`, `project_client`) пересоздаются с `ON DELETE CASCADE` — проект удаляется админом вместе с заявками (события/фото и так каскадные с `0006`/`0008`) | +| `0014_recurring_rules` | таблицы `recurring_rules` (правило повторяющейся заявки: шаблон + расписание, составные FK как у заявки, каскад с проектом/объектом) и `recurring_runs` (история срабатываний: какая заявка создана по какому правилу; `ticket_id` → `SET NULL`) | +| `0015_recurring_annual` | `recurring_rules`: `interval_months` заменяется на `run_month`/`run_day` (повтор ежегодно в эту дату, месяц 1..12, день 1..31; существующие правила переводятся на день/месяц создания) | +| `0016_recurring_period` | `recurring_rules`: `run_month`/`run_day` заменяются на `anchor_at` (дата-время первого запуска) и `period_days` (период в днях, 1..3650 — «каждые N дней/недель»); существующие правила получают `anchor_at = COALESCE(last_run_at, created_at)` и `period_days = 365` — ритм «раз в год» сохраняется | + +Правила: + +1. **Применённую миграцию редактировать нельзя** — при несовпадении checksum + приложение не стартует. Изменение — это всегда новая миграция со следующим + номером (`0008_*.sql`). +2. Все миграции написаны так, чтобы сработать и на пустой базе, и на базе, + где таблицы были созданы руками до появления миграций (`IF NOT EXISTS` + + приведение ограничений к эталону). + +--- + +## Ошибки API + +Handlers не отдают тексты ошибок БД наружу — они пишутся в лог, клиенту +уходит фиксированное сообщение: + +| Код | Класс (`internal/repository`) | Ответ | +|---|---|---| +| 404 | `ErrNotFound` | `{"error":"not found"}` | +| 409 | `ErrConflict` (уникальность, конфликт состояния) | `{"error":"conflict"}` | +| 422 | `ErrInvalid` (нарушение CHECK/FK) | `{"error":"invalid request"}` | +| 500 | инфраструктура (сеть, падение БД) | `{"error":"internal server error"}` | + +Исключение — сообщения, которые полезны клиенту и не раскрывают внутренности: +`email already exists`, `invalid credentials`, `worker not found`, +`invalid token`, `invitation is already pending`, +`worker is already in the project`, `invitation expired`, +`invitation is not pending`, +`invitation is pending: the worker has not accepted it`, +`invitation was declined`, `photo too large`, `invalid file`, +`unsupported photo type`, `too many photos`, `photo cannot be removed`, +`user has related records`. + +--- + +## Надёжность и безопасность + +**Токены и сессии** + +- access-токен — JWT HS256, живёт **1 час** (`auth.AccessTTL`); refresh-токен — + случайный секрет на 30 дней, в БД хранится только его **sha256** + (`auth.HashToken`), а клиенту секрет отдаётся **только в httpOnly-куке** + `refresh_token` (`Path=/auth`, `SameSite=Lax`, `Secure` — через + `COOKIE_SECURE=true`); в JSON его нет и в `localStorage` он не попадает; +- `POST /auth/refresh` делает **ротацию**: на входе старый токен (из куки), + на выходе новая пара, старая сессия гасится. Повторное предъявление + отозванного токена закрывает **все** сессии пользователя — это признак + того, что секретом владеет кто-то второй; событие пишется в лог + с префиксом `security:`; +- `POST /auth/logout` гасит одну сессию, `POST /auth/logout-all` — все; +- протухшие и отозванные записи чистятся при старте приложения. + +**Брутфорс** + +- `/auth/login` — 10 попыток в минуту с одного IP, + `/auth/refresh`, `/auth/logout` и `/auth/reset-password` — 30/мин, + `/auth/forgot-password` — 10/мин на IP **плюс 3 письма в сутки на + один ящик** (ключ — `lower(email)`, счётчик тикает и для + несуществующих ящиков: 429 не раскрывает, что адрес зарегистрирован); +- при превышении — `429 {"error":"too many attempts, try again later"}`; +- лимитер в PostgreSQL (таблица `rate_limits`, миграция `0009`, ключ — + `«лимит»|IP`): счётчик переживает рестарт API и общий для нескольких + инстансов; при недоступности БД лимитер **fail-open** (пропускает + запрос и пишет `warn` в лог) — вход не должен падать из-за счётчика; +- ключ берётся из `c.ClientIP()`, поэтому `gin.SetTrustedProxies` настроен + из `TRUSTED_PROXIES` (по умолчанию только loopback, за reverse proxy — + его подсеть): без этого gin верил бы любому `X-Forwarded-For` — левый + элемент списка подделал бы и ключ лимита, и `client_ip` в логах. + **Прокси обязан перезаписывать заголовок** (`$remote_addr`), а не + дополнять: иначе левым останется присланный клиентом мусор. + +**Сеть** + +- у `http.Server` включены таймауты: `ReadHeaderTimeout` 10с — защита + от Slowloris, `ReadTimeout`/`WriteTimeout` 60с — с запасом на загрузку + фотоотчёта, `IdleTimeout` 120с — висящие keep-alive соединения; +- Swagger UI по умолчанию **выключен** (открытая схема в проде не нужна), + включается флагом `SWAGGER=true` — так в `.env` локальной разработки. + +**CORS** + +- разрешённые источники фронта задаются `CORS_ORIGINS` через запятую + (например `http://localhost:3000,http://127.0.0.1:3000`); +- пустое значение — заголовки не отдаются **вовсе**: одностраничнику на + том же домене CORS не нужен, а чужому домену их и не должно быть; +- разрешённый origin получает свой точный адрес в + `Access-Control-Allow-Origin` (не `*`: рядом `Authorization`, звёздочка + запрещена спецификацией), плюс `Vary: Origin`,Methods/Headers и + `Access-Control-Max-Age: 600`; +- preflight `OPTIONS` отвечает `204` до авторизации — браузер спрашивает + «можно ли» заранее, запроса с токеном ещё нет. + +**Учётные записи** + +- email нормализуется при записи (обрезка + нижний регистр) и ищется + без учёта регистра: `Admin@x.ru` и `admin@x.ru` — один пользователь, + дубликат любого регистра — `409` (держит индекс `profiles_email_lower_key`); +- выключить аккаунт — `PATCH /users/:id {"is_active": false}`: админ — + любой, клиент — только свой мастер (активный участник его проектов + либо созданный им; граница в SQL: чужой мастер неотличим от + несуществующего → `404`). + Выключенный не войдёт (login → `403 account disabled`), **все его + сессии закрываются сразу**, а не по истечении access-токена; выключить + самого себя нельзя. Флаг общий на все проекты мастера — осознанный + побочный эффект: он перестаёт работать везде; включить обратно может + и админ (`GET /users?include_inactive=1` показывает отключённых — в + обоих разделах), и владелец проекта (кнопкой в проекте); +- удалить аккаунт — `DELETE /users/:id`: админ удаляет любой «чистый» + профиль, его refresh-сессии и токены сброса уходят каскадом. Клиент — + только своего мастера (состоит в одном из его проектов — любой статус + членства — либо создан им): членства в проектах клиента удаляются в + той же транзакции, чужие проекты не трогаются. Если на мастера + ссылаются заявки либо + чужие участия (FK без каскада), БД не отдаёт строку → + `409 user has related records`, и **вся транзакция откатывается** — + членства остаются на месте; в этом случае аккаунт отключают, + историю не стираем. Чужой мастер и несуществующий id неразличимы + (`404`), удалить себя нельзя; +- подтвердить аккаунт — `POST /users/:id/confirm`: админ — любой + неподтверждённый, клиент — только созданного им мастера (границы в + SQL: `404` на чужого, `409 already confirmed` на повтор); после + успеха пользователю уходит письмо «аккаунт подтверждён», SMTP + недоступен — подтверждение остаётся, в лог пишется `warn`; +- `GET /db-ping` закрыт от посторонних: по ответу наружу уходило состояние базы. + +--- + +## Логи и request-id + +Логирование — структурное, через `log/slog` (`internal/logging`): + +- `LOG_FORMAT=text` (по умолчанию, читаемо в консоли) или `json` + (для агрегатора логов); +- `LOG_LEVEL=debug|info|warn|error` — по умолчанию `info`. + +Каждый запрос проходит цепочку middleware (`cmd/api`, порядок важен): + +1. `middleware.RequestID` — берёт входящий `X-Request-Id`, если он осмысленный + (до 64 символов из `[A-Za-z0-9._-]`), иначе генерирует свой; кладёт id + в контекст и возвращает его тем же заголовком; +2. `middleware.Recovery` — паника не роняет процесс: клиенту `500` + с фиксированным текстом, в лог — причина и стек; +3. `middleware.AccessLog` — одна запись на запрос: `method`, `path`, `status`, + `duration_ms`, `client_ip`, `user_id` (если есть) и `request_id`. + Уровень по статусу: `5xx` → error, `4xx` → warn, остальное → info. + +Ошибки, которые логируются отдельно (в `respondError`, логинах, деактивации), +тоже несут `request_id` — один запрос находится в логах по одному значению. + +--- + +## Инженерная культура + +- **Линтер** — `golangci-lint` v2, конфиг в `.golangci.yml`: стандартный набор + (`errcheck`, `govet`, `staticcheck`, `ineffassign`, `unused`) плюс `gocritic`, + `misspell`, `revive`, форматтеры `gofmt`/`goimports`. Исключения узкие и + задокументированы (в тестах `t` идёт первым аргументом). + + ```bash + golangci-lint run ./... # или: make lint + ``` + +- **CI** — Drone (`.drone.yml`, сервер подключён к Gitea): `vet` → накат + миграций на сервисный PostgreSQL → `go test -race ./...` → `go build` → + `golangci-lint` → сборка docker-образа. Тесты в CI идут **на живой + базе**, а не скипаются: без `DATABASE_URL` интеграционные тесты + пропускаются молча. `-race` на живой базе дороже, зато ловит гонки + между пулом соединений, лимитером и горутинами хендлеров. Деплой в + прод — ручным запуском того же пайплайна (см. «CI/CD (Drone + Gitea)»). + +- **Команды** — `Makefile`: `run`, `build`, `test`, `race`, `vet`, `lint`, + `fmt`, `swag`, `migrate`, `seed`, `up`, `down`, `tidy`. На Windows без + `make` те же пункты — обычные `go`-команды (список выше и есть канон); + `up`/`down` — это `docker compose up -d --wait` / `docker compose down`. + +- **Graceful shutdown** — сервер слушает `SIGINT`/`SIGTERM` и останавливается + аккуратно: `http.Server.Shutdown` ждёт текущие запросы (до 15 секунд), + затем закрывается пул соединений; процесс завершается с кодом `0`. + Точка входа устроена как `main()` → `os.Exit(run())`, чтобы `defer` + (в частности `pool.Close()`) гарантированно отработал — `log.Fatal` + внутри `run` их бы не выполнил. + +- **Первый запуск в новой базе**: `go run ./cmd/migrate`, затем `go run ./cmd/api`. + +--- + +## Swagger UI + +`GET /swagger/index.html` — интерактивная документация по всем эндпоинтам API: +схемы тел, коды ответов, кнопка **Authorize** (подставляет `Bearer `). +Роут регистрируется только при `SWAGGER=true` (по умолчанию выключен — +на проде открытая схема не нужна; в `.env` локальной разработки он включён). + +Схема генерируется из аннотаций над хендлерами: + +```bash +go install github.com/swaggo/swag/cmd/swag@latest +swag init -g cmd/api/main.go -o docs --parseInternal +``` + +На выходе — каталог `docs/` (`docs.go`, `swagger.json`, `swagger.yaml`), +встроенный в бинарник через импорт `_ "building-mgmt/docs"` в `cmd/api/main.go`. + +**Поменяли эндпоинт — перегенерируйте** (иначе документация отстаёт от кода). +Забыли — свидетельство прогресса, а не дыра: работает ровно то, что +зашито в `docs/`. + +--- + +## Тесты + +```bash +go test ./... # или: make test +go test -race ./... # или: make race — как в CI +``` + +`-race` требует CGO и C-компилятора: в CI (linux-контейнер) они есть, на +Windows без MinGW команда отвечает `go: -race requires cgo` — там гонки +ищет CI. + +- `internal/auth` — JWT: подделка `alg`, чужой секрет, просроченный токен, + пустой секрет; генерация refresh-секрета; +- `internal/ratelimit` — лимитер: исчерпание бюджета, сброс окна, чистка + протухших ключей, `429` от middleware; +- `internal/middleware` — CORS: разрешённый origin получает точные заголовки + и preflight `204` без тела, чужой origin и пустой список — ничего вовсе; +- `internal/db` — пул соединений (`applyPoolEnv`): пустые `DB_*` не трогают + дефолты pgxpool, кривые значения — фейл-фаст на старте, а не молчаливый + дефолт; +- `internal/repository` — интеграционные на реальной БД: статусная машина, + ограничения схемы (включая констрейнты `0002`/`0003`), фильтры списка, + конфликт уникальности, нормализация email, жизненный цикл refresh-сессий + (выпуск, отзыв, `logout-all`, чистка протухших), изоляция проектов + (`project_test.go`): чужой проект/объект неотличим от несуществующего, + дубликаты имён, членство мастера, отзыв мастера при открытых заявках, + назначение только «своего» мастера и составной FK на объект чужого проекта; +- приглашения (`0005`): invite → accept/decline, отказ, просрочка + (принять нельзя, отклонить можно), повторное приглашение по той же + строке, точные причины дубликатов, назначение только после `accept`; +- журнал событий (`TestTicketEvents`): полный цикл заявки оставляет + точную хронологию с авторами и причинами, `from_status` отмены — + настоящий статус на момент отмены, пагинация не сбрасывает `total`; +- сброс пароля (`TestPasswordReset`, `TestPasswordResetExpired`, + `TestSetPasswordDirect`): токен одноразовый, выпуск нового гасит + прежний, просроченный не меняет пароль; прямая установка админом + меняет хеш, гасит выпущенные токены и отдаёт `ErrNotFound` на + неизвестный id; +- фотоотчёт (`TestPhotoReportLifecycle`): запрет до старта работ даже для + админа, загрузка активным мастером проекта и отказ чужому, лимит файлов + (`ErrTooManyPhotos`), события `photo_added`/`photo_removed` с + `from_status = to_status`, удаление (своё — можно, повторное — 404, + чужая заявка — 404, закрытая заявка мастеру — 409, админу — можно); + юнит-тесты хендлера проверяют сигнатуры форматов и вычистку имени файла; +- повторяющиеся заявки (`recurring_test.go`): срок (`nextDue`: точка + сетки в будущем, граница точки, пропуск многих периодов, недельный и + годовой периоды), правило не созрело до срока, по созревании создаётся + заявка-копия (адрес, событие `created` от автора правила, `last_run_at` + и `recurring_runs` одной транзакцией), незакрытая заявка блокирует + следующий запуск **без** сдвига срока, после закрытия цикл продолжается; + чужое правило + неотличимо от несуществующего (чтение/правка/удаление — `ErrNotFound`), + выключенное правило планировщик пропускает, каскад при удалении + проекта; частичный `UpdateRule` меняет только переданные поля (ручной + запуск (`RunRuleNow`) обходит ожидание срока, но не рубежи: + открытая заявка — `ErrConflict`+`ErrRuleOpen`, выключенное правило — + `ErrRuleNotActive`, чужое — `ErrNotFound`); заявка с галочкой + повтора создаёт правило одной операцией (`CreateWithRule`) и видит + его в детали (`GetByID`); автоочистка удаляет строку истории + сироты вместе с заявкой (`DeleteExpired`). + +Интеграционные тесты берут `DATABASE_URL`, выполняют сценарий **в транзакции +и откатывают её** — данные в базу не остаются. Без `DATABASE_URL` они +пропускаются. + +В CI (`make test` не нужен — там свои шаги) `DATABASE_URL` задан, база +поднимается сервисным контейнером, схема накатывается через `cmd/migrate`, +поэтому «зелёный прогон» означает «тесты действительно выполнены». + +--- + +## Деплой в прод + +Прод — один сервер и один compose-стек (`compose.prod.yaml`): postgres, +MinIO, API и веб-nginx в **одной** docker-сети; наружу отдаёт только +веб (порт 80). Postgres и MinIO портов не пробрасывают вовсе — видны +только контейнерам приложения. БД и S3 живут внутри стека, данные — в +томах `pgdata`/`miniodata`, обновления переживают. + +### Первый запуск + +```bash +# 1. Код и env-файл (env — на сервере, в git его нет) +git clone http:///building-mgmt-backend.git /opt/building-mgmt-repo +mkdir -p /opt/building-mgmt +cp /opt/building-mgmt-repo/.env.prod.example /opt/building-mgmt/.env +chmod 600 /opt/building-mgmt/.env +$EDITOR /opt/building-mgmt/.env # пароли, секреты, домен — см. комментарии + +# 2. Образы (Drone собирает их сам; вручную — если CI ещё не подключён) +docker build -t building-mgmt-api:latest /opt/building-mgmt-repo +docker build -t building-mgmt-web:latest <клон репо web> + +# 3. Стек +docker compose -p building-mgmt \ + --env-file /opt/building-mgmt/.env \ + -f /opt/building-mgmt-repo/compose.prod.yaml up -d --wait +``` + +`-p building-mgmt` обязателен и в ручных запусках, и в Drone: имя +проекта задаёт имя сети, разные имена разнесут web и api по разным +сетям, и прокси перестанет находить api. Проверка: `http://<домен>/ping` +→ `{"message":"pong"}`. `APP_ENV=production` (задан в шаблоне) блокирует +`seed` — демо-аккаунты на бою не заводятся. + +TLS и домен: сертификат вешается на терминал перед веб-nginx +(балансировщик / Caddy / Traefik); сам nginx отдаёт HSTS и проставляет +`X-Forwarded-Proto`. `TRUSTED_PROXIES=172.16.0.0/12` обязателен: без +него api увидит IP nginx-контейнера и лимиты логина всех пользователей +сложатся в один общий ключ. + +### Обновление и операции + +```bash +# Обновление: образы новые, данные в томах не трогаются. +docker compose -p building-mgmt --env-file /opt/building-mgmt/.env \ + -f compose.prod.yaml up -d --wait + +docker compose -p building-mgmt logs -f api # логи +docker compose -p building-mgmt exec api migrate # накат схемы вручную +``` + +Бэкапы (вне зоны compose, но обязательно): + +```bash +docker compose -p building-mgmt exec db pg_dump -U building building_mgmt \ + > backup-$(date +%F).sql +docker run --rm -v building-mgmt_miniodata:/data -v "$PWD":/backup alpine \ + tar czf /backup/minio-$(date +%F).tgz -C /data . +``` + +Ограничение: API — ровно один инстанс (поллер Telegram держит очередь +`getUpdates` на процесс; см. «Известные ограничения») — в стеке это +соблюдается само. + +--- + +## CI/CD (Drone + Gitea) + +CI живёт в Drone: сервер подключён к Gitea, пайплайны — `.drone.yml` в +обоих репозиториях (бэкенд и веб). GitHub Actions больше нет. + +### Установка (однократно, на прод-сервере) + +```bash +# В Gitea: Настройки → Applications → OAuth2-приложение, +# Redirect URI: http://<хост-drone>:8081/authorize +mkdir -p /opt/drone && cp deploy/drone/compose.yaml /opt/drone/ +# /opt/drone/.env: GITEA_SERVER, DRONE_GITEA_CLIENT_ID, +# DRONE_GITEA_CLIENT_SECRET, DRONE_GITEA_ADMIN_USERNAME, +# DRONE_RPC_SECRET= # openssl rand -hex 32 +docker compose -p drone --env-file /opt/drone/.env \ + -f /opt/drone/compose.yaml up -d +``` + +Drone UI — `http://<хост>:8081` (за TLS-терминалом); оттуда же Gitea +шлёт webhook. Runner исполняет шаги пайплайнов в docker на этом же +сервере, одна задача за раз (`capacity=1` — деплои не пересекаются). + +### Секреты пайплайна (UI Drone, репозиторий веба) + +- `gitea_host`, `gitea_user`, `gitea_token` — доступ к приватному репо + бэкенда: web-пайплайн клонирует его за токен, чтобы перезапустить + `web` тем же `compose.prod.yaml` (один стек, один проект). + +Секреты стека (`/opt/building-mgmt/.env`) в Drone не хранятся: пайплайн +только читает файл на сервере флагом `--env-file`. + +### Что делает каждый пайплайн + +- бэкенд (push/PR и ручной запуск): `vet` → `migrate` → `test -race` → + `go build` → `golangci-lint` → сборка образа + `building-mgmt-api:latest` + тег по SHA; +- веб (push/PR и ручной запуск): `node --check` js → сборка образа + `building-mgmt-web:latest` + тег по SHA; +- **деплой — только ручным запуском** (кнопка **Run Pipeline** в UI, + событие `custom`): бэкенд — `docker compose -p building-mgmt + --env-file /opt/building-mgmt/.env -f compose.prod.yaml up -d --wait` + (пересоздаёт api новым образом, данные не трогает); веб — то же + с `--no-deps web`. Push в `main` образы собирает, но на сервер ничего + не ставит. + +--- + +## Известные ограничения + +- подтверждение email — реализовано (см. раздел + «Подтверждение аккаунта (SMTP)»): мастер, созданный клиентом, + активен только после `POST /users/:id/confirm`, до этого вход закрыт + (`403 account not confirmed`); саморегистрации нет, аккаунты заводит + только админ (и клиент — мастеров); сброс пароля по письму + (`POST /auth/forgot-password`) работает, админу доступны оба своих + способа — выпустить токен (`POST /users/:id/password-reset`) либо + задать пароль напрямую (`POST /users/:id/password-set`); +- несколько инстансов API: лимиты запросов уже считают на общей БД, но их + ключ — `client_ip`, поэтому reverse proxy каждого инстанса обязан + перезаписывать `X-Forwarded-For` одинаково (см. `TRUSTED_PROXIES`); + к ним же — поллеры Telegram (`internal/notify`): очередь `getUpdates` + одна на бота, поэтому инстансов API должно быть один, пока у кого-то + есть включённый бот (глобальный `TELEGRAM_BOT_TOKEN` или личный токен + из «Настроек»), иначе привязка чата достанется случайному; +- выключенный пользователь теряет сессии сразу, но **существующий access-токен + остаётся валидным до часа** — проверка `is_active` на каждый запрос не делается, + чтобы не ходить в БД за каждой операцией. +- мастер, заведённый через `POST /users`, но не принявший приглашение ни + в один проект, виден только создавшему его клиенту и админу: + `GET /users?role=worker` без `q` возвращает мастеров проектов клиента + со статусом `active` плюс созданных им самим. Найти для приглашения + можно поиском `&q=<имя или email>` (мин. 2 символа) — или сразу + пригласить тем же запросом `POST /users`, получив `worker_id` в ответе; +- журнал событий ведётся с момента наката `0006`: у более старых заявок + `GET /tickets/:id/events` пуст, хотя статусы у них есть; +- подписанные ссылки на фото живут 15 минут и по истечении перестают + работать: UI перезапрашивает список, получая свежие `url`; у битых ссылок + нет фонового переиздания на стороне API; +- просроченные приглашения фоновой задачей не чистятся: они просто + перестают приниматься и показываться, отозвать строку — ручной `DELETE`. diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..4a86a93 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,736 @@ +// Команда api — точка входа HTTP-сервера: конфигурация, роуты, +// лимитеры и graceful shutdown. Запуск: go run ./cmd/api. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + + "building-mgmt/internal/auth" + "building-mgmt/internal/crypt" + "building-mgmt/internal/db" + "building-mgmt/internal/handlers" + "building-mgmt/internal/logging" + "building-mgmt/internal/mailer" + "building-mgmt/internal/middleware" + "building-mgmt/internal/notify" + "building-mgmt/internal/ratelimit" + "building-mgmt/internal/repository" + "building-mgmt/internal/storage" + "building-mgmt/internal/telegram" + + // Импорт регистрирует сгенерированную схему (docs/swagger.json) + // для Swagger UI — без него /swagger/index.html не найдёт doc.json. + _ "building-mgmt/docs" + + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +// @title Building Management API +// @version 1.0 +// @description Управление заявками на обслуживание: админ создаёт клиента, клиент заводит проект, объекты в нём и мастеров, назначает заявку мастеру проекта, мастер выполняет, клиент подтверждает работу или отклоняет её на доработку. Финальный статус — closed. +// @BasePath / +// @host localhost:8080 +// @schemes http +// @securityDefinitions.apikey Bearer +// @in header +// @name Authorization +// @description JWT формата Bearer, получить через POST /auth/login. Срок жизни — 1 час, дальше POST /auth/refresh. +func main() { + os.Exit(run()) +} + +// run возвращает код выхода, а не вызывает os.Exit внутри: так defer +// (закрытие пула) гарантированно отрабатывает — в том числе после +// graceful shutdown. `log.Fatal` здесь был бы той же ловушкой. +func run() int { + _ = godotenv.Load() + logger := logging.Setup() + + // Фейл-фаст до подключения к БД: без секрета токены подписывались бы + // пустой строкой, и любой знал бы «секрет». + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + logger.Error("JWT_SECRET is not set — refusing to start") + return 1 + } + if len(jwtSecret) < 32 { + logger.Warn("JWT_SECRET is short", "length", len(jwtSecret), "recommended", 32) + } + + // gin по умолчанию пишет баннер и свой цветной access-log поверх + // структурных логов. GIN_MODE задаётся явно — режим не трогаем. + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + + ctx := context.Background() + pool, err := db.Connect(ctx, os.Getenv("DATABASE_URL")) + if err != nil { + logger.Error("db connect failed", "err", err) + return 1 + } + defer pool.Close() + logger.Info("DB connected") + + if err := db.Migrate(ctx, pool); err != nil { + logger.Error("migrations failed", "err", err) + return 1 + } + + profileRepo := repository.NewProfileRepo(pool) + refreshRepo := repository.NewRefreshTokenRepo(pool) + // Secure-флаг на refresh-куке: локально сайт по HTTP и браузер + // отверг бы Secure-куку, поэтому включается только в проде (COOKIE_SECURE). + cookieSecure := os.Getenv("COOKIE_SECURE") == "true" + logger.Info("refresh cookie", "secure", cookieSecure) + + // Почта подтверждений (SMTP, go-smtp): пустой SMTP_HOST — заглушка + // без отправки (dev), неверные настройки — фейл-фаст на старте, + // как у S3 и JWT: сюрприз на первом подтверждении не нужен. + ml, err := mailer.New(mailer.Config{ + Host: os.Getenv("SMTP_HOST"), + Port: os.Getenv("SMTP_PORT"), + User: os.Getenv("SMTP_USER"), + Password: os.Getenv("SMTP_PASSWORD"), + From: os.Getenv("SMTP_FROM"), + TLS: os.Getenv("SMTP_TLS"), + BaseURL: os.Getenv("APP_BASE_URL"), + }, logger) + if err != nil { + logger.Error("smtp config failed", "err", err) + return 1 + } + + // Лимит писем «забыли пароль» на ящик: 3 в сутки, ключ — email + // (смотрит сам хендлер — middleware тут не подходит, ключ зависит + // от тела запроса). Стоит здесь, потому что уходит в конструктор + // хендлера; IP-лимиты того же эндпоинта — в общем блоке ниже. + forgotMailLimiter := ratelimit.NewDB(pool, "forgot_mail", 3, 24*time.Hour) + + authHandler := handlers.NewAuthHandler(profileRepo, refreshRepo, jwtSecret, cookieSecure, ml, forgotMailLimiter) + userHandler := handlers.NewUserHandler(profileRepo, refreshRepo, ml) + + projectRepo := repository.NewProjectRepo(pool) + + ticketRepo := repository.NewTicketRepo(pool) + ticketHandler := handlers.NewTicketHandler(ticketRepo, profileRepo, projectRepo) + + // Правила повторяющихся заявок (миграция 0014): репозиторий — здесь, + // хендлер собирается ниже, после Telegram-блока (ему нужно на уведомление + // при ручном запуске), фоновое создание заявок — горутина runRecurring. + recurringRepo := repository.NewRecurringRepo(pool) + + // Объектное хранилище фото (S3): локально MinIO в compose, в проде — + // любой S3-совместимый. Фейл-фаст как с JWT: без бакета загрузка + // упала бы на первом же фото, а не на старте. + s3Cfg, err := storage.ConfigFromEnv() + if err != nil { + logger.Error("s3 config failed", "err", err) + return 1 + } + photoStore, err := storage.NewS3(ctx, s3Cfg) + if err != nil { + logger.Error("s3 init failed", "err", err) + return 1 + } + if err := photoStore.EnsureBucket(ctx); err != nil { + logger.Error("s3 bucket ensure failed", "err", err) + return 1 + } + logger.Info("S3 ready", "endpoint", s3Cfg.Endpoint, "bucket", photoStore.Bucket()) + + // Проектный хендлер — после хранилища: удаление проекта чистит + // объекты фото заявок (store в конструкторе). + projectHandler := handlers.NewProjectHandler(projectRepo, profileRepo, photoStore) + + // Лимиты фотоотчёта: 0 → дефолт внутри хендлера (10 МБ / 20 файлов). + photoMaxBytes := int64(0) + if v := os.Getenv("PHOTO_MAX_BYTES"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n <= 0 { + logger.Error("PHOTO_MAX_BYTES must be a positive number of bytes", "value", v) + return 1 + } + photoMaxBytes = n + } + photoMaxPer := 0 + if v := os.Getenv("PHOTO_MAX_PER_TICKET"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + logger.Error("PHOTO_MAX_PER_TICKET must be a positive number", "value", v) + return 1 + } + photoMaxPer = n + } + photoRepo := repository.NewPhotoRepo(pool) + photoHandler := handlers.NewPhotoHandler(ticketRepo, photoRepo, projectRepo, photoStore, photoMaxBytes, photoMaxPer) + + // Протухшие сессии никому не нужны: чистим один раз при старте, + // дальше таблица держится в актуальном состоянии сама. + if n, err := refreshRepo.DeleteExpired(ctx); err != nil { + logger.Warn("refresh tokens cleanup failed", "err", err) + } else if n > 0 { + logger.Info("refresh tokens cleanup", "removed", n) + } + + // Автоочистка старых заявок: закрытые старше TICKET_RETENTION_DAYS + // (дефолт 60 дней, 0 — чистка выключена). Прогон сразу при старте и + // дальше раз в сутки: заявки накапливаются медленно, суточного тика + // хватает, а таблица не растёт без верхней границы. + retentionDays := 60 + if v := os.Getenv("TICKET_RETENTION_DAYS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + logger.Error("TICKET_RETENTION_DAYS must be a non-negative number of days", "value", v) + return 1 + } + retentionDays = n + } + if retentionDays > 0 { + ticketsCtx, ticketsCancel := context.WithCancel(context.Background()) + defer ticketsCancel() + go runTicketsCleanup(ticketsCtx, logger, ticketRepo, photoStore, + time.Duration(retentionDays)*24*time.Hour) + } + + // Брутфорс-лимиты по IP — в БД (таблица rate_limits, миграция 0009): + // счётчик переживает рестарт API и общий для нескольких инстансов. + // Числа — здесь же, рядом с тем, куда вешаются. + loginLimiter := ratelimit.NewDB(pool, "login", 10, time.Minute) + refreshLimiter := ratelimit.NewDB(pool, "refresh", 30, time.Minute) + resetLimiter := ratelimit.NewDB(pool, "reset", 30, time.Minute) + // forgot — IP-лимит «забыли пароль»; лимит на сам ящик (3 письма + // в сутки) живёт в forgotMailLimiter рядом с конструктором хендлера. + forgotLimiter := ratelimit.NewDB(pool, "forgot", 10, time.Minute) + const rateLimitMsg = "too many attempts, try again later" + + // Чистка протухших строк лимитов — фоном, до самого выхода процесса. + limiterCtx, limiterCancel := context.WithCancel(context.Background()) + defer limiterCancel() + go ratelimit.RunCleanup(limiterCtx, pool, time.Minute) + + // Telegram: напоминание клиенту о заявке, которая простояла в статусе + // 'new' дольше порога, и привязка его чата по одноразовому коду. + // Два источника бота: собственный токен пользователя (вводится в + // «Настройках», хранится зашифрованным) и глобальный бот сервера + // TELEGRAM_BOT_TOKEN как запасной для тех, кто токен не ввёл. + tgCodeLimiter := ratelimit.NewDB(pool, "tg_code", 5, 5*time.Minute) + // Сохранение токена — сетевой getMe, поэтому свой лимит: подбор + // токенов упирается в потолок до сети и до базы. + tgTokenLimiter := ratelimit.NewDB(pool, "tg_token", 5, 5*time.Minute) + tgRepo := repository.NewTelegramRepo(pool) + tgAPIURL := os.Getenv("TELEGRAM_API_URL") + tgBot := telegram.New(telegram.Config{ + Token: os.Getenv("TELEGRAM_BOT_TOKEN"), + APIURL: tgAPIURL, + }) + + // Контекст фоновых задач Telegram живёт до выхода процесса: на нём + // висят нотификатор, поллер глобального бота и все поллеры личных + // токенов (defer гасит их разом при graceful shutdown). + tgCtx, tgCancel := context.WithCancel(context.Background()) + defer tgCancel() + + // Реестр решает, чьим ботом слать (личный токен приоритетнее + // глобального) и владеет поллерами личных ботов. + tgRegistry := notify.NewRegistry(tgCtx, tgRepo, tgBot, logger) + + // Шифрование токенов ботов (AES-256-GCM, ключ — DATA_ENCRYPTION_KEY). + // Без ключа ввод токена выключен: сохранять его некуда, а лежащие + // в БД прочитать невозможно — это осознанная блокировка, а не сбой. + var tgCipher *crypt.Cipher + if v := os.Getenv("DATA_ENCRYPTION_KEY"); v != "" { + c, err := crypt.New(v) + if err != nil { + logger.Error("DATA_ENCRYPTION_KEY is invalid (32 bytes, base64 or hex)", "err", err) + return 1 + } + tgCipher = c + } else { + logger.Info("telegram user bot tokens disabled (DATA_ENCRYPTION_KEY is empty)") + } + + // Порог напоминания и период тика — env с явными дефолтами: + // 24 часа, как в требованиях, и минута — чтобы «взяли в работу» + // заметилось почти сразу после гашения уведомления. + tgAfter := 24 * time.Hour + if v := os.Getenv("TELEGRAM_NOTIFY_AFTER"); v != "" { + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + logger.Error("TELEGRAM_NOTIFY_AFTER must be a positive duration, e.g. 24h", "value", v) + return 1 + } + tgAfter = d + } + tgTick := time.Minute + if v := os.Getenv("TELEGRAM_TICK_EVERY"); v != "" { + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + logger.Error("TELEGRAM_TICK_EVERY must be a positive duration, e.g. 1m", "value", v) + return 1 + } + tgTick = d + } + // Ссылка в уведомлении — как и в письмах: пустой APP_BASE_URL + // означает локальный запуск (см. mailer). + appBaseURL := os.Getenv("APP_BASE_URL") + if appBaseURL == "" { + appBaseURL = "http://localhost:3000" + } + + tgHandler := handlers.NewTelegramHandler(handlers.TelegramConfig{ + Repo: tgRepo, + Bots: tgRegistry, + Cipher: tgCipher, + // Фабрика без сети: клиент создаётся здесь, getMe делает сам + // хендлер — он же отвечает за то, чтобы невалидный токен не + // добрался до базы. + NewBot: func(token string) telegram.API { + return telegram.New(telegram.Config{Token: token, APIURL: tgAPIURL}) + }, + CodeLimiter: tgCodeLimiter, + TokenLimiter: tgTokenLimiter, + }) + + // Сохранённые токены читаем один раз: поднимаем клиенты и поллеры + // личных ботов. Ключ сменили — токены не расшифровываются: такие + // строки пропускаем с warn'ом, пользователь введёт токен заново. + if tgCipher != nil { + saved, err := tgRepo.BotTokens(ctx) + if err != nil { + logger.Error("telegram bot tokens load failed", "err", err) + return 1 + } + for _, ub := range saved { + token, err := tgCipher.Open(ub.TokenEnc) + if err != nil { + logger.Warn("telegram bot token unreadable (encryption key changed?)", + "user_id", ub.UserID, "err", err) + continue + } + tgRegistry.Register(ub.UserID, telegram.New(telegram.Config{ + Token: token, + APIURL: tgAPIURL, + })) + } + if n := tgRegistry.Users(); n > 0 { + logger.Info("telegram user bots loaded", "count", n) + } + } + + if tgBot.Enabled() { + // getMe — имя бота для ссылки привязки; сбой не фатален: + // повтор сделает редкий запрос кода (см. TelegramHandler.username). + if err := tgBot.Init(tgCtx); err != nil { + logger.Warn("telegram getMe failed", "err", err) + } else { + logger.Info("telegram bot ready", "username", tgBot.Username()) + } + // Поллер глобального бота один на процесс: несколько инстансов + // драли бы одну очередь getUpdates (в контейнере их один — см. README). + go notify.RunPoller(tgCtx, tgRepo, tgBot, logger) + } else { + logger.Info("telegram global bot disabled (TELEGRAM_BOT_TOKEN is empty)") + } + + // Нотификатор крутится, пока есть хоть какой-то бот: без единого + // токена он просто не найдёт кандидатов и не шлёт ничего. + if tgBot.Enabled() || tgCipher != nil { + go notify.Run(tgCtx, tgRepo, tgRegistry, tgAfter, tgTick, appBaseURL, logger) + } else { + logger.Info("telegram notifications disabled (no bot configured)") + } + + // Повторяющиеся заявки: прогон сразу при старте и дальше раз в сутки + // (интервалы в месяцах, суточной точности хватает). Созданные заявки + // клиенту уведомляются тем же Telegram, что и обычные напоминания; + // сбой отправки — warn, заявка уже создана (fail-open, как в notify). + recurringCtx, recurringCancel := context.WithCancel(context.Background()) + defer recurringCancel() + go runRecurring(recurringCtx, logger, recurringRepo, tgRegistry, appBaseURL) + + // Хендлер правил — после Telegram-блока: ручной «Запустить сейчас» + // шлёт уведомление тем же кодом, что и фоновое срабатывание. + recurringHandler := handlers.NewRecurringHandler(recurringRepo, projectRepo, + func(ctx context.Context, run repository.DueRun) { + notifyRecurring(ctx, logger, tgRegistry, appBaseURL, run) + }) + + // CORS: разрешённые источники фронта через запятую (CORS_ORIGINS). + // Пусто — заголовки не ставятся вовсе: одностраничнику на том же + // домене они не нужны, а чужому домену их и не должно быть. + allowedOrigins := middleware.ParseOrigins(os.Getenv("CORS_ORIGINS")) + if len(allowedOrigins) > 0 { + logger.Info("CORS origins", "origins", allowedOrigins) + } + + r := gin.New() + + // Доверенные прокси: gin по умолчанию верит любому X-Forwarded-For — + // это подделка ключа рейт-лимитов (login/reset/forgot) и ложный + // client_ip в логах. Без TRUSTED_PROXIES доверяем только loopback; + // за reverse proxy (app-nginx в контейнерной сети) указываем его + // подсеть: TRUSTED_PROXIES=172.0.0.0/12 (loopback доверяем всегда). + // Прокси обязан ПЕРЕЗАПИСЫВАТЬ X-Forwarded-For ($remote_addr), + // а не дополнять: gin берёт левый элемент списка. + trusted := []string{"127.0.0.1/32", "::1/32"} + if v := os.Getenv("TRUSTED_PROXIES"); v != "" { + for _, s := range strings.Split(v, ",") { + if s = strings.TrimSpace(s); s != "" { + trusted = append(trusted, s) + } + } + } + if err := r.SetTrustedProxies(trusted); err != nil { + logger.Error("invalid TRUSTED_PROXIES", "err", err) + return 1 + } + logger.Info("trusted proxies", "proxies", trusted) + + // Порядок важен: request_id — первый, чтобы recovery и access-log + // уже видели id запроса; CORS — сразу после логов, но до роутов, + // чтобы preflight-OPTIONS отвечал 204 ещё до авторизации. + r.Use( + middleware.RequestID(), + middleware.Recovery(), + middleware.AccessLog(), + middleware.CORS(allowedOrigins), + ) + + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "pong"}) + }) + + // Swagger UI: /swagger/index.html. Схема генерируется из аннотаций + // над хендлерами командой `swag init -g cmd/api/main.go` (см. README). + // По умолчанию выключен: открытая схема в проде не нужна — + // включается флагом SWAGGER=true (так в локальной разработке). + if os.Getenv("SWAGGER") == "true" { + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + logger.Info("swagger enabled") + } + + // Состояние БД — отдельный хелпер, раньше был открыт всем: + // по ответу было видно, что база живёт и какая версия (500 vs 200). + adminOnly := r.Group("/") + adminOnly.Use(auth.Middleware(jwtSecret)) + adminOnly.Use(auth.RequireRole("admin")) + adminOnly.GET("/db-ping", func(c *gin.Context) { + if err := pool.Ping(c.Request.Context()); err != nil { + // Детали (host, user, DSN) наружу не уходят — только в лог. + logger.Error("db ping failed", "err", err, "request_id", middleware.RequestIDFrom(c)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "db unavailable"}) + return + } + c.JSON(http.StatusOK, gin.H{"db": "ok"}) + }) + + // Auth — открытые роуты (лимитируются, чтобы логин нельзя было перебирать) + r.POST("/auth/login", + ratelimit.Middleware(loginLimiter, ratelimit.ByIP, rateLimitMsg), + authHandler.Login) + r.POST("/auth/refresh", + ratelimit.Middleware(refreshLimiter, ratelimit.ByIP, rateLimitMsg), + authHandler.Refresh) + r.POST("/auth/logout", + ratelimit.Middleware(refreshLimiter, ratelimit.ByIP, rateLimitMsg), + authHandler.Logout) + + // Сброс пароля: письмо со ссылкой запрашивается здесь (лимит IP + + // лимит на ящик в самом хендлере), токен применяется ниже. + // Лимит отдельный от логина: подбор токена должен упереться + // в потолок раньше, чем в чужую сессию. + r.POST("/auth/forgot-password", + ratelimit.Middleware(forgotLimiter, ratelimit.ByIP, rateLimitMsg), + authHandler.ForgotPassword) + r.POST("/auth/reset-password", + ratelimit.Middleware(resetLimiter, ratelimit.ByIP, rateLimitMsg), + authHandler.ResetPassword) + + // Защищённые роуты — любой авторизованный + authGroup := r.Group("/") + authGroup.Use(auth.Middleware(jwtSecret)) + { + authGroup.GET("/auth/me", authHandler.Me) + authGroup.POST("/auth/logout-all", authHandler.LogoutAll) + + // Привязка Telegram для уведомлений: свой статус, одноразовый + // код привязки, ручной ввод группы/канала, отключение — плюс + // собственный бот пользователя (токен хранится зашифрованным, + // наружу не отдаётся; см. internal/crypt). + authGroup.GET("/auth/telegram", tgHandler.Status) + authGroup.POST("/auth/telegram/code", tgHandler.IssueCode) + authGroup.PUT("/auth/telegram", tgHandler.Set) + authGroup.DELETE("/auth/telegram", tgHandler.Unbind) + authGroup.POST("/auth/telegram/bot", tgHandler.SetBot) + authGroup.DELETE("/auth/telegram/bot", tgHandler.DeleteBot) + } + + // Проекты клиентов: объекты внутри проекта и мастера проекта. + // Роли проверяются внутри хендлера (админ — всё, клиент — своё); + // удаление проекта вынесено на RequireRole("admin") в роуте. + projectGroup := r.Group("/projects") + projectGroup.Use(auth.Middleware(jwtSecret)) + { + projectGroup.GET("", projectHandler.List) + projectGroup.POST("", projectHandler.Create) + projectGroup.GET("/:id", projectHandler.Get) + projectGroup.PATCH("/:id", projectHandler.Update) + // Удаление проекта — только админ (решение): проект уходит + // вместе с заявками, объектами и участиями (каскад 0013). + projectGroup.DELETE("/:id", auth.RequireRole("admin"), projectHandler.Delete) + + projectGroup.GET("/:id/objects", projectHandler.ListObjects) + projectGroup.POST("/:id/objects", projectHandler.CreateObject) + + projectGroup.GET("/:id/workers", projectHandler.ListWorkers) + projectGroup.POST("/:id/workers", projectHandler.AddWorker) + projectGroup.DELETE("/:id/workers/:worker_id", projectHandler.RemoveWorker) + + // Приглашения: решает только сам мастер (роль worker). + projectGroup.POST("/:id/invitation/accept", auth.RequireRole("worker"), projectHandler.AcceptInvitation) + projectGroup.POST("/:id/invitation/decline", auth.RequireRole("worker"), projectHandler.DeclineInvitation) + } + + // Мои приглашения: каждый мастер видит только своё (worker_id берётся + // из токена, не из запроса). + invitationsGroup := r.Group("/invitations") + invitationsGroup.Use(auth.Middleware(jwtSecret)) + invitationsGroup.GET("", auth.RequireRole("worker"), projectHandler.ListInvitations) + + // Правка объекта вынесена на свой путь: /objects/:id, те же права. + objectGroup := r.Group("/objects") + objectGroup.Use(auth.Middleware(jwtSecret)) + { + objectGroup.PATCH("/:id", projectHandler.UpdateObject) + } + + ticketGroup := r.Group("/tickets") + ticketGroup.Use(auth.Middleware(jwtSecret)) + { + // Создавать могут только клиенты + ticketGroup.POST("", auth.RequireRole("client"), ticketHandler.Create) + + // Список и детали — все авторизованные, но фильтруются по роли внутри + ticketGroup.GET("", ticketHandler.List) + ticketGroup.GET("/:id", ticketHandler.Get) + // Журнал — те же права, что на чтение заявки (проверка внутри). + ticketGroup.GET("/:id/events", ticketHandler.Events) + + // Фотоотчёт: список видят все, кто видит заявку; загружает и + // удаляет мастер или админ — дальше те же правила проверяются + // в canView и в самом SQL (статус, членство, лимит). + ticketGroup.GET("/:id/photos", photoHandler.List) + ticketGroup.POST("/:id/photos", auth.RequireRole("worker", "admin"), photoHandler.Upload) + ticketGroup.DELETE("/:id/photos/:photo_id", auth.RequireRole("worker", "admin"), photoHandler.Delete) + + // Назначает админ или клиент-владелец, либо сам мастер берёт + // свободную заявку (take) — дальше проверка внутри и в SQL. + ticketGroup.PATCH("/:id/assign", auth.RequireRole("admin", "client"), ticketHandler.Assign) + ticketGroup.PATCH("/:id/take", auth.RequireRole("worker"), ticketHandler.Take) + ticketGroup.PATCH("/:id/start", auth.RequireRole("worker"), ticketHandler.Start) + ticketGroup.PATCH("/:id/done", auth.RequireRole("worker"), ticketHandler.Done) + + // Закрытие цикла: принимаем / отклоняем (на доработку) / отменяем + ticketGroup.PATCH("/:id/confirm", auth.RequireRole("client"), ticketHandler.Confirm) + ticketGroup.PATCH("/:id/reject", auth.RequireRole("client"), ticketHandler.Reject) + ticketGroup.PATCH("/:id/cancel", auth.RequireRole("client", "admin"), ticketHandler.Cancel) + } + + // Повторяющиеся заявки: правила периодических работ («осмотр + // вентиляции раз в 3 месяца»). Роли — админ и клиент; чужое правило + // неотличимо от несуществующего (проверка внутри хендлера). + recurringGroup := r.Group("/recurring") + recurringGroup.Use(auth.Middleware(jwtSecret)) + { + recurringGroup.GET("", auth.RequireRole("admin", "client"), recurringHandler.List) + recurringGroup.POST("", auth.RequireRole("admin", "client"), recurringHandler.Create) + recurringGroup.GET("/:id", auth.RequireRole("admin", "client"), recurringHandler.Get) + recurringGroup.PATCH("/:id", auth.RequireRole("admin", "client"), recurringHandler.Update) + recurringGroup.DELETE("/:id", auth.RequireRole("admin", "client"), recurringHandler.Delete) + // Ручной запуск: заявка сразу, без ожидания интервала (иначе — 409). + recurringGroup.POST("/:id/run", auth.RequireRole("admin", "client"), recurringHandler.Run) + // История срабатываний: какая заявка создавалась по правилу и когда. + recurringGroup.GET("/:id/runs", auth.RequireRole("admin", "client"), recurringHandler.Runs) + } + + // Пользователи: создание и список открыты админу и клиенту + // (клиенту — только мастера и только свои), удаление — только админу, + // деактивация — админу и клиенту (клиенту — только свой мастер). + usersGroup := r.Group("/users") + usersGroup.Use(auth.Middleware(jwtSecret)) + { + usersGroup.POST("", auth.RequireRole("admin", "client"), userHandler.Create) + usersGroup.GET("", auth.RequireRole("admin", "client"), userHandler.List) + // Подтверждение аккаунта: админ — любой неподтверждённый, клиент — + // только созданного им мастера (право целиком в SQL). + usersGroup.POST("/:id/confirm", auth.RequireRole("admin", "client"), userHandler.Confirm) + usersGroup.PATCH("/:id", auth.RequireRole("admin", "client"), userHandler.Update) + usersGroup.DELETE("/:id", auth.RequireRole("admin", "client"), userHandler.Delete) + // Токен сброса пароля — только админ: он и передаст его пользователю. + usersGroup.POST("/:id/password-reset", auth.RequireRole("admin"), userHandler.PasswordReset) + // Прямая установка пароля админом — та же граница: пароль меняется + // без участия пользователя, сессии при этом закрываются. + usersGroup.POST("/:id/password-set", auth.RequireRole("admin"), userHandler.SetPassword) + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + addr := ":" + port + + // Таймауты — защита от Slowloris и висящих соединений: заголовки + // должны прийти быстро, тело и ответ — с запасом на загрузку + // фотоотчёта (PHOTO_MAX_BYTES, по умолчанию 10 МБ). + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + logger.Info("listening", "addr", addr) + + // SIGTERM (деплой) и SIGINT (Ctrl+C) — оба повод остановиться + // аккуратно: дождаться текущих запросов, закрыть пул. + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + logger.Error("server failed", "err", err) + return 1 + } + return 0 + case sig := <-quit: + logger.Info("shutdown signal received", "signal", sig.String()) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("graceful shutdown failed", "err", err) + return 1 + } + <-errCh // ListenAndServe вернул ErrServerClosed + logger.Info("server stopped") + return 0 +} + +// runTicketsCleanup — фоновая чистка закрытых заявок старше retention: +// прогон сразу при старте и дальше каждые сутки, пока жив контекст. +// Файлы в хранилище удаляются после строк best-effort: сбой чистки +// объекта уходит в лог, строка в БД уже удалена — порядок «сначала +// строка, потом объект», как в чистке фотоотчёта. +func runTicketsCleanup(ctx context.Context, logger *slog.Logger, + repo *repository.TicketRepo, store *storage.S3Store, retention time.Duration, +) { + cleanup := func() { + ids, keys, err := repo.DeleteExpired(ctx, time.Now().Add(-retention)) + if err != nil { + logger.Warn("tickets cleanup failed", "err", err) + return + } + photos := 0 + for _, key := range keys { + if err := store.Delete(ctx, key); err != nil { + logger.Warn("photo object cleanup failed", "key", key, "err", err) + continue + } + photos++ + } + if len(ids) > 0 { + logger.Info("tickets cleanup", "deleted", len(ids), "photo_objects", photos) + } + } + + cleanup() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + cleanup() + case <-ctx.Done(): + return + } + } +} + +// notifyRecurring — уведомление клиенту о заявке, созданной по правилу +// повторения (фоновым планировщиком или ручным «Запустить сейчас»): +// один текст и одна точка отказа на двоих. Fail-open, как в notify: +// созданная заявка остаётся созданной, даже если Telegram недоступен — +// ошибка уходит в warn, а напоминание уйдёт штатным нотификатором позже. +func notifyRecurring(ctx context.Context, logger *slog.Logger, + bots notify.Bots, baseURL string, run repository.DueRun, +) { + if run.Target == "" { + return // слать некуда — уведомит штатный нотификатор + } + bot := bots.BotFor(run.ClientID) + if bot == nil || !bot.Enabled() { + return + } + if err := bot.SendMessage(ctx, run.Target, notify.RecurringMessage(run, baseURL)); err != nil { + logger.Warn("recurring notify failed", "rule_id", run.RuleID, + "ticket_id", run.TicketID, "err", err) + } +} + +// runRecurring — фоновый планировщик повторяющихся заявок (миграция 0014): +// прогон сразу при старте и дальше каждые сутки, пока жив контекст. +// Интервалы в месяцах, поэтому суточного тика хватает. +// +// Уведомления шлётся общим хелпером notifyRecurring — fail-open, +// как в notify. +func runRecurring(ctx context.Context, logger *slog.Logger, + repo *repository.RecurringRepo, bots notify.Bots, baseURL string, +) { + run := func() { + created, err := repo.RunDueRules(ctx, time.Now()) + if err != nil { + logger.Warn("recurring rules run failed", "err", err) + } + if len(created) == 0 { + return + } + logger.Info("recurring tickets created", "count", len(created)) + for _, t := range created { + if ctx.Err() != nil { + return + } + notifyRecurring(ctx, logger, bots, baseURL, t) + } + } + + run() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + run() + case <-ctx.Done(): + return + } + } +} diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..466398a --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,53 @@ +// Команда migrate — накат схемы без запуска API. +package main + +// Разовая команда: применить миграции, не поднимая API. +// Нужна CI (таблицы должны существовать до интеграционных тестов) +// и любому процессу, который накатывает схему без запуска сервера: +// +// go run ./cmd/migrate +// +// Тот же код, что выполняется при старте cmd/api, — расхождений +// между «как стартует приложение» и «как накатывает CI» нет. + +import ( + "context" + "os" + + "github.com/joho/godotenv" + + "building-mgmt/internal/db" + "building-mgmt/internal/logging" +) + +func main() { + os.Exit(run()) +} + +// run возвращает код выхода, а не вызывает os.Exit внутри: так defer +// (закрытие пула) отрабатывает до завершения процесса. +func run() int { + _ = godotenv.Load() + logger := logging.Setup() + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + logger.Error("DATABASE_URL is not set") + return 1 + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + logger.Error("db connect failed", "err", err) + return 1 + } + defer pool.Close() + + if err := db.Migrate(ctx, pool); err != nil { + logger.Error("migrations failed", "err", err) + return 1 + } + logger.Info("schema is up to date") + return 0 +} diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..e7922cc --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,343 @@ +// Команда seed — демо-данные, чтобы руками проверять API через Swagger. +package main + +// Разовая команда для локальной или демо-базы: +// +// go run ./cmd/seed --yes # или: make seed +// +// Создаёт клиента, мастеров, проект с объектами и заявки в разных +// статусах — то, что обычно приходится собирать пятью запросами руками. +// Повторный запуск ничего не дублирует: без --force команда просто +// сообщает, что данные уже есть. +// +// В прод не запускать: пароли демо-аккаунтов фиксированные и печатаются +// в stdout. Флаг --yes добавляет и админа — без него команду можно гонять +// даже на базе, где уже есть боевые пользователи. + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/google/uuid" + "github.com/joho/godotenv" + + "building-mgmt/internal/auth" + "building-mgmt/internal/db" + "building-mgmt/internal/logging" + "building-mgmt/internal/models" + "building-mgmt/internal/repository" +) + +// Демо-аккаунты. Один пароль на всех — его не надо помнить, он печатается. +const ( + demoPassword = "demo-password-123" + + demoClientEmail = "demo@example.com" + demoWorkerEmail = "demo-worker@example.com" + demoWorker2Email = "demo-worker2@example.com" + demoAdminEmail = "demo-admin@example.com" +) + +func main() { + os.Exit(run()) +} + +// run возвращает код выхода, чтобы defer (закрытие пула, откат +// транзакции) гарантированно отработал до завершения процесса. +func run() int { + force := flag.Bool("force", false, "пересоздать демо-данные: старые удаляются") + withAdmin := flag.Bool("yes", false, "создать и демо-админа (аккаунт с известным паролем)") + flag.Parse() + + _ = godotenv.Load() + logger := logging.Setup() + + // Прод закрыт: демо-аккаунты с фиксированным паролем — дыра в + // безопасности, а не рутина. Бинарник в образе остаётся (наличие + // инструмента не страшно), а вот запуститься на бою он не сможет. + if os.Getenv("APP_ENV") == "production" { + logger.Error("seed refuses to run with APP_ENV=production") + return 1 + } + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + logger.Error("DATABASE_URL is not set") + return 1 + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + logger.Error("db connect failed", "err", err) + return 1 + } + defer pool.Close() + + // Схема должна существовать — та же команда, что и при старте API. + // Заодно команда честно падает, если БД недостижима или миграция сломана. + if err := db.Migrate(ctx, pool); err != nil { + logger.Error("migrations failed", "err", err) + return 1 + } + + hasClient, hasAdmin, err := demoExists(ctx, pool) + if err != nil { + logger.Error("demo lookup failed", "err", err) + return 1 + } + if hasClient && !*force { + logger.Info("demo data already present, nothing to do (use --force to recreate)") + printAccounts(hasAdmin) + return 0 + } + + tx, err := pool.Begin(ctx) + if err != nil { + logger.Error("begin tx failed", "err", err) + return 1 + } + defer func() { _ = tx.Rollback(ctx) }() + + if *force { + if err := dropDemo(ctx, tx); err != nil { + logger.Error("drop demo data failed", "err", err) + return 1 + } + } + + adminID, err := seed(ctx, tx, *withAdmin) + if err != nil { + logger.Error("seed failed", "err", err) + return 1 + } + if err := tx.Commit(ctx); err != nil { + logger.Error("commit failed", "err", err) + return 1 + } + + if *withAdmin && adminID != "" { + logger.Warn("demo admin created with a known password — never run seed against production") + } + logger.Info("demo data ready") + printAccounts(*withAdmin) + return 0 +} + +// demoExists — есть ли уже демо-клиент и дем-админ (аккаунт админа +// добавляется только по --yes, поэтому проверяется отдельно). +func demoExists(ctx context.Context, q repository.Querier) (client, admin bool, err error) { + err = q.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM profiles WHERE email = $1), + EXISTS (SELECT 1 FROM profiles WHERE email = $2)`, + demoClientEmail, demoAdminEmail, + ).Scan(&client, &admin) + return client, admin, err +} + +// dropDemo — удалить прошлые демо-данные в порядке, диктуемом FK: +// сначала заявки, потом структура проекта, профили в конце. +func dropDemo(ctx context.Context, q repository.Querier) error { + emails := []string{demoClientEmail, demoWorkerEmail, demoWorker2Email, demoAdminEmail} + + _, err := q.Exec(ctx, ` + DELETE FROM tickets + WHERE client_id IN (SELECT id FROM profiles WHERE email = ANY($1))`, emails) + if err != nil { + return fmt.Errorf("tickets: %w", err) + } + _, err = q.Exec(ctx, ` + DELETE FROM project_workers + WHERE project_id IN (SELECT id FROM projects WHERE client_id IN + (SELECT id FROM profiles WHERE email = ANY($1)))`, emails) + if err != nil { + return fmt.Errorf("workers: %w", err) + } + _, err = q.Exec(ctx, ` + DELETE FROM project_objects + WHERE project_id IN (SELECT id FROM projects WHERE client_id IN + (SELECT id FROM profiles WHERE email = ANY($1)))`, emails) + if err != nil { + return fmt.Errorf("objects: %w", err) + } + _, err = q.Exec(ctx, ` + DELETE FROM projects WHERE client_id IN + (SELECT id FROM profiles WHERE email = ANY($1))`, emails) + if err != nil { + return fmt.Errorf("projects: %w", err) + } + _, err = q.Exec(ctx, `DELETE FROM profiles WHERE email = ANY($1)`, emails) + if err != nil { + return fmt.Errorf("profiles: %w", err) + } + return nil +} + +// seed — вся демо-выборка одной транзакцией: либо данные появляются +// целиком, либо ничего. Возвращает id админа (пусто, если не создавали). +func seed(ctx context.Context, q repository.Querier, withAdmin bool) (string, error) { + hash, err := auth.HashPassword(demoPassword) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + + profiles := repository.NewProfileRepo(q) + projects := repository.NewProjectRepo(q) + tickets := repository.NewTicketRepo(q) + + client := newProfile(demoClientEmail, "Демо клиент", "client", hash) + worker := newProfile(demoWorkerEmail, "Демо мастер", "worker", hash) + worker2 := newProfile(demoWorker2Email, "Демо мастер №2", "worker", hash) + + var created []*models.Profile + for _, p := range []*models.Profile{client, worker, worker2} { + if err := profiles.Create(ctx, p); err != nil { + return "", fmt.Errorf("create %s: %w", p.Email, err) + } + created = append(created, p) + } + + adminID := "" + if withAdmin { + admin := newProfile(demoAdminEmail, "Демо админ", "admin", hash) + if err := profiles.Create(ctx, admin); err != nil { + return "", fmt.Errorf("create %s: %w", admin.Email, err) + } + adminID = admin.ID + created = append(created, admin) + } + + // Демо-аккаунты подтверждены сразу (0010): показ должен логиниться + // без лишних действий — Create вставляет без актора и потому + // оставляет confirmed_at NULL. Трогаем только созданные здесь профили. + for _, p := range created { + if _, err := q.Exec(ctx, + `UPDATE profiles SET confirmed_at = now() WHERE id = $1`, p.ID); err != nil { + return "", fmt.Errorf("confirm %s: %w", p.Email, err) + } + } + + // Проект с двумя объектами и мастером — ядро новой модели. + project := &models.Project{ClientID: client.ID, Name: "Демо-проект", IsActive: true} + if err := projects.CreateProject(ctx, project); err != nil { + return "", fmt.Errorf("create project: %w", err) + } + o1 := &models.Object{ProjectID: project.ID, Name: "Демо-дом", Address: "ул. Демо, 1", IsActive: true} + o2 := &models.Object{ProjectID: project.ID, Name: "Корпус 2", Address: "ул. Демо, 2", IsActive: true} + for _, o := range []*models.Object{o1, o2} { + if err := projects.CreateObject(ctx, o); err != nil { + return "", fmt.Errorf("create object: %w", err) + } + } + // Мастер входит по приглашению (0005): клиент зовёт, мастер принимает — + // только после accept ему можно назначать заявки. + if _, err := projects.InviteWorker(ctx, project.ID, worker.ID, client.ID); err != nil { + return "", fmt.Errorf("invite worker: %w", err) + } + if err := projects.AcceptInvitation(ctx, project.ID, worker.ID); err != nil { + return "", fmt.Errorf("accept invitation: %w", err) + } + + // Четыре заявки, закрывающие все ветки статусной машины. + if err := makeTicket(ctx, tickets, client.ID, project.ID, o1.ID, o1.Address, + "electric", "Не горит свет на втором этаже", "high", + "assigned", worker.ID); err != nil { + return "", err + } + if err := makeTicket(ctx, tickets, client.ID, project.ID, o1.ID, o1.Address, + "plumbing", "Течёт кран в санузле", "normal", + "done", worker.ID); err != nil { + return "", err + } + if err := makeTicket(ctx, tickets, client.ID, project.ID, o2.ID, o2.Address, + "elevator", "Лифт останавливается между этажами", "high", + "new", ""); err != nil { + return "", err + } + if err := makeTicket(ctx, tickets, client.ID, project.ID, o2.ID, o2.Address, + "climate", "Кондиционер не включается", "low", + "closed", worker.ID); err != nil { + return "", err + } + + return adminID, nil +} + +// makeTicket создаёт заявку и доводит её до нужного статуса — +// теми же методами репозитория, что и боевые хендлеры. +func makeTicket( + ctx context.Context, + tickets *repository.TicketRepo, + clientID string, + projectID, objectID int64, + address, category, description, priority, status, workerID string, +) error { + t := &models.Ticket{ + ClientID: clientID, + ProjectID: projectID, + ObjectID: objectID, + Address: address, + Category: category, + Description: description, + Priority: priority, + } + if err := tickets.Create(ctx, t); err != nil { + return fmt.Errorf("create ticket: %w", err) + } + if status == "new" { + return nil + } + + if err := tickets.Assign(ctx, t.ID, workerID, clientID); err != nil { + return fmt.Errorf("assign ticket: %w", err) + } + if status == "assigned" { + return nil + } + if err := tickets.Start(ctx, t.ID, workerID); err != nil { + return fmt.Errorf("start ticket: %w", err) + } + if status == "in_progress" { + return nil + } + if err := tickets.Done(ctx, t.ID, workerID); err != nil { + return fmt.Errorf("done ticket: %w", err) + } + if status == "done" { + return nil + } + // Последняя стадия — closed через подтверждение клиента. + return tickets.Confirm(ctx, t.ID, clientID) +} + +func newProfile(email, name, role, hash string) *models.Profile { + return &models.Profile{ + ID: uuid.NewString(), + Email: email, + PasswordHash: hash, + Name: name, + Role: role, + IsActive: true, + } +} + +// printAccounts — итог для человека: что за аккаунты появились +// и под каким паролем логиниться в Swagger. +func printAccounts(withAdmin bool) { + fmt.Println() + fmt.Println("Демо-данные. Пароль один у всех: " + demoPassword) + fmt.Println() + fmt.Println(" роль email") + fmt.Println(" клиент " + demoClientEmail) + fmt.Println(" мастер " + demoWorkerEmail) + fmt.Println(" мастер " + demoWorker2Email) + if withAdmin { + fmt.Println(" админ " + demoAdminEmail) + } else { + fmt.Println(" админ — не создан (запустите с --yes, если нужен)") + } + fmt.Println() + fmt.Println("Проект «Демо-проект»: 2 объекта, 1 мастер, 4 заявки (new, assigned, done, closed).") +} diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..0f39b79 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,160 @@ +# Прод-стек: db, minio, api и web в ОДНОЙ сети; наружу отдаёт только +# web (порт 80). Postgres и MinIO портов наружу не пробрасывают вовсе — +# они видны только контейнерам приложения. +# +# Образы api и web заранее собраны Drone (или вручную): +# docker build -t building-mgmt-api:latest . # в этом репо +# docker build -t building-mgmt-web:latest . # в репо web +# +# Запуск и обновление (env-файл живёт на сервере, в git не входит): +# docker compose -p building-mgmt \ +# --env-file /opt/building-mgmt/.env \ +# -f compose.prod.yaml up -d --wait +# +# Имя проекта (-p) закреплено: по нему считается имя сети. Drone и +# ручной запуск обязаны давать одно и то же имя, иначе web не найдёт +# api и стек разъедется на два. +# +# ${VAR} берутся из env-файла (--env-file): секреты в YAML не +# дублируются. `:?` — фейл-фаст: без обязательной переменной compose +# упадёт при разборе файла, а не поднимет стек с пустым паролем. + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 12 + # Потолок памяти: без него при аномалии postgres сожрёт всю машину. + # Подправьте под ресурсы сервера. + mem_limit: 1g + networks: + - app + # Портов нет: наружу база не видна, только сеть app. + + minio: + # Образ собирается из официального бинарника релиза 2024-07-26 — + # версии до ухода MinIO из опенсорса (см. deploy/minio/Dockerfile). + build: + context: deploy/minio + image: minio/minio:RELEASE.2024-07-26T20-48-21Z + restart: unless-stopped + command: server /data + environment: + # Ключи MinIO — это же S3-ключи приложения: одна пара секретов + # вместо двух. + MINIO_ROOT_USER: ${S3_ACCESS_KEY:?S3_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?S3_SECRET_KEY is required} + volumes: + - miniodata:/data + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:9000/minio/health/live || exit 1"] + interval: 5s + timeout: 5s + retries: 12 + mem_limit: 512m + networks: + - app + # Портов нет: S3 и консоль видна только сеть app. Разовый проброс + # консоли для отладки: + # docker compose -p building-mgmt --env-file /opt/building-mgmt/.env \ + # -f compose.prod.yaml run --rm -p 9443:9001 minio server /data --console-address :9001 + + api: + image: building-mgmt-api:latest + # build: — на случай ручной сборки без Drone: если образа нет, + # compose соберёт его из этого же репо; готовый образ не трогает. + build: + context: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + minio: + condition: service_healthy + environment: + # Ссылки на сервисы внутри сети подставляются сами — в env-файле + # их держать не нужно (там только секреты и внешние настройки). + - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable + - S3_ENDPOINT=http://minio:9000 + # path-style обязателен: поддоменные имена внутри сети не резолвятся. + - S3_USE_PATH_STYLE=true + # Дальше — передача значений из env-файла (--env-file): не + # заданная переменная просто не попадёт в контейнер, и упадут + # дефолты приложения (тот же JWT_SECRET/S3 фейл-фаст на старте). + - S3_REGION + - S3_BUCKET + - S3_ACCESS_KEY + - S3_SECRET_KEY + - JWT_SECRET + - APP_ENV + - COOKIE_SECURE + - TRUSTED_PROXIES + - CORS_ORIGINS + - LOG_LEVEL + - LOG_FORMAT + - GIN_MODE + - SWAGGER + - APP_BASE_URL + - PHOTO_MAX_BYTES + - PHOTO_MAX_PER_TICKET + - TICKET_RETENTION_DAYS + - DB_MAX_CONNS + - DB_MIN_CONNS + - DB_MAX_CONN_LIFETIME + - DB_MAX_CONN_IDLE_TIME + - SMTP_HOST + - SMTP_PORT + - SMTP_USER + - SMTP_PASSWORD + - SMTP_FROM + - SMTP_TLS + - DATA_ENCRYPTION_KEY + - TELEGRAM_BOT_TOKEN + - TELEGRAM_API_URL + - TELEGRAM_NOTIFY_AFTER + - TELEGRAM_TICK_EVERY + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ping || exit 1"] + interval: 30s + timeout: 3s + retries: 5 + # Датасерверу 15 секунд на завершение текущих запросов (graceful + # shutdown внутри api рассчитан на 15s), потом SIGKILL. + stop_grace_period: 20s + mem_limit: 512m + networks: + - app + # Портов нет: API доступен только web-контейнеру. + + web: + # Образ собирается в репо веба: docker build -t building-mgmt-web:latest . + image: building-mgmt-web:latest + restart: unless-stopped + depends_on: + api: + condition: service_healthy + ports: + # Единственный порт наружу. TLS терминалирует перед этим nginx + # (балансировщик / Caddy / Traefik), HSTS веб отдаёт сам. + - "80:80" + mem_limit: 256m + networks: + - app + +networks: + app: + driver: bridge + +volumes: + pgdata: + miniodata: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..19ea386 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,74 @@ +# Локальная база для разработки: поднимается одной командой и не зависит +# от туннеля до БД на другом сервере. +# +# docker compose up -d --wait # или: make up +# docker compose down # или: make down +# +# Наружу проброшен порт 5433, а не 5432: 5432 обычно занят пробросом +# рабочей базы, и две БД должны уживаться одновременно. Для compose-базы +# в .env подставляется: +# +# DATABASE_URL=postgres://building_app:building_app@localhost:5433/building_mgmt?sslmode=disable +# +# Данные живут в томе pgdata и переживают `down`; полная зачистка — +# `docker compose down -v`. + +services: + db: + image: postgres:18-alpine + container_name: building-mgmt-db + restart: unless-stopped + # Ресурсные потолки dev-базы: 512m хватает с запасом + # (shared_buffers по умолчанию 128m), больше ядра отдавать не нужно. + mem_limit: 512m + cpus: 1 + environment: + POSTGRES_USER: building_app + POSTGRES_PASSWORD: building_app + POSTGRES_DB: building_mgmt + ports: + - "5433:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + # --wait дожидается healthcheck: `up -d --wait` возвращает готовую базу + test: ["CMD-SHELL", "pg_isready -U building_app -d building_mgmt"] + interval: 5s + timeout: 5s + retries: 12 + + minio: + # Локальное S3 для фотоотчёта (порт 5434, консоль 5435). Образ + # собирается из официального бинарника релиза 2024-07-26 — версии + # до ухода MinIO из опенсорса: на Docker Hub minio/minio архивирован, + # pull отдан невозможен (см. deploy/minio/Dockerfile). + build: + context: deploy/minio + image: minio/minio:RELEASE.2024-07-26T20-48-21Z + container_name: building-mgmt-minio + restart: unless-stopped + # Потолки dev-сервиса: MinIO съедает меньше гигабайта даже под нагрузкой. + mem_limit: 512m + cpus: 1 + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + # 5434/5435 — чтобы не пересекаться с пробросами других сервисов + # (5432 занят PostgreSQL, 9000/9001 — типовые порты MinIO). + - "5434:9000" + - "5435:9001" + volumes: + - miniodata:/data + healthcheck: + # busybox wget входит в alpine: /minio/health/live отвечает 200, + # когда сервер готов принимать запросы. + test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:9000/minio/health/live || exit 1"] + interval: 5s + timeout: 5s + retries: 12 + +volumes: + pgdata: + miniodata: diff --git a/deploy/drone/compose.yaml b/deploy/drone/compose.yaml new file mode 100644 index 0000000..497f20c --- /dev/null +++ b/deploy/drone/compose.yaml @@ -0,0 +1,70 @@ +# Drone CI: server (интерфейс, webhook от Gitea) и docker-runner +# (исполняет шаги .drone.yml в контейнерах этого хоста). +# +# Перед запуском — в Gitea: Настройки → Applications → OAuth2-приложение, +# Redirect URI: http://<хост-drone>:8081/authorize +# +# .env рядом с этим файлом (в git не входит): +# GITEA_SERVER=https://gitea.example.com +# DRONE_GITEA_CLIENT_ID=... +# DRONE_GITEA_CLIENT_SECRET=... +# DRONE_GITEA_ADMIN_USERNAME=admin # аккаунт Gitea, станет админом Drone +# DRONE_SERVER_HOST=drone.example.com +# DRONE_SERVER_PROTO=https +# DRONE_RPC_SECRET= # openssl rand -hex 32 +# +# Запуск: docker compose -p drone --env-file .env -f compose.yaml up -d + +services: + drone: + image: drone/drone:2 + restart: unless-stopped + ports: + # Веб-интерфейс и цель webhook от Gitea; за TLS-терминалом. + - "8081:80" + environment: + DRONE_GITEA_SERVER: ${GITEA_SERVER:?GITEA_SERVER is required} + DRONE_GITEA_CLIENT_ID: ${DRONE_GITEA_CLIENT_ID:?DRONE_GITEA_CLIENT_ID is required} + DRONE_GITEA_CLIENT_SECRET: ${DRONE_GITEA_CLIENT_SECRET:?DRONE_GITEA_CLIENT_SECRET is required} + # Общий секрет server ↔ runner: openssl rand -hex 32. + DRONE_RPC_SECRET: ${DRONE_RPC_SECRET:?DRONE_RPC_SECRET is required} + # Первый залогинившийся через Gitea становится админом Drone. + DRONE_USER_CREATE_OAUTH: username=${DRONE_GITEA_ADMIN_USERNAME:?DRONE_GITEA_ADMIN_USERNAME is required},admin=true + # Внешний адрес интерфейса: его видят Gitea (webhook) и браузер. + DRONE_SERVER_HOST: ${DRONE_SERVER_HOST:?DRONE_SERVER_HOST is required} + DRONE_SERVER_PROTO: ${DRONE_SERVER_PROTO:-https} + volumes: + - drone:/data + networks: + - drone + + runner: + image: drone/drone-runner-docker:1 + restart: unless-stopped + depends_on: + - drone + environment: + DRONE_RPC_PROTO: http + DRONE_RPC_HOST: drone + DRONE_RPC_SECRET: ${DRONE_RPC_SECRET:?DRONE_RPC_SECRET is required} + # Один конвейер за раз: деплои не должны пересекаться. + DRONE_RUNNER_CAPACITY: "1" + DRONE_RUNNER_NAME: prod-server + # Шаги пайплайнов (и их сервисы — postgres для тестов) поднимаются + # именно в этой сети: имя сети указано явно (name: drone), чтобы + # совпадало со значением DRONE_RUNNER_NETWORKS. + DRONE_RUNNER_NETWORKS: drone + volumes: + # Доступ к docker-демону: сборка образов, compose, деплой. + - /var/run/docker.sock:/var/run/docker.sock + networks: + - drone + +networks: + # Имя фиксируем: DRONE_RUNNER_NETWORKS ссылается на него буквально, + # а префикс проекта (drone_drone) runner бы не узнал. + drone: + name: drone + +volumes: + drone: diff --git a/deploy/minio/Dockerfile b/deploy/minio/Dockerfile new file mode 100644 index 0000000..61a11a1 --- /dev/null +++ b/deploy/minio/Dockerfile @@ -0,0 +1,26 @@ +# MinIO для локальной разработки: официальный бинарник релиза +# RELEASE.2024-07-26T20-48-21Z (AGPLv3, открытый код). +# +# Собираем сами, а не тянем minio/minio: на Docker Hub репозиторий +# архивирован — все теги, включая старые, отвечают 401, зеркала не +# помогают, а dl.min.io больше не отдаёт архив. Источник — официальные +# assets GitHub-релиза minio/minio; хеш сверен с мини-файлом +# minio.linux-amd64..sha256sum того же релиза. +FROM alpine:3.19 + +ARG MINIO_RELEASE=RELEASE.2024-07-26T20-48-21Z +# sha256 официального asset minio.linux-amd64.RELEASE.2024-07-26T20-48-21Z. +ARG MINIO_SHA256=0956c4ad6fbbbd27bfbc73b82c2689703735ebeb2abd509ebbd8723345da35fd + +# curl+ca-certificates — только на время скачивания: наружу остаётся +# один бинарник (минимум слоёв и лишних пакетов в образе). +RUN apk add --no-cache ca-certificates curl \ + && curl -fsSL -o /usr/local/bin/minio \ + "https://github.com/minio/minio/releases/download/${MINIO_RELEASE}/minio.linux-amd64.${MINIO_RELEASE}" \ + && echo "${MINIO_SHA256} /usr/local/bin/minio" | sha256sum -c - \ + && chmod 0755 /usr/local/bin/minio \ + && apk del curl + +EXPOSE 9000 9001 +# Аргументы `server …` приходят из compose (command:). +ENTRYPOINT ["/usr/local/bin/minio"] diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..09ef477 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,4787 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/forgot-password": { + "post": { + "description": "Отправляет на email письмо со ссылкой формы сброса (токен живёт 24 часа, одноразовый). Ответ всегда одинаковый — 200 с ok:true, независимо от того, существует ящик, подтверждён он и активен: эндпоинт не перечисляет аккаунты. Письмо уходит только подтверждённым активным аккаунтам. Лимиты: 10 запросов в минуту с одного IP; 3 письма в сутки на один ящик (счётчик тикает при любом запросе — и для несуществующих ящиков тоже), при превышении — 429.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Запрос письма для сброса пароля", + "parameters": [ + { + "description": "Email для восстановления", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.forgotPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "всегда ok:true, даже если ящик не найден", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Возвращает access-токен; refresh-токен — только в httpOnly-куке (Path=/auth, SameSite=Lax, Secure — через COOKIE_SECURE), в JSON его нет. Email ищется без учёта регистра, пароль — bcrypt. Неподтверждённый аккаунт — 403 account not confirmed (отличайте от account disabled). Лимит: 10 попыток в минуту с одного IP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Вход", + "parameters": [ + { + "description": "Учётные данные", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.loginReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "неверный логин или пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "аккаунт отключён либо не подтверждён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Гасит refresh-сессию из куки refresh_token и очищает куку. Тело запроса не нужно. Auth-middleware намеренно не используется: выйти должен пользователь и с протухшим access-токеном.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выйти (одно устройство)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/logout-all": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Закрывает все сессии текущего пользователя.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выйти со всех устройств", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Текущий пользователь", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refresh-токен читается из httpOnly-куки refresh_token (Path=/auth), тело запроса не требуется. Ротация: на выходе новый access-токен и новая кука, старая сессия гасится. Повторное использование отозванного токена закрывает все сессии пользователя — признак того, что секретом владеет кто-то второй.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Обновить пару токенов", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "401": { + "description": "куки нет, токен неизвестен, отозван или истёк", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "аккаунт отключён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/reset-password": { + "post": { + "description": "Токен одноразовый и живёт 24 часа: приходит письмом после POST /auth/forgot-password либо выдаётся админом через POST /users/{id}/password-reset. Новый пароль — по политике (10..72 символа, буквы и цифры). Успех гасит токен и все refresh-сессии пользователя. Лимит: 30 попыток в минуту с одного IP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Сброс пароля по токену", + "parameters": [ + { + "description": "Токен и новый пароль", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.resetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректное тело или токен неизвестен, использован либо истёк; слабый пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/telegram": { + "get": { + "description": "Куда сейчас приходят уведомления о заявках: цель (чат или группа), имя бота (свой или глобальный), подключён ли собственный токен бота (has_bot_token) и можно ли его ввести (token_configurable — зависит от DATA_ENCRYPTION_KEY на сервере). Сам токен наружу не отдаётся.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Статус уведомлений в Telegram", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TelegramStatusResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Принимает @имя группы/канала (бот обязан быть в нём) или числовой chat_id; пустое значение отключает уведомления. Сначала проверяется формат, затем бот проверяет чат через getChat — чужой или недоступный чат не сохраняется.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Указать цель уведомлений вручную", + "parameters": [ + { + "description": "Цель: @группа, chat_id или пусто", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setTelegramReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "invalid telegram target", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Очищает цель уведомлений текущего пользователя (то же, что PUT с пустым значением). Токен бота при этом не трогается — он живёт на своём эндпоинте.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Отключить уведомления в Telegram", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/telegram/bot": { + "post": { + "description": "Принимает токен Bot API (формат «id:secret» от @BotFather), проверяет его через getMe, шифрует и сохраняет. Дальше уведомления пользователя идут через этого бота, его поллер поднимается сразу. Лимит: 5 попыток за 5 минут. Без DATA_ENCRYPTION_KEY на сервере — 400.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Подключить своего бота", + "parameters": [ + { + "description": "Токен бота", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setBotReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "токен не сохранён (формат, getMe или фича выключена)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Стирает сохранённый токен текущего пользователя и останавливает его поллер. Повторный вызов безопасен — ответ тот же 200.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Отключить своего бота", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/telegram/code": { + "post": { + "description": "Одноразовый код и deep-link t.me/{bot}?start={code}: откройте ссылку в Telegram и отправьте боту код — этот чат начнёт получать уведомления о заявках. Бот — ваш собственный токен, а если его нет — глобальный бот сервера. Код живёт 15 минут, прежние коды гаснут. Лимит: 5 выпусков за 5 минут на пользователя.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выдать код привязки Telegram", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BindCodeResponse" + } + }, + "400": { + "description": "ни свой бот, ни глобальный не настроены", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много кодов", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/invitations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Список приглашений для текущего мастера: по каждому — проект, кто пригласил и до какого срока. Просроченные сюда не попадают — принять их уже нельзя.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Мои приглашения в проекты", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "invited_at", + "expires_at", + "project_name" + ], + "type": "string", + "default": "invited_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.InvitationListResponse" + } + }, + "400": { + "description": "некорректные параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/objects/{id}": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Поля опциональны. У уже созданных заявок адрес остаётся тем, что был на момент создания — это осознанно.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Изменить объект", + "parameters": [ + { + "type": "integer", + "description": "ID объекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Что изменить", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateObjectReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Object" + } + }, + "400": { + "description": "некорректный id или пустое тело", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "объект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "имя уже занято в проекте", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент видит только свои проекты, админ — все (можно сузить через client_id). Мастерам проекты не отдаются. Ответ — страница: items плюс total/limit/offset.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Список проектов", + "parameters": [ + { + "type": "string", + "description": "Фильтр по клиенту (только admin)", + "name": "client_id", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "created_at", + "name", + "id" + ], + "type": "string", + "default": "created_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProjectListResponse" + } + }, + "400": { + "description": "некорректные параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент создаёт проект себе; админ — для любого клиента (в этом случае обязателен client_id). Проект — контур, внутри которого живут объекты и мастера.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Создать проект", + "parameters": [ + { + "description": "Данные проекта", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createProjectReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Project" + } + }, + "400": { + "description": "некорректное тело; client_id обязателен для admin; владелец не клиент", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "у клиента уже есть проект с таким именем", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ — любой проект, клиент — только свой; чужой проект неотличим от несуществующего (404).", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Детали проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Project" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Действие необратимо: вместе с проектом удаляются его объекты, участия мастеров и все заявки (вместе с событиями и фото). Объекты фото в хранилище убираются после успешного удаления в БД. Клиент своих проектов не удаляет — только админ.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Удалить проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Поля опциональны, меняется только переданное. Выключенный проект не принимает новые заявки и объекты.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Изменить проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Что изменить", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateProjectReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Project" + } + }, + "400": { + "description": "некорректный id или пустое тело", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "имя уже занято у этого клиента", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/invitation/accept": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "invited → active: с этого момента мастер состоит в проекте и ему можно назначать заявки. Действует только на своё непросроченное приглашение (срок — 7 дней, см. repository.InvitationTTL).", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Принять приглашение в проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "приглашения нет", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "срок вышел или приглашение уже закрыто", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/invitation/decline": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "invited → declined: заявок мастер не получает, клиент видит отказ в составе проекта и может пригласить повторно (строка та же, история ответа обновляется).", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Отклонить приглашение в проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "приглашения нет", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "приглашение уже закрыто", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/objects": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Здания/корпуса, входящие в проект. Адрес заявки берётся из объекта.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Объекты проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "name", + "created_at", + "id" + ], + "type": "string", + "default": "name", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ObjectListResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Объект — здание/корпус с адресом; заявки создаются именно на объект. Выключенный проект объектов не принимает.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Создать объект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Данные объекта", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createObjectReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Object" + } + }, + "400": { + "description": "некорректный id или тело", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "проект выключен или имя объекта занято", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/workers": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Кто приглашён в проект и кем принят: статус invited — приглашение ждёт решения, active — мастер работает с заявками, declined — отказал. Хеши паролей в ответ не попадают.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Мастера проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "name", + "created_at", + "email", + "id" + ], + "type": "string", + "default": "name", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProjectWorkerListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "С 0005 это приглашение, а не готовое членство: мастер становится участником только сам, приняв приглашение (POST /projects/{id}/invitation/accept) — до этого заявки проекта ему не назначаются. Приглашение живёт 7 дней. Мастер может состоять в нескольких проектах; аккаунт должен быть активным, с ролью worker и подтверждённым (0010) — неподтверждённого сначала подтверждает тот, кто его создал.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Пригласить мастера в проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Мастер", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addWorkerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.InvitationResponse" + } + }, + "400": { + "description": "некорректный id/тело; не мастер, неактивен или не подтверждён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект или мастер не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "мастер уже состоит в проекте / приглашение уже ждёт ответа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/workers/{worker_id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Нельзя убрать, пока за ним закреплены незакрытые заявки проекта (409) — иначе работа осталась бы без исполнителя. Заявки из истории при этом не теряются.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Убрать мастера из проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "ID мастера (uuid)", + "name": "worker_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден или мастер не состоит в проекте", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "за мастером есть незакрытые заявки", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент видит только свои правила, админ — все. Ответ — страница: items плюс total/limit/offset.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Список правил повторения", + "parameters": [ + { + "type": "integer", + "description": "Сузить до одного проекта", + "name": "project_id", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "created_at", + "id", + "date" + ], + "type": "string", + "default": "created_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RecurringRuleListResponse" + } + }, + "400": { + "description": "некорректные параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Правило — шаблон заявки (объект, категория, описание, приоритет) плюс расписание: anchor_at (дата-время первого запуска) и period_days — «каждые N дней/недель». Фоновый планировщик сам создаёт заявку, когда срок наступил, — но только если прошлая по правилу уже закрыта (без дублей). Клиент создаёт правило для своего проекта, админ — для любого. Обычно правило заводится чекбоксом в POST /tickets; этот эндпоинт — для управления без заявки.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Создать правило повторяющейся заявки", + "parameters": [ + { + "description": "Данные правила", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createRecurringReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "объект не найден (или чужой — неотличим)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "проект или объект выключен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ — любое правило, клиент — только своё; чужое неотличимо от несуществующего (404).", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Детали правила повторения", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Удаляется само правило, его история срабатываний (какая заявка создавалась и когда) и — каскадом — будущие заявки ещё не созданы. Созданные ранее заявки не трогаются.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Удалить правило повторения", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Частичное обновление: применяются только присланные поля, рамки те же, что при создании (anchor_at — любая дата-время, period_days 1..3650). object_id не меняется (перенос — через удаление и создание). Срок не сдвигается вручную: он пересчитывается от последнего срабатывания с новым расписанием.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Изменить правило повторения", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Что изменить (хотя бы одно поле)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateRecurringReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "400": { + "description": "некорректный id, пустое тело или значение вне рамок", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring/{id}/run": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Заявка создаётся сразу, без ожидания интервала. Остальные рубежи как у планировщика: правило, проект и объект включены, прошлая заявка по правилу закрыта — иначе 409, дубль не создаём. Срок сдвигается на момент запуска (следующий фоновый тик отсчитает интервал от него), уведомление уходит тем же текстом, что при фоновом срабатывании.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Запустить правило сейчас", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.RecurringRunResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено (или чужое — неотличим)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "правило, проект или объект выключены, либо есть открытая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring/{id}/runs": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Какие заявки создавались по правилу и когда (аудит). Сначала свежие. Строка живёт вместе со своей заявкой: когда заявку удаляет автоочистка (TICKET_RETENTION_DAYS), строка истории уходит следом.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "История срабатываний правила", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "run_at", + "id" + ], + "type": "string", + "default": "run_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RecurringRunListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Выборка режется по роли: клиент видит свои, мастер — назначенные ему, админ — всё. Клиенту и мастеру параметры client_id/worker_id игнорируются: подсмотреть чужое нельзя. Фильтры накладываются поверх выборки по роли. Ответ — страница: items плюс total/limit/offset.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Список заявок", + "parameters": [ + { + "enum": [ + "new", + "assigned", + "in_progress", + "done", + "confirmed", + "rejected", + "closed" + ], + "type": "string", + "description": "Фильтр по статусу", + "name": "status", + "in": "query" + }, + { + "enum": [ + "electric", + "plumbing", + "climate", + "elevator", + "other" + ], + "type": "string", + "description": "Фильтр по категории", + "name": "category", + "in": "query" + }, + { + "enum": [ + "low", + "normal", + "high" + ], + "type": "string", + "description": "Фильтр по приоритету", + "name": "priority", + "in": "query" + }, + { + "type": "string", + "description": "Фильтр по клиенту (только admin)", + "name": "client_id", + "in": "query" + }, + { + "type": "string", + "description": "Фильтр по мастеру (только admin)", + "name": "worker_id", + "in": "query" + }, + { + "type": "integer", + "description": "Фильтр по проекту", + "name": "project_id", + "in": "query" + }, + { + "type": "integer", + "description": "Фильтр по объекту", + "name": "object_id", + "in": "query" + }, + { + "type": "string", + "description": "created_at с, YYYY-MM-DD (включительно, с полуночи UTC)", + "name": "date_from", + "in": "query" + }, + { + "type": "string", + "description": "created_at по, YYYY-MM-DD (включительно, до полуночи UTC следующего дня)", + "name": "date_to", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "created_at", + "priority", + "id" + ], + "type": "string", + "default": "created_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TicketListResponse" + } + }, + "400": { + "description": "некорректный фильтр или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "неизвестная роль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент создаёт заявку на объект своего проекта. Адрес берётся из объекта на момент создания, статус всегда new, мастер не назначен. Если передан recurring — той же операцией создаётся правило повторения (anchor_at — первый запуск, period_days — период в днях), связанное с заявкой: оно видно в GET /tickets/{id} в поле recurring, управление — через PATCH /recurring/{id}.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Создать заявку", + "parameters": [ + { + "description": "Данные заявки", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createTicketReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "объект не найден (или чужой — неотличим)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "проект или объект выключен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ — любая заявка, клиент — только своя, мастер — назначенная ему либо свободная (new) из его проектов.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Детали заявки", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "заявка не принадлежит вам", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/assign": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "new → assigned. Доступно админу и владельцу-клиенту. Мастер должен состоять в проекте заявки и быть активным — иначе 400/409.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Назначить мастера", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Мастер", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.assignReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id/тело, не-мастер, неактивен, приглашение не принято или не состоит в проекте заявки", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client; чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка уже назначена или закрыта", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/cancel": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Закрывает заявку с cancel_reason и closed_by. Клиент — только свои в new/assigned/done/rejected; админ — любую кроме уже закрытой, включая in_progress. Причина обязательна.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Отменить заявку (финал)", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Причина отмены, минимум 5 символов", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reasonReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id или тело: причина обязательна", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "чужая заявка или роль не client/admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "нельзя закрыть из текущего статуса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/confirm": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "done → closed с confirmed_at. Доступно только владельцу-клиенту.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Подтвердить работу (финал)", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "заявка не принадлежит вам", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка не в статусе done", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/done": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "in_progress → done. Дальше клиент подтверждает или отклоняет.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Завершить работу", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "не тот статус или заявка назначена другому", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/events": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Хронология переходов заявки с авторами: created, assigned, started, done, confirmed, rejected, canceled. from_status у события «создана» нет. События пишутся той же SQL-операцией, что и переход, поэтому история не расходится со статусом.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Журнал событий заявки", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "id", + "created_at" + ], + "type": "string", + "default": "id", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TicketEventListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/photos": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Файлы заявки в хронологии (id ASC по умолчанию). В каждом элементе url — временная подписанная ссылка на чтение: бакет приватный, сам ключ наружу не уходит.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Список фото отчёта", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "id", + "created_at" + ], + "type": "string", + "default": "id", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PhotoListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "multipart/form-data, поле file: только фото (jpeg/png/webp, определяется по сигнатуре байтов), не больше PHOTO_MAX_BYTES на файл и PHOTO_MAX_PER_TICKET файлов на заявку. Доступно мастеру (заявка in_progress — после «Работа выполнена» отчёт заморожен, мастер — активный участник проекта) или админу. Каждая загрузка оставляет событие photo_added в журнале заявки.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Загрузить фото отчёта", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "Файл фотографии (jpeg/png/webp)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "фото загружено, url — временная ссылка на чтение", + "schema": { + "$ref": "#/definitions/models.TicketPhoto" + } + }, + "400": { + "description": "некорректный id, файл отсутствует, не фото или больше лимита", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker или admin; чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "лимит файлов исчерпан или заявка не в статусе in_progress", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "хранилище недоступно", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/photos/{photo_id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Своё фото удаляет автор, пока заявка в работе (in_progress): после «Работа выполнена» отчёт заморожен; админу — любое фото. Событие photo_removed пишется той же операцией, что и удаление.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Удалить фото", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID фото", + "name": "photo_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id или photo_id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker или admin; чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка или фото не найдены", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "чужое фото или заявка уже не в работе", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/reject": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "done → rejected. Не финал: мастер берёт заявку в работу повторно, rework_count увеличивается. Причина обязательна — без неё мастер не поймёт, что переделывать.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Отклонить работу (на доработку)", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Причина отклонения, минимум 5 символов", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reasonReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id или тело: причина обязательна", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "заявка не принадлежит вам", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка не в статусе done", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/start": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "assigned → in_progress, а после отклонения (rejected) — повторный запуск. started_at первого круга сохраняется.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Взять в работу", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "не тот статус или заявка назначена другому", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/take": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "new → assigned: мастер назначает себя на свободную заявку своего проекта. Дальше — «Взять в работу» (start) как обычно.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Взять заявку самому", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id; приглашение не принято или отклонено; не состоит в проекте заявки", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка уже взята или не в статусе new", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ видит любую роль и всех; клиенту доступна только роль worker и только его собственные мастера (чужие организации не видны). С параметром q (минимум 2 символа) возвращается поиск по имени/email среди активных — так клиент пригласит в проект существующего мастера, не видя при этом полного списка. include_inactive=1 дополняет список отключёнными аккаунтами — только для админа.", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Список пользователей по роли", + "parameters": [ + { + "enum": [ + "worker", + "admin", + "client" + ], + "type": "string", + "default": "worker", + "description": "Роль", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "Поиск по имени или email, минимум 2 символа", + "name": "q", + "in": "query" + }, + { + "enum": [ + "0", + "1" + ], + "type": "string", + "default": "0", + "description": "Показать и отключённых (только для админа)", + "name": "include_inactive", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "name", + "created_at", + "email", + "id" + ], + "type": "string", + "default": "name", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProfileListResponse" + } + }, + "400": { + "description": "неизвестная роль, параметры страницы или короткий q", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "клиент запрашивает не мастеров", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ создаёт мастеров, админов и клиентов; клиент — только мастеров (для себя, далее мастер добавляется в проект через POST /projects/{id}/workers). Роли admin и client клиентом недоступны. Пароль — по политике (10..72 символа, буквы и цифры). Подтверждение: аккаунт, созданный админом, подтверждён сразу; созданный клиентом — ждёт подтверждения этим же клиентом, после чего мастеру уходит письмо.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Создать пользователя", + "parameters": [ + { + "description": "Данные пользователя", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createUserReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Profile" + } + }, + "400": { + "description": "некорректное тело запроса или слабый пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "клиент пытается создать админа или клиента", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "email уже занят", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ удаляет любой «чистый» аккаунт: refresh-сессии и токены сброса удаляются каскадом, но наличие заявок, проектов или участий блокирует удаление (409 user has related records) — в этом случае отключите аккаунт через PATCH /users/{id}, историю не стираем. Клиент удаляет только мастера, состоящего в одном из его проектов: членства клиента в проектах удаляются в той же транзакции, но если на мастера ссылаются заявки либо чужие проекты — тоже 409, ничего не меняется. Чужой мастер и несуществующий id неразличимы (404). Свой id удалить нельзя.", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Удалить пользователя", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id; нельзя удалить себя", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "у пользователя есть связанные записи", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ меняет любой аккаунт; клиент — только мастера, который активный участник одного из его проектов (иного клиента или чужого мастера не видит — 404). При деактивации все refresh-сессии пользователя закрываются сразу, мастер перестаёт работать во всех проектах. Выключить самого себя нельзя — вы остались бы без входа.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Включить/выключить аккаунт", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Новый флаг", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + }, + "400": { + "description": "некорректный id или тело; попытка выключить себя", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден (для клиента — не его мастер)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}/confirm": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Подтверждает неподтверждённый аккаунт и шлёт пользователю письмо «аккаунт подтверждён» со ссылкой на вход. Право — в SQL: админ подтверждает любой аккаунт, клиент — только созданного им мастера (иной и несуществующий не видны — 404). Повторное подтверждение — 409. Письмо отправляется после подтверждения; недоступный SMTP не откатывает подтверждение (письмо просто не уйдёт).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Подтвердить аккаунт", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден (для клиента — не созданный им мастер)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "аккаунт уже подтверждён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}/password-reset": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Токен возвращается в ответе — админ передаёт его пользователю сам, любым каналом. Для пользователя есть почта: POST /auth/forgot-password шлёт письмо со ссылкой. Токен одноразовый, живёт 24 часа (repository.ResetTTL); выпуск нового гасит прежние неиспользованные токены пользователя (включая свежие из писем). В БД хранится только sha256 токена.", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Выдать токен сброса пароля", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PasswordResetResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}/password-set": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ ставит пароль пользователю сам, без ссылки. Политика та же, что при создании и при сбросе по токену (10..72 символа, буквы и цифры). Успех закрывает все refresh-сессии пользователя и гасит неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Альтернатива — одноразовая ссылка через POST /users/{id}/password-reset.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Задать пароль напрямую", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Новый пароль", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id, тело или слабый пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.BindCodeResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "link": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.InvitationListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectInvitation" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.InvitationResponse": { + "type": "object", + "properties": { + "expires_at": { + "description": "До какого срока мастер может ответить (см. repository.InvitationTTL).", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "status": { + "description": "Всегда \"invited\": стать участником мастер может только сам, через\nPOST /projects/{id}/invitation/accept.", + "type": "string" + } + } + }, + "handlers.OKResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "revoked": { + "description": "Revoked — сколько сессий закрыто; заполняется только в logout-all.", + "type": "integer" + } + } + }, + "handlers.ObjectListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Object" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.PasswordResetResponse": { + "type": "object", + "properties": { + "expires_at": { + "description": "См. repository.ResetTTL (24 часа).", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "handlers.PhotoListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TicketPhoto" + } + }, + "limit": { + "type": "integer" + }, + "max_per_ticket": { + "description": "MaxPerTicket — лимит файлов на заявку (PHOTO_MAX_PER_TICKET):\nUI рисует «n / N», не зная настройки окружения.", + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.ProfileListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Profile" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.ProjectListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Project" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.ProjectWorkerListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectWorker" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.RecurringRuleListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.RecurringRunListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.RecurringRun" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.RecurringRunResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "ticket_id": { + "type": "integer" + } + } + }, + "handlers.TelegramStatusResponse": { + "type": "object", + "properties": { + "bot_username": { + "description": "BotUsername — @имя бота, через который придут уведомления и\nссылка привязки; пусто, пока getMe не удался.", + "type": "string" + }, + "bound": { + "description": "Bound — есть ли уже цель уведомлений.", + "type": "boolean" + }, + "enabled": { + "description": "Enabled — может ли этот пользователь получать уведомления вообще:\nесть свой токен бота либо настроен глобальный бот сервера.", + "type": "boolean" + }, + "has_bot_token": { + "description": "HasBotToken — подключён ли пользователь свой бот (токен в БД).", + "type": "boolean" + }, + "target": { + "description": "Target — текущая цель: числовой chat_id либо @группа.", + "type": "string" + }, + "token_configurable": { + "description": "TokenConfigurable — можно ли ввести токен прямо сейчас: false,\nесли на сервере не задан DATA_ENCRYPTION_KEY.", + "type": "boolean" + } + } + }, + "handlers.TicketEventListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TicketEvent" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.TicketListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Ticket" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.TokenResponse": { + "type": "object", + "properties": { + "expires_in": { + "description": "Срок жизни access-токена в секундах.", + "type": "integer" + }, + "token": { + "description": "Access-токен: JWT на auth.AccessTTL.", + "type": "string" + }, + "user": { + "$ref": "#/definitions/models.Profile" + } + } + }, + "handlers.addWorkerReq": { + "type": "object", + "required": [ + "worker_id" + ], + "properties": { + "worker_id": { + "type": "string" + } + } + }, + "handlers.assignReq": { + "type": "object", + "required": [ + "worker_id" + ], + "properties": { + "worker_id": { + "type": "string" + } + } + }, + "handlers.createObjectReq": { + "type": "object", + "required": [ + "address", + "name" + ], + "properties": { + "address": { + "type": "string", + "minLength": 5 + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.createProjectReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "client_id": { + "description": "ClientID — только для админа: для какого клиента создаётся проект.\nУ клиента владелец берётся из токена, поле игнорируется.", + "type": "string" + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.createRecurringReq": { + "type": "object", + "required": [ + "anchor_at", + "category", + "description", + "object_id", + "period_days" + ], + "properties": { + "anchor_at": { + "description": "Расписание: «каждые PeriodDays дней/недель от AnchorAt» —\nAnchorAt (RFC3339) — дата-время первого запуска, PeriodDays —\nпериод в днях (1..3650; недели кратны 7).", + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "electric", + "plumbing", + "climate", + "elevator", + "other" + ] + }, + "description": { + "type": "string", + "minLength": 5 + }, + "floor": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "period_days": { + "type": "integer", + "maximum": 3650, + "minimum": 1 + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ] + } + } + }, + "handlers.createTicketReq": { + "type": "object", + "required": [ + "category", + "description", + "object_id" + ], + "properties": { + "category": { + "type": "string", + "enum": [ + "electric", + "plumbing", + "climate", + "elevator", + "other" + ] + }, + "description": { + "type": "string", + "minLength": 5 + }, + "floor": { + "type": "string" + }, + "object_id": { + "description": "Объект, на который создаётся заявка: адрес копируется из объекта.", + "type": "integer" + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ] + }, + "recurring": { + "description": "Recurring — задан, если заявку делают повторяющейся.", + "allOf": [ + { + "$ref": "#/definitions/handlers.recurringSchedule" + } + ] + } + } + }, + "handlers.createUserReq": { + "type": "object", + "required": [ + "email", + "name", + "password", + "role" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "worker", + "admin", + "client" + ] + } + } + }, + "handlers.forgotPasswordReq": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "handlers.loginReq": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handlers.reasonReq": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 5 + } + } + }, + "handlers.recurringSchedule": { + "type": "object", + "required": [ + "anchor_at", + "period_days" + ], + "properties": { + "anchor_at": { + "description": "AnchorAt — дата-время первого запуска (RFC3339): точка отсчёта\nсетки сроков, от неё идут «каждые period_days».", + "type": "string" + }, + "period_days": { + "description": "PeriodDays — период в днях (1..3650; недели кратны 7).", + "type": "integer", + "maximum": 3650, + "minimum": 1 + } + } + }, + "handlers.resetPasswordReq": { + "type": "object", + "required": [ + "password", + "token" + ], + "properties": { + "password": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "handlers.setBotReq": { + "type": "object", + "properties": { + "token": { + "description": "Token — токен бота от @BotFather; пусто невалидно (нужен явный ввод).", + "type": "string" + } + } + }, + "handlers.setPasswordReq": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + }, + "handlers.setTelegramReq": { + "type": "object", + "properties": { + "target": { + "description": "Target — @группа/@канал или числовой chat_id; пусто — отключить.", + "type": "string" + } + } + }, + "handlers.setUserReq": { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "description": "*bool обязателен: на bool с binding:\"required\" запрос\n«деактивировать» (is_active=false) не прошёл бы валидацию.", + "type": "boolean" + } + } + }, + "handlers.updateObjectReq": { + "type": "object", + "properties": { + "address": { + "type": "string", + "minLength": 5 + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.updateProjectReq": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.updateRecurringReq": { + "type": "object", + "properties": { + "anchor_at": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "floor": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "period_days": { + "type": "integer" + }, + "priority": { + "type": "string" + } + } + }, + "models.Object": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "description": "Денормализации для UI.", + "type": "string" + } + } + }, + "models.Profile": { + "type": "object", + "properties": { + "confirmed_at": { + "description": "ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON)\n— ожидает подтверждения, вход до этого запрещён (403\naccount not confirmed). Существующие пользователи подтверждены\nбэкфиллом миграции 0010.", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "models.Project": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_name": { + "description": "Денормализация для UI: чей проект (админ видит все сразу).", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "models.ProjectInvitation": { + "type": "object", + "properties": { + "client_name": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "invited_at": { + "type": "string" + }, + "invited_by": { + "description": "id пригласившего", + "type": "string" + }, + "invited_by_name": { + "description": "его имя для UI", + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + } + } + }, + "models.ProjectWorker": { + "type": "object", + "properties": { + "confirmed_at": { + "description": "ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON)\n— ожидает подтверждения, вход до этого запрещён (403\naccount not confirmed). Существующие пользователи подтверждены\nбэкфиллом миграции 0010.", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt — крайний срок ответа; заполнен у приглашений.", + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "responded_at": { + "description": "RespondedAt — когда мастер принял/отклонил; пусто, пока не ответил.", + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "description": "invited | active | declined (см. 0005_project_invitations.sql).", + "type": "string" + } + } + }, + "models.RecurringRule": { + "type": "object", + "properties": { + "address": { + "description": "Адрес объекта — снимок, как у заявки: правило показывает, куда едут.", + "type": "string" + }, + "anchor_at": { + "description": "Расписание: «каждые PeriodDays дней/недель от AnchorAt» —\nAnchorAt — дата-время первого запуска (точка отсчёта сетки\nсроков), PeriodDays — период в днях (1..3650, недели кратны 7).", + "type": "string" + }, + "category": { + "description": "Шаблон заявки — те же поля, что у tickets.", + "type": "string" + }, + "client_id": { + "description": "ClientID — владелец будущих заявок (клиент проекта).", + "type": "string" + }, + "client_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "description": "CreatedBy — кто завёл правило (админ или клиент); в журнале\nсозданной заявки это же лицо будет автором события «created».", + "type": "string" + }, + "created_by_name": { + "description": "Кто завёл правило (админ или сам клиент) — для аудита, в JSON идёт имя.", + "type": "string" + }, + "description": { + "type": "string" + }, + "floor": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "last_run_at": { + "description": "LastRunAt — когда последний раз срабатывали; null (в JSON — null)\nозначает «ещё ни разу», следующий запуск считается от created_at.", + "type": "string" + }, + "next_run_at": { + "description": "NextRunAt — ближайший запуск после последнего срабатывания\n(вычисляется при чтении; у выключенного правила — когда включат).", + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "object_name": { + "type": "string" + }, + "period_days": { + "type": "integer" + }, + "priority": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "description": "Денормализации для UI.", + "type": "string" + } + } + }, + "models.RecurringRun": { + "type": "object", + "properties": { + "address": { + "description": "Адрес и категория — из заявки на момент создания (для списка истории).", + "type": "string" + }, + "category": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "rule_id": { + "type": "integer" + }, + "run_at": { + "type": "string" + }, + "ticket_id": { + "description": "TicketID — созданная заявка (её номер = id); null, если её удалили\nкаскадом вместе с проектом.", + "type": "integer" + } + } + }, + "models.Ticket": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "assigned_at": { + "type": "string" + }, + "assigned_by": { + "type": "string" + }, + "cancel_reason": { + "type": "string" + }, + "category": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_name": { + "description": "Денормализованные поля для UI — подтягиваются JOIN'ом", + "type": "string" + }, + "closed_at": { + "type": "string" + }, + "closed_by": { + "type": "string" + }, + "confirmed_at": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "done_at": { + "type": "string" + }, + "floor": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "object_id": { + "type": "integer" + }, + "object_name": { + "type": "string" + }, + "priority": { + "type": "string" + }, + "project_id": { + "description": "Принадлежность: заявка живёт в объекте проекта клиента.\nПроверяется составными FK в БД (миграция 0004).", + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "recurring": { + "description": "Recurring — правило повторения, если заявка связана с ним:\nсоздана вместе с правилом (чекбокс «Новая заявка») или самим\nпланировщиком. Заполняется только в детали (GetByID) — спискам\nблок на странице заявки не нужен.", + "allOf": [ + { + "$ref": "#/definitions/models.TicketRecurring" + } + ] + }, + "reject_reason": { + "type": "string" + }, + "rejected_at": { + "description": "Закрытие цикла: rejected — это доработка, closed — финал.", + "type": "string" + }, + "rework_count": { + "description": "Сколько раз клиент отклонял работу — чтобы доработки не бесконечны\nбыли незаметны.", + "type": "integer" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "worker_name": { + "type": "string" + } + } + }, + "models.TicketEvent": { + "type": "object", + "properties": { + "action": { + "description": "created | assigned | started | done | confirmed | rejected |\ncanceled | photo_added | photo_removed.", + "type": "string" + }, + "actor_id": { + "description": "\"\" — автор удалён (событие сохранится, имя нет).", + "type": "string" + }, + "actor_name": { + "type": "string" + }, + "comment": { + "description": "Причина для rejected/canceled, имя файла для photo_*; иначе пусто.", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "from_status": { + "description": "\"\" — у события «создана» не было предыдущего статуса.\nУ photo_added/photo_removed статус не менялся: оба равны текущему.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "ticket_id": { + "type": "integer" + }, + "to_status": { + "type": "string" + } + } + }, + "models.TicketPhoto": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "original_name": { + "description": "Имя, которое дал файл при загрузке: лежит и в журнале (comment).", + "type": "string" + }, + "size_bytes": { + "type": "integer" + }, + "ticket_id": { + "type": "integer" + }, + "uploader_id": { + "description": "\"\" — аккаунт загрузившего удалён, файл остаётся.", + "type": "string" + }, + "uploader_name": { + "type": "string" + }, + "url": { + "description": "URL — presigned GET (срок жизни ограничен); заполняется только\nв ответах API. ObjectKey — внутренняя деталь, в JSON не уходит.", + "type": "string" + } + } + }, + "models.TicketRecurring": { + "type": "object", + "properties": { + "anchor_at": { + "description": "Расписание: «каждые PeriodDays дней/недель от AnchorAt» —\nточка отсчёта плюс период в днях.", + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "next_run_at": { + "description": "NextRunAt — ближайший запуск, вычисляется при чтении\n(см. repository.nextDue); у выключенного правила — когда включат.", + "type": "string" + }, + "period_days": { + "type": "integer" + }, + "rule_id": { + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "Bearer": { + "description": "JWT формата Bearer, получить через POST /auth/login. Срок жизни — 1 час, дальше POST /auth/refresh.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/", + Schemes: []string{"http"}, + Title: "Building Management API", + Description: "Управление заявками на обслуживание: админ создаёт клиента, клиент заводит проект, объекты в нём и мастеров, назначает заявку мастеру проекта, мастер выполняет, клиент подтверждает работу или отклоняет её на доработку. Финальный статус — closed.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..21c3c9a --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,4766 @@ +{ + "schemes": [ + "http" + ], + "swagger": "2.0", + "info": { + "description": "Управление заявками на обслуживание: админ создаёт клиента, клиент заводит проект, объекты в нём и мастеров, назначает заявку мастеру проекта, мастер выполняет, клиент подтверждает работу или отклоняет её на доработку. Финальный статус — closed.", + "title": "Building Management API", + "contact": {}, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/auth/forgot-password": { + "post": { + "description": "Отправляет на email письмо со ссылкой формы сброса (токен живёт 24 часа, одноразовый). Ответ всегда одинаковый — 200 с ok:true, независимо от того, существует ящик, подтверждён он и активен: эндпоинт не перечисляет аккаунты. Письмо уходит только подтверждённым активным аккаунтам. Лимиты: 10 запросов в минуту с одного IP; 3 письма в сутки на один ящик (счётчик тикает при любом запросе — и для несуществующих ящиков тоже), при превышении — 429.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Запрос письма для сброса пароля", + "parameters": [ + { + "description": "Email для восстановления", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.forgotPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "всегда ok:true, даже если ящик не найден", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Возвращает access-токен; refresh-токен — только в httpOnly-куке (Path=/auth, SameSite=Lax, Secure — через COOKIE_SECURE), в JSON его нет. Email ищется без учёта регистра, пароль — bcrypt. Неподтверждённый аккаунт — 403 account not confirmed (отличайте от account disabled). Лимит: 10 попыток в минуту с одного IP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Вход", + "parameters": [ + { + "description": "Учётные данные", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.loginReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "неверный логин или пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "аккаунт отключён либо не подтверждён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "description": "Гасит refresh-сессию из куки refresh_token и очищает куку. Тело запроса не нужно. Auth-middleware намеренно не используется: выйти должен пользователь и с протухшим access-токеном.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выйти (одно устройство)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/logout-all": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Закрывает все сессии текущего пользователя.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выйти со всех устройств", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Текущий пользователь", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refresh-токен читается из httpOnly-куки refresh_token (Path=/auth), тело запроса не требуется. Ротация: на выходе новый access-токен и новая кука, старая сессия гасится. Повторное использование отозванного токена закрывает все сессии пользователя — признак того, что секретом владеет кто-то второй.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Обновить пару токенов", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "401": { + "description": "куки нет, токен неизвестен, отозван или истёк", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "аккаунт отключён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/reset-password": { + "post": { + "description": "Токен одноразовый и живёт 24 часа: приходит письмом после POST /auth/forgot-password либо выдаётся админом через POST /users/{id}/password-reset. Новый пароль — по политике (10..72 символа, буквы и цифры). Успех гасит токен и все refresh-сессии пользователя. Лимит: 30 попыток в минуту с одного IP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Сброс пароля по токену", + "parameters": [ + { + "description": "Токен и новый пароль", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.resetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректное тело или токен неизвестен, использован либо истёк; слабый пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/telegram": { + "get": { + "description": "Куда сейчас приходят уведомления о заявках: цель (чат или группа), имя бота (свой или глобальный), подключён ли собственный токен бота (has_bot_token) и можно ли его ввести (token_configurable — зависит от DATA_ENCRYPTION_KEY на сервере). Сам токен наружу не отдаётся.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Статус уведомлений в Telegram", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TelegramStatusResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Принимает @имя группы/канала (бот обязан быть в нём) или числовой chat_id; пустое значение отключает уведомления. Сначала проверяется формат, затем бот проверяет чат через getChat — чужой или недоступный чат не сохраняется.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Указать цель уведомлений вручную", + "parameters": [ + { + "description": "Цель: @группа, chat_id или пусто", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setTelegramReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "invalid telegram target", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Очищает цель уведомлений текущего пользователя (то же, что PUT с пустым значением). Токен бота при этом не трогается — он живёт на своём эндпоинте.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Отключить уведомления в Telegram", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/telegram/bot": { + "post": { + "description": "Принимает токен Bot API (формат «id:secret» от @BotFather), проверяет его через getMe, шифрует и сохраняет. Дальше уведомления пользователя идут через этого бота, его поллер поднимается сразу. Лимит: 5 попыток за 5 минут. Без DATA_ENCRYPTION_KEY на сервере — 400.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Подключить своего бота", + "parameters": [ + { + "description": "Токен бота", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setBotReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "токен не сохранён (формат, getMe или фича выключена)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много попыток", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Стирает сохранённый токен текущего пользователя и останавливает его поллер. Повторный вызов безопасен — ответ тот же 200.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Отключить своего бота", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "401": { + "description": "нет входа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/telegram/code": { + "post": { + "description": "Одноразовый код и deep-link t.me/{bot}?start={code}: откройте ссылку в Telegram и отправьте боту код — этот чат начнёт получать уведомления о заявках. Бот — ваш собственный токен, а если его нет — глобальный бот сервера. Код живёт 15 минут, прежние коды гаснут. Лимит: 5 выпусков за 5 минут на пользователя.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выдать код привязки Telegram", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BindCodeResponse" + } + }, + "400": { + "description": "ни свой бот, ни глобальный не настроены", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "профиль не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "429": { + "description": "слишком много кодов", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/invitations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Список приглашений для текущего мастера: по каждому — проект, кто пригласил и до какого срока. Просроченные сюда не попадают — принять их уже нельзя.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Мои приглашения в проекты", + "parameters": [ + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "invited_at", + "expires_at", + "project_name" + ], + "type": "string", + "default": "invited_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.InvitationListResponse" + } + }, + "400": { + "description": "некорректные параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/objects/{id}": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Поля опциональны. У уже созданных заявок адрес остаётся тем, что был на момент создания — это осознанно.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Изменить объект", + "parameters": [ + { + "type": "integer", + "description": "ID объекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Что изменить", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateObjectReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Object" + } + }, + "400": { + "description": "некорректный id или пустое тело", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "объект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "имя уже занято в проекте", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент видит только свои проекты, админ — все (можно сузить через client_id). Мастерам проекты не отдаются. Ответ — страница: items плюс total/limit/offset.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Список проектов", + "parameters": [ + { + "type": "string", + "description": "Фильтр по клиенту (только admin)", + "name": "client_id", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "created_at", + "name", + "id" + ], + "type": "string", + "default": "created_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProjectListResponse" + } + }, + "400": { + "description": "некорректные параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент создаёт проект себе; админ — для любого клиента (в этом случае обязателен client_id). Проект — контур, внутри которого живут объекты и мастера.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Создать проект", + "parameters": [ + { + "description": "Данные проекта", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createProjectReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Project" + } + }, + "400": { + "description": "некорректное тело; client_id обязателен для admin; владелец не клиент", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "у клиента уже есть проект с таким именем", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ — любой проект, клиент — только свой; чужой проект неотличим от несуществующего (404).", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Детали проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Project" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Действие необратимо: вместе с проектом удаляются его объекты, участия мастеров и все заявки (вместе с событиями и фото). Объекты фото в хранилище убираются после успешного удаления в БД. Клиент своих проектов не удаляет — только админ.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Удалить проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Поля опциональны, меняется только переданное. Выключенный проект не принимает новые заявки и объекты.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Изменить проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Что изменить", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateProjectReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Project" + } + }, + "400": { + "description": "некорректный id или пустое тело", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "имя уже занято у этого клиента", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/invitation/accept": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "invited → active: с этого момента мастер состоит в проекте и ему можно назначать заявки. Действует только на своё непросроченное приглашение (срок — 7 дней, см. repository.InvitationTTL).", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Принять приглашение в проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "приглашения нет", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "срок вышел или приглашение уже закрыто", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/invitation/decline": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "invited → declined: заявок мастер не получает, клиент видит отказ в составе проекта и может пригласить повторно (строка та же, история ответа обновляется).", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Отклонить приглашение в проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "приглашения нет", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "приглашение уже закрыто", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/objects": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Здания/корпуса, входящие в проект. Адрес заявки берётся из объекта.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Объекты проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "name", + "created_at", + "id" + ], + "type": "string", + "default": "name", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ObjectListResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Объект — здание/корпус с адресом; заявки создаются именно на объект. Выключенный проект объектов не принимает.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Создать объект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Данные объекта", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createObjectReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Object" + } + }, + "400": { + "description": "некорректный id или тело", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "проект выключен или имя объекта занято", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/workers": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Кто приглашён в проект и кем принят: статус invited — приглашение ждёт решения, active — мастер работает с заявками, declined — отказал. Хеши паролей в ответ не попадают.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Мастера проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "name", + "created_at", + "email", + "id" + ], + "type": "string", + "default": "name", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProjectWorkerListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "С 0005 это приглашение, а не готовое членство: мастер становится участником только сам, приняв приглашение (POST /projects/{id}/invitation/accept) — до этого заявки проекта ему не назначаются. Приглашение живёт 7 дней. Мастер может состоять в нескольких проектах; аккаунт должен быть активным, с ролью worker и подтверждённым (0010) — неподтверждённого сначала подтверждает тот, кто его создал.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Пригласить мастера в проект", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Мастер", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.addWorkerReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.InvitationResponse" + } + }, + "400": { + "description": "некорректный id/тело; не мастер, неактивен или не подтверждён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект или мастер не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "мастер уже состоит в проекте / приглашение уже ждёт ответа", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/projects/{id}/workers/{worker_id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Нельзя убрать, пока за ним закреплены незакрытые заявки проекта (409) — иначе работа осталась бы без исполнителя. Заявки из истории при этом не теряются.", + "produces": [ + "application/json" + ], + "tags": [ + "projects" + ], + "summary": "Убрать мастера из проекта", + "parameters": [ + { + "type": "integer", + "description": "ID проекта", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "ID мастера (uuid)", + "name": "worker_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "проект не найден или мастер не состоит в проекте", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "за мастером есть незакрытые заявки", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент видит только свои правила, админ — все. Ответ — страница: items плюс total/limit/offset.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Список правил повторения", + "parameters": [ + { + "type": "integer", + "description": "Сузить до одного проекта", + "name": "project_id", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "created_at", + "id", + "date" + ], + "type": "string", + "default": "created_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RecurringRuleListResponse" + } + }, + "400": { + "description": "некорректные параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Правило — шаблон заявки (объект, категория, описание, приоритет) плюс расписание: anchor_at (дата-время первого запуска) и period_days — «каждые N дней/недель». Фоновый планировщик сам создаёт заявку, когда срок наступил, — но только если прошлая по правилу уже закрыта (без дублей). Клиент создаёт правило для своего проекта, админ — для любого. Обычно правило заводится чекбоксом в POST /tickets; этот эндпоинт — для управления без заявки.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Создать правило повторяющейся заявки", + "parameters": [ + { + "description": "Данные правила", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createRecurringReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "объект не найден (или чужой — неотличим)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "проект или объект выключен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ — любое правило, клиент — только своё; чужое неотличимо от несуществующего (404).", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Детали правила повторения", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Удаляется само правило, его история срабатываний (какая заявка создавалась и когда) и — каскадом — будущие заявки ещё не созданы. Созданные ранее заявки не трогаются.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Удалить правило повторения", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Частичное обновление: применяются только присланные поля, рамки те же, что при создании (anchor_at — любая дата-время, period_days 1..3650). object_id не меняется (перенос — через удаление и создание). Срок не сдвигается вручную: он пересчитывается от последнего срабатывания с новым расписанием.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Изменить правило повторения", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Что изменить (хотя бы одно поле)", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateRecurringReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "400": { + "description": "некорректный id, пустое тело или значение вне рамок", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring/{id}/run": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Заявка создаётся сразу, без ожидания интервала. Остальные рубежи как у планировщика: правило, проект и объект включены, прошлая заявка по правилу закрыта — иначе 409, дубль не создаём. Срок сдвигается на момент запуска (следующий фоновый тик отсчитает интервал от него), уведомление уходит тем же текстом, что при фоновом срабатывании.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "Запустить правило сейчас", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.RecurringRunResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено (или чужое — неотличим)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "правило, проект или объект выключены, либо есть открытая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/recurring/{id}/runs": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Какие заявки создавались по правилу и когда (аудит). Сначала свежие. Строка живёт вместе со своей заявкой: когда заявку удаляет автоочистка (TICKET_RETENTION_DAYS), строка истории уходит следом.", + "produces": [ + "application/json" + ], + "tags": [ + "recurring" + ], + "summary": "История срабатываний правила", + "parameters": [ + { + "type": "integer", + "description": "ID правила", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "run_at", + "id" + ], + "type": "string", + "default": "run_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RecurringRunListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client или admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "правило не найдено", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Выборка режется по роли: клиент видит свои, мастер — назначенные ему, админ — всё. Клиенту и мастеру параметры client_id/worker_id игнорируются: подсмотреть чужое нельзя. Фильтры накладываются поверх выборки по роли. Ответ — страница: items плюс total/limit/offset.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Список заявок", + "parameters": [ + { + "enum": [ + "new", + "assigned", + "in_progress", + "done", + "confirmed", + "rejected", + "closed" + ], + "type": "string", + "description": "Фильтр по статусу", + "name": "status", + "in": "query" + }, + { + "enum": [ + "electric", + "plumbing", + "climate", + "elevator", + "other" + ], + "type": "string", + "description": "Фильтр по категории", + "name": "category", + "in": "query" + }, + { + "enum": [ + "low", + "normal", + "high" + ], + "type": "string", + "description": "Фильтр по приоритету", + "name": "priority", + "in": "query" + }, + { + "type": "string", + "description": "Фильтр по клиенту (только admin)", + "name": "client_id", + "in": "query" + }, + { + "type": "string", + "description": "Фильтр по мастеру (только admin)", + "name": "worker_id", + "in": "query" + }, + { + "type": "integer", + "description": "Фильтр по проекту", + "name": "project_id", + "in": "query" + }, + { + "type": "integer", + "description": "Фильтр по объекту", + "name": "object_id", + "in": "query" + }, + { + "type": "string", + "description": "created_at с, YYYY-MM-DD (включительно, с полуночи UTC)", + "name": "date_from", + "in": "query" + }, + { + "type": "string", + "description": "created_at по, YYYY-MM-DD (включительно, до полуночи UTC следующего дня)", + "name": "date_to", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "created_at", + "priority", + "id" + ], + "type": "string", + "default": "created_at", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TicketListResponse" + } + }, + "400": { + "description": "некорректный фильтр или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "неизвестная роль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Клиент создаёт заявку на объект своего проекта. Адрес берётся из объекта на момент создания, статус всегда new, мастер не назначен. Если передан recurring — той же операцией создаётся правило повторения (anchor_at — первый запуск, period_days — период в днях), связанное с заявкой: оно видно в GET /tickets/{id} в поле recurring, управление — через PATCH /recurring/{id}.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Создать заявку", + "parameters": [ + { + "description": "Данные заявки", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createTicketReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректное тело запроса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "объект не найден (или чужой — неотличим)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "проект или объект выключен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ — любая заявка, клиент — только своя, мастер — назначенная ему либо свободная (new) из его проектов.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Детали заявки", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "заявка не принадлежит вам", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/assign": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "new → assigned. Доступно админу и владельцу-клиенту. Мастер должен состоять в проекте заявки и быть активным — иначе 400/409.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Назначить мастера", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Мастер", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.assignReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id/тело, не-мастер, неактивен, приглашение не принято или не состоит в проекте заявки", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client; чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка уже назначена или закрыта", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/cancel": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Закрывает заявку с cancel_reason и closed_by. Клиент — только свои в new/assigned/done/rejected; админ — любую кроме уже закрытой, включая in_progress. Причина обязательна.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Отменить заявку (финал)", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Причина отмены, минимум 5 символов", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reasonReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id или тело: причина обязательна", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "чужая заявка или роль не client/admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "нельзя закрыть из текущего статуса", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/confirm": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "done → closed с confirmed_at. Доступно только владельцу-клиенту.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Подтвердить работу (финал)", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "заявка не принадлежит вам", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка не в статусе done", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/done": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "in_progress → done. Дальше клиент подтверждает или отклоняет.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Завершить работу", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "не тот статус или заявка назначена другому", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/events": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Хронология переходов заявки с авторами: created, assigned, started, done, confirmed, rejected, canceled. from_status у события «создана» нет. События пишутся той же SQL-операцией, что и переход, поэтому история не расходится со статусом.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Журнал событий заявки", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "id", + "created_at" + ], + "type": "string", + "default": "id", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TicketEventListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/photos": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Файлы заявки в хронологии (id ASC по умолчанию). В каждом элементе url — временная подписанная ссылка на чтение: бакет приватный, сам ключ наружу не уходит.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Список фото отчёта", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "id", + "created_at" + ], + "type": "string", + "default": "id", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PhotoListResponse" + } + }, + "400": { + "description": "некорректный id или параметры страницы", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "multipart/form-data, поле file: только фото (jpeg/png/webp, определяется по сигнатуре байтов), не больше PHOTO_MAX_BYTES на файл и PHOTO_MAX_PER_TICKET файлов на заявку. Доступно мастеру (заявка in_progress — после «Работа выполнена» отчёт заморожен, мастер — активный участник проекта) или админу. Каждая загрузка оставляет событие photo_added в журнале заявки.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Загрузить фото отчёта", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "Файл фотографии (jpeg/png/webp)", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "фото загружено, url — временная ссылка на чтение", + "schema": { + "$ref": "#/definitions/models.TicketPhoto" + } + }, + "400": { + "description": "некорректный id, файл отсутствует, не фото или больше лимита", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker или admin; чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "лимит файлов исчерпан или заявка не в статусе in_progress", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "хранилище недоступно", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/photos/{photo_id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Своё фото удаляет автор, пока заявка в работе (in_progress): после «Работа выполнена» отчёт заморожен; админу — любое фото. Событие photo_removed пишется той же операцией, что и удаление.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Удалить фото", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "ID фото", + "name": "photo_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id или photo_id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker или admin; чужая заявка", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка или фото не найдены", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "чужое фото или заявка уже не в работе", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/reject": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "done → rejected. Не финал: мастер берёт заявку в работу повторно, rework_count увеличивается. Причина обязательна — без неё мастер не поймёт, что переделывать.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Отклонить работу (на доработку)", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Причина отклонения, минимум 5 символов", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.reasonReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id или тело: причина обязательна", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "заявка не принадлежит вам", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка не в статусе done", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/start": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "assigned → in_progress, а после отклонения (rejected) — повторный запуск. started_at первого круга сохраняется.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Взять в работу", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "не тот статус или заявка назначена другому", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/tickets/{id}/take": { + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "new → assigned: мастер назначает себя на свободную заявку своего проекта. Дальше — «Взять в работу» (start) как обычно.", + "produces": [ + "application/json" + ], + "tags": [ + "tickets" + ], + "summary": "Взять заявку самому", + "parameters": [ + { + "type": "integer", + "description": "ID заявки", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Ticket" + } + }, + "400": { + "description": "некорректный id; приглашение не принято или отклонено; не состоит в проекте заявки", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль worker", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "заявка не найдена", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "заявка уже взята или не в статусе new", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ видит любую роль и всех; клиенту доступна только роль worker и только его собственные мастера (чужие организации не видны). С параметром q (минимум 2 символа) возвращается поиск по имени/email среди активных — так клиент пригласит в проект существующего мастера, не видя при этом полного списка. include_inactive=1 дополняет список отключёнными аккаунтами — только для админа.", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Список пользователей по роли", + "parameters": [ + { + "enum": [ + "worker", + "admin", + "client" + ], + "type": "string", + "default": "worker", + "description": "Роль", + "name": "role", + "in": "query" + }, + { + "type": "string", + "description": "Поиск по имени или email, минимум 2 символа", + "name": "q", + "in": "query" + }, + { + "enum": [ + "0", + "1" + ], + "type": "string", + "default": "0", + "description": "Показать и отключённых (только для админа)", + "name": "include_inactive", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Строк на странице, 1..100", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Сдвиг от начала выборки", + "name": "offset", + "in": "query" + }, + { + "enum": [ + "name", + "created_at", + "email", + "id" + ], + "type": "string", + "default": "name", + "description": "Поле сортировки", + "name": "sort", + "in": "query" + }, + { + "enum": [ + "asc", + "desc" + ], + "type": "string", + "description": "Направление сортировки", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProfileListResponse" + } + }, + "400": { + "description": "неизвестная роль, параметры страницы или короткий q", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "клиент запрашивает не мастеров", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ создаёт мастеров, админов и клиентов; клиент — только мастеров (для себя, далее мастер добавляется в проект через POST /projects/{id}/workers). Роли admin и client клиентом недоступны. Пароль — по политике (10..72 символа, буквы и цифры). Подтверждение: аккаунт, созданный админом, подтверждён сразу; созданный клиентом — ждёт подтверждения этим же клиентом, после чего мастеру уходит письмо.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Создать пользователя", + "parameters": [ + { + "description": "Данные пользователя", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createUserReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Profile" + } + }, + "400": { + "description": "некорректное тело запроса или слабый пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "клиент пытается создать админа или клиента", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "email уже занят", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}": { + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ удаляет любой «чистый» аккаунт: refresh-сессии и токены сброса удаляются каскадом, но наличие заявок, проектов или участий блокирует удаление (409 user has related records) — в этом случае отключите аккаунт через PATCH /users/{id}, историю не стираем. Клиент удаляет только мастера, состоящего в одном из его проектов: членства клиента в проектах удаляются в той же транзакции, но если на мастера ссылаются заявки либо чужие проекты — тоже 409, ничего не меняется. Чужой мастер и несуществующий id неразличимы (404). Свой id удалить нельзя.", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Удалить пользователя", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id; нельзя удалить себя", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "у пользователя есть связанные записи", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "patch": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ меняет любой аккаунт; клиент — только мастера, который активный участник одного из его проектов (иного клиента или чужого мастера не видит — 404). При деактивации все refresh-сессии пользователя закрываются сразу, мастер перестаёт работать во всех проектах. Выключить самого себя нельзя — вы остались бы без входа.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Включить/выключить аккаунт", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Новый флаг", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setUserReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Profile" + } + }, + "400": { + "description": "некорректный id или тело; попытка выключить себя", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден (для клиента — не его мастер)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}/confirm": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Подтверждает неподтверждённый аккаунт и шлёт пользователю письмо «аккаунт подтверждён» со ссылкой на вход. Право — в SQL: админ подтверждает любой аккаунт, клиент — только созданного им мастера (иной и несуществующий не видны — 404). Повторное подтверждение — 409. Письмо отправляется после подтверждения; недоступный SMTP не откатывает подтверждение (письмо просто не уйдёт).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Подтвердить аккаунт", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin или client", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден (для клиента — не созданный им мастер)", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "аккаунт уже подтверждён", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}/password-reset": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Токен возвращается в ответе — админ передаёт его пользователю сам, любым каналом. Для пользователя есть почта: POST /auth/forgot-password шлёт письмо со ссылкой. Токен одноразовый, живёт 24 часа (repository.ResetTTL); выпуск нового гасит прежние неиспользованные токены пользователя (включая свежие из писем). В БД хранится только sha256 токена.", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Выдать токен сброса пароля", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PasswordResetResponse" + } + }, + "400": { + "description": "некорректный id", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/users/{id}/password-set": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Админ ставит пароль пользователю сам, без ссылки. Политика та же, что при создании и при сбросе по токену (10..72 символа, буквы и цифры). Успех закрывает все refresh-сессии пользователя и гасит неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Альтернатива — одноразовая ссылка через POST /users/{id}/password-reset.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Задать пароль напрямую", + "parameters": [ + { + "type": "string", + "description": "ID пользователя (uuid)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Новый пароль", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.setPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.OKResponse" + } + }, + "400": { + "description": "некорректный id, тело или слабый пароль", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "нет или неверный токен", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "нужна роль admin", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "пользователь не найден", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.BindCodeResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "link": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.InvitationListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectInvitation" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.InvitationResponse": { + "type": "object", + "properties": { + "expires_at": { + "description": "До какого срока мастер может ответить (см. repository.InvitationTTL).", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "status": { + "description": "Всегда \"invited\": стать участником мастер может только сам, через\nPOST /projects/{id}/invitation/accept.", + "type": "string" + } + } + }, + "handlers.OKResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "revoked": { + "description": "Revoked — сколько сессий закрыто; заполняется только в logout-all.", + "type": "integer" + } + } + }, + "handlers.ObjectListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Object" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.PasswordResetResponse": { + "type": "object", + "properties": { + "expires_at": { + "description": "См. repository.ResetTTL (24 часа).", + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "handlers.PhotoListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TicketPhoto" + } + }, + "limit": { + "type": "integer" + }, + "max_per_ticket": { + "description": "MaxPerTicket — лимит файлов на заявку (PHOTO_MAX_PER_TICKET):\nUI рисует «n / N», не зная настройки окружения.", + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.ProfileListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Profile" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.ProjectListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Project" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.ProjectWorkerListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProjectWorker" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.RecurringRuleListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.RecurringRule" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.RecurringRunListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.RecurringRun" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.RecurringRunResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "ticket_id": { + "type": "integer" + } + } + }, + "handlers.TelegramStatusResponse": { + "type": "object", + "properties": { + "bot_username": { + "description": "BotUsername — @имя бота, через который придут уведомления и\nссылка привязки; пусто, пока getMe не удался.", + "type": "string" + }, + "bound": { + "description": "Bound — есть ли уже цель уведомлений.", + "type": "boolean" + }, + "enabled": { + "description": "Enabled — может ли этот пользователь получать уведомления вообще:\nесть свой токен бота либо настроен глобальный бот сервера.", + "type": "boolean" + }, + "has_bot_token": { + "description": "HasBotToken — подключён ли пользователь свой бот (токен в БД).", + "type": "boolean" + }, + "target": { + "description": "Target — текущая цель: числовой chat_id либо @группа.", + "type": "string" + }, + "token_configurable": { + "description": "TokenConfigurable — можно ли ввести токен прямо сейчас: false,\nесли на сервере не задан DATA_ENCRYPTION_KEY.", + "type": "boolean" + } + } + }, + "handlers.TicketEventListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TicketEvent" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.TicketListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Ticket" + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.TokenResponse": { + "type": "object", + "properties": { + "expires_in": { + "description": "Срок жизни access-токена в секундах.", + "type": "integer" + }, + "token": { + "description": "Access-токен: JWT на auth.AccessTTL.", + "type": "string" + }, + "user": { + "$ref": "#/definitions/models.Profile" + } + } + }, + "handlers.addWorkerReq": { + "type": "object", + "required": [ + "worker_id" + ], + "properties": { + "worker_id": { + "type": "string" + } + } + }, + "handlers.assignReq": { + "type": "object", + "required": [ + "worker_id" + ], + "properties": { + "worker_id": { + "type": "string" + } + } + }, + "handlers.createObjectReq": { + "type": "object", + "required": [ + "address", + "name" + ], + "properties": { + "address": { + "type": "string", + "minLength": 5 + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.createProjectReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "client_id": { + "description": "ClientID — только для админа: для какого клиента создаётся проект.\nУ клиента владелец берётся из токена, поле игнорируется.", + "type": "string" + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.createRecurringReq": { + "type": "object", + "required": [ + "anchor_at", + "category", + "description", + "object_id", + "period_days" + ], + "properties": { + "anchor_at": { + "description": "Расписание: «каждые PeriodDays дней/недель от AnchorAt» —\nAnchorAt (RFC3339) — дата-время первого запуска, PeriodDays —\nпериод в днях (1..3650; недели кратны 7).", + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "electric", + "plumbing", + "climate", + "elevator", + "other" + ] + }, + "description": { + "type": "string", + "minLength": 5 + }, + "floor": { + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "period_days": { + "type": "integer", + "maximum": 3650, + "minimum": 1 + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ] + } + } + }, + "handlers.createTicketReq": { + "type": "object", + "required": [ + "category", + "description", + "object_id" + ], + "properties": { + "category": { + "type": "string", + "enum": [ + "electric", + "plumbing", + "climate", + "elevator", + "other" + ] + }, + "description": { + "type": "string", + "minLength": 5 + }, + "floor": { + "type": "string" + }, + "object_id": { + "description": "Объект, на который создаётся заявка: адрес копируется из объекта.", + "type": "integer" + }, + "priority": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ] + }, + "recurring": { + "description": "Recurring — задан, если заявку делают повторяющейся.", + "allOf": [ + { + "$ref": "#/definitions/handlers.recurringSchedule" + } + ] + } + } + }, + "handlers.createUserReq": { + "type": "object", + "required": [ + "email", + "name", + "password", + "role" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "worker", + "admin", + "client" + ] + } + } + }, + "handlers.forgotPasswordReq": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "handlers.loginReq": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handlers.reasonReq": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 5 + } + } + }, + "handlers.recurringSchedule": { + "type": "object", + "required": [ + "anchor_at", + "period_days" + ], + "properties": { + "anchor_at": { + "description": "AnchorAt — дата-время первого запуска (RFC3339): точка отсчёта\nсетки сроков, от неё идут «каждые period_days».", + "type": "string" + }, + "period_days": { + "description": "PeriodDays — период в днях (1..3650; недели кратны 7).", + "type": "integer", + "maximum": 3650, + "minimum": 1 + } + } + }, + "handlers.resetPasswordReq": { + "type": "object", + "required": [ + "password", + "token" + ], + "properties": { + "password": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "handlers.setBotReq": { + "type": "object", + "properties": { + "token": { + "description": "Token — токен бота от @BotFather; пусто невалидно (нужен явный ввод).", + "type": "string" + } + } + }, + "handlers.setPasswordReq": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + }, + "handlers.setTelegramReq": { + "type": "object", + "properties": { + "target": { + "description": "Target — @группа/@канал или числовой chat_id; пусто — отключить.", + "type": "string" + } + } + }, + "handlers.setUserReq": { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "description": "*bool обязателен: на bool с binding:\"required\" запрос\n«деактивировать» (is_active=false) не прошёл бы валидацию.", + "type": "boolean" + } + } + }, + "handlers.updateObjectReq": { + "type": "object", + "properties": { + "address": { + "type": "string", + "minLength": 5 + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.updateProjectReq": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.updateRecurringReq": { + "type": "object", + "properties": { + "anchor_at": { + "type": "string" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "floor": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "period_days": { + "type": "integer" + }, + "priority": { + "type": "string" + } + } + }, + "models.Object": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "description": "Денормализации для UI.", + "type": "string" + } + } + }, + "models.Profile": { + "type": "object", + "properties": { + "confirmed_at": { + "description": "ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON)\n— ожидает подтверждения, вход до этого запрещён (403\naccount not confirmed). Существующие пользователи подтверждены\nбэкфиллом миграции 0010.", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "models.Project": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_name": { + "description": "Денормализация для UI: чей проект (админ видит все сразу).", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "models.ProjectInvitation": { + "type": "object", + "properties": { + "client_name": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "invited_at": { + "type": "string" + }, + "invited_by": { + "description": "id пригласившего", + "type": "string" + }, + "invited_by_name": { + "description": "его имя для UI", + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + } + } + }, + "models.ProjectWorker": { + "type": "object", + "properties": { + "confirmed_at": { + "description": "ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON)\n— ожидает подтверждения, вход до этого запрещён (403\naccount not confirmed). Существующие пользователи подтверждены\nбэкфиллом миграции 0010.", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt — крайний срок ответа; заполнен у приглашений.", + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "responded_at": { + "description": "RespondedAt — когда мастер принял/отклонил; пусто, пока не ответил.", + "type": "string" + }, + "role": { + "type": "string" + }, + "status": { + "description": "invited | active | declined (см. 0005_project_invitations.sql).", + "type": "string" + } + } + }, + "models.RecurringRule": { + "type": "object", + "properties": { + "address": { + "description": "Адрес объекта — снимок, как у заявки: правило показывает, куда едут.", + "type": "string" + }, + "anchor_at": { + "description": "Расписание: «каждые PeriodDays дней/недель от AnchorAt» —\nAnchorAt — дата-время первого запуска (точка отсчёта сетки\nсроков), PeriodDays — период в днях (1..3650, недели кратны 7).", + "type": "string" + }, + "category": { + "description": "Шаблон заявки — те же поля, что у tickets.", + "type": "string" + }, + "client_id": { + "description": "ClientID — владелец будущих заявок (клиент проекта).", + "type": "string" + }, + "client_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "description": "CreatedBy — кто завёл правило (админ или клиент); в журнале\nсозданной заявки это же лицо будет автором события «created».", + "type": "string" + }, + "created_by_name": { + "description": "Кто завёл правило (админ или сам клиент) — для аудита, в JSON идёт имя.", + "type": "string" + }, + "description": { + "type": "string" + }, + "floor": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "last_run_at": { + "description": "LastRunAt — когда последний раз срабатывали; null (в JSON — null)\nозначает «ещё ни разу», следующий запуск считается от created_at.", + "type": "string" + }, + "next_run_at": { + "description": "NextRunAt — ближайший запуск после последнего срабатывания\n(вычисляется при чтении; у выключенного правила — когда включат).", + "type": "string" + }, + "object_id": { + "type": "integer" + }, + "object_name": { + "type": "string" + }, + "period_days": { + "type": "integer" + }, + "priority": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "description": "Денормализации для UI.", + "type": "string" + } + } + }, + "models.RecurringRun": { + "type": "object", + "properties": { + "address": { + "description": "Адрес и категория — из заявки на момент создания (для списка истории).", + "type": "string" + }, + "category": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "rule_id": { + "type": "integer" + }, + "run_at": { + "type": "string" + }, + "ticket_id": { + "description": "TicketID — созданная заявка (её номер = id); null, если её удалили\nкаскадом вместе с проектом.", + "type": "integer" + } + } + }, + "models.Ticket": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "assigned_at": { + "type": "string" + }, + "assigned_by": { + "type": "string" + }, + "cancel_reason": { + "type": "string" + }, + "category": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_name": { + "description": "Денормализованные поля для UI — подтягиваются JOIN'ом", + "type": "string" + }, + "closed_at": { + "type": "string" + }, + "closed_by": { + "type": "string" + }, + "confirmed_at": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "done_at": { + "type": "string" + }, + "floor": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "object_id": { + "type": "integer" + }, + "object_name": { + "type": "string" + }, + "priority": { + "type": "string" + }, + "project_id": { + "description": "Принадлежность: заявка живёт в объекте проекта клиента.\nПроверяется составными FK в БД (миграция 0004).", + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "recurring": { + "description": "Recurring — правило повторения, если заявка связана с ним:\nсоздана вместе с правилом (чекбокс «Новая заявка») или самим\nпланировщиком. Заполняется только в детали (GetByID) — спискам\nблок на странице заявки не нужен.", + "allOf": [ + { + "$ref": "#/definitions/models.TicketRecurring" + } + ] + }, + "reject_reason": { + "type": "string" + }, + "rejected_at": { + "description": "Закрытие цикла: rejected — это доработка, closed — финал.", + "type": "string" + }, + "rework_count": { + "description": "Сколько раз клиент отклонял работу — чтобы доработки не бесконечны\nбыли незаметны.", + "type": "integer" + }, + "started_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "worker_name": { + "type": "string" + } + } + }, + "models.TicketEvent": { + "type": "object", + "properties": { + "action": { + "description": "created | assigned | started | done | confirmed | rejected |\ncanceled | photo_added | photo_removed.", + "type": "string" + }, + "actor_id": { + "description": "\"\" — автор удалён (событие сохранится, имя нет).", + "type": "string" + }, + "actor_name": { + "type": "string" + }, + "comment": { + "description": "Причина для rejected/canceled, имя файла для photo_*; иначе пусто.", + "type": "string" + }, + "created_at": { + "type": "string" + }, + "from_status": { + "description": "\"\" — у события «создана» не было предыдущего статуса.\nУ photo_added/photo_removed статус не менялся: оба равны текущему.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "ticket_id": { + "type": "integer" + }, + "to_status": { + "type": "string" + } + } + }, + "models.TicketPhoto": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "original_name": { + "description": "Имя, которое дал файл при загрузке: лежит и в журнале (comment).", + "type": "string" + }, + "size_bytes": { + "type": "integer" + }, + "ticket_id": { + "type": "integer" + }, + "uploader_id": { + "description": "\"\" — аккаунт загрузившего удалён, файл остаётся.", + "type": "string" + }, + "uploader_name": { + "type": "string" + }, + "url": { + "description": "URL — presigned GET (срок жизни ограничен); заполняется только\nв ответах API. ObjectKey — внутренняя деталь, в JSON не уходит.", + "type": "string" + } + } + }, + "models.TicketRecurring": { + "type": "object", + "properties": { + "anchor_at": { + "description": "Расписание: «каждые PeriodDays дней/недель от AnchorAt» —\nточка отсчёта плюс период в днях.", + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "next_run_at": { + "description": "NextRunAt — ближайший запуск, вычисляется при чтении\n(см. repository.nextDue); у выключенного правила — когда включат.", + "type": "string" + }, + "period_days": { + "type": "integer" + }, + "rule_id": { + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "Bearer": { + "description": "JWT формата Bearer, получить через POST /auth/login. Срок жизни — 1 час, дальше POST /auth/refresh.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..283521f --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,3343 @@ +basePath: / +definitions: + handlers.BindCodeResponse: + properties: + code: + type: string + expires_at: + type: string + link: + type: string + type: object + handlers.ErrorResponse: + properties: + error: + type: string + type: object + handlers.InvitationListResponse: + properties: + items: + items: + $ref: '#/definitions/models.ProjectInvitation' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.InvitationResponse: + properties: + expires_at: + description: До какого срока мастер может ответить (см. repository.InvitationTTL). + type: string + ok: + type: boolean + status: + description: |- + Всегда "invited": стать участником мастер может только сам, через + POST /projects/{id}/invitation/accept. + type: string + type: object + handlers.OKResponse: + properties: + ok: + type: boolean + revoked: + description: Revoked — сколько сессий закрыто; заполняется только в logout-all. + type: integer + type: object + handlers.ObjectListResponse: + properties: + items: + items: + $ref: '#/definitions/models.Object' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.PasswordResetResponse: + properties: + expires_at: + description: См. repository.ResetTTL (24 часа). + type: string + ok: + type: boolean + token: + type: string + type: object + handlers.PhotoListResponse: + properties: + items: + items: + $ref: '#/definitions/models.TicketPhoto' + type: array + limit: + type: integer + max_per_ticket: + description: |- + MaxPerTicket — лимит файлов на заявку (PHOTO_MAX_PER_TICKET): + UI рисует «n / N», не зная настройки окружения. + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.ProfileListResponse: + properties: + items: + items: + $ref: '#/definitions/models.Profile' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.ProjectListResponse: + properties: + items: + items: + $ref: '#/definitions/models.Project' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.ProjectWorkerListResponse: + properties: + items: + items: + $ref: '#/definitions/models.ProjectWorker' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.RecurringRuleListResponse: + properties: + items: + items: + $ref: '#/definitions/models.RecurringRule' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.RecurringRunListResponse: + properties: + items: + items: + $ref: '#/definitions/models.RecurringRun' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.RecurringRunResponse: + properties: + ok: + type: boolean + ticket_id: + type: integer + type: object + handlers.TelegramStatusResponse: + properties: + bot_username: + description: |- + BotUsername — @имя бота, через который придут уведомления и + ссылка привязки; пусто, пока getMe не удался. + type: string + bound: + description: Bound — есть ли уже цель уведомлений. + type: boolean + enabled: + description: |- + Enabled — может ли этот пользователь получать уведомления вообще: + есть свой токен бота либо настроен глобальный бот сервера. + type: boolean + has_bot_token: + description: HasBotToken — подключён ли пользователь свой бот (токен в БД). + type: boolean + target: + description: 'Target — текущая цель: числовой chat_id либо @группа.' + type: string + token_configurable: + description: |- + TokenConfigurable — можно ли ввести токен прямо сейчас: false, + если на сервере не задан DATA_ENCRYPTION_KEY. + type: boolean + type: object + handlers.TicketEventListResponse: + properties: + items: + items: + $ref: '#/definitions/models.TicketEvent' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.TicketListResponse: + properties: + items: + items: + $ref: '#/definitions/models.Ticket' + type: array + limit: + type: integer + offset: + type: integer + total: + type: integer + type: object + handlers.TokenResponse: + properties: + expires_in: + description: Срок жизни access-токена в секундах. + type: integer + token: + description: 'Access-токен: JWT на auth.AccessTTL.' + type: string + user: + $ref: '#/definitions/models.Profile' + type: object + handlers.addWorkerReq: + properties: + worker_id: + type: string + required: + - worker_id + type: object + handlers.assignReq: + properties: + worker_id: + type: string + required: + - worker_id + type: object + handlers.createObjectReq: + properties: + address: + minLength: 5 + type: string + name: + minLength: 2 + type: string + required: + - address + - name + type: object + handlers.createProjectReq: + properties: + client_id: + description: |- + ClientID — только для админа: для какого клиента создаётся проект. + У клиента владелец берётся из токена, поле игнорируется. + type: string + name: + minLength: 2 + type: string + required: + - name + type: object + handlers.createRecurringReq: + properties: + anchor_at: + description: |- + Расписание: «каждые PeriodDays дней/недель от AnchorAt» — + AnchorAt (RFC3339) — дата-время первого запуска, PeriodDays — + период в днях (1..3650; недели кратны 7). + type: string + category: + enum: + - electric + - plumbing + - climate + - elevator + - other + type: string + description: + minLength: 5 + type: string + floor: + type: string + object_id: + type: integer + period_days: + maximum: 3650 + minimum: 1 + type: integer + priority: + enum: + - low + - normal + - high + type: string + required: + - anchor_at + - category + - description + - object_id + - period_days + type: object + handlers.createTicketReq: + properties: + category: + enum: + - electric + - plumbing + - climate + - elevator + - other + type: string + description: + minLength: 5 + type: string + floor: + type: string + object_id: + description: 'Объект, на который создаётся заявка: адрес копируется из объекта.' + type: integer + priority: + enum: + - low + - normal + - high + type: string + recurring: + allOf: + - $ref: '#/definitions/handlers.recurringSchedule' + description: Recurring — задан, если заявку делают повторяющейся. + required: + - category + - description + - object_id + type: object + handlers.createUserReq: + properties: + email: + type: string + name: + type: string + password: + type: string + phone: + type: string + role: + enum: + - worker + - admin + - client + type: string + required: + - email + - name + - password + - role + type: object + handlers.forgotPasswordReq: + properties: + email: + type: string + required: + - email + type: object + handlers.loginReq: + properties: + email: + type: string + password: + type: string + required: + - email + - password + type: object + handlers.reasonReq: + properties: + reason: + minLength: 5 + type: string + required: + - reason + type: object + handlers.recurringSchedule: + properties: + anchor_at: + description: |- + AnchorAt — дата-время первого запуска (RFC3339): точка отсчёта + сетки сроков, от неё идут «каждые period_days». + type: string + period_days: + description: PeriodDays — период в днях (1..3650; недели кратны 7). + maximum: 3650 + minimum: 1 + type: integer + required: + - anchor_at + - period_days + type: object + handlers.resetPasswordReq: + properties: + password: + type: string + token: + type: string + required: + - password + - token + type: object + handlers.setBotReq: + properties: + token: + description: Token — токен бота от @BotFather; пусто невалидно (нужен явный + ввод). + type: string + type: object + handlers.setPasswordReq: + properties: + password: + type: string + required: + - password + type: object + handlers.setTelegramReq: + properties: + target: + description: Target — @группа/@канал или числовой chat_id; пусто — отключить. + type: string + type: object + handlers.setUserReq: + properties: + is_active: + description: |- + *bool обязателен: на bool с binding:"required" запрос + «деактивировать» (is_active=false) не прошёл бы валидацию. + type: boolean + required: + - is_active + type: object + handlers.updateObjectReq: + properties: + address: + minLength: 5 + type: string + is_active: + type: boolean + name: + minLength: 2 + type: string + type: object + handlers.updateProjectReq: + properties: + is_active: + type: boolean + name: + minLength: 2 + type: string + type: object + handlers.updateRecurringReq: + properties: + anchor_at: + type: string + category: + type: string + description: + type: string + floor: + type: string + is_active: + type: boolean + period_days: + type: integer + priority: + type: string + type: object + models.Object: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + project_id: + type: integer + project_name: + description: Денормализации для UI. + type: string + type: object + models.Profile: + properties: + confirmed_at: + description: |- + ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON) + — ожидает подтверждения, вход до этого запрещён (403 + account not confirmed). Существующие пользователи подтверждены + бэкфиллом миграции 0010. + type: string + created_at: + type: string + email: + type: string + id: + type: string + is_active: + type: boolean + name: + type: string + phone: + type: string + role: + type: string + type: object + models.Project: + properties: + client_id: + type: string + client_name: + description: 'Денормализация для UI: чей проект (админ видит все сразу).' + type: string + created_at: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + type: object + models.ProjectInvitation: + properties: + client_name: + type: string + expires_at: + type: string + invited_at: + type: string + invited_by: + description: id пригласившего + type: string + invited_by_name: + description: его имя для UI + type: string + project_id: + type: integer + project_name: + type: string + type: object + models.ProjectWorker: + properties: + confirmed_at: + description: |- + ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON) + — ожидает подтверждения, вход до этого запрещён (403 + account not confirmed). Существующие пользователи подтверждены + бэкфиллом миграции 0010. + type: string + created_at: + type: string + email: + type: string + expires_at: + description: ExpiresAt — крайний срок ответа; заполнен у приглашений. + type: string + id: + type: string + is_active: + type: boolean + name: + type: string + phone: + type: string + responded_at: + description: RespondedAt — когда мастер принял/отклонил; пусто, пока не ответил. + type: string + role: + type: string + status: + description: invited | active | declined (см. 0005_project_invitations.sql). + type: string + type: object + models.RecurringRule: + properties: + address: + description: 'Адрес объекта — снимок, как у заявки: правило показывает, куда + едут.' + type: string + anchor_at: + description: |- + Расписание: «каждые PeriodDays дней/недель от AnchorAt» — + AnchorAt — дата-время первого запуска (точка отсчёта сетки + сроков), PeriodDays — период в днях (1..3650, недели кратны 7). + type: string + category: + description: Шаблон заявки — те же поля, что у tickets. + type: string + client_id: + description: ClientID — владелец будущих заявок (клиент проекта). + type: string + client_name: + type: string + created_at: + type: string + created_by: + description: |- + CreatedBy — кто завёл правило (админ или клиент); в журнале + созданной заявки это же лицо будет автором события «created». + type: string + created_by_name: + description: Кто завёл правило (админ или сам клиент) — для аудита, в JSON + идёт имя. + type: string + description: + type: string + floor: + type: string + id: + type: integer + is_active: + type: boolean + last_run_at: + description: |- + LastRunAt — когда последний раз срабатывали; null (в JSON — null) + означает «ещё ни разу», следующий запуск считается от created_at. + type: string + next_run_at: + description: |- + NextRunAt — ближайший запуск после последнего срабатывания + (вычисляется при чтении; у выключенного правила — когда включат). + type: string + object_id: + type: integer + object_name: + type: string + period_days: + type: integer + priority: + type: string + project_id: + type: integer + project_name: + description: Денормализации для UI. + type: string + type: object + models.RecurringRun: + properties: + address: + description: Адрес и категория — из заявки на момент создания (для списка + истории). + type: string + category: + type: string + id: + type: integer + rule_id: + type: integer + run_at: + type: string + ticket_id: + description: |- + TicketID — созданная заявка (её номер = id); null, если её удалили + каскадом вместе с проектом. + type: integer + type: object + models.Ticket: + properties: + address: + type: string + assigned_at: + type: string + assigned_by: + type: string + cancel_reason: + type: string + category: + type: string + client_id: + type: string + client_name: + description: Денормализованные поля для UI — подтягиваются JOIN'ом + type: string + closed_at: + type: string + closed_by: + type: string + confirmed_at: + type: string + created_at: + type: string + description: + type: string + done_at: + type: string + floor: + type: string + id: + type: integer + object_id: + type: integer + object_name: + type: string + priority: + type: string + project_id: + description: |- + Принадлежность: заявка живёт в объекте проекта клиента. + Проверяется составными FK в БД (миграция 0004). + type: integer + project_name: + type: string + recurring: + allOf: + - $ref: '#/definitions/models.TicketRecurring' + description: |- + Recurring — правило повторения, если заявка связана с ним: + создана вместе с правилом (чекбокс «Новая заявка») или самим + планировщиком. Заполняется только в детали (GetByID) — спискам + блок на странице заявки не нужен. + reject_reason: + type: string + rejected_at: + description: 'Закрытие цикла: rejected — это доработка, closed — финал.' + type: string + rework_count: + description: |- + Сколько раз клиент отклонял работу — чтобы доработки не бесконечны + были незаметны. + type: integer + started_at: + type: string + status: + type: string + worker_id: + type: string + worker_name: + type: string + type: object + models.TicketEvent: + properties: + action: + description: |- + created | assigned | started | done | confirmed | rejected | + canceled | photo_added | photo_removed. + type: string + actor_id: + description: '"" — автор удалён (событие сохранится, имя нет).' + type: string + actor_name: + type: string + comment: + description: Причина для rejected/canceled, имя файла для photo_*; иначе пусто. + type: string + created_at: + type: string + from_status: + description: |- + "" — у события «создана» не было предыдущего статуса. + У photo_added/photo_removed статус не менялся: оба равны текущему. + type: string + id: + type: integer + ticket_id: + type: integer + to_status: + type: string + type: object + models.TicketPhoto: + properties: + content_type: + type: string + created_at: + type: string + id: + type: integer + original_name: + description: 'Имя, которое дал файл при загрузке: лежит и в журнале (comment).' + type: string + size_bytes: + type: integer + ticket_id: + type: integer + uploader_id: + description: '"" — аккаунт загрузившего удалён, файл остаётся.' + type: string + uploader_name: + type: string + url: + description: |- + URL — presigned GET (срок жизни ограничен); заполняется только + в ответах API. ObjectKey — внутренняя деталь, в JSON не уходит. + type: string + type: object + models.TicketRecurring: + properties: + anchor_at: + description: |- + Расписание: «каждые PeriodDays дней/недель от AnchorAt» — + точка отсчёта плюс период в днях. + type: string + is_active: + type: boolean + next_run_at: + description: |- + NextRunAt — ближайший запуск, вычисляется при чтении + (см. repository.nextDue); у выключенного правила — когда включат. + type: string + period_days: + type: integer + rule_id: + type: integer + type: object +host: localhost:8080 +info: + contact: {} + description: 'Управление заявками на обслуживание: админ создаёт клиента, клиент + заводит проект, объекты в нём и мастеров, назначает заявку мастеру проекта, мастер + выполняет, клиент подтверждает работу или отклоняет её на доработку. Финальный + статус — closed.' + title: Building Management API + version: "1.0" +paths: + /auth/forgot-password: + post: + consumes: + - application/json + description: 'Отправляет на email письмо со ссылкой формы сброса (токен живёт + 24 часа, одноразовый). Ответ всегда одинаковый — 200 с ok:true, независимо + от того, существует ящик, подтверждён он и активен: эндпоинт не перечисляет + аккаунты. Письмо уходит только подтверждённым активным аккаунтам. Лимиты: + 10 запросов в минуту с одного IP; 3 письма в сутки на один ящик (счётчик тикает + при любом запросе — и для несуществующих ящиков тоже), при превышении — 429.' + parameters: + - description: Email для восстановления + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.forgotPasswordReq' + produces: + - application/json + responses: + "200": + description: всегда ok:true, даже если ящик не найден + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректное тело запроса + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "429": + description: слишком много попыток + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Запрос письма для сброса пароля + tags: + - auth + /auth/login: + post: + consumes: + - application/json + description: 'Возвращает access-токен; refresh-токен — только в httpOnly-куке + (Path=/auth, SameSite=Lax, Secure — через COOKIE_SECURE), в JSON его нет. + Email ищется без учёта регистра, пароль — bcrypt. Неподтверждённый аккаунт + — 403 account not confirmed (отличайте от account disabled). Лимит: 10 попыток + в минуту с одного IP.' + parameters: + - description: Учётные данные + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.loginReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TokenResponse' + "400": + description: некорректное тело запроса + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: неверный логин или пароль + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: аккаунт отключён либо не подтверждён + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "429": + description: слишком много попыток + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Вход + tags: + - auth + /auth/logout: + post: + description: 'Гасит refresh-сессию из куки refresh_token и очищает куку. Тело + запроса не нужно. Auth-middleware намеренно не используется: выйти должен + пользователь и с протухшим access-токеном.' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "429": + description: слишком много попыток + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Выйти (одно устройство) + tags: + - auth + /auth/logout-all: + post: + description: Закрывает все сессии текущего пользователя. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Выйти со всех устройств + tags: + - auth + /auth/me: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Profile' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Текущий пользователь + tags: + - auth + /auth/refresh: + post: + description: 'Refresh-токен читается из httpOnly-куки refresh_token (Path=/auth), + тело запроса не требуется. Ротация: на выходе новый access-токен и новая кука, + старая сессия гасится. Повторное использование отозванного токена закрывает + все сессии пользователя — признак того, что секретом владеет кто-то второй.' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TokenResponse' + "401": + description: куки нет, токен неизвестен, отозван или истёк + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: аккаунт отключён + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "429": + description: слишком много попыток + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Обновить пару токенов + tags: + - auth + /auth/reset-password: + post: + consumes: + - application/json + description: 'Токен одноразовый и живёт 24 часа: приходит письмом после POST + /auth/forgot-password либо выдаётся админом через POST /users/{id}/password-reset. + Новый пароль — по политике (10..72 символа, буквы и цифры). Успех гасит токен + и все refresh-сессии пользователя. Лимит: 30 попыток в минуту с одного IP.' + parameters: + - description: Токен и новый пароль + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.resetPasswordReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректное тело или токен неизвестен, использован либо истёк; + слабый пароль + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "429": + description: слишком много попыток + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Сброс пароля по токену + tags: + - auth + /auth/telegram: + delete: + description: Очищает цель уведомлений текущего пользователя (то же, что PUT + с пустым значением). Токен бота при этом не трогается — он живёт на своём + эндпоинте. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "401": + description: нет входа + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Отключить уведомления в Telegram + tags: + - auth + get: + description: 'Куда сейчас приходят уведомления о заявках: цель (чат или группа), + имя бота (свой или глобальный), подключён ли собственный токен бота (has_bot_token) + и можно ли его ввести (token_configurable — зависит от DATA_ENCRYPTION_KEY + на сервере). Сам токен наружу не отдаётся.' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TelegramStatusResponse' + "401": + description: нет входа + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: профиль не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Статус уведомлений в Telegram + tags: + - auth + put: + consumes: + - application/json + description: Принимает @имя группы/канала (бот обязан быть в нём) или числовой + chat_id; пустое значение отключает уведомления. Сначала проверяется формат, + затем бот проверяет чат через getChat — чужой или недоступный чат не сохраняется. + parameters: + - description: 'Цель: @группа, chat_id или пусто' + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.setTelegramReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: invalid telegram target + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет входа + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Указать цель уведомлений вручную + tags: + - auth + /auth/telegram/bot: + delete: + description: Стирает сохранённый токен текущего пользователя и останавливает + его поллер. Повторный вызов безопасен — ответ тот же 200. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "401": + description: нет входа + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: профиль не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Отключить своего бота + tags: + - auth + post: + consumes: + - application/json + description: 'Принимает токен Bot API (формат «id:secret» от @BotFather), проверяет + его через getMe, шифрует и сохраняет. Дальше уведомления пользователя идут + через этого бота, его поллер поднимается сразу. Лимит: 5 попыток за 5 минут. + Без DATA_ENCRYPTION_KEY на сервере — 400.' + parameters: + - description: Токен бота + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.setBotReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: токен не сохранён (формат, getMe или фича выключена) + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет входа + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: профиль не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "429": + description: слишком много попыток + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Подключить своего бота + tags: + - auth + /auth/telegram/code: + post: + consumes: + - application/json + description: 'Одноразовый код и deep-link t.me/{bot}?start={code}: откройте + ссылку в Telegram и отправьте боту код — этот чат начнёт получать уведомления + о заявках. Бот — ваш собственный токен, а если его нет — глобальный бот сервера. + Код живёт 15 минут, прежние коды гаснут. Лимит: 5 выпусков за 5 минут на пользователя.' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BindCodeResponse' + "400": + description: ни свой бот, ни глобальный не настроены + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: профиль не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "429": + description: слишком много кодов + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Выдать код привязки Telegram + tags: + - auth + /invitations: + get: + description: 'Список приглашений для текущего мастера: по каждому — проект, + кто пригласил и до какого срока. Просроченные сюда не попадают — принять их + уже нельзя.' + parameters: + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: invited_at + description: Поле сортировки + enum: + - invited_at + - expires_at + - project_name + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.InvitationListResponse' + "400": + description: некорректные параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Мои приглашения в проекты + tags: + - projects + /objects/{id}: + patch: + consumes: + - application/json + description: Поля опциональны. У уже созданных заявок адрес остаётся тем, что + был на момент создания — это осознанно. + parameters: + - description: ID объекта + in: path + name: id + required: true + type: integer + - description: Что изменить + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateObjectReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Object' + "400": + description: некорректный id или пустое тело + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: объект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: имя уже занято в проекте + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Изменить объект + tags: + - projects + /projects: + get: + description: 'Клиент видит только свои проекты, админ — все (можно сузить через + client_id). Мастерам проекты не отдаются. Ответ — страница: items плюс total/limit/offset.' + parameters: + - description: Фильтр по клиенту (только admin) + in: query + name: client_id + type: string + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: created_at + description: Поле сортировки + enum: + - created_at + - name + - id + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProjectListResponse' + "400": + description: некорректные параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Список проектов + tags: + - projects + post: + consumes: + - application/json + description: Клиент создаёт проект себе; админ — для любого клиента (в этом + случае обязателен client_id). Проект — контур, внутри которого живут объекты + и мастера. + parameters: + - description: Данные проекта + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createProjectReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Project' + "400": + description: некорректное тело; client_id обязателен для admin; владелец + не клиент + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: у клиента уже есть проект с таким именем + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Создать проект + tags: + - projects + /projects/{id}: + delete: + description: 'Действие необратимо: вместе с проектом удаляются его объекты, + участия мастеров и все заявки (вместе с событиями и фото). Объекты фото в + хранилище убираются после успешного удаления в БД. Клиент своих проектов не + удаляет — только админ.' + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Удалить проект + tags: + - projects + get: + description: Админ — любой проект, клиент — только свой; чужой проект неотличим + от несуществующего (404). + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Project' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Детали проекта + tags: + - projects + patch: + consumes: + - application/json + description: Поля опциональны, меняется только переданное. Выключенный проект + не принимает новые заявки и объекты. + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + - description: Что изменить + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateProjectReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Project' + "400": + description: некорректный id или пустое тело + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: имя уже занято у этого клиента + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Изменить проект + tags: + - projects + /projects/{id}/invitation/accept: + post: + description: 'invited → active: с этого момента мастер состоит в проекте и ему + можно назначать заявки. Действует только на своё непросроченное приглашение + (срок — 7 дней, см. repository.InvitationTTL).' + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: приглашения нет + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: срок вышел или приглашение уже закрыто + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Принять приглашение в проект + tags: + - projects + /projects/{id}/invitation/decline: + post: + description: 'invited → declined: заявок мастер не получает, клиент видит отказ + в составе проекта и может пригласить повторно (строка та же, история ответа + обновляется).' + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: приглашения нет + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: приглашение уже закрыто + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Отклонить приглашение в проект + tags: + - projects + /projects/{id}/objects: + get: + description: Здания/корпуса, входящие в проект. Адрес заявки берётся из объекта. + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: name + description: Поле сортировки + enum: + - name + - created_at + - id + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ObjectListResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Объекты проекта + tags: + - projects + post: + consumes: + - application/json + description: Объект — здание/корпус с адресом; заявки создаются именно на объект. + Выключенный проект объектов не принимает. + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + - description: Данные объекта + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createObjectReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Object' + "400": + description: некорректный id или тело + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: проект выключен или имя объекта занято + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Создать объект + tags: + - projects + /projects/{id}/workers: + get: + description: 'Кто приглашён в проект и кем принят: статус invited — приглашение + ждёт решения, active — мастер работает с заявками, declined — отказал. Хеши + паролей в ответ не попадают.' + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: name + description: Поле сортировки + enum: + - name + - created_at + - email + - id + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProjectWorkerListResponse' + "400": + description: некорректный id или параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Мастера проекта + tags: + - projects + post: + consumes: + - application/json + description: 'С 0005 это приглашение, а не готовое членство: мастер становится + участником только сам, приняв приглашение (POST /projects/{id}/invitation/accept) + — до этого заявки проекта ему не назначаются. Приглашение живёт 7 дней. Мастер + может состоять в нескольких проектах; аккаунт должен быть активным, с ролью + worker и подтверждённым (0010) — неподтверждённого сначала подтверждает тот, + кто его создал.' + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + - description: Мастер + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.addWorkerReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.InvitationResponse' + "400": + description: некорректный id/тело; не мастер, неактивен или не подтверждён + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект или мастер не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: мастер уже состоит в проекте / приглашение уже ждёт ответа + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Пригласить мастера в проект + tags: + - projects + /projects/{id}/workers/{worker_id}: + delete: + description: Нельзя убрать, пока за ним закреплены незакрытые заявки проекта + (409) — иначе работа осталась бы без исполнителя. Заявки из истории при этом + не теряются. + parameters: + - description: ID проекта + in: path + name: id + required: true + type: integer + - description: ID мастера (uuid) + in: path + name: worker_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: проект не найден или мастер не состоит в проекте + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: за мастером есть незакрытые заявки + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Убрать мастера из проекта + tags: + - projects + /recurring: + get: + description: 'Клиент видит только свои правила, админ — все. Ответ — страница: + items плюс total/limit/offset.' + parameters: + - description: Сузить до одного проекта + in: query + name: project_id + type: integer + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: created_at + description: Поле сортировки + enum: + - created_at + - id + - date + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.RecurringRuleListResponse' + "400": + description: некорректные параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Список правил повторения + tags: + - recurring + post: + consumes: + - application/json + description: 'Правило — шаблон заявки (объект, категория, описание, приоритет) + плюс расписание: anchor_at (дата-время первого запуска) и period_days — «каждые + N дней/недель». Фоновый планировщик сам создаёт заявку, когда срок наступил, + — но только если прошлая по правилу уже закрыта (без дублей). Клиент создаёт + правило для своего проекта, админ — для любого. Обычно правило заводится чекбоксом + в POST /tickets; этот эндпоинт — для управления без заявки.' + parameters: + - description: Данные правила + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createRecurringReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.RecurringRule' + "400": + description: некорректное тело запроса + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: объект не найден (или чужой — неотличим) + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: проект или объект выключен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Создать правило повторяющейся заявки + tags: + - recurring + /recurring/{id}: + delete: + description: Удаляется само правило, его история срабатываний (какая заявка + создавалась и когда) и — каскадом — будущие заявки ещё не созданы. Созданные + ранее заявки не трогаются. + parameters: + - description: ID правила + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: правило не найдено + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Удалить правило повторения + tags: + - recurring + get: + description: Админ — любое правило, клиент — только своё; чужое неотличимо от + несуществующего (404). + parameters: + - description: ID правила + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.RecurringRule' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: правило не найдено + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Детали правила повторения + tags: + - recurring + patch: + consumes: + - application/json + description: 'Частичное обновление: применяются только присланные поля, рамки + те же, что при создании (anchor_at — любая дата-время, period_days 1..3650). + object_id не меняется (перенос — через удаление и создание). Срок не сдвигается + вручную: он пересчитывается от последнего срабатывания с новым расписанием.' + parameters: + - description: ID правила + in: path + name: id + required: true + type: integer + - description: Что изменить (хотя бы одно поле) + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.updateRecurringReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.RecurringRule' + "400": + description: некорректный id, пустое тело или значение вне рамок + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: правило не найдено + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Изменить правило повторения + tags: + - recurring + /recurring/{id}/run: + post: + description: 'Заявка создаётся сразу, без ожидания интервала. Остальные рубежи + как у планировщика: правило, проект и объект включены, прошлая заявка по правилу + закрыта — иначе 409, дубль не создаём. Срок сдвигается на момент запуска (следующий + фоновый тик отсчитает интервал от него), уведомление уходит тем же текстом, + что при фоновом срабатывании.' + parameters: + - description: ID правила + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.RecurringRunResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: правило не найдено (или чужое — неотличим) + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: правило, проект или объект выключены, либо есть открытая заявка + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Запустить правило сейчас + tags: + - recurring + /recurring/{id}/runs: + get: + description: 'Какие заявки создавались по правилу и когда (аудит). Сначала свежие. + Строка живёт вместе со своей заявкой: когда заявку удаляет автоочистка (TICKET_RETENTION_DAYS), + строка истории уходит следом.' + parameters: + - description: ID правила + in: path + name: id + required: true + type: integer + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: run_at + description: Поле сортировки + enum: + - run_at + - id + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.RecurringRunListResponse' + "400": + description: некорректный id или параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client или admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: правило не найдено + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: История срабатываний правила + tags: + - recurring + /tickets: + get: + description: 'Выборка режется по роли: клиент видит свои, мастер — назначенные + ему, админ — всё. Клиенту и мастеру параметры client_id/worker_id игнорируются: + подсмотреть чужое нельзя. Фильтры накладываются поверх выборки по роли. Ответ + — страница: items плюс total/limit/offset.' + parameters: + - description: Фильтр по статусу + enum: + - new + - assigned + - in_progress + - done + - confirmed + - rejected + - closed + in: query + name: status + type: string + - description: Фильтр по категории + enum: + - electric + - plumbing + - climate + - elevator + - other + in: query + name: category + type: string + - description: Фильтр по приоритету + enum: + - low + - normal + - high + in: query + name: priority + type: string + - description: Фильтр по клиенту (только admin) + in: query + name: client_id + type: string + - description: Фильтр по мастеру (только admin) + in: query + name: worker_id + type: string + - description: Фильтр по проекту + in: query + name: project_id + type: integer + - description: Фильтр по объекту + in: query + name: object_id + type: integer + - description: created_at с, YYYY-MM-DD (включительно, с полуночи UTC) + in: query + name: date_from + type: string + - description: created_at по, YYYY-MM-DD (включительно, до полуночи UTC следующего + дня) + in: query + name: date_to + type: string + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: created_at + description: Поле сортировки + enum: + - created_at + - priority + - id + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TicketListResponse' + "400": + description: некорректный фильтр или параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: неизвестная роль + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Список заявок + tags: + - tickets + post: + consumes: + - application/json + description: 'Клиент создаёт заявку на объект своего проекта. Адрес берётся + из объекта на момент создания, статус всегда new, мастер не назначен. Если + передан recurring — той же операцией создаётся правило повторения (anchor_at + — первый запуск, period_days — период в днях), связанное с заявкой: оно видно + в GET /tickets/{id} в поле recurring, управление — через PATCH /recurring/{id}.' + parameters: + - description: Данные заявки + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createTicketReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректное тело запроса + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль client + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: объект не найден (или чужой — неотличим) + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: проект или объект выключен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Создать заявку + tags: + - tickets + /tickets/{id}: + get: + description: Админ — любая заявка, клиент — только своя, мастер — назначенная + ему либо свободная (new) из его проектов. + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: заявка не принадлежит вам + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Детали заявки + tags: + - tickets + /tickets/{id}/assign: + patch: + consumes: + - application/json + description: new → assigned. Доступно админу и владельцу-клиенту. Мастер должен + состоять в проекте заявки и быть активным — иначе 400/409. + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - description: Мастер + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.assignReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректный id/тело, не-мастер, неактивен, приглашение не + принято или не состоит в проекте заявки + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin или client; чужая заявка + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: заявка уже назначена или закрыта + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Назначить мастера + tags: + - tickets + /tickets/{id}/cancel: + patch: + consumes: + - application/json + description: Закрывает заявку с cancel_reason и closed_by. Клиент — только свои + в new/assigned/done/rejected; админ — любую кроме уже закрытой, включая in_progress. + Причина обязательна. + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - description: Причина отмены, минимум 5 символов + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reasonReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: 'некорректный id или тело: причина обязательна' + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: чужая заявка или роль не client/admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: нельзя закрыть из текущего статуса + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Отменить заявку (финал) + tags: + - tickets + /tickets/{id}/confirm: + patch: + description: done → closed с confirmed_at. Доступно только владельцу-клиенту. + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: заявка не принадлежит вам + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: заявка не в статусе done + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Подтвердить работу (финал) + tags: + - tickets + /tickets/{id}/done: + patch: + description: in_progress → done. Дальше клиент подтверждает или отклоняет. + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: не тот статус или заявка назначена другому + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Завершить работу + tags: + - tickets + /tickets/{id}/events: + get: + description: 'Хронология переходов заявки с авторами: created, assigned, started, + done, confirmed, rejected, canceled. from_status у события «создана» нет. + События пишутся той же SQL-операцией, что и переход, поэтому история не расходится + со статусом.' + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: id + description: Поле сортировки + enum: + - id + - created_at + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TicketEventListResponse' + "400": + description: некорректный id или параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: чужая заявка + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Журнал событий заявки + tags: + - tickets + /tickets/{id}/photos: + get: + description: 'Файлы заявки в хронологии (id ASC по умолчанию). В каждом элементе + url — временная подписанная ссылка на чтение: бакет приватный, сам ключ наружу + не уходит.' + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: id + description: Поле сортировки + enum: + - id + - created_at + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.PhotoListResponse' + "400": + description: некорректный id или параметры страницы + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: чужая заявка + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Список фото отчёта + tags: + - tickets + post: + consumes: + - multipart/form-data + description: 'multipart/form-data, поле file: только фото (jpeg/png/webp, определяется + по сигнатуре байтов), не больше PHOTO_MAX_BYTES на файл и PHOTO_MAX_PER_TICKET + файлов на заявку. Доступно мастеру (заявка in_progress — после «Работа выполнена» + отчёт заморожен, мастер — активный участник проекта) или админу. Каждая загрузка + оставляет событие photo_added в журнале заявки.' + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - description: Файл фотографии (jpeg/png/webp) + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "201": + description: фото загружено, url — временная ссылка на чтение + schema: + $ref: '#/definitions/models.TicketPhoto' + "400": + description: некорректный id, файл отсутствует, не фото или больше лимита + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker или admin; чужая заявка + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: лимит файлов исчерпан или заявка не в статусе in_progress + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: хранилище недоступно + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Загрузить фото отчёта + tags: + - tickets + /tickets/{id}/photos/{photo_id}: + delete: + description: 'Своё фото удаляет автор, пока заявка в работе (in_progress): после + «Работа выполнена» отчёт заморожен; админу — любое фото. Событие photo_removed + пишется той же операцией, что и удаление.' + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - description: ID фото + in: path + name: photo_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id или photo_id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker или admin; чужая заявка + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка или фото не найдены + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: чужое фото или заявка уже не в работе + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Удалить фото + tags: + - tickets + /tickets/{id}/reject: + patch: + consumes: + - application/json + description: 'done → rejected. Не финал: мастер берёт заявку в работу повторно, + rework_count увеличивается. Причина обязательна — без неё мастер не поймёт, + что переделывать.' + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + - description: Причина отклонения, минимум 5 символов + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.reasonReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: 'некорректный id или тело: причина обязательна' + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: заявка не принадлежит вам + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: заявка не в статусе done + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Отклонить работу (на доработку) + tags: + - tickets + /tickets/{id}/start: + patch: + description: assigned → in_progress, а после отклонения (rejected) — повторный + запуск. started_at первого круга сохраняется. + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: не тот статус или заявка назначена другому + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Взять в работу + tags: + - tickets + /tickets/{id}/take: + patch: + description: 'new → assigned: мастер назначает себя на свободную заявку своего + проекта. Дальше — «Взять в работу» (start) как обычно.' + parameters: + - description: ID заявки + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Ticket' + "400": + description: некорректный id; приглашение не принято или отклонено; не состоит + в проекте заявки + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль worker + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: заявка не найдена + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: заявка уже взята или не в статусе new + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Взять заявку самому + tags: + - tickets + /users: + get: + description: Админ видит любую роль и всех; клиенту доступна только роль worker + и только его собственные мастера (чужие организации не видны). С параметром + q (минимум 2 символа) возвращается поиск по имени/email среди активных — так + клиент пригласит в проект существующего мастера, не видя при этом полного + списка. include_inactive=1 дополняет список отключёнными аккаунтами — только + для админа. + parameters: + - default: worker + description: Роль + enum: + - worker + - admin + - client + in: query + name: role + type: string + - description: Поиск по имени или email, минимум 2 символа + in: query + name: q + type: string + - default: "0" + description: Показать и отключённых (только для админа) + enum: + - "0" + - "1" + in: query + name: include_inactive + type: string + - default: 20 + description: Строк на странице, 1..100 + in: query + name: limit + type: integer + - default: 0 + description: Сдвиг от начала выборки + in: query + name: offset + type: integer + - default: name + description: Поле сортировки + enum: + - name + - created_at + - email + - id + in: query + name: sort + type: string + - description: Направление сортировки + enum: + - asc + - desc + in: query + name: order + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProfileListResponse' + "400": + description: неизвестная роль, параметры страницы или короткий q + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: клиент запрашивает не мастеров + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Список пользователей по роли + tags: + - users + post: + consumes: + - application/json + description: 'Админ создаёт мастеров, админов и клиентов; клиент — только мастеров + (для себя, далее мастер добавляется в проект через POST /projects/{id}/workers). + Роли admin и client клиентом недоступны. Пароль — по политике (10..72 символа, + буквы и цифры). Подтверждение: аккаунт, созданный админом, подтверждён сразу; + созданный клиентом — ждёт подтверждения этим же клиентом, после чего мастеру + уходит письмо.' + parameters: + - description: Данные пользователя + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createUserReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Profile' + "400": + description: некорректное тело запроса или слабый пароль + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: клиент пытается создать админа или клиента + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: email уже занят + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Создать пользователя + tags: + - users + /users/{id}: + delete: + description: 'Админ удаляет любой «чистый» аккаунт: refresh-сессии и токены + сброса удаляются каскадом, но наличие заявок, проектов или участий блокирует + удаление (409 user has related records) — в этом случае отключите аккаунт + через PATCH /users/{id}, историю не стираем. Клиент удаляет только мастера, + состоящего в одном из его проектов: членства клиента в проектах удаляются + в той же транзакции, но если на мастера ссылаются заявки либо чужие проекты + — тоже 409, ничего не меняется. Чужой мастер и несуществующий id неразличимы + (404). Свой id удалить нельзя.' + parameters: + - description: ID пользователя (uuid) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id; нельзя удалить себя + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin или client + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: пользователь не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: у пользователя есть связанные записи + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Удалить пользователя + tags: + - users + patch: + consumes: + - application/json + description: Админ меняет любой аккаунт; клиент — только мастера, который активный + участник одного из его проектов (иного клиента или чужого мастера не видит + — 404). При деактивации все refresh-сессии пользователя закрываются сразу, + мастер перестаёт работать во всех проектах. Выключить самого себя нельзя — + вы остались бы без входа. + parameters: + - description: ID пользователя (uuid) + in: path + name: id + required: true + type: string + - description: Новый флаг + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.setUserReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Profile' + "400": + description: некорректный id или тело; попытка выключить себя + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin или client + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: пользователь не найден (для клиента — не его мастер) + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Включить/выключить аккаунт + tags: + - users + /users/{id}/confirm: + post: + consumes: + - application/json + description: 'Подтверждает неподтверждённый аккаунт и шлёт пользователю письмо + «аккаунт подтверждён» со ссылкой на вход. Право — в SQL: админ подтверждает + любой аккаунт, клиент — только созданного им мастера (иной и несуществующий + не видны — 404). Повторное подтверждение — 409. Письмо отправляется после + подтверждения; недоступный SMTP не откатывает подтверждение (письмо просто + не уйдёт).' + parameters: + - description: ID пользователя (uuid) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin или client + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: пользователь не найден (для клиента — не созданный им мастер) + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: аккаунт уже подтверждён + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Подтвердить аккаунт + tags: + - users + /users/{id}/password-reset: + post: + description: 'Токен возвращается в ответе — админ передаёт его пользователю + сам, любым каналом. Для пользователя есть почта: POST /auth/forgot-password + шлёт письмо со ссылкой. Токен одноразовый, живёт 24 часа (repository.ResetTTL); + выпуск нового гасит прежние неиспользованные токены пользователя (включая + свежие из писем). В БД хранится только sha256 токена.' + parameters: + - description: ID пользователя (uuid) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.PasswordResetResponse' + "400": + description: некорректный id + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: пользователь не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Выдать токен сброса пароля + tags: + - users + /users/{id}/password-set: + post: + consumes: + - application/json + description: Админ ставит пароль пользователю сам, без ссылки. Политика та же, + что при создании и при сбросе по токену (10..72 символа, буквы и цифры). Успех + закрывает все refresh-сессии пользователя и гасит неиспользованные токены + сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают + работать. Альтернатива — одноразовая ссылка через POST /users/{id}/password-reset. + parameters: + - description: ID пользователя (uuid) + in: path + name: id + required: true + type: string + - description: Новый пароль + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.setPasswordReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.OKResponse' + "400": + description: некорректный id, тело или слабый пароль + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: нет или неверный токен + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: нужна роль admin + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: пользователь не найден + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - Bearer: [] + summary: Задать пароль напрямую + tags: + - users +schemes: +- http +securityDefinitions: + Bearer: + description: JWT формата Bearer, получить через POST /auth/login. Срок жизни — + 1 час, дальше POST /auth/refresh. + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56239e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,82 @@ +module building-mgmt + +go 1.27.1 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/go-playground/validator/v10 v10.30.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/joho/godotenv v1.5.1 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.57.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/aws/aws-sdk-go-v2 v1.47.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect + github.com/aws/smithy-go v1.28.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-smtp v0.25.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3dfd0cb --- /dev/null +++ b/go.sum @@ -0,0 +1,230 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= +github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug= +github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw= +github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.25.0 h1:krfiHrme2JbJYDh0DGuSRbvPpbnQTH/v9CIfPincl1I= +github.com/emersion/go-smtp v0.25.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..972b4f0 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,85 @@ +// Package auth — выпуск и проверка JWT, хеши паролей и middleware +// авторизации. Пакет ничего не знает о базе: работает только со строками. +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// allowedAlgorithms — алгоритмы, которыми могут быть подписаны токены. +// Раньше ключевая функция возвращала секрет для любого t.Method: подделанный +// заголовок alg проходил бы без проверки. Теперь и алгоритм, и ключ +// сверяются явно, а сама библиотека сравнивает подпись за постоянное время. +var allowedAlgorithms = map[string]bool{"HS256": true} + +const ( + // AccessTTL — жизнь короткого access-токена. Дальше клиент идёт + // на /auth/refresh с refresh-токеном, который отозвать можно. + AccessTTL = time.Hour + // RefreshTTL — жизнь refresh-токена; при каждом обновлении он + // меняется на новый (ротация), старый гасится. + RefreshTTL = 30 * 24 * time.Hour +) + +// Claims — полезная нагрузка нашего токена: идентификатор пользователя, +// роль и стандартные временные поля (exp, iat). +type Claims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// GenerateToken подписывает access-токен на AccessTTL. Пустой секрет — +// ошибка, а не подпись пустой строкой: раньше так можно было войти, +// подставив любой ключ. +func GenerateToken(secret, userID, role string) (string, error) { + if secret == "" { + return "", errors.New("jwt secret is empty") + } + claims := Claims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTTL)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +// ParseToken проверяет подпись, алгоритм и срок жизни и возвращает +// claims. Любой спорный случай (чужой секрет, alg≠HS256, просрочка) — +// ошибка: наружу она не уходит, middleware отдаёт 401. +func ParseToken(secret, tokenStr string) (*Claims, error) { + if secret == "" { + return nil, errors.New("jwt secret is empty") + } + + claims := &Claims{} + token, err := jwt.ParseWithClaims( + tokenStr, + claims, + func(t *jwt.Token) (interface{}, error) { + if !allowedAlgorithms[t.Method.Alg()] { + return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) + } + return []byte(secret), nil + }, + // belt & braces: WithValidMethods повторяет проверку выше, + // WithExpirationRequired не пускает токены без exp. + jwt.WithValidMethods([]string{"HS256"}), + jwt.WithExpirationRequired(), + ) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, jwt.ErrTokenInvalidClaims + } + return claims, nil +} diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go new file mode 100644 index 0000000..020389f --- /dev/null +++ b/internal/auth/jwt_test.go @@ -0,0 +1,104 @@ +package auth + +import ( + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testSecret = "test-secret-at-least-32-characters-long" + +func TestGenerateAndParseToken(t *testing.T) { + token, err := GenerateToken(testSecret, "user-1", "worker") + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := ParseToken(testSecret, token) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.UserID != "user-1" || claims.Role != "worker" { + t.Errorf("claims = %+v, want user_id=user-1 role=worker", claims) + } +} + +func TestParseTokenRejects(t *testing.T) { + cases := []struct { + name string + token string + }{ + { + name: "подписан другим секретом", + token: sign(t, "another-secret", jwt.SigningMethodHS256, time.Hour), + }, + { + name: "другой алгоритм при том же секрете (HS512)", + token: sign(t, testSecret, jwt.SigningMethodHS512, time.Hour), + }, + { + name: "просроченный", + token: sign(t, testSecret, jwt.SigningMethodHS256, -time.Hour), + }, + { + name: "alg=none, подпись не подставлена", + token: forgeNone(t), + }, + { + name: "мусор вместо токена", + token: "not-a-token", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := ParseToken(testSecret, tc.token); err == nil { + t.Error("token was accepted, want rejection") + } + }) + } +} + +func TestEmptySecretFailsFast(t *testing.T) { + if _, err := GenerateToken("", "user-1", "client"); err == nil { + t.Error("GenerateToken with empty secret: want error, got token") + } + if _, err := ParseToken("", "whatever"); err == nil { + t.Error("ParseToken with empty secret: want error, got claims") + } +} + +// sign собирает токен вручную, чтобы протестировать сценарии, +// которые GenerateToken сам не порождает. +func sign(t *testing.T, secret string, method jwt.SigningMethod, ttl time.Duration) string { + t.Helper() + + claims := Claims{ + UserID: "user-1", + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token, err := jwt.NewWithClaims(method, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign: %v", err) + } + return token +} + +// forgeNone подделывает токен с alg=none: раньше такой заголовок +// проходил, потому что ключевая функция не сверяла метод подписи. +func forgeNone(t *testing.T) string { + t.Helper() + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString( + []byte(`{"user_id":"hacker","role":"admin","exp":4102444800}`), + ) + return strings.Join([]string{header, payload, ""}, ".") +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..8abfe64 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,43 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// Middleware пропускает запрос только с валидным Bearer-токеном и кладёт +// в контекст user_id и role; без токена или с ошибкой — 401. +func Middleware(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"}) + return + } + claims, err := ParseToken(secret, strings.TrimPrefix(header, "Bearer ")) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + c.Set("user_id", claims.UserID) + c.Set("role", claims.Role) + c.Next() + } +} + +// RequireRole пропускает запрос, если роль в списке разрешённых. +// Ставится после Middleware: роль берётся из уже проверенного токена. +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + role := c.GetString("role") + for _, r := range roles { + if r == role { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"}) + } +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..f926482 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,68 @@ +package auth + +import ( + "errors" + "unicode" + + "golang.org/x/crypto/bcrypt" +) + +// Политика установки пароля. Применяется только при создании/смене +// пароля: уже существующие аккаунты входят со своими старыми паролями. +const ( + // MinPasswordLen — минимум символов (считаем руны, не байты: + // кириллический символ — уже два байта). + MinPasswordLen = 10 + // MaxPasswordLen — потолок bcrypt: он режет вход длиннее 72 байт, + // пароль «длиннее максимума» молча стал бы короче. + MaxPasswordLen = 72 +) + +// Ошибки политики пароля. Тексты — короткие и англоязычные, +// как и все ошибки API. +var ( + ErrPasswordShort = errors.New("password must be at least 10 characters") + ErrPasswordLong = errors.New("password must be at most 72 characters") + ErrPasswordWeak = errors.New("password must contain letters and digits") +) + +// ValidatePassword проверяет пароль по политике: длина 10..72 и +// обязательные буквы И цифры. Спецсимволы не требуются — их принуждение +// чаще порождает «P@ssw0rd!», чем стойкость; зато цифры и буквы отсекают +// «password» и «1234567890». +func ValidatePassword(password string) error { + n := 0 + var hasLetter, hasDigit bool + for _, r := range password { + n++ + switch { + case unicode.IsLetter(r): + hasLetter = true + case unicode.IsDigit(r): + hasDigit = true + } + } + if n < MinPasswordLen { + return ErrPasswordShort + } + if len(password) > MaxPasswordLen { + return ErrPasswordLong + } + if !hasLetter || !hasDigit { + return ErrPasswordWeak + } + return nil +} + +// HashPassword считает bcrypt-хеш с cost 12. Ошибка возможна только +// на очень длинных паролях (bcrypt режет вход длиннее 72 байт). +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) + return string(bytes), err +} + +// CheckPassword сверяет пароль с хешем. Сравнение за постоянное время — +// перебор по времени ответа не даёт выигрыша. +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..198d041 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,34 @@ +package auth + +import ( + "errors" + "strings" + "testing" +) + +func TestValidatePassword(t *testing.T) { + cases := []struct { + name string + pw string + want error + }{ + {"нормальный", "Password123", nil}, + {"ровно минимум (10)", "abcdefg123", nil}, + {"с кириллицей", "пароль1234", nil}, + {"слишком короткий", "Password1", ErrPasswordShort}, + {"ровно 9", "short1234"[:9], ErrPasswordShort}, + {"пустой", "", ErrPasswordShort}, + {"только буквы", "verylongpassword", ErrPasswordWeak}, + {"только цифры", "1234567890", ErrPasswordWeak}, + {"без цифры в середине", "abcdefghij1", nil}, + {"длиннее 72 байт", strings.Repeat("a1", 40), ErrPasswordLong}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidatePassword(tc.pw) + if !errors.Is(err, tc.want) { + t.Errorf("ValidatePassword(%q) = %v, want %v", tc.pw, err, tc.want) + } + }) + } +} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..10c4c81 --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,28 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" +) + +// refreshSecretBytes — длина секрета refresh-токена (256 бит энтропии). +const refreshSecretBytes = 32 + +// NewRefreshToken — случайный секрет для refresh-токена. +// В открытом виде он существует только у клиента: в БД уходит хеш. +func NewRefreshToken() (string, error) { + buf := make([]byte, refreshSecretBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// HashToken — sha256 в hex. Хранение хеша позволяет искать токен +// в БД без раскрытия секрета и не даёт вытащить его при утечке дампа. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/crypt/cipher.go b/internal/crypt/cipher.go new file mode 100644 index 0000000..4ac685f --- /dev/null +++ b/internal/crypt/cipher.go @@ -0,0 +1,114 @@ +// Package crypt — шифрование чувствительных полей перед записью в БД: +// сейчас это токены собственных ботов Telegram (миграция 0012). +// +// Токен бота — полный контроль над ботом, поэтому в базе он лежит +// зашифрованным AES-256-GCM (authenticated encryption): утечка дампа +// не даёт читать токены, а подмена байтов в колонке ловится при +// расшифровке. Ключ — секрет сервера DATA_ENCRYPTION_KEY (32 байта, +// base64 или hex), сам он в коде и в логах не появляется. +// +// Без ключа фича выключается на уровне хендлеров (Cipher == nil): +// ввод токена отвечает 400, сохранённое расшифровать некому — ключ +// меняется только вместе с явным планом миграции. +package crypt + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" +) + +const ( + // keyLen — AES-256: 32 байта. + keyLen = 32 + // nonceLen — размер nonce для GCM: 96 бит, стандарт из RFC 5116. + nonceLen = 12 + // tagLen — аутентифицирующая метка GCM (не считается в nonce). + tagLen = 16 +) + +var ( + // ErrKeySize — ключ не 32 байта (неверный base64/hex). + ErrKeySize = errors.New("encryption key must be 32 bytes") + // ErrCorrupt — данные не читаются: не наш формат или биты подделаны. + ErrCorrupt = errors.New("ciphertext is corrupt") +) + +// Cipher — AES-256-GCM поверх одного ключа. Потокобезопасен: +// stateless-шум в nonce, общих структур нет. +type Cipher struct { + aead cipher.AEAD +} + +// New создаёт шифровальщик из ключа в base64 (стандартное кодирование, +// допускается и без паддинга) либо в hex. Сам ключ генерируется так: +// `openssl rand -base64 32` либо `openssl rand -hex 32`. +func New(key string) (*Cipher, error) { + raw, err := decodeKey(strings.TrimSpace(key)) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(raw) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Cipher{aead: aead}, nil +} + +// Seal шифрует строку: к возвращаемому значению приклеивается случайный +// nonce (12 байт), весь результат — base64. Результат не содержит исходного +// текста и не выглядит как base64 от него. +func (c *Cipher) Seal(plain string) (string, error) { + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("nonce: %w", err) + } + out := c.aead.Seal(nonce, nonce, []byte(plain), nil) + return base64.StdEncoding.EncodeToString(out), nil +} + +// Open расшифровывает то, что вернул Seal. Ошибка — либо чужие данные, +// либо другой ключ: причина наружу не раскрывается (ErrCorrupt единый), +// детали логирует вызывающий. +func (c *Cipher) Open(enc string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) + if err != nil { + return "", ErrCorrupt + } + if len(raw) < nonceLen+tagLen { + return "", ErrCorrupt + } + nonce, sealed := raw[:nonceLen], raw[nonceLen:] + plain, err := c.aead.Open(nil, nonce, sealed, nil) + if err != nil { + return "", ErrCorrupt + } + return string(plain), nil +} + +// decodeKey — base64 или hex, ровно 32 байта. +func decodeKey(key string) ([]byte, error) { + if key == "" { + return nil, ErrKeySize + } + for _, dec := range []func(string) ([]byte, error){ + base64.StdEncoding.DecodeString, + base64.RawStdEncoding.DecodeString, + hex.DecodeString, + } { + if raw, err := dec(key); err == nil && len(raw) == keyLen { + return raw, nil + } + } + return nil, ErrKeySize +} diff --git a/internal/crypt/cipher_test.go b/internal/crypt/cipher_test.go new file mode 100644 index 0000000..dbf5179 --- /dev/null +++ b/internal/crypt/cipher_test.go @@ -0,0 +1,128 @@ +package crypt + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "testing" +) + +// testKey — 32 байта в base64 (та же строка ниже и в hex). +var ( + testKeyB64 = base64.StdEncoding.EncodeToString(make([]byte, 32)) + testKeyHex = hex.EncodeToString(make([]byte, 32)) +) + +// TestNewKeyFormat — принимаются base64 и hex, отклоняется прочее: +// пустой ключ, короткий, мусор и неверный размер. +func TestNewKeyFormat(t *testing.T) { + for _, key := range []string{testKeyB64, testKeyHex, strings.TrimRight(testKeyB64, "=")} { + if _, err := New(key); err != nil { + t.Errorf("New(%q): %v", key[:12], err) + } + } + for _, key := range []string{ + "", + "short", + base64.StdEncoding.EncodeToString(make([]byte, 16)), // 16 байт — AES-128, не наш формат + strings.Repeat("z", 44), // base64 от 33 байт + } { + if _, err := New(key); !errors.Is(err, ErrKeySize) { + t.Errorf("New(%q): err = %v, want ErrKeySize", key, err) + } + } +} + +// TestSealOpen — основной путь: шифрование обратимо, ciphertext не +// содержит исходный текст и меняется от прогона к прогону (nonce). +func TestSealOpen(t *testing.T) { + c, err := New(testKeyB64) + if err != nil { + t.Fatalf("New: %v", err) + } + const secret = "123456789:AAFakeBotTokenForTests0123456789" + + enc, err := c.Seal(secret) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if strings.Contains(enc, secret) || strings.Contains(enc, "AAFake") { + t.Errorf("ciphertext leaks plaintext: %q", enc) + } + got, err := c.Open(enc) + if err != nil { + t.Fatalf("Open: %v", err) + } + if got != secret { + t.Errorf("roundtrip = %q, want %q", got, secret) + } + + // Та же строка шифруется заново: nonce другой, результат другой — + // иначе два пользователя с одинаковым токеном дали бы одинаковый blob. + again, err := c.Seal(secret) + if err != nil { + t.Fatalf("Seal again: %v", err) + } + if again == enc { + t.Error("two seals of the same value are equal (nonce reuse?)") + } +} + +// TestOpenRejects — битые и чужие данные: обрезанный результат, битый +// base64, подделанный байт (GCM поймает), другой ключ. +func TestOpenRejects(t *testing.T) { + c, err := New(testKeyB64) + if err != nil { + t.Fatalf("New: %v", err) + } + other, err := New(hex.EncodeToString(append(make([]byte, 31), 1))) + if err != nil { + t.Fatalf("New other: %v", err) + } + enc, err := c.Seal("topsecret") + if err != nil { + t.Fatalf("Seal: %v", err) + } + + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + raw[len(raw)-1] ^= 0xFF // битый тэг GCM + tampered := base64.StdEncoding.EncodeToString(raw) + + for name, tc := range map[string]struct { + c *Cipher + in string + }{ + "not base64": {c, "не base64"}, + "too short": {c, base64.StdEncoding.EncodeToString([]byte("abc"))}, + "tampered": {c, tampered}, + "another key": {other, enc}, + "empty": {c, ""}, + } { + t.Run(name, func(t *testing.T) { + if _, err := tc.c.Open(tc.in); !errors.Is(err, ErrCorrupt) { + t.Errorf("err = %v, want ErrCorrupt", err) + } + }) + } +} + +// TestSealEmpty — пустая строка шифруется и расшифровывается (нужно, +// когда пустое значение хранят сознательно). +func TestSealEmpty(t *testing.T) { + c, err := New(testKeyHex) + if err != nil { + t.Fatalf("New: %v", err) + } + enc, err := c.Seal("") + if err != nil { + t.Fatalf("Seal: %v", err) + } + got, err := c.Open(enc) + if err != nil || got != "" { + t.Errorf("got %q, err %v, want empty", got, err) + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f9c7480 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,77 @@ +// Package db — подключение к PostgreSQL и рантайм-раннер миграций +// (embed SQL-файлов + checksum применённых версий). +package db + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Connect создаёт пул и проверяет соединение пингом: ошибку лучше +// поймать здесь, чем на первом запросе хендлера. +// +// Пул настраивается по env (DB_*), и только если переменная задана: +// без них работают дефолты pgxpool — dev и CI не меняются, а в проде +// потолок соединений задаётся явно (см. .env.prod.example). Без +// потолка MaxConns = max(4, 4*CPU) — на большой машине это лишние +// соединения к PostgreSQL. +func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + if err := applyPoolEnv(cfg); err != nil { + return nil, err + } + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + // Пинг упал — пул уже создал соединения, закрываем их: + // утечка на старте незаметна, но копится с каждым рестартом. + pool.Close() + return nil, err + } + return pool, nil +} + +// applyPoolEnv читает DB_* настройки пула. Пустая строка = «не задано», +// дефолты остаются от pgxpool. Ошибка здесь — фейл-фаст на старте: +// молча работать с чужим потолком нельзя. +func applyPoolEnv(cfg *pgxpool.Config) error { + if v := os.Getenv("DB_MAX_CONNS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return fmt.Errorf("DB_MAX_CONNS must be a positive number, got %q", v) + } + cfg.MaxConns = int32(n) + } + if v := os.Getenv("DB_MIN_CONNS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return fmt.Errorf("DB_MIN_CONNS must be a non-negative number, got %q", v) + } + cfg.MinConns = int32(n) + } + if v := os.Getenv("DB_MAX_CONN_LIFETIME"); v != "" { + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return fmt.Errorf("DB_MAX_CONN_LIFETIME must be a positive duration, e.g. 1h, got %q", v) + } + cfg.MaxConnLifetime = d + } + if v := os.Getenv("DB_MAX_CONN_IDLE_TIME"); v != "" { + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return fmt.Errorf("DB_MAX_CONN_IDLE_TIME must be a positive duration, e.g. 30m, got %q", v) + } + cfg.MaxConnIdleTime = d + } + return nil +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..fc2efdf --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,62 @@ +package db + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// applyPoolEnv читает env: пустые переменные не трогают дефолты pgxpool, +// заполненные — применяются, кривые — ошибка на старте. +func TestApplyPoolEnv(t *testing.T) { + // Дефолт: ни одной переменной — конфиг не меняется. + cfg := &pgxpool.Config{} + before := *cfg + if err := applyPoolEnv(cfg); err != nil { + t.Fatalf("empty env: %v", err) + } + if cfg.MaxConns != before.MaxConns || cfg.MinConns != before.MinConns { + t.Fatalf("empty env must not touch config: %+v", cfg) + } + + // Заданные значения применяются как есть. + t.Setenv("DB_MAX_CONNS", "20") + t.Setenv("DB_MIN_CONNS", "2") + t.Setenv("DB_MAX_CONN_LIFETIME", "1h") + t.Setenv("DB_MAX_CONN_IDLE_TIME", "30m") + cfg = &pgxpool.Config{} + if err := applyPoolEnv(cfg); err != nil { + t.Fatalf("valid env: %v", err) + } + if cfg.MaxConns != 20 || cfg.MinConns != 2 { + t.Fatalf("conns not applied: max=%d min=%d", cfg.MaxConns, cfg.MinConns) + } + if cfg.MaxConnLifetime.Hours() != 1 { + t.Fatalf("lifetime not applied: %v", cfg.MaxConnLifetime) + } + if cfg.MaxConnIdleTime.Minutes() != 30 { + t.Fatalf("idle time not applied: %v", cfg.MaxConnIdleTime) + } +} + +// Кривые значения — фейл-фаст, а не молчаливый дефолт. +func TestApplyPoolEnvInvalid(t *testing.T) { + cases := []struct { + env string + val string + }{ + {"DB_MAX_CONNS", "ten"}, + {"DB_MAX_CONNS", "0"}, + {"DB_MIN_CONNS", "-1"}, + {"DB_MAX_CONN_LIFETIME", "1h30"}, + {"DB_MAX_CONN_IDLE_TIME", "0s"}, + } + for _, c := range cases { + t.Run(c.env+"="+c.val, func(t *testing.T) { + t.Setenv(c.env, c.val) + if err := applyPoolEnv(&pgxpool.Config{}); err == nil { + t.Fatalf("%s=%s must fail", c.env, c.val) + } + }) + } +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..5f005e9 --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,169 @@ +package db + +import ( + "context" + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "io/fs" + "log/slog" + "path" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +const migrationsDir = "migrations" + +// Имя файла: _.sql, например 0001_init.sql. +var migrationFileRe = regexp.MustCompile(`^(\d+)_([a-z0-9_-]+)\.sql$`) + +type migration struct { + Version int + Name string + Checksum string + SQL string +} + +// Migrate применяет недостающие миграции в порядке версии. +// Каждая миграция выполняется в отдельной транзакции; применённые +// фиксируются в schema_migrations вместе с checksum файла. +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + migrations, err := loadMigrations() + if err != nil { + return err + } + + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version int PRIMARY KEY, + name text NOT NULL, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + applied, err := appliedVersions(ctx, pool) + if err != nil { + return err + } + + for _, m := range migrations { + checksum, ok := applied[m.Version] + if !ok { + if err := applyMigration(ctx, pool, m); err != nil { + return fmt.Errorf("migration %04d_%s: %w", m.Version, m.Name, err) + } + slog.Info("migration applied", "version", m.Version, "name", m.Name) + continue + } + if checksum != m.Checksum { + // Лучше упасть на старте, чем работать с непредсказуемой схемой. + return fmt.Errorf( + "migration %04d_%s: checksum changed after apply (db=%s, file=%s); "+ + "applied migrations must not be edited — add a new migration instead", + m.Version, m.Name, checksum[:12], m.Checksum[:12], + ) + } + } + return nil +} + +// AppliedVersions — версии, которые уже накатаны. Экспортируется для тестов. +func AppliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[int]string, error) { + return appliedVersions(ctx, pool) +} + +func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[int]string, error) { + rows, err := pool.Query(ctx, `SELECT version, checksum FROM schema_migrations`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[int]string{} + for rows.Next() { + var version int + var checksum string + if err := rows.Scan(&version, &checksum); err != nil { + return nil, err + } + out[version] = checksum + } + return out, rows.Err() +} + +func applyMigration(ctx context.Context, pool *pgxpool.Pool, m migration) error { + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) //nolint:errcheck // откат не нужен после Commit + + // Без аргументов pgx уходит в simple protocol — разрешает несколько + // statement'ов в одной строке, что удобно для DDL-файлов. + if _, err := tx.Exec(ctx, m.SQL); err != nil { + return err + } + if _, err := tx.Exec(ctx, + `INSERT INTO schema_migrations (version, name, checksum) VALUES ($1, $2, $3)`, + m.Version, m.Name, m.Checksum, + ); err != nil { + return err + } + return tx.Commit(ctx) +} + +func loadMigrations() ([]migration, error) { + entries, err := fs.ReadDir(migrationsFS, migrationsDir) + if err != nil { + return nil, fmt.Errorf("read migrations: %w", err) + } + + var out []migration + seen := map[int]string{} + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + match := migrationFileRe.FindStringSubmatch(entry.Name()) + if match == nil { + return nil, fmt.Errorf("migration %q: filename must be _.sql", entry.Name()) + } + + version, err := strconv.Atoi(match[1]) + if err != nil { + return nil, fmt.Errorf("migration %q: bad version: %w", entry.Name(), err) + } + if prev, ok := seen[version]; ok { + return nil, fmt.Errorf("migration version %d used twice: %s and %s", version, prev, entry.Name()) + } + seen[version] = entry.Name() + + body, err := fs.ReadFile(migrationsFS, path.Join(migrationsDir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("migration %q: %w", entry.Name(), err) + } + sum := sha256.Sum256(body) + + out = append(out, migration{ + Version: version, + Name: match[2], + Checksum: hex.EncodeToString(sum[:]), + SQL: string(body), + }) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version }) + return out, nil +} diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..f9ff5c0 --- /dev/null +++ b/internal/db/migrations/0001_init.sql @@ -0,0 +1,129 @@ +-- 0001_init — начальная схема. +-- +-- Файл рассчитан на два случая: +-- * свежая БД — всё создаётся с нуля; +-- * БД, где таблицы были созданы вручную до появления миграций — +-- CREATE ... IF NOT EXISTS ничего не ломает, а блоки ALTER/DROP/ADD +-- приводят ограничения к эталону (атомарно, в одной транзакции). +-- +-- После применения файл редактировать нельзя: раннер хранит checksum +-- и упадёт на старте. Любое изменение — это новая миграция 0002_*.sql. + +-- ============ profiles ============ + +CREATE TABLE IF NOT EXISTS profiles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, + password_hash text NOT NULL, + name text NOT NULL, + phone text, + role text NOT NULL DEFAULT 'client', + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT profiles_role_check CHECK (role IN ('client', 'worker', 'admin')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS profiles_email_key ON profiles (email); +CREATE INDEX IF NOT EXISTS profiles_role_idx ON profiles (role) WHERE is_active; + +ALTER TABLE profiles DROP CONSTRAINT IF EXISTS profiles_role_check; +ALTER TABLE profiles ADD CONSTRAINT profiles_role_check + CHECK (role IN ('client', 'worker', 'admin')); + +-- ============ tickets ============ + +CREATE TABLE IF NOT EXISTS tickets ( + id bigserial PRIMARY KEY, + client_id uuid NOT NULL REFERENCES profiles (id), + worker_id uuid REFERENCES profiles (id), + assigned_by uuid REFERENCES profiles (id), + address text NOT NULL, + floor text, + category text NOT NULL, + description text NOT NULL, + priority text NOT NULL DEFAULT 'normal', + status text NOT NULL DEFAULT 'new', + created_at timestamptz NOT NULL DEFAULT now(), + assigned_at timestamptz, + started_at timestamptz, + done_at timestamptz, + confirmed_at timestamptz, + closed_at timestamptz, + + CONSTRAINT tickets_category_check + CHECK (category IN ('electric', 'plumbing', 'climate', 'elevator', 'other')), + CONSTRAINT tickets_priority_check + CHECK (priority IN ('low', 'normal', 'high')), + -- 'rejected' оставлен из исходной ручной схемы, 'closed' добавлен, + -- чтобы поле closed_at имело свой статус. + CONSTRAINT tickets_status_check + CHECK (status IN ('new', 'assigned', 'in_progress', 'done', + 'confirmed', 'rejected', 'closed')), + + -- Ядро модели: свободная заявка ⇔ статус 'new'. + -- Назначение мастера всегда меняет worker_id и status одной операцией, + -- поэтому пул свободных заявок не «протекает» в другие статусы. + CONSTRAINT tickets_free_iff_new + CHECK ((worker_id IS NULL) = (status = 'new')), + + -- Назначение фиксируется временем — иначе UI не покажет «когда назначили». + CONSTRAINT tickets_assigned_time + CHECK (worker_id IS NULL OR assigned_at IS NOT NULL), + + -- Статусы, которых достигли, обязаны иметь свой таймстамп. + CONSTRAINT tickets_started_time + CHECK (status <> 'in_progress' OR started_at IS NOT NULL), + CONSTRAINT tickets_done_time + CHECK (status <> 'done' OR done_at IS NOT NULL), + CONSTRAINT tickets_confirmed_time + CHECK (status <> 'confirmed' OR confirmed_at IS NOT NULL), + CONSTRAINT tickets_closed_time + CHECK (status <> 'closed' OR closed_at IS NOT NULL) +); + +-- Приводим ограничения к эталону, если таблица существовала до миграций. +-- DROP + ADD вместо IF NOT EXISTS: у ALTER TABLE ADD CONSTRAINT его нет. +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_category_check; +ALTER TABLE tickets ADD CONSTRAINT tickets_category_check + CHECK (category IN ('electric', 'plumbing', 'climate', 'elevator', 'other')); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_priority_check; +ALTER TABLE tickets ADD CONSTRAINT tickets_priority_check + CHECK (priority IN ('low', 'normal', 'high')); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_status_check; +ALTER TABLE tickets ADD CONSTRAINT tickets_status_check + CHECK (status IN ('new', 'assigned', 'in_progress', 'done', + 'confirmed', 'rejected', 'closed')); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_free_iff_new; +ALTER TABLE tickets ADD CONSTRAINT tickets_free_iff_new + CHECK ((worker_id IS NULL) = (status = 'new')); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_assigned_time; +ALTER TABLE tickets ADD CONSTRAINT tickets_assigned_time + CHECK (worker_id IS NULL OR assigned_at IS NOT NULL); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_started_time; +ALTER TABLE tickets ADD CONSTRAINT tickets_started_time + CHECK (status <> 'in_progress' OR started_at IS NOT NULL); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_done_time; +ALTER TABLE tickets ADD CONSTRAINT tickets_done_time + CHECK (status <> 'done' OR done_at IS NOT NULL); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_confirmed_time; +ALTER TABLE tickets ADD CONSTRAINT tickets_confirmed_time + CHECK (status <> 'confirmed' OR confirmed_at IS NOT NULL); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_closed_time; +ALTER TABLE tickets ADD CONSTRAINT tickets_closed_time + CHECK (status <> 'closed' OR closed_at IS NOT NULL); + +-- Ленты: клиент — свои, мастер — назначенные ему, админ/дашборд — по статусу. +-- Имена совпадают с уже существующими в ручной схеме, чтобы не плодить дубли. +CREATE INDEX IF NOT EXISTS idx_tickets_client ON tickets (client_id); +CREATE INDEX IF NOT EXISTS idx_tickets_worker ON tickets (worker_id); +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status); +CREATE INDEX IF NOT EXISTS idx_tickets_created_at ON tickets (created_at DESC); diff --git a/internal/db/migrations/0002_ticket_resolution.sql b/internal/db/migrations/0002_ticket_resolution.sql new file mode 100644 index 0000000..07b5d53 --- /dev/null +++ b/internal/db/migrations/0002_ticket_resolution.sql @@ -0,0 +1,44 @@ +-- 0002_ticket_resolution — закрытие цикла заявки. +-- +-- Семантика (см. README): +-- * confirm (клиент, из done) -> closed + confirmed_at — работа принята; +-- * reject (клиент, из done) -> rejected — НЕ финал, нужна доработка; +-- * cancel (клиент/админ) -> closed + cancel_reason — окончательный отказ/отмена. +-- Финал у заявки всегда один — статус 'closed'; исход различают +-- confirmed_at (принято клиентом) и cancel_reason (отменено/отклонено). +-- +-- Как и в 0001, применённый файл редактировать нельзя: раннер хранит checksum. + +-- ============ новые колонки ============ + +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS reject_reason text; +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS cancel_reason text; +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS rejected_at timestamptz; +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS closed_by uuid REFERENCES profiles (id); +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS rework_count integer NOT NULL DEFAULT 0; + +-- ============ ограничения ============ + +-- Было: (worker_id IS NULL) = (status = 'new'). +-- Отмена заявки в статусе 'new' даёт 'closed' при worker_id = NULL, +-- поэтому «свободная ⇔ new» перестаёт работать. Новое правило: +-- мастер обязателен во всех статусах, кроме 'new' и закрытых. +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_free_iff_new; +ALTER TABLE tickets ADD CONSTRAINT tickets_needs_worker + CHECK (status IN ('new', 'closed') OR worker_id IS NOT NULL); + +-- Достигнутый статус обязан иметь свой таймстамп. +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_rejected_time; +ALTER TABLE tickets ADD CONSTRAINT tickets_rejected_time + CHECK (status <> 'rejected' OR rejected_at IS NOT NULL); + +-- Отклонение возможно только из 'done' и всегда с причиной: +-- мастер должен понимать, что переделывать. +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_rejected_reason; +ALTER TABLE tickets ADD CONSTRAINT tickets_rejected_reason + CHECK (status <> 'rejected' OR (reject_reason IS NOT NULL AND done_at IS NOT NULL)); + +-- Закрытая заявка либо принята клиентом, либо отменена с указанием причины. +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_closed_reason; +ALTER TABLE tickets ADD CONSTRAINT tickets_closed_reason + CHECK (status <> 'closed' OR confirmed_at IS NOT NULL OR cancel_reason IS NOT NULL); diff --git a/internal/db/migrations/0003_hardening.sql b/internal/db/migrations/0003_hardening.sql new file mode 100644 index 0000000..d7fa641 --- /dev/null +++ b/internal/db/migrations/0003_hardening.sql @@ -0,0 +1,30 @@ +-- 0003_hardening — надёжность и безопасность. +-- +-- 1) email сравнивается и хранится без учёта регистра: раньше +-- Admin@x.ru и admin@x.ru считались разными пользователями. +-- Перед созданием индекса дубликатов быть не должно +-- (проверено: 0), иначе миграция откатится транзакцией. +-- 2) refresh-токены: в БД хранится только sha256-хеш, сам секрет +-- известен клиенту один раз. Выдача — с ротацией при каждом обновлении. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +CREATE UNIQUE INDEX IF NOT EXISTS profiles_email_lower_key ON profiles (lower(email)); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES profiles (id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + user_agent text, + ip text +); + +-- Сессии пользователя (для logout-all и отзыва при деактивации). +CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens (user_id); + +-- Активные токены — для очистки протухших. +CREATE INDEX IF NOT EXISTS refresh_tokens_live_idx + ON refresh_tokens (expires_at) WHERE revoked_at IS NULL; diff --git a/internal/db/migrations/0004_projects.sql b/internal/db/migrations/0004_projects.sql new file mode 100644 index 0000000..213a538 --- /dev/null +++ b/internal/db/migrations/0004_projects.sql @@ -0,0 +1,104 @@ +-- 0004_projects — проекты, объекты и мастера проекта. +-- +-- Иерархия (см. README): +-- админ создаёт клиента +-- └── клиент создаёт проект +-- ├── объекты (здания, адреса) — входят в проект +-- └── мастера — добавлены в проект (мастер может быть в нескольких) +-- └── заявка привязана к объекту и достаётся мастеру проекта +-- +-- Принцип прежний: принадлежность проверяется на уровне БД, а не только +-- в коде. Составные FK: +-- tickets (object_id, project_id) → project_objects (id, project_id) +-- «объект входит в этот проект»; +-- tickets (project_id, client_id) → projects (id, client_id) +-- «проект принадлежит клиенту заявки». +-- Членство мастера в проекте — не FK, а проверка внутри UPDATE назначения: +-- FK отдалял бы уход мастера из проекта навсегда (ссылки остаются от +-- закрытых заявок), а «назначить чужого» не даёт ту же строку WHERE. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +-- Существующие заявки сделаны до появления объектов и не имеют object_id. +-- В базе они тестовые (решение принято), удаляем — иначе NOT NULL не ввести. +DELETE FROM tickets; + +-- ============ projects ============ + +CREATE TABLE IF NOT EXISTS projects ( + id bigserial PRIMARY KEY, + client_id uuid NOT NULL REFERENCES profiles (id), + name text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Один клиент не держит два проекта с одинаковым именем. +ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_client_name_key; +ALTER TABLE projects ADD CONSTRAINT projects_client_name_key UNIQUE (client_id, name); + +-- Цель для составного FK из tickets (project_id, client_id). +ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_id_client_key; +ALTER TABLE projects ADD CONSTRAINT projects_id_client_key UNIQUE (id, client_id); + +CREATE INDEX IF NOT EXISTS projects_client_idx ON projects (client_id); + +-- ============ project_objects ============ + +CREATE TABLE IF NOT EXISTS project_objects ( + id bigserial PRIMARY KEY, + project_id bigint NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + name text NOT NULL, + address text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE project_objects DROP CONSTRAINT IF EXISTS project_objects_project_name_key; +ALTER TABLE project_objects ADD CONSTRAINT project_objects_project_name_key UNIQUE (project_id, name); + +-- Цель для составного FK из tickets (object_id, project_id). +ALTER TABLE project_objects DROP CONSTRAINT IF EXISTS project_objects_id_project_key; +ALTER TABLE project_objects ADD CONSTRAINT project_objects_id_project_key UNIQUE (id, project_id); + +CREATE INDEX IF NOT EXISTS project_objects_project_idx ON project_objects (project_id); + +-- ============ project_workers ============ + +-- Мастер ↔ проект: многие-ко-многим (мастер работает на разных объектах +-- и в разных проектах одного клиента). +CREATE TABLE IF NOT EXISTS project_workers ( + worker_id uuid NOT NULL REFERENCES profiles (id), + project_id bigint NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + added_by uuid REFERENCES profiles (id), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (worker_id, project_id) +); + +CREATE INDEX IF NOT EXISTS project_workers_project_idx ON project_workers (project_id); + +-- ============ tickets ============ + +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS project_id bigint; +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS object_id bigint; + +-- Заявка обязана жить в объекте: адрес копируется из объекта на момент +-- создания (история работ показывает адрес, который был тогда). +-- Таблица к этому моменту пуста — SET NOT NULL безопасен. +ALTER TABLE tickets ALTER COLUMN project_id SET NOT NULL; +ALTER TABLE tickets ALTER COLUMN object_id SET NOT NULL; + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_object_project_fk; +ALTER TABLE tickets ADD CONSTRAINT tickets_object_project_fk + FOREIGN KEY (object_id, project_id) + REFERENCES project_objects (id, project_id); + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_project_client_fk; +ALTER TABLE tickets ADD CONSTRAINT tickets_project_client_fk + FOREIGN KEY (project_id, client_id) + REFERENCES projects (id, client_id); + +-- Членство мастера: проверяется в UPDATE назначения +-- (repository.TicketRepo.Assign), см. комментарий в шапке файла. +CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets (project_id); +CREATE INDEX IF NOT EXISTS idx_tickets_object ON tickets (object_id); diff --git a/internal/db/migrations/0005_project_invitations.sql b/internal/db/migrations/0005_project_invitations.sql new file mode 100644 index 0000000..94b22d0 --- /dev/null +++ b/internal/db/migrations/0005_project_invitations.sql @@ -0,0 +1,60 @@ +-- 0005_project_invitations — приглашения мастеров в проект. +-- +-- Раньше POST /projects/:id/workers сразу давало членство. Теперь это +-- ровно два шага и без обходных путей: +-- 1) клиент/админ отправляет приглашение — строка получает 'invited' +-- и срок жизни expires_at (7 дней); +-- 2) мастер сам решает: accept → 'active' (и только тогда получает +-- заявки проекта) или decline → 'declined' (повторное приглашение +-- перезапускает ту же строку). +-- Принудительного членства (force) нет: активным мастер становится +-- только своим решением. +-- +-- Статусы строки project_workers: +-- invited — ждём решения мастера (expires_at обязателен, см. CHECK); +-- active — мастер принял: назначение заявок разрешено; +-- declined — мастер отказал; строка остаётся как история отказа +-- и допускает повторное приглашение. +-- +-- Строки, созданные до этой миграции, получают DEFAULT 'active' — +-- членство существующих мастеров не ломается. +-- +-- Принцип прежний: переходы проверяются в самом SQL: +-- * назначение заявки требует членства status='active' — приглашённый +-- не получает заявки, пока не принял (см. Assign в repository/ticket.go); +-- * accept/decline двигают только строку в статусе 'invited'; +-- * уход мастера (DELETE) снимает строку любого статуса — так же +-- отзывается и не принятое приглашение. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +ALTER TABLE project_workers + ADD COLUMN status text NOT NULL DEFAULT 'active', + ADD COLUMN invited_by uuid REFERENCES profiles(id) ON DELETE SET NULL, + ADD COLUMN invited_at timestamptz, + ADD COLUMN responded_at timestamptz, + ADD COLUMN expires_at timestamptz; + +ALTER TABLE project_workers + ADD CONSTRAINT project_workers_status_check + CHECK (status IN ('invited', 'active', 'declined')); + +-- У приглашения всегда есть пригласивший и срок; у обычного членства +-- (включая строки до миграции) этих полей может не быть. +ALTER TABLE project_workers + ADD CONSTRAINT project_workers_invited_shape + CHECK (status <> 'invited' + OR (invited_at IS NOT NULL AND invited_by IS NOT NULL + AND expires_at IS NOT NULL AND responded_at IS NULL)); + +-- Принятое/отклонённое приглашение фиксирует момент ответа. +ALTER TABLE project_workers + ADD CONSTRAINT project_workers_responded_shape + CHECK (status = 'invited' OR responded_at IS NOT NULL + OR invited_at IS NULL); -- строки до миграции без ответа + +-- Списки: мастера проекта (по статусу) и приглашения мастера (worker_id + срок). +CREATE INDEX IF NOT EXISTS idx_project_workers_project_status + ON project_workers (project_id, status); +CREATE INDEX IF NOT EXISTS idx_project_workers_worker_status + ON project_workers (worker_id, status, expires_at); diff --git a/internal/db/migrations/0006_ticket_events.sql b/internal/db/migrations/0006_ticket_events.sql new file mode 100644 index 0000000..ea88203 --- /dev/null +++ b/internal/db/migrations/0006_ticket_events.sql @@ -0,0 +1,55 @@ +-- 0006_ticket_events — журнал событий заявки. +-- +-- Каждый переход статусной машины оставляет запись: кто сделал, что было, +-- что стало, причина (для rejected/canceled) и когда. Запись создаётся +-- **в той же SQL-операции**, что и сам переход (CTE: UPDATE … RETURNING +-- → INSERT в журнал), поэтому «заявка перешла, а истории нет» невозможно: +-- либо обе части, либо ни одной. +-- +-- from_status NULL — только у события «создана»: заявка появилась +-- из ничего. to_status всегда заполнен. actor_id может стать NULL +-- при удалении аккаунта — история не пропадает, остаётся «кто-то». +-- +-- Действия: created, assigned, started, done, confirmed, rejected, canceled. +-- Названия действий отделены от статусов: одинаковый to_status='closed' +-- дают и принятие клиента (confirmed), и отмена (canceled) — журналу +-- важно именно «что сделали», а не только «куда ушло». +-- +-- Старые заявки (до этой миграции) журнала не имеют — история начинается +-- с момента наката. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +CREATE TABLE ticket_events ( + id bigserial PRIMARY KEY, + ticket_id bigint NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + -- NULL — аккаунт автора удалён (ON DELETE SET NULL) + actor_id uuid REFERENCES profiles(id) ON DELETE SET NULL, + action text NOT NULL, + from_status text, + to_status text NOT NULL, + -- причина отклонения/отмены, для остальных действий пусто + comment text, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT ticket_events_action_check + CHECK (action IN ('created', 'assigned', 'started', 'done', + 'confirmed', 'rejected', 'canceled')), + CONSTRAINT ticket_events_from_status_check + CHECK (from_status IS NULL OR from_status IN + ('new', 'assigned', 'in_progress', 'done', 'confirmed', + 'rejected', 'closed')), + CONSTRAINT ticket_events_to_status_check + CHECK (to_status IN + ('new', 'assigned', 'in_progress', 'done', 'confirmed', + 'rejected', 'closed')), + -- причина нужна ровно там, где её требует машина статусов + CONSTRAINT ticket_events_comment_shape + CHECK (action NOT IN ('rejected', 'canceled') + OR comment IS NOT NULL) +); + +-- Журнал читается по заявке в хронологии: (ticket_id, id) покрывает +-- и выборку страницы, и сортировку по умолчанию. +CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket + ON ticket_events (ticket_id, id); diff --git a/internal/db/migrations/0007_password_reset.sql b/internal/db/migrations/0007_password_reset.sql new file mode 100644 index 0000000..e48aba7 --- /dev/null +++ b/internal/db/migrations/0007_password_reset.sql @@ -0,0 +1,42 @@ +-- 0007_password_reset — сброс пароля по одноразовому токену. +-- +-- SMTP в проекте нет по решению: токен выдаёт админ +-- (POST /users/:id/password-reset) и передаёт его пользователю сам — +-- любым каналом, кроме почты внутри приложения. Пользователь меняет +-- пароль через POST /auth/reset-password. +-- +-- В таблице лежит ТОЛЬКО sha256 токена: утекшая база не даёт +-- переиспользовать выпущенные токены. Свойство «увидел — значит чужой»: +-- зная хеш, токен не восстановить (256 бит поискового пространства). +-- +-- Правила, проверяемые в SQL, а не в хендлерах: +-- * токен одноразовый: WHERE used_at IS NULL в самом UPDATE; +-- * срок: WHERE expires_at > now(), срок всегда позже выпуска (CHECK); +-- * у пользователя действует ровно один свежий токен — выпуск нового +-- гасит предыдущие (WITH в CreateResetToken); +-- * вместе с паролем закрываются refresh-сессии (в коде, сразу после +-- успешного UPDATE — смена пароля без разлогина бессмысленна). +-- +-- Строки живут после использования (история), чистка — по желанию: +-- просроченные токены бесполезны, таблица растёт медленно. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +CREATE TABLE password_reset_tokens ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + -- sha256 токена в hex; сам токен в базе не хранится + token_hash text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + -- NULL — ещё не использован; момент смены пароля + used_at timestamptz, + + CONSTRAINT password_reset_tokens_expiry + CHECK (expires_at > created_at) +); + +-- Выпуск нового токена гасит старые конкретного пользователя, +-- поэтому выборка «незакрытые токены пользователя» должна быть быстрой. +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user + ON password_reset_tokens (user_id, created_at); diff --git a/internal/db/migrations/0008_ticket_photos.sql b/internal/db/migrations/0008_ticket_photos.sql new file mode 100644 index 0000000..f8aaba9 --- /dev/null +++ b/internal/db/migrations/0008_ticket_photos.sql @@ -0,0 +1,65 @@ +-- 0008_ticket_photos — фотоотчёт мастера: файлы в S3, метаданные в БД. +-- +-- Мастер прикрепляет фото к заявке в работе (in_progress/done), клиент и +-- админ их смотрят. Сами байты живут в объектном хранилище (S3-совместимом: +-- локально MinIO в compose, в проде — любой S3): в таблице — только то, +-- без чего нельзя проверить права и показать список. +-- +-- Правила, проверяемые в SQL, а не в хендлерах (INSERT/DELETE — CTE, +-- см. photo.go): +-- * загрузка только в статусах in_progress/done; +-- * загрузивший — админ или мастер со статусом active в проекте +-- заявки (тот же приём, что Assign в 0004/0005); +-- * лимит файлов на заявку — счётчик в той же CTE, гонку ловит SQL; +-- * удаление — своё фото и только пока заявка не закрыта, либо любое +-- у админа; событие в журнале пишется той же операцией (как переходы +-- статусов в 0006: либо обе части, либо ни одной). +-- +-- object_key генерирует сервер (uuid), клиент ключа не задаёт: лишняя +-- точка взлома и путаницы с чужими ключами. UNIQUE — дубликат ключа +-- невозможен даже при гонке. +-- +-- uploaded_by NULL возможен: аккаунт автора удалён (SET NULL, как +-- actor_id в 0006) — фото и журнал остаются. +-- +-- Журнал: action расширяется двумя значениями photo_added/photo_removed +-- (CHECK в той же миграции — расширение списка, не ослабление условий). +-- Для этих событий from_status = to_status = текущий статус: статусная +-- машина не двигалась, журнал лишь отмечает факт загрузки. В comment — +-- исходное имя файла. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +CREATE TABLE ticket_photos ( + id bigserial PRIMARY KEY, + ticket_id bigint NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + -- NULL — аккаунт загрузившего удалён (ON DELETE SET NULL) + uploaded_by uuid REFERENCES profiles(id) ON DELETE SET NULL, + -- ключ объекта в хранилище, генерируется сервером + object_key text NOT NULL UNIQUE, + content_type text NOT NULL, + size_bytes bigint NOT NULL CHECK (size_bytes > 0), + -- имя файла у пользователя (для журнала и UI), может быть пустым + original_name text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + + -- белый список форматов держится и здесь: даже обход валидации + -- в хендлере не запишет в БД ничего кроме картинки + CONSTRAINT ticket_photos_content_type_check + CHECK (content_type IN ('image/jpeg', 'image/png', 'image/webp')), + -- осмысленный потолок: мусор из 0 (теоретически возможен только + -- обходом, но пусть и это проверит БД) + CONSTRAINT ticket_photos_size_check + CHECK (size_bytes <= 268435456) +); + +-- Список фото заявки: хронология по id. +CREATE INDEX IF NOT EXISTS idx_ticket_photos_ticket + ON ticket_photos (ticket_id, id); + +-- Журнал: два новых действия (расширение CHECK из 0006). +ALTER TABLE ticket_events DROP CONSTRAINT ticket_events_action_check; +ALTER TABLE ticket_events ADD CONSTRAINT ticket_events_action_check + CHECK (action IN ('created', 'assigned', 'started', 'done', + 'confirmed', 'rejected', 'canceled', + 'photo_added', 'photo_removed')); diff --git a/internal/db/migrations/0009_rate_limits.sql b/internal/db/migrations/0009_rate_limits.sql new file mode 100644 index 0000000..cb33e8f --- /dev/null +++ b/internal/db/migrations/0009_rate_limits.sql @@ -0,0 +1,32 @@ +-- 0009_rate_limits — счётчики брутфорс-лимитов в БД вместо памяти. +-- +-- Раньше лимитер был in-memory (internal/ratelimit): перезапуск API +-- обнулял все счётчики, а при нескольких инстансах каждый считал своё. +-- Таблица решает оба: строка переживает рестарт и общая для всех +-- инстансов, подключённых к базе. +-- +-- Семантика — то же фиксированное окно, что и раньше: строка с ключом +-- живёт window_start..window_start+window, внутри — счётчик попыток; +-- инкремент и сброс окна делаются одним upsert'ом (см. ratelimit.DBLimiter). +-- +-- Ключ — «имя лимита|IP» (login|1.2.3.4): четыре эндпоинта с разными +-- потолками живут в одной таблице, IP не обязан быть глобальным ключом. +-- +-- Чистка протухших строк — фоновым тикером в cmd/api (RunCleanup): +-- все окна — минутные, поэтому строки старше 10 минут бесполезны. +-- Без чистки таблица росла бы со скоростью уникальных IP. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +CREATE TABLE rate_limits ( + -- «имя лимита|IP»; primary key — и есть точка апсерта + key text PRIMARY KEY, + -- начало текущего окна; старше окна — счётчик начинается заново + window_start timestamptz NOT NULL DEFAULT now(), + -- попыток в текущем окне + count integer NOT NULL DEFAULT 0, + CHECK (count >= 0) +); + +-- Чистка по времени: DELETE по window_start идёт по индексу. +CREATE INDEX rate_limits_window_start_idx ON rate_limits (window_start); diff --git a/internal/db/migrations/0010_user_confirmation.sql b/internal/db/migrations/0010_user_confirmation.sql new file mode 100644 index 0000000..ba85ac0 --- /dev/null +++ b/internal/db/migrations/0010_user_confirmation.sql @@ -0,0 +1,31 @@ +-- 0010_user_confirmation — подтверждение аккаунта после регистрации. +-- +-- Саморегистрация (POST /auth/register) создаёт неподтверждённого +-- клиента: войти нельзя (Login → 403 account not confirmed), пока +-- подтверждение не выставит админ (POST /users/:id/confirm). +-- До этой миграции SMTP и подтверждения не было вовсе — поэтому +-- бэкфилл помечает существующих пользователей подтверждёнными. +-- +-- Правила, проверяемые в SQL, а не в хендлерах: +-- * подтверждает админ — любой неподтверждённый аккаунт; клиент — +-- только созданного им мастера (ConfirmUser: обе ветки в WHERE, +-- чужой мастер и несуществующий id неразличимы — 404); +-- * повторное подтверждение отсекает WHERE confirmed_at IS NULL +-- (иначе 409 already confirmed); +-- * создание админом = подтверждение сразу («создатель и есть +-- подтверждающий») — в том же INSERT, что и создание строки; +-- * созданные админом аккаунты работают без дополнительных кликов. +-- +-- confirmed_by/created_by — ON DELETE SET NULL: удалённый создатель +-- не должен ломать каскадное удаление профилей. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +ALTER TABLE profiles + ADD COLUMN confirmed_at timestamptz, -- NULL — ждёт подтверждения + ADD COLUMN confirmed_by uuid REFERENCES profiles(id) ON DELETE SET NULL, + ADD COLUMN created_by uuid REFERENCES profiles(id) ON DELETE SET NULL; + +-- Бэкфилл: подтверждения раньше не существовало, все существующие +-- аккаунты обязаны входить как прежде. +UPDATE profiles SET confirmed_at = now() WHERE confirmed_at IS NULL; diff --git a/internal/db/migrations/0011_telegram_notifications.sql b/internal/db/migrations/0011_telegram_notifications.sql new file mode 100644 index 0000000..bb09544 --- /dev/null +++ b/internal/db/migrations/0011_telegram_notifications.sql @@ -0,0 +1,48 @@ +-- 0011_telegram_notifications — уведомления в Telegram о заявках, +-- которые простояли в статусе 'new' дольше 24 часов. +-- +-- Куда слать — profiles.telegram_target, одно поле и два способа его +-- заполнить: +-- * числовой chat_id — записывает long-polling поллер, когда человек +-- написал боту /start <код> из раздела «Настройки» (личная привязка); +-- * @группа или @канал — вводит сам пользователь (бот обязан быть +-- добавлен в этот чат; личный @ник боту недоступен, пока человек +-- сам боту не напишет — поэтому личная привязка и через код). +-- Пусто/NULL — уведомления этому пользователю не приходят. +-- +-- Дедуп — tickets.tg_notified_at: когда уходило последнее сообщение. +-- Повторять можно, если поле NULL или старше порога (24 ч): напоминание +-- уходит раз в сутки, пока заявка остаётся 'new'. Как только её взяли — +-- условие отваливается само; поле не чистим, чтобы после возврата в +-- 'new' отсчёт начался заново. +-- +-- Коды привязки — одноразовые и короткоживущие (15 минут). В таблице +-- лежит только sha256 кода, как и токены сброса пароля (0007): утекшая +-- база не даёт привязать чужой чат — по хешу код не восстановить. +-- Выпуск нового кода гасит прежние неиспользованные (см. CreateBindCode). +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS telegram_target text; +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS tg_notified_at timestamptz; + +CREATE TABLE IF NOT EXISTS telegram_bind_codes ( + user_id uuid NOT NULL REFERENCES profiles (id) ON DELETE CASCADE, + -- sha256 кода в hex; сам код показывается пользователю один раз + code_hash text PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + used_at timestamptz, + + CONSTRAINT telegram_bind_codes_expiry CHECK (expires_at > created_at) +); + +-- У одного пользователя обычно один живой код; гашение прежних идёт +-- по его строкам, чистка — по expires_at. +CREATE INDEX IF NOT EXISTS telegram_bind_codes_user_idx + ON telegram_bind_codes (user_id, created_at); + +-- Кандидаты на уведомление — все ещё не взятые заявки: отбор по +-- created_at внутри статуса 'new' (частичный индекс держит запрос дешёвым). +CREATE INDEX IF NOT EXISTS tickets_new_created_idx + ON tickets (created_at) WHERE status = 'new'; diff --git a/internal/db/migrations/0012_user_bot_tokens.sql b/internal/db/migrations/0012_user_bot_tokens.sql new file mode 100644 index 0000000..17b1fd9 --- /dev/null +++ b/internal/db/migrations/0012_user_bot_tokens.sql @@ -0,0 +1,25 @@ +-- 0012_user_bot_tokens — токен собственного бота у каждого пользователя. +-- +-- Зачем: раньше бот был один на весь сервис (TELEGRAM_BOT_TOKEN в env +-- сервера), и уведомления шли только от него. Теперь пользователь может +-- подключить своего бота сам, из раздела «Настройки»: создаёт его в +-- @BotFather, вставляет токен — уведомления уходят через его бота. +-- Глобальный бот остаётся запасным вариантом для тех, кто токен не ввёл. +-- +-- Хранение: токен зашифрован на стороне приложения (AES-256-GCM, ключ — +-- секрет DATA_ENCRYPTION_KEY), в базе лежит bytea. Ответ «что это за +-- бот» (telegram_bot_username) берётся из getMe при сохранении и лежит +-- открыто: имя бота секрета не представляет. +-- +-- Кто чем владеет: +-- * telegram_bot_token — приватно, читается только своим пользователем, +-- в API не отдаётся вообще (в статусе только has_bot_token); +-- * telegram_target — куда слать (та же колонка, что и раньше). +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS telegram_bot_token bytea; +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS telegram_bot_username text; + +-- Отбор пользователей со своим ботом при старте процесса (реестр ботов +-- и их поллеры): колонка трогается редко, индекс не нужен. diff --git a/internal/db/migrations/0013_project_delete_cascade.sql b/internal/db/migrations/0013_project_delete_cascade.sql new file mode 100644 index 0000000..9b9cd9a --- /dev/null +++ b/internal/db/migrations/0013_project_delete_cascade.sql @@ -0,0 +1,30 @@ +-- 0013_project_delete_cascade — удаление проекта администратором (каскадом). +-- +-- Удаление проекта — действие админа (DELETE /projects/:id, RequireRole +-- "admin"). Решение: проект удаляется вместе с заявками, а не запретом 409 +-- на непустой проект. Каскады событий/фото на tickets (0006/0008) и +-- объектов/участий на projects (0004) уже есть — здесь пересоздаются +-- только два FK из tickets, чтобы и заявки уходили вместе с проектом: +-- +-- tickets (object_id, project_id) → project_objects (id, project_id) +-- каскад нужен, чтобы объекты проекта уносили свои заявки; +-- tickets (project_id, client_id) → projects (id, client_id) +-- каскад: заявка проекта уходит вместе с проектом. +-- +-- Ограничения профилей из заявок (client_id/worker_id/assigned_by → +-- profiles) остаются NO ACTION: удаление клиента с заявками — по-прежнему +-- 409 «отключите вместо удаления». +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_object_project_fk; +ALTER TABLE tickets ADD CONSTRAINT tickets_object_project_fk + FOREIGN KEY (object_id, project_id) + REFERENCES project_objects (id, project_id) + ON DELETE CASCADE; + +ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_project_client_fk; +ALTER TABLE tickets ADD CONSTRAINT tickets_project_client_fk + FOREIGN KEY (project_id, client_id) + REFERENCES projects (id, client_id) + ON DELETE CASCADE; diff --git a/internal/db/migrations/0014_recurring_rules.sql b/internal/db/migrations/0014_recurring_rules.sql new file mode 100644 index 0000000..2656e57 --- /dev/null +++ b/internal/db/migrations/0014_recurring_rules.sql @@ -0,0 +1,81 @@ +-- 0014_recurring_rules — повторяющиеся заявки по расписанию. +-- +-- Зачем: периодические работы («осмотр вентиляции раз в 3 месяца») +-- заводятся один раз правилом, а фоновый планировщик сам создаёт +-- заявку, когда истекает интервал. Правило — шаблон будущей заявки +-- (объект, категория, описание, приоритет) плюс интервал в месяцах. +-- +-- Кто владеет: client_id — клиент-владелец (заявка создаётся от его +-- имени, как ручная); created_by — кто завёл правило (админ или сам +-- клиент). Правило живёт в проекте и объекте: каскады унесут его +-- вместе с ними (удаление проекта админом из 0013). +-- +-- Повторный запуск: правило созрело, но прошлая заявка ещё не +-- закрыта — новую не создаём (ждём закрытия, без дублей). Состояние +-- хранится в last_run_at (когда последний раз срабатывали) и в +-- recurring_runs (история: какая заявка создана по какому правилу). +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +-- ============ recurring_rules ============ + +CREATE TABLE IF NOT EXISTS recurring_rules ( + id bigserial PRIMARY KEY, + -- Владелец будущих заявок: client_id проекта продублирован, чтобы + -- заявка создавалась без JOIN и составной FK projects был соблюдён. + client_id uuid NOT NULL REFERENCES profiles (id), + project_id bigint NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + object_id bigint NOT NULL REFERENCES project_objects (id) ON DELETE CASCADE, + + -- Шаблон заявки (те же колонки, что в tickets). + category text NOT NULL, + description text NOT NULL, + priority text NOT NULL DEFAULT 'normal', + floor text, + + -- Расписание: 3 — квартальный осмотр, 12 — годовой. + interval_months int NOT NULL, + is_active boolean NOT NULL DEFAULT true, + + -- Когда последний раз срабатывали; NULL — ещё ни разу. + last_run_at timestamptz, + + created_by uuid REFERENCES profiles (id), + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT recurring_rules_category_check + CHECK (category IN ('electric', 'plumbing', 'climate', 'elevator', 'other')), + CONSTRAINT recurring_rules_priority_check + CHECK (priority IN ('low', 'normal', 'high')), + CONSTRAINT recurring_rules_interval_check + CHECK (interval_months >= 1), + + -- Правило обязано жить в объекте своего проекта (то же ядро, что + -- у заявки): составной FK держит объект и проект согласованными. + CONSTRAINT recurring_rules_object_project_fk + FOREIGN KEY (object_id, project_id) + REFERENCES project_objects (id, project_id), + CONSTRAINT recurring_rules_project_client_fk + FOREIGN KEY (project_id, client_id) + REFERENCES projects (id, client_id) +); + +-- Лента правил: клиент — свои, админ — все проекты. +CREATE INDEX IF NOT EXISTS recurring_rules_client_idx ON recurring_rules (client_id); +CREATE INDEX IF NOT EXISTS recurring_rules_project_idx ON recurring_rules (project_id); +-- Отбор созревших правил поллером. +CREATE INDEX IF NOT EXISTS recurring_rules_due_idx + ON recurring_rules (last_run_at) WHERE is_active; + +-- ============ recurring_runs ============ + +-- История срабатываний: аудит «кто и когда создал авто-заявку». +CREATE TABLE IF NOT EXISTS recurring_runs ( + id bigserial PRIMARY KEY, + rule_id bigint NOT NULL REFERENCES recurring_rules (id) ON DELETE CASCADE, + -- NULL, если созданную заявку потом удалили каскадом (проект). + ticket_id bigint REFERENCES tickets (id) ON DELETE SET NULL, + run_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS recurring_runs_rule_idx ON recurring_runs (rule_id, run_at DESC); diff --git a/internal/db/migrations/0015_recurring_annual.sql b/internal/db/migrations/0015_recurring_annual.sql new file mode 100644 index 0000000..3e3f708 --- /dev/null +++ b/internal/db/migrations/0015_recurring_annual.sql @@ -0,0 +1,35 @@ +-- 0015_recurring_annual — расписание «ежегодно по дате» вместо интервала. +-- +-- Зачем: «повторять каждые N месяцев» заменяется на конкретную дату — +-- день и месяц («15 марта»): правило срабатывает раз в год в эту дату. +-- Нагляднее для человека и попадает в «Новая заявка» чекбоксом, а не +-- отдельной секцией в проекте (см. README). +-- +-- Существующие правила переводятся на день/месяц своего создания — +-- семантика «первая годовщина после заведения» сохраняется. +-- +-- Невозможные даты прижимаются к последнему дню месяца при расчёте +-- срока (29 февраля в невисокосный → 28), здесь — только рамки поля: +-- день 1..31, месяц 1..12. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +-- ============ recurring_rules: interval_months → run_month/run_day ============ + +ALTER TABLE recurring_rules + ADD COLUMN run_month smallint NOT NULL DEFAULT 1, + ADD COLUMN run_day smallint NOT NULL DEFAULT 1; + +UPDATE recurring_rules + SET run_month = EXTRACT(MONTH FROM created_at AT TIME ZONE 'UTC')::smallint, + run_day = EXTRACT(DAY FROM created_at AT TIME ZONE 'UTC')::smallint; + +-- CHECK interval_months (interval >= 1) уходит вместе с колонкой. +ALTER TABLE recurring_rules + DROP COLUMN interval_months; + +ALTER TABLE recurring_rules + ADD CONSTRAINT recurring_rules_run_month_check + CHECK (run_month BETWEEN 1 AND 12), + ADD CONSTRAINT recurring_rules_run_day_check + CHECK (run_day BETWEEN 1 AND 31); diff --git a/internal/db/migrations/0016_recurring_period.sql b/internal/db/migrations/0016_recurring_period.sql new file mode 100644 index 0000000..1494507 --- /dev/null +++ b/internal/db/migrations/0016_recurring_period.sql @@ -0,0 +1,35 @@ +-- 0016_recurring_period — расписание «период в днях от даты-времени». +-- +-- Зачем: годовое расписание по «дню + месяцу» (0015) заменяется +-- периодом: пользователь выбирает точку отсчёта (дата-время первого +-- запуска) и период «каждые N дней/недель». Точность расписания — +-- до суток: тик планировщика раз в сутки, выбранное время — ориентир, +-- заявка появится ближайшим тиком после срока. +-- +-- Существующие правила не теряют ритм: anchor_at = +-- COALESCE(last_run_at, created_at) — та же отправная точка срока, +-- что считалась в Go, period_days = 365 — «раз в год» прежних правил. +-- +-- Ограничение period_days (1..3650 = 10 лет) — рамки поля и для API: +-- хендлеры повторяют те же границы, чтобы ошибка приходила до БД. +-- +-- Применённый файл не редактировать (checksum в schema_migrations). + +-- ============ recurring_rules: run_month/run_day → anchor_at/period_days ============ + +ALTER TABLE recurring_rules + ADD COLUMN anchor_at timestamptz, + ADD COLUMN period_days int NOT NULL DEFAULT 365; + +UPDATE recurring_rules + SET anchor_at = COALESCE(last_run_at, created_at); + +ALTER TABLE recurring_rules + ALTER COLUMN anchor_at SET NOT NULL, + ADD CONSTRAINT recurring_rules_period_days_check + CHECK (period_days BETWEEN 1 AND 3650); + +-- CHECK колонок даты (run_month/run_day) уходит вместе с ними. +ALTER TABLE recurring_rules + DROP COLUMN run_month, + DROP COLUMN run_day; diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..132a991 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,459 @@ +// Package handlers — HTTP-слой: хендлеры gin, разбор тел запросов и +// маппинг ошибок репозиториев в коды ответа. Никакой бизнес-логики — +// только проверки прав, валидация и вызов репозиториев. +package handlers + +import ( + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "building-mgmt/internal/auth" + "building-mgmt/internal/mailer" + "building-mgmt/internal/models" + "building-mgmt/internal/ratelimit" + "building-mgmt/internal/repository" +) + +// AuthHandler — эндпоинты входа, работы с сессиями и сброса пароля. +// Создания аккаунтов здесь нет: их заводит только админ (POST /users). +type AuthHandler struct { + repo *repository.ProfileRepo + rtRepo *repository.RefreshTokenRepo + jwtSecret string + // cookieSecure — ставить ли Secure-флаг на refresh-куку: браузер + // отвергает Secure-куку по HTTP, поэтому локально флаг выключен, + // в проде за TLS включается через COOKIE_SECURE=true. + cookieSecure bool + mailer mailer.Mailer + // forgotMail — лимит писем «забыли пароль» на ящик (3 за сутки), + // ключ — lower(email). Тикает ДО поиска в базе и всегда: счётчик + // одинаков для существующих и несуществующих адресов, иначе 429 + // выдавал бы, что ящик зарегистрирован. + forgotMail ratelimit.Allower +} + +// NewAuthHandler собирает хендлер: профили, refresh-сессии, секрет +// подписи, почта сброса и лимит писем на ящик. +func NewAuthHandler(repo *repository.ProfileRepo, rtRepo *repository.RefreshTokenRepo, secret string, cookieSecure bool, ml mailer.Mailer, forgotMail ratelimit.Allower) *AuthHandler { + return &AuthHandler{ + repo: repo, + rtRepo: rtRepo, + jwtSecret: secret, + cookieSecure: cookieSecure, + mailer: ml, + forgotMail: forgotMail, + } +} + +// refreshCookieName — имя куки с refresh-токеном. +const refreshCookieName = "refresh_token" + +// setRefreshCookie ставит refresh-куку: HttpOnly (JS недоступен — XSS не +// утащит сессию), SameSite=Lax (в cross-site POST кука не уходит — это и +// есть защита от CSRF на refresh/logout), Path=/auth (кука летает только +// к эндпоинтам сессии, а не к каждому запросу). +func (h *AuthHandler) setRefreshCookie(c *gin.Context, value string) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: refreshCookieName, + Value: value, + Path: "/auth", + MaxAge: int(auth.RefreshTTL.Seconds()), + HttpOnly: true, + Secure: h.cookieSecure, + SameSite: http.SameSiteLaxMode, + }) +} + +// clearRefreshCookie гасит куку (MaxAge<0 — браузер удаляет её сразу). +func (h *AuthHandler) clearRefreshCookie(c *gin.Context) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: refreshCookieName, + Value: "", + Path: "/auth", + MaxAge: -1, + HttpOnly: true, + Secure: h.cookieSecure, + SameSite: http.SameSiteLaxMode, + }) +} + +// issueTokens — выпустить пару «короткий access + ротируемый refresh». +// Единая точка выдачи: login и refresh отвечают одинаково, и ни один +// из вызовов не забудет про refresh-токен: секрет уходит только в +// httpOnly-куку, в JSON его не отдаём. +func (h *AuthHandler) issueTokens(c *gin.Context, p *models.Profile) (*TokenResponse, error) { + access, err := auth.GenerateToken(h.jwtSecret, p.ID, p.Role) + if err != nil { + return nil, err + } + secret, err := auth.NewRefreshToken() + if err != nil { + return nil, err + } + err = h.rtRepo.Create( + c.Request.Context(), + uuid.NewString(), + p.ID, + auth.HashToken(secret), + time.Now().Add(auth.RefreshTTL), + c.Request.UserAgent(), + c.ClientIP(), + ) + if err != nil { + return nil, err + } + h.setRefreshCookie(c, secret) + return &TokenResponse{ + Token: access, + ExpiresIn: int(auth.AccessTTL.Seconds()), + User: p, + }, nil +} + +type loginReq struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// Login — POST /auth/login. +// +// @Summary Вход +// @Description Возвращает access-токен; refresh-токен — только в httpOnly-куке (Path=/auth, SameSite=Lax, Secure — через COOKIE_SECURE), в JSON его нет. Email ищется без учёта регистра, пароль — bcrypt. Неподтверждённый аккаунт — 403 account not confirmed (отличайте от account disabled). Лимит: 10 попыток в минуту с одного IP. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body loginReq true "Учётные данные" +// @Success 200 {object} handlers.TokenResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса" +// @Failure 401 {object} handlers.ErrorResponse "неверный логин или пароль" +// @Failure 403 {object} handlers.ErrorResponse "аккаунт отключён либо не подтверждён" +// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" +// @Router /auth/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req loginReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + profile, err := h.repo.GetByEmail(c.Request.Context(), req.Email) + if errors.Is(err, repository.ErrNotFound) { + // Не подсказываем, какой именно логин неверен. + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if err != nil { + respondError(c, err) + return + } + + if !auth.CheckPassword(profile.PasswordHash, req.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + + if !profile.IsActive { + c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"}) + return + } + + // Отдельно от «disabled»: неподтверждённый — не отключён, ему + // просто ждём подтверждения от админа (POST /users/:id/confirm). + if profile.ConfirmedAt == nil { + c.JSON(http.StatusForbidden, gin.H{"error": "account not confirmed"}) + return + } + + resp, err := h.issueTokens(c, profile) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, resp) +} + +// Refresh — POST /auth/refresh. Обновить пару токенов. +// Тело не нужно: refresh-токен приходит в httpOnly-куке, браузер +// отправляет её сам (same-origin). Каждый вызов даёт новую пару, +// старая сессия гасится (ротация): украденный refresh-токен живёт +// не дольше одного использования. +// Если отозванный токен предъявляют повторно — это признак того, что +// секретом пользуется не тот клиент, поэтому гасим все сессии пользователя. +// @Summary Обновить пару токенов +// @Description Refresh-токен читается из httpOnly-куки refresh_token (Path=/auth), тело запроса не требуется. Ротация: на выходе новый access-токен и новая кука, старая сессия гасится. Повторное использование отозванного токена закрывает все сессии пользователя — признак того, что секретом владеет кто-то второй. +// @Tags auth +// @Produce json +// @Success 200 {object} handlers.TokenResponse +// @Failure 401 {object} handlers.ErrorResponse "куки нет, токен неизвестен, отозван или истёк" +// @Failure 403 {object} handlers.ErrorResponse "аккаунт отключён" +// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" +// @Router /auth/refresh [post] +func (h *AuthHandler) Refresh(c *gin.Context) { + secret, err := c.Cookie(refreshCookieName) + if err != nil || secret == "" { + // Нет куки — неотличимо от неизвестного токена: лишних + // подробностей перебору не даём. + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + + ctx := c.Request.Context() + hash := auth.HashToken(secret) + + rt, err := h.rtRepo.GetByHash(ctx, hash) + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + if err != nil { + respondError(c, err) + return + } + + if rt.RevokedAt != nil { + // Повторное использование отозванного токена — признак того, что + // секретом владеет кто-то второй. Гасим все сессии пользователя; + // клиенту всё равно «invalid refresh token», деталь — в лог. + if n, err := h.rtRepo.RevokeAllForUser(ctx, rt.UserID); err == nil && n > 0 { + slog.Warn("security: refresh token reuse detected", + "user_id", rt.UserID, "sessions_revoked", n) + } + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + if time.Now().After(rt.ExpiresAt) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"}) + return + } + + profile, err := h.repo.GetByID(ctx, rt.UserID) + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + if err != nil { + respondError(c, err) + return + } + if !profile.IsActive { + // Деактивированный не получает новую сессию даже с валидным токеном. + c.JSON(http.StatusForbidden, gin.H{"error": "account disabled"}) + return + } + + resp, err := h.issueTokens(c, profile) + if err != nil { + respondError(c, err) + return + } + + // Ротация: новая сессия уже создана, старую гасим. Если гас не вышел — + // это не повод отдавать 500: клиент получил рабочую пару, а обе + // валидные сессии — менее страшно, чем отдать 500 на удачном обновлении. + if _, err := h.rtRepo.RevokeByHash(ctx, hash); err != nil { + slog.Warn("refresh rotation: failed to revoke old session", "err", err) + } + + c.JSON(http.StatusOK, resp) +} + +// Logout — POST /auth/logout. Гасит refresh-сессию из httpOnly-куки +// и саму куку очищает. Auth-middleware намеренно не используется: +// выйти должен пользователь и с протухшим access-токеном. Ответ неотличим +// для отсутствующего токена — чужой не должен узнавать, был ли токен в базе. +// @Summary Выйти (одно устройство) +// @Description Гасит refresh-сессию из куки refresh_token и очищает куку. Тело запроса не нужно. Auth-middleware намеренно не используется: выйти должен пользователь и с протухшим access-токеном. +// @Tags auth +// @Produce json +// @Success 200 {object} handlers.OKResponse +// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" +// @Router /auth/logout [post] +func (h *AuthHandler) Logout(c *gin.Context) { + h.clearRefreshCookie(c) + + secret, err := c.Cookie(refreshCookieName) + if err != nil || secret == "" { + // Куки нет — и гасить нечего: выход идемпотентен. + c.JSON(http.StatusOK, &OKResponse{OK: true}) + return + } + if _, err := h.rtRepo.RevokeByHash(c.Request.Context(), auth.HashToken(secret)); err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, &OKResponse{OK: true}) +} + +// LogoutAll — POST /auth/logout-all. Закрыть все сессии текущего +// пользователя («выйти со всех устройств»). Требует валидного access-токена. +// @Summary Выйти со всех устройств +// @Description Закрывает все сессии текущего пользователя. +// @Tags auth +// @Produce json +// @Security Bearer +// @Success 200 {object} handlers.OKResponse +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Router /auth/logout-all [post] +func (h *AuthHandler) LogoutAll(c *gin.Context) { + n, err := h.rtRepo.RevokeAllForUser(c.Request.Context(), c.GetString("user_id")) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, &OKResponse{OK: true, Revoked: n}) +} + +// Me — GET /auth/me. Профиль текущего авторизованного пользователя. +// +// @Summary Текущий пользователь +// @Tags auth +// @Produce json +// @Security Bearer +// @Success 200 {object} models.Profile +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Router /auth/me [get] +func (h *AuthHandler) Me(c *gin.Context) { + profile, err := h.repo.GetByID(c.Request.Context(), c.GetString("user_id")) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, profile) +} + +type forgotPasswordReq struct { + Email string `json:"email" binding:"required,email"` +} + +// ForgotPassword — POST /auth/forgot-password. Письмо со ссылкой сброса. +// +// @Summary Запрос письма для сброса пароля +// @Description Отправляет на email письмо со ссылкой формы сброса (токен живёт 24 часа, одноразовый). Ответ всегда одинаковый — 200 с ok:true, независимо от того, существует ящик, подтверждён он и активен: эндпоинт не перечисляет аккаунты. Письмо уходит только подтверждённым активным аккаунтам. Лимиты: 10 запросов в минуту с одного IP; 3 письма в сутки на один ящик (счётчик тикает при любом запросе — и для несуществующих ящиков тоже), при превышении — 429. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body forgotPasswordReq true "Email для восстановления" +// @Success 200 {object} handlers.OKResponse "всегда ok:true, даже если ящик не найден" +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса" +// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" +// @Router /auth/forgot-password [post] +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var req forgotPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + // Лимит на ящик — до всякого поиска в базе и независимо от его + // результата: счётчик одинаков для существующих и несуществующих + // адресов, иначе 429 раскрыл бы, что ящик зарегистрирован. + // Ключ — lower(email) в обоих регистрах: поиск ниже идёт по lower(). + if !h.forgotMail.Allow(strings.ToLower(req.Email)) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + ctx := c.Request.Context() + profile, err := h.repo.GetByEmail(ctx, req.Email) + // Не найден, не подтверждён или выключен — ответ тот же, что при + // успехе: перечисление ящиков через этот эндпоинт невозможно. + // Письмо в этих случаях не шлём. + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusOK, OKResponse{OK: true}) + return + } + if err != nil { + respondError(c, err) + return + } + if profile.ConfirmedAt == nil || !profile.IsActive { + c.JSON(http.StatusOK, OKResponse{OK: true}) + return + } + + // Прежние токены пользователя гаснут внутри CreateResetToken — + // в живой остаётся только последняя ссылка из свежего письма. + token, expires, err := h.repo.CreateResetToken(ctx, profile.ID) + if err != nil { + respondError(c, err) + return + } + + // Fail-open, как в confirm: подтверждение уже не откатывается, а + // здесь — письмо не повод отвечать 500 (ящик существует — не будем + // это и подтверждать). Ошибка SMTP уходит в warn. + if err := h.mailer.SendPasswordReset(ctx, mailer.PasswordReset{ + To: profile.Email, + Name: profile.Name, + Token: token, + ExpiresAt: expires, + }); err != nil { + slog.Warn("password reset email failed", "err", err, "user_id", profile.ID) + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +type resetPasswordReq struct { + Token string `json:"token" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// ResetPassword — POST /auth/reset-password. Смена пароля по токену. +// +// @Summary Сброс пароля по токену +// @Description Токен одноразовый и живёт 24 часа: приходит письмом после POST /auth/forgot-password либо выдаётся админом через POST /users/{id}/password-reset. Новый пароль — по политике (10..72 символа, буквы и цифры). Успех гасит токен и все refresh-сессии пользователя. Лимит: 30 попыток в минуту с одного IP. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body resetPasswordReq true "Токен и новый пароль" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело или токен неизвестен, использован либо истёк; слабый пароль" +// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" +// @Router /auth/reset-password [post] +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var req resetPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + if err := auth.ValidatePassword(req.Password); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"}) + return + } + + ctx := c.Request.Context() + userID, err := h.repo.UseResetToken(ctx, req.Token, hash) + if errors.Is(err, repository.ErrResetToken) { + // Причину не раскрываем: неизвестен, использован или истёк — + // для перебора это одно и то же. + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"}) + return + } + if err != nil { + respondError(c, err) + return + } + + // Пароль сменился — все сессии закрываем: прежний владелец не должен + // остаться в системе с access-токеном (он живёт до часа) или с + // refresh-сессией. Ошибка разлогина — не повод отдавать 500: + // пароль-то уже обновлён, деталь уходит в лог. + if n, err := h.rtRepo.RevokeAllForUser(ctx, userID); err != nil { + slog.Warn("password reset: failed to revoke sessions", "user_id", userID, "err", err) + } else { + slog.Info("security: password reset", "user_id", userID, "sessions_revoked", n) + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} diff --git a/internal/handlers/auth_test.go b/internal/handlers/auth_test.go new file mode 100644 index 0000000..af307c0 --- /dev/null +++ b/internal/handlers/auth_test.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/auth" + "building-mgmt/internal/ratelimit" +) + +// newAuthPostContext — контекст POST с JSON-телом к auth-хендлеру +// (newPostContext из users_test.go тоже подошёл бы, но путь другой). +func newAuthPostContext(t *testing.T, path, body string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, w +} + +// --- refresh-кука: атрибуты безопасности --- + +// TestRefreshCookieFlags — кука ровно такая, как задумано: HttpOnly +// (JS не читает), SameSite=Lax (CSRF), Path=/auth (только к эндпоинтам +// сессии), Secure ровно по COOKIE_SECURE (браузер отверг бы Secure-куку +// по HTTP). +func TestRefreshCookieFlags(t *testing.T) { + for _, tc := range []struct { + name string + secure bool + }{ + {"локально без Secure", false}, + {"в проде за TLS — Secure", true}, + } { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/auth/login", nil) + + h := &AuthHandler{cookieSecure: tc.secure} + h.setRefreshCookie(c, "secret-value") + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("кук в ответе %d, want 1", len(cookies)) + } + ck := cookies[0] + if ck.Name != "refresh_token" { + t.Errorf("Name = %q", ck.Name) + } + if ck.Value != "secret-value" { + t.Errorf("Value = %q", ck.Value) + } + if !ck.HttpOnly { + t.Error("кука не HttpOnly — XSS утащит сессию") + } + if ck.Secure != tc.secure { + t.Errorf("Secure = %v, want %v", ck.Secure, tc.secure) + } + if ck.Path != "/auth" { + t.Errorf("Path = %q, want /auth", ck.Path) + } + if ck.SameSite != http.SameSiteLaxMode { + t.Errorf("SameSite = %v, want Lax", ck.SameSite) + } + if want := int(auth.RefreshTTL.Seconds()); ck.MaxAge != want { + t.Errorf("MaxAge = %d, want %d", ck.MaxAge, want) + } + }) + } +} + +// TestClearRefreshCookie — logout ставит пустую куку с MaxAge<0: +// браузер удаляет её немедленно, а не по истечении срока. +func TestClearRefreshCookie(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + + h := &AuthHandler{} + h.clearRefreshCookie(c) + + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("кук в ответе %d, want 1", len(cookies)) + } + ck := cookies[0] + if ck.MaxAge >= 0 { + t.Errorf("MaxAge = %d, want < 0 (мгновенное удаление)", ck.MaxAge) + } + if ck.Value != "" { + t.Errorf("Value = %q, want пусто", ck.Value) + } +} + +// --- поведение без куки: до базы не доходим, репозиторий nil --- + +// TestRefreshWithoutCookie — без refresh-куки refresh отвечает 401 +// так же, как с неизвестным токеном: никаких лишних подробностей. +func TestRefreshWithoutCookie(t *testing.T) { + c, w := newAuthPostContext(t, "/auth/refresh", `{}`) + + h := &AuthHandler{} + h.Refresh(c) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (body %q)", w.Code, w.Body.String()) + } + if got := errorBody(t, w); got != "invalid refresh token" { + t.Errorf("error = %q", got) + } +} + +// TestLogoutWithoutCookie — выход идемпотентен: куки нет — гасить +// нечего, ответ 200 и кука всё равно очищается. +func TestLogoutWithoutCookie(t *testing.T) { + c, w := newAuthPostContext(t, "/auth/logout", `{}`) + + h := &AuthHandler{} + h.Logout(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %q)", w.Code, w.Body.String()) + } + if cookies := w.Result().Cookies(); len(cookies) != 1 || cookies[0].MaxAge >= 0 { + t.Errorf("кука очистки не поставлена: %+v", w.Result().Cookies()) + } +} + +// --- политика пароля на установке (до базы) --- + +// TestResetPasswordPolicy — тот же контроль на смене пароля по токену. +func TestResetPasswordPolicy(t *testing.T) { + c, w := newAuthPostContext(t, "/auth/reset-password", + `{"token":"t","password":"short123"}`) + + h := &AuthHandler{} + h.ResetPassword(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body %q)", w.Code, w.Body.String()) + } + if got := errorBody(t, w); !strings.Contains(got, "password must be at least 10") { + t.Errorf("error = %q", got) + } +} + +// --- POST /auth/forgot-password: проверки до базы --- + +// denyLimiter — лимитер, который всегда запрещает (превышен потолок). +type denyLimiter struct{} + +func (denyLimiter) Allow(string) bool { return false } + +// TestForgotPasswordValidation — порядок проверок до базы: кривое тело +// — 400 (лимит и репозиторий не трогаются), исчерпанный лимит ящика — +// 429 (репозиторий не трогается), валидное тело доходит до поиска в +// базе (паника nil-репозитория — доказательство). +// Одинаковый ответ для существующих и несуществующих ящиков — +// интеграционно, здесь только порядок веток. +func TestForgotPasswordValidation(t *testing.T) { + t.Run("кривое тело", func(t *testing.T) { + for _, body := range []string{ + `{}`, + `{"email":"not-an-email"}`, + } { + c, w := newAuthPostContext(t, "/auth/forgot-password", body) + + h := &AuthHandler{forgotMail: denyLimiter{}} // до лимита не дошли + h.ForgotPassword(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("body %s: status = %d, want 400 (body %q)", body, w.Code, w.Body.String()) + } + } + }) + + t.Run("ящик исчерпал лимит — 429 до базы", func(t *testing.T) { + c, w := newAuthPostContext(t, "/auth/forgot-password", + `{"email":"user@example.test"}`) + + h := &AuthHandler{forgotMail: denyLimiter{}} // nil-репозиторий: до базы не дошли + h.ForgotPassword(c) + + if w.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429 (body %q)", w.Code, w.Body.String()) + } + if got := errorBody(t, w); !strings.Contains(got, "too many attempts") { + t.Errorf("error = %q", got) + } + }) + + t.Run("валидное тело доходит в репозиторий", func(t *testing.T) { + c, _ := newAuthPostContext(t, "/auth/forgot-password", + `{"email":"user@example.test"}`) + + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") + } + }() + h := &AuthHandler{forgotMail: ratelimit.New(1, time.Minute)} + h.ForgotPassword(c) + }) +} diff --git a/internal/handlers/binding.go b/internal/handlers/binding.go new file mode 100644 index 0000000..9a500e1 --- /dev/null +++ b/internal/handlers/binding.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +// bindingError отвечает на ошибку разбора тела запроса. +// Сырые тексты gin/validator (имена структур и тегов) наружу не уходят: +// клиенту уходит «поле: причина», написанная понятным языком. +func bindingError(c *gin.Context, err error) { + var ve validator.ValidationErrors + if errors.As(err, &ve) { + msgs := make([]string, 0, len(ve)) + for _, fe := range ve { + msgs = append(msgs, fe.Field()+" "+fieldMessage(fe)) + } + c.JSON(http.StatusBadRequest, gin.H{"error": strings.Join(msgs, "; ")}) + return + } + // Невалидный JSON, не тот тип поля и прочее. + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) +} + +func fieldMessage(fe validator.FieldError) string { + switch fe.Tag() { + case "required": + return "is required" + case "email": + return "must be a valid email" + case "min": + return "must be at least " + fe.Param() + " characters" + case "max": + return "must be at most " + fe.Param() + " characters" + case "oneof": + return "must be one of: " + fe.Param() + case "uuid": + return "must be a valid UUID" + default: + return "is invalid" + } +} diff --git a/internal/handlers/filters.go b/internal/handlers/filters.go new file mode 100644 index 0000000..a397745 --- /dev/null +++ b/internal/handlers/filters.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "net/http" + "slices" + "time" + + "github.com/gin-gonic/gin" +) + +// Фильтры списков, которым нужна проверка значения до ухода в SQL. + +// checkFilter — сверяет ?param= со списком допустимых значений. +// Плохое значение — 400 с именем параметра, а не пустой список: +// «введено что-то не то» и «таких записей нет» — разные вещи. +func checkFilter(c *gin.Context, param string, allowed []string) bool { + v := c.Query(param) + if v == "" || slices.Contains(allowed, v) { + return true + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + param}) + return false +} + +// dateRange разбирает date_from/date_to в формате YYYY-MM-DD (даты +// по created_at). Верхняя граница сдвигается на сутки: date_to=2026-10-01 +// означает «весь день 1 октября», а не «до полуночи 30 сентября». +func dateRange(c *gin.Context) (from, to *time.Time, ok bool) { + parse := func(param string, end bool) (*time.Time, bool) { + v := c.Query(param) + if v == "" { + return nil, true + } + d, err := time.Parse("2006-01-02", v) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + param}) + return nil, false + } + if end { + d = d.AddDate(0, 0, 1) // верхняя граница исключительная + } + return &d, true + } + + if from, ok = parse("date_from", false); !ok { + return nil, nil, false + } + if to, ok = parse("date_to", true); !ok { + return nil, nil, false + } + if from != nil && to != nil && !to.After(*from) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date range"}) + return nil, nil, false + } + return from, to, true +} diff --git a/internal/handlers/httperr.go b/internal/handlers/httperr.go new file mode 100644 index 0000000..046b6c9 --- /dev/null +++ b/internal/handlers/httperr.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/middleware" + "building-mgmt/internal/repository" +) + +// respondError отдаёт JSON-ответ по классу ошибки репозитория. +// +// Тексты ошибок БД наружу не попадают: инфраструктурные ошибки +// пишутся в лог и превращаются в 500, а на клиент уходят только +// фиксированные сообщения. Если endpoint'у нужно своё сообщение, +// он ветвится через errors.Is(...) сам, до вызова respondError. +func respondError(c *gin.Context, err error) { + // Общие атрибуты, чтобы ошибку можно было найти по запросу. + attrs := []any{ + "err", err, + "request_id", middleware.RequestIDFrom(c), + "method", c.Request.Method, + "path", c.Request.URL.Path, + } + + switch { + case errors.Is(err, repository.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + case errors.Is(err, repository.ErrConflict): + c.JSON(http.StatusConflict, gin.H{"error": "conflict"}) + case errors.Is(err, repository.ErrInvalid): + // Нарушение ограничения — это баг вызывающего кода, логируем. + slog.Warn("constraint violation", attrs...) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid request"}) + default: + slog.Error("internal error", attrs...) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } +} diff --git a/internal/handlers/list_test.go b/internal/handlers/list_test.go new file mode 100644 index 0000000..d91aa3a --- /dev/null +++ b/internal/handlers/list_test.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/models" + "building-mgmt/internal/repository" +) + +// Наборы — чистые функции разбора query, поэтому здесь живут и тестируются +// без БД и без роутера: контексту достаточно Request с query-строкой. + +func newQueryContext(t *testing.T, rawQuery string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/list?"+rawQuery, nil) + return c, w +} + +// errorBody — {"error": ...} из ответа для сообщений об ошибках. +func errorBody(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Error string `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("разбор ответа %q: %v", w.Body.String(), err) + } + return body.Error +} + +func TestParsePageDefaults(t *testing.T) { + c, w := newQueryContext(t, "") + + page, ok := parsePage(c, repository.TicketSorts) + if !ok { + t.Fatalf("ok = false, не должно быть ошибки; ответ: %s", w.Body.String()) + } + if page.Limit != repository.DefaultLimit || page.Offset != 0 || page.Sort != "" || page.Order != "" { + t.Errorf("page = %+v, want дефолт limit=%d", page, repository.DefaultLimit) + } +} + +func TestParsePageValid(t *testing.T) { + c, _ := newQueryContext(t, "limit=100&offset=50&sort=priority&order=asc") + + page, ok := parsePage(c, repository.TicketSorts) + if !ok { + t.Fatal("ok = false, валидные параметры не должны падать") + } + if page.Limit != 100 || page.Offset != 50 || page.Sort != "priority" || page.Order != "asc" { + t.Errorf("page = %+v", page) + } +} + +func TestParsePageRejects(t *testing.T) { + cases := []struct { + name string + query string + want string + }{ + {"лимит нулевой", "limit=0", "invalid limit"}, + {"лимит за потолком", "limit=101", "invalid limit"}, + {"лимит не число", "limit=abc", "invalid limit"}, + {"лимит отрицательный", "limit=-5", "invalid limit"}, + {"сдвиг отрицательный", "offset=-1", "invalid offset"}, + {"сдвиг не число", "offset=1.5", "invalid offset"}, + {"порядок не из списка", "order=up", "invalid order"}, + {"сортировка не из списка", "sort=password_hash", "invalid sort"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, w := newQueryContext(t, tc.query) + + if _, ok := parsePage(c, repository.TicketSorts); ok { + t.Fatal("ok = true, ожидался отказ") + } + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if got := errorBody(t, w); got != tc.want { + t.Errorf("error = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCheckFilter(t *testing.T) { + t.Run("подходит", func(t *testing.T) { + c, _ := newQueryContext(t, "status=closed") + if !checkFilter(c, "status", models.TicketStatuses) { + t.Error("ok = false, допустимое значение отклонено") + } + }) + + t.Run("параметр не задан", func(t *testing.T) { + c, _ := newQueryContext(t, "") + if !checkFilter(c, "status", models.TicketStatuses) { + t.Error("ok = false, пустой фильтр должен проходить") + } + }) + + t.Run("мусор", func(t *testing.T) { + c, w := newQueryContext(t, "status=nope") + if checkFilter(c, "status", models.TicketStatuses) { + t.Fatal("ok = true, мусорное значение должно отклоняться") + } + if w.Code != http.StatusBadRequest || errorBody(t, w) != "invalid status" { + t.Errorf("status=%d error=%q, want 400/invalid status", w.Code, errorBody(t, w)) + } + }) +} + +func TestDateRange(t *testing.T) { + t.Run("оба пусты", func(t *testing.T) { + c, _ := newQueryContext(t, "") + from, to, ok := dateRange(c) + if !ok || from != nil || to != nil { + t.Errorf("from=%v to=%v ok=%v, want nil/nil/true", from, to, ok) + } + }) + + t.Run("date_to включает весь свой день", func(t *testing.T) { + c, _ := newQueryContext(t, "date_from=2026-10-01&date_to=2026-10-01") + from, to, ok := dateRange(c) + if !ok { + t.Fatal("ok = false") + } + wantFrom := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + if !from.Equal(wantFrom) { + t.Errorf("from = %v, want %v", from, wantFrom) + } + // Верхняя граница исключительная — сутки после date_to. + if want := wantFrom.AddDate(0, 0, 1); !to.Equal(want) { + t.Errorf("to = %v, want %v (весь день целиком)", to, want) + } + }) + + t.Run("формат не тот", func(t *testing.T) { + c, w := newQueryContext(t, "date_from=01.10.2026") + if _, _, ok := dateRange(c); ok { + t.Fatal("ok = false ожидался") + } + if w.Code != http.StatusBadRequest || errorBody(t, w) != "invalid date_from" { + t.Errorf("status=%d error=%q", w.Code, errorBody(t, w)) + } + }) + + t.Run("диапазон наоборот", func(t *testing.T) { + c, w := newQueryContext(t, "date_from=2026-10-05&date_to=2026-10-01") + if _, _, ok := dateRange(c); ok { + t.Fatal("ok = false ожидался") + } + if w.Code != http.StatusBadRequest || errorBody(t, w) != "invalid date range" { + t.Errorf("status=%d error=%q", w.Code, errorBody(t, w)) + } + }) +} diff --git a/internal/handlers/pagination.go b/internal/handlers/pagination.go new file mode 100644 index 0000000..0343d07 --- /dev/null +++ b/internal/handlers/pagination.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/repository" +) + +// Пагинация списков: одинаковые параметры limit/offset/sort/order +// у всех GET-списков, поэтому разбор вынесен отдельно. +// Словари сортировок приходят из repository — того же источника, +// что и ORDER BY в SQL: валидация и запрос не могут разъехаться. + +// parsePage разбирает параметры страницы. Возвращает ok=false после +// ответа 400 — вызывающий хендлер обязан сразу вернуться. +func parsePage(c *gin.Context, sorts map[string]repository.SortSpec) (repository.Page, bool) { + page := repository.Page{ + Limit: repository.DefaultLimit, + Sort: c.Query("sort"), + Order: c.Query("order"), + } + + if v := c.Query("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > repository.MaxLimit { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) + return page, false + } + page.Limit = n + } + if v := c.Query("offset"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"}) + return page, false + } + page.Offset = n + } + if page.Order != "" && page.Order != "asc" && page.Order != "desc" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid order"}) + return page, false + } + if page.Sort != "" { + if _, ok := sorts[page.Sort]; !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort"}) + return page, false + } + } + return page, true +} diff --git a/internal/handlers/photos.go b/internal/handlers/photos.go new file mode 100644 index 0000000..cc92d23 --- /dev/null +++ b/internal/handlers/photos.go @@ -0,0 +1,374 @@ +package handlers + +import ( + "bytes" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "building-mgmt/internal/middleware" + "building-mgmt/internal/models" + "building-mgmt/internal/repository" + "building-mgmt/internal/storage" +) + +// photoPresignTTL — сколько живёт ссылка на чтение файла. Бакет +// приватный, поэтому наружу уходит только подписанная ссылка, +// а не ключ объекта; срок короткий, чтобы не расползаться. +const photoPresignTTL = 15 * time.Minute + +// photoDefaultMaxBytes / photoDefaultMaxPer — дефолты лимитов, +// если PHOTO_MAX_BYTES / PHOTO_MAX_PER_TICKET не заданы (см. main). +const ( + photoDefaultMaxBytes = int64(10 << 20) // 10 МБ на файл + photoDefaultMaxPer = 20 // файлов на заявку + photoMaxNameRunes = 100 // длина имени файла в журнале + photoMultipartSlackByte = 8 << 10 // запас MaxBytesReader на заголовки/границы multipart +) + +// PhotoHandler — фотоотчёт заявки: загрузка (мастер/админ), просмотр +// (все, кто видит заявку), удаление (своё/админ). Байты уходят в +// объектное хранилище (storage.Store), в БД — только метаданные +// плюс событие в журнале, записываемое той же операцией. +type PhotoHandler struct { + tickets *repository.TicketRepo + photos *repository.PhotoRepo + // проекты — нужен canView: мастеру открыты свободные (new) + // заявки его проектов, членство проверяется в БД. + projects *repository.ProjectRepo + store storage.Store + + maxBytes int64 // потолок одного файла (PHOTO_MAX_BYTES) + maxPer int // файлов на заявку (PHOTO_MAX_PER_TICKET) +} + +// NewPhotoHandler собирает хендлер. Лимиты приходят из main +// (разбор env — там же, рядом с остальной конфигурацией). +func NewPhotoHandler( + tickets *repository.TicketRepo, + photos *repository.PhotoRepo, + projects *repository.ProjectRepo, + store storage.Store, + maxBytes int64, + maxPer int, +) *PhotoHandler { + if maxBytes <= 0 { + maxBytes = photoDefaultMaxBytes + } + if maxPer <= 0 { + maxPer = photoDefaultMaxPer + } + return &PhotoHandler{ + tickets: tickets, + photos: photos, + projects: projects, + store: store, + maxBytes: maxBytes, + maxPer: maxPer, + } +} + +// ticket — разобрать :id и загрузить заявку вместе с проверкой, что +// авторизованный вообще имеет право о ней знать. false — ответ +// (400/403/404/500) уже отправлен. +func (h *PhotoHandler) ticket(c *gin.Context) (*models.Ticket, bool) { + t, ok := loadTicket(c, h.tickets) + if !ok { + return nil, false + } + visible, err := canView(c.Request.Context(), c, t, h.projects) + if err != nil { + respondError(c, err) + return nil, false + } + if !visible { + forbidden(c) + return nil, false + } + return t, true +} + +// Upload — POST /tickets/:id/photos. Файл приходит multipart-ом в поле file. +// +// Порядок операций выбран так, чтобы не оставлять мусора: +// 1. валидация (размер, сигнатура, precheck лимита) — ничего не пишем; +// 2. Put в хранилище — файл должен существовать до строки в БД; +// 3. INSERT строки + события одной SQL-операцией; при её сбое +// объект удаляется компенсаторно (иначе стал бы сиротой). +// +// @Summary Загрузить фото отчёта +// @Description multipart/form-data, поле file: только фото (jpeg/png/webp, определяется по сигнатуре байтов), не больше PHOTO_MAX_BYTES на файл и PHOTO_MAX_PER_TICKET файлов на заявку. Доступно мастеру (заявка in_progress — после «Работа выполнена» отчёт заморожен, мастер — активный участник проекта) или админу. Каждая загрузка оставляет событие photo_added в журнале заявки. +// @Tags tickets +// @Accept multipart/form-data +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param file formData file true "Файл фотографии (jpeg/png/webp)" +// @Success 201 {object} models.TicketPhoto "фото загружено, url — временная ссылка на чтение" +// @Failure 400 {object} handlers.ErrorResponse "некорректный id, файл отсутствует, не фото или больше лимита" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker или admin; чужая заявка" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "лимит файлов исчерпан или заявка не в статусе in_progress" +// @Failure 500 {object} handlers.ErrorResponse "хранилище недоступно" +// @Router /tickets/{id}/photos [post] +func (h *PhotoHandler) Upload(c *gin.Context) { + t, ok := h.ticket(c) + if !ok { + return + } + + // Потолок на всё тело запроса: MaxBytesReader обрывает его на + // границе — гигабайтный «файл» не успеет занять память/диск. + // +случай — запас на заголовки и границы multipart. + if c.Request.ContentLength > h.maxBytes+photoMultipartSlackByte { + c.JSON(http.StatusBadRequest, gin.H{"error": "photo too large"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, + h.maxBytes+photoMultipartSlackByte) + + file, header, err := c.Request.FormFile("file") + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") { + c.JSON(http.StatusBadRequest, gin.H{"error": "photo too large"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file"}) + return + } + defer func() { _ = file.Close() }() + + // LimitReader(max+1): header.Size клиент может врать, поэтому + // верим только прочитанным байтам — и не держим в памяти больше + // потолка. + data, err := io.ReadAll(io.LimitReader(file, h.maxBytes+1)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file"}) + return + } + if int64(len(data)) > h.maxBytes { + c.JSON(http.StatusBadRequest, gin.H{"error": "photo too large"}) + return + } + if len(data) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file"}) + return + } + contentType, ext, ok := sniffImage(data) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported photo type"}) + return + } + + // Дешёвый precheck лимита — до заливки в хранилище. Гонку + // «последнее место» всё равно решает счётчик в SQL (AddPhoto), + // а здесь мы просто не тратим 10 МБ впустую. + count, err := h.photos.CountPhotos(c.Request.Context(), t.ID) + if err != nil { + respondError(c, err) + return + } + if count >= h.maxPer { + c.JSON(http.StatusConflict, gin.H{"error": "too many photos"}) + return + } + + key := fmt.Sprintf("tickets/%d/%s.%s", t.ID, uuid.NewString(), ext) + if err := h.store.Put(c.Request.Context(), key, data, contentType); err != nil { + // Детали хранилища — только в лог. + slog.Error("photo put failed", + "err", err, + "request_id", middleware.RequestIDFrom(c), + "ticket_id", t.ID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + photo, err := h.photos.AddPhoto(c.Request.Context(), repository.PhotoMeta{ + TicketID: t.ID, + UploaderID: c.GetString("user_id"), + ObjectKey: key, + ContentType: contentType, + SizeBytes: int64(len(data)), + OriginalName: photoName(header.Filename), + MaxPerTicket: h.maxPer, + }, c.GetString("role")) + if err != nil { + // Строка не записалась — компенсируем: объект в хранилище + // стал бы сиротой без владельца в БД. + if delErr := h.store.Delete(c.Request.Context(), key); delErr != nil { + slog.Error("photo compensation delete failed", + "err", delErr, "key", key, + "request_id", middleware.RequestIDFrom(c)) + } + if errors.Is(err, repository.ErrTooManyPhotos) { + c.JSON(http.StatusConflict, gin.H{"error": "too many photos"}) + return + } + respondError(c, err) + return + } + + photo.URL = h.presign(c, photo.ObjectKey) + c.JSON(http.StatusCreated, photo) +} + +// presign — подписанная ссылка на чтение. Сбой подписи не должен +// прятать удавшуюся операцию: лог, пустой url (список подпишет заново). +func (h *PhotoHandler) presign(c *gin.Context, key string) string { + url, err := h.store.PresignGet(c.Request.Context(), key, photoPresignTTL) + if err != nil { + slog.Warn("photo presign failed", + "err", err, + "request_id", middleware.RequestIDFrom(c)) + return "" + } + return url +} + +// List — GET /tickets/:id/photos. Смотрят все, кому видна заявка. +// +// @Summary Список фото отчёта +// @Description Файлы заявки в хронологии (id ASC по умолчанию). В каждом элементе url — временная подписанная ссылка на чтение: бакет приватный, сам ключ наружу не уходит. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(id, created_at) Default(id) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.PhotoListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "чужая заявка" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Router /tickets/{id}/photos [get] +func (h *PhotoHandler) List(c *gin.Context) { + t, ok := h.ticket(c) + if !ok { + return + } + + page, ok := parsePage(c, repository.PhotoSorts) + if !ok { + return + } + photos, total, err := h.photos.ListPhotos(c.Request.Context(), t.ID, page) + if err != nil { + respondError(c, err) + return + } + if photos == nil { + photos = []models.TicketPhoto{} + } + for i := range photos { + photos[i].URL = h.presign(c, photos[i].ObjectKey) + } + c.JSON(http.StatusOK, PhotoListResponse{ + Items: photos, Total: total, Limit: page.Limit, Offset: page.Offset, + MaxPerTicket: h.maxPer, + }) +} + +// Delete — DELETE /tickets/:id/photos/:photo_id. Удаляет своё фото, +// пока заявка в работе (in_progress); админу — любое. Объект из хранилища убирается +// после записи в БД: если уборка сбойнёт, в логе останется ключ +// сироты, а журнал не разойдётся со строкой. +// +// @Summary Удалить фото +// @Description Своё фото удаляет автор, пока заявка в работе (in_progress): после «Работа выполнена» отчёт заморожен; админу — любое фото. Событие photo_removed пишется той же операцией, что и удаление. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param photo_id path int true "ID фото" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или photo_id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker или admin; чужая заявка" +// @Failure 404 {object} handlers.ErrorResponse "заявка или фото не найдены" +// @Failure 409 {object} handlers.ErrorResponse "чужое фото или заявка уже не в работе" +// @Router /tickets/{id}/photos/{photo_id} [delete] +func (h *PhotoHandler) Delete(c *gin.Context) { + t, ok := h.ticket(c) + if !ok { + return + } + photoID, err := strconv.ParseInt(c.Param("photo_id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid photo id"}) + return + } + + key, err := h.photos.DeletePhoto(c.Request.Context(), t.ID, photoID, + c.GetString("user_id"), c.GetString("role")) + if err != nil { + if errors.Is(err, repository.ErrPhotoNotRemovable) { + c.JSON(http.StatusConflict, gin.H{"error": "photo cannot be removed"}) + return + } + respondError(c, err) + return + } + + // Строка и событие уже записаны: объект чистим отдельно: если + // хранилище недоступно — это лог и сирота, а не отмена удаления. + if err := h.store.Delete(c.Request.Context(), key); err != nil { + slog.Error("photo object delete failed", + "err", err, "key", key, + "request_id", middleware.RequestIDFrom(c)) + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// sniffImage — формат по сигнатуре байтов, а не по заголовку +// Content-Type: клиент мог подписать что угодно, а в БД (CHECK в 0008) +// и в браузер уйдёт ровно то, что здесь. Разрешены только фото — +// gif, svg и прочие форматы этап не принимает. +func sniffImage(b []byte) (contentType, ext string, ok bool) { + switch { + case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF: // JPEG + return "image/jpeg", "jpg", true + case len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): // PNG + return "image/png", "png", true + case len(b) >= 12 && bytes.Equal(b[:4], []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP + return "image/webp", "webp", true + } + return "", "", false +} + +// photoName — имя файла для журнала (comment события) и UI: без +// управляющих символов, без упоминаний пути («C:\evil.png» → «evil.png»), +// не длиннее photoMaxNameRunes. Пустое имя тоже допустимо — загрузчик +// мог его не передать. +func photoName(raw string) string { + base := path.Base(strings.ReplaceAll(raw, `\`, "/")) + var b strings.Builder + for _, r := range base { + if r < 0x20 || r == 0x7f { // управляющие — только в лог, не в UI + continue + } + b.WriteRune(r) + } + name := b.String() + if utf8.RuneCountInString(name) > photoMaxNameRunes { + name = string([]rune(name)[:photoMaxNameRunes]) + } + if name == "." || name == "/" { + return "" + } + return name +} diff --git a/internal/handlers/photos_test.go b/internal/handlers/photos_test.go new file mode 100644 index 0000000..e428fd5 --- /dev/null +++ b/internal/handlers/photos_test.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "strings" + "testing" +) + +// TestSniffImage — белый список форматов по сигнатуре: только фото. +func TestSniffImage(t *testing.T) { + jpeg := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} + png := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00} + webp := []byte("RIFF\x24\x00\x00\x00WEBPVP8 ") + + cases := []struct { + name string + data []byte + contentType string + ext string + }{ + {"jpeg", jpeg, "image/jpeg", "jpg"}, + {"png", png, "image/png", "png"}, + {"webp", webp, "image/webp", "webp"}, + {"gif is not photo", []byte("GIF89a"), "", ""}, + {"bmp is not photo", []byte("BM\x00\x00"), "", ""}, + {"svg text", []byte(``), "", ""}, + {"empty", nil, "", ""}, + {"truncated jpeg", []byte{0xFF, 0xD8}, "", ""}, + {"riff but not webp", []byte("RIFF\x00\x00\x00\x00WAVEfmt "), "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + contentType, ext, ok := sniffImage(tc.data) + if tc.contentType == "" { + if ok { + t.Errorf("sniff = (%q, %q, true), want reject", contentType, ext) + } + return + } + if !ok || contentType != tc.contentType || ext != tc.ext { + t.Errorf("sniff = (%q, %q, %v), want (%q, %q, true)", + contentType, ext, ok, tc.contentType, tc.ext) + } + }) + } +} + +// TestPhotoName — имя файла из multipart: без путей, управляющих +// символов и без бесконечных строк (оно уходит в журнал событий). +func TestPhotoName(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"photo.jpg", "photo.jpg"}, + {`C:\Users\me\photo.jpg`, "photo.jpg"}, // имя с Windows-путём + {"../../etc/passwd", "passwd"}, // path traversal вырезается + {"a\x00b\x1fc.png", "abc.png"}, // управляющие символы + {"", ""}, // имя не передали + {".", ""}, // path.Base(".") = "." + {strings.Repeat("я", 150) + ".jpg", strings.Repeat("я", 100)}, // обрезка по рунам + } + for _, tc := range cases { + if got := photoName(tc.in); got != tc.want { + t.Errorf("photoName(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestNewPhotoHandlerDefaults — лимиты из env могли быть нулём +// (не заданы): хендлер обязан подставить дефолты, а не считать с нулём. +func TestNewPhotoHandlerDefaults(t *testing.T) { + h := NewPhotoHandler(nil, nil, nil, nil, 0, -1) + if h.maxBytes != photoDefaultMaxBytes { + t.Errorf("maxBytes = %d, want %d", h.maxBytes, photoDefaultMaxBytes) + } + if h.maxPer != photoDefaultMaxPer { + t.Errorf("maxPer = %d, want %d", h.maxPer, photoDefaultMaxPer) + } + + custom := NewPhotoHandler(nil, nil, nil, nil, 4096, 3) + if custom.maxBytes != 4096 || custom.maxPer != 3 { + t.Errorf("custom limits = %d/%d, want 4096/3", custom.maxBytes, custom.maxPer) + } +} diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go new file mode 100644 index 0000000..5772359 --- /dev/null +++ b/internal/handlers/projects.go @@ -0,0 +1,785 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "building-mgmt/internal/models" + "building-mgmt/internal/repository" + "building-mgmt/internal/storage" +) + +// ProjectHandler — проекты клиентов, объекты внутри проектов и мастера, +// добавленные в проект. Схема: миграция 0004 (проект → объекты, мастера). +type ProjectHandler struct { + repo *repository.ProjectRepo + profileRepo *repository.ProfileRepo + // store — объектное хранилище: нужно удалению проекта, чтобы после + // коммита убрать объекты фото заявок (сбой — в лог, БД уже согласована). + store storage.Store +} + +// NewProjectHandler собирает хендлер: profileRepo нужен, чтобы при +// добавлении мастера в проект проверить роль и активность аккаунта, +// store — чтобы удаление проекта чистило объекты фото в хранилище. +func NewProjectHandler(repo *repository.ProjectRepo, profileRepo *repository.ProfileRepo, store storage.Store) *ProjectHandler { + return &ProjectHandler{repo: repo, profileRepo: profileRepo, store: store} +} + +// ownerScope — clientID для проверки владельца: клиенту — свой id, +// админу — пусто (видит всё). Остальным ролям — 403 сразу. +func ownerScope(c *gin.Context) (string, bool) { + switch c.GetString("role") { + case "admin": + return "", true + case "client": + return c.GetString("user_id"), true + } + forbidden(c) + return "", false +} + +// pathID — разобрать :id из пути. +func pathID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return 0, false + } + return id, true +} + +// respondOKProject — вернуть проект; перечитываем, чтобы в ответе были +// client_name и подтверждённые БД значения. +func (h *ProjectHandler) respondProject(c *gin.Context, id int64) { + p, err := h.repo.OwnedProject(c.Request.Context(), id, "") + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, p) +} + +type createProjectReq struct { + Name string `json:"name" binding:"required,min=2"` + // ClientID — только для админа: для какого клиента создаётся проект. + // У клиента владелец берётся из токена, поле игнорируется. + ClientID string `json:"client_id"` +} + +// Create — POST /projects. Создать проект, клиент или админ. +// +// @Summary Создать проект +// @Description Клиент создаёт проект себе; админ — для любого клиента (в этом случае обязателен client_id). Проект — контур, внутри которого живут объекты и мастера. +// @Tags projects +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body createProjectReq true "Данные проекта" +// @Success 201 {object} models.Project +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело; client_id обязателен для admin; владелец не клиент" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 409 {object} handlers.ErrorResponse "у клиента уже есть проект с таким именем" +// @Router /projects [post] +func (h *ProjectHandler) Create(c *gin.Context) { + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req createProjectReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + // Владелец: у клиента — он сам, админ назначает клиента полем client_id. + clientID := scopeID + if clientID == "" { + clientID = req.ClientID + if clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "client_id is required"}) + return + } + } + + owner, err := h.profileRepo.GetByID(c.Request.Context(), clientID) + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "client not found"}) + return + } + if err != nil { + respondError(c, err) + return + } + if owner.Role != "client" || !owner.IsActive { + c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active client"}) + return + } + + p := &models.Project{ClientID: clientID, Name: req.Name, IsActive: true} + if err := h.repo.CreateProject(c.Request.Context(), p); err != nil { + if errors.Is(err, repository.ErrConflict) { + c.JSON(http.StatusConflict, gin.H{"error": "project name already exists"}) + return + } + respondError(c, err) + return + } + + full, err := h.repo.OwnedProject(c.Request.Context(), p.ID, "") + if err != nil { + c.JSON(http.StatusCreated, p) + return + } + c.JSON(http.StatusCreated, full) +} + +// List — GET /projects. Список проектов, выборка режется по роли. +// +// @Summary Список проектов +// @Description Клиент видит только свои проекты, админ — все (можно сузить через client_id). Мастерам проекты не отдаются. Ответ — страница: items плюс total/limit/offset. +// @Tags projects +// @Produce json +// @Security Bearer +// @Param client_id query string false "Фильтр по клиенту (только admin)" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(created_at, name, id) Default(created_at) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.ProjectListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Router /projects [get] +func (h *ProjectHandler) List(c *gin.Context) { + scopeID, ok := ownerScope(c) + if !ok { + return + } + if scopeID == "" { + if q := c.Query("client_id"); q != "" { + scopeID = q + } + } + page, ok := parsePage(c, repository.ProjectSorts) + if !ok { + return + } + + projects, total, err := h.repo.ListProjects(c.Request.Context(), scopeID, page) + if err != nil { + respondError(c, err) + return + } + if projects == nil { + projects = []models.Project{} + } + c.JSON(http.StatusOK, ProjectListResponse{ + Items: projects, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +// Get — GET /projects/:id. Детали проекта. +// +// @Summary Детали проекта +// @Description Админ — любой проект, клиент — только свой; чужой проект неотличим от несуществующего (404). +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Success 200 {object} models.Project +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден" +// @Router /projects/{id} [get] +func (h *ProjectHandler) Get(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + p, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, p) +} + +type updateProjectReq struct { + Name *string `json:"name" binding:"omitempty,min=2"` + IsActive *bool `json:"is_active"` +} + +// Update — PATCH /projects/:id. Переименование и/или включение-выключение. +// +// @Summary Изменить проект +// @Description Поля опциональны, меняется только переданное. Выключенный проект не принимает новые заявки и объекты. +// @Tags projects +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Param body body updateProjectReq true "Что изменить" +// @Success 200 {object} models.Project +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или пустое тело" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден" +// @Failure 409 {object} handlers.ErrorResponse "имя уже занято у этого клиента" +// @Router /projects/{id} [patch] +func (h *ProjectHandler) Update(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req updateProjectReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + if req.Name == nil && req.IsActive == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Сначала права: чужой проект не должен отличаться от несуществующего. + if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + if err := h.repo.UpdateProject(c.Request.Context(), id, req.Name, req.IsActive); err != nil { + if errors.Is(err, repository.ErrConflict) { + c.JSON(http.StatusConflict, gin.H{"error": "project name already exists"}) + return + } + respondError(c, err) + return + } + h.respondProject(c, id) +} + +// Delete — DELETE /projects/:id. Удалить проект целиком (только админ). +// +// @Summary Удалить проект +// @Description Действие необратимо: вместе с проектом удаляются его объекты, участия мастеров и все заявки (вместе с событиями и фото). Объекты фото в хранилище убираются после успешного удаления в БД. Клиент своих проектов не удаляет — только админ. +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден" +// @Router /projects/{id} [delete] +func (h *ProjectHandler) Delete(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + + keys, err := h.repo.DeleteProject(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + + // Строки в БД уже удалены (транзакция закоммичена): объекты фото + // чистим best-effort — сбой не должен отменить успех операции, + // как в фоновой чистке заявок (см. runTicketsCleanup). + for _, key := range keys { + if err := h.store.Delete(c.Request.Context(), key); err != nil { + slog.Warn("project delete: photo object cleanup failed", "key", key, "err", err) + } + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// ============ объекты ============ + +// ListObjects — GET /projects/:id/objects. Объекты проекта. +// +// @Summary Объекты проекта +// @Description Здания/корпуса, входящие в проект. Адрес заявки берётся из объекта. +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(name, created_at, id) Default(name) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.ObjectListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден" +// @Router /projects/{id}/objects [get] +func (h *ProjectHandler) ListObjects(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + page, ok := parsePage(c, repository.ObjectSorts) + if !ok { + return + } + + if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + objects, total, err := h.repo.ListObjects(c.Request.Context(), id, page) + if err != nil { + respondError(c, err) + return + } + if objects == nil { + objects = []models.Object{} + } + c.JSON(http.StatusOK, ObjectListResponse{ + Items: objects, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +type createObjectReq struct { + Name string `json:"name" binding:"required,min=2"` + Address string `json:"address" binding:"required,min=5"` +} + +// CreateObject — POST /projects/:id/objects. Добавить объект в проект. +// +// @Summary Создать объект +// @Description Объект — здание/корпус с адресом; заявки создаются именно на объект. Выключенный проект объектов не принимает. +// @Tags projects +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Param body body createObjectReq true "Данные объекта" +// @Success 201 {object} models.Object +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден" +// @Failure 409 {object} handlers.ErrorResponse "проект выключен или имя объекта занято" +// @Router /projects/{id}/objects [post] +func (h *ProjectHandler) CreateObject(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req createObjectReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + p, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID) + if err != nil { + respondError(c, err) + return + } + if !p.IsActive { + c.JSON(http.StatusConflict, gin.H{"error": "project is not active"}) + return + } + + o := &models.Object{ + ProjectID: p.ID, + Name: req.Name, + Address: req.Address, + IsActive: true, + } + if err := h.repo.CreateObject(c.Request.Context(), o); err != nil { + if errors.Is(err, repository.ErrConflict) { + c.JSON(http.StatusConflict, gin.H{"error": "object name already exists"}) + return + } + respondError(c, err) + return + } + + full, err := h.repo.OwnedObject(c.Request.Context(), o.ID, "") + if err != nil { + c.JSON(http.StatusCreated, o) + return + } + c.JSON(http.StatusCreated, full) +} + +type updateObjectReq struct { + Name *string `json:"name" binding:"omitempty,min=2"` + Address *string `json:"address" binding:"omitempty,min=5"` + IsActive *bool `json:"is_active"` +} + +// UpdateObject — PATCH /objects/:id. Правка объекта. +// +// @Summary Изменить объект +// @Description Поля опциональны. У уже созданных заявок адрес остаётся тем, что был на момент создания — это осознанно. +// @Tags projects +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID объекта" +// @Param body body updateObjectReq true "Что изменить" +// @Success 200 {object} models.Object +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или пустое тело" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "объект не найден" +// @Failure 409 {object} handlers.ErrorResponse "имя уже занято в проекте" +// @Router /objects/{id} [patch] +func (h *ProjectHandler) UpdateObject(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req updateObjectReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + if req.Name == nil && req.Address == nil && req.IsActive == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + + // Права: чужой объект неотличим от несуществующего. + if _, err := h.repo.OwnedObject(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + if err := h.repo.UpdateObject(c.Request.Context(), id, req.Name, req.Address, req.IsActive); err != nil { + if errors.Is(err, repository.ErrConflict) { + c.JSON(http.StatusConflict, gin.H{"error": "object name already exists"}) + return + } + respondError(c, err) + return + } + + full, err := h.repo.OwnedObject(c.Request.Context(), id, "") + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, full) +} + +// ============ мастера проекта ============ + +// ListWorkers — GET /projects/:id/workers. Мастера проекта. +// +// @Summary Мастера проекта +// @Description Кто приглашён в проект и кем принят: статус invited — приглашение ждёт решения, active — мастер работает с заявками, declined — отказал. Хеши паролей в ответ не попадают. +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(name, created_at, email, id) Default(name) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.ProjectWorkerListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден" +// @Router /projects/{id}/workers [get] +func (h *ProjectHandler) ListWorkers(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + page, ok := parsePage(c, repository.ProfileSorts) + if !ok { + return + } + + if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + workers, total, err := h.repo.ListWorkers(c.Request.Context(), id, page) + if err != nil { + respondError(c, err) + return + } + if workers == nil { + workers = []models.ProjectWorker{} + } + c.JSON(http.StatusOK, ProjectWorkerListResponse{ + Items: workers, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +type addWorkerReq struct { + WorkerID string `json:"worker_id" binding:"required,uuid"` +} + +// AddWorker — POST /projects/:id/workers. Пригласить мастера в проект. +// +// @Summary Пригласить мастера в проект +// @Description С 0005 это приглашение, а не готовое членство: мастер становится участником только сам, приняв приглашение (POST /projects/{id}/invitation/accept) — до этого заявки проекта ему не назначаются. Приглашение живёт 7 дней. Мастер может состоять в нескольких проектах; аккаунт должен быть активным, с ролью worker и подтверждённым (0010) — неподтверждённого сначала подтверждает тот, кто его создал. +// @Tags projects +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Param body body addWorkerReq true "Мастер" +// @Success 200 {object} handlers.InvitationResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id/тело; не мастер, неактивен или не подтверждён" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект или мастер не найден" +// @Failure 409 {object} handlers.ErrorResponse "мастер уже состоит в проекте / приглашение уже ждёт ответа" +// @Router /projects/{id}/workers [post] +func (h *ProjectHandler) AddWorker(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req addWorkerReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + // Приглашаем только реального активного мастера — как в assign. + worker, err := h.profileRepo.GetByID(c.Request.Context(), req.WorkerID) + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "worker not found"}) + return + } + if err != nil { + respondError(c, err) + return + } + if worker.Role != "worker" || !worker.IsActive { + c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active worker"}) + return + } + // Неподтверждённому приглашение не шлём (0010): он всё равно не + // сможет войти и принять его — сначала подтверждение. + if worker.ConfirmedAt == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user is not confirmed"}) + return + } + + expires, err := h.repo.InviteWorker(c.Request.Context(), id, req.WorkerID, c.GetString("user_id")) + if err != nil { + switch { + case errors.Is(err, repository.ErrMembershipActive): + c.JSON(http.StatusConflict, gin.H{"error": "worker is already in the project"}) + case errors.Is(err, repository.ErrInvitationPending): + c.JSON(http.StatusConflict, gin.H{"error": "invitation is already pending"}) + default: + respondError(c, err) + } + return + } + c.JSON(http.StatusOK, InvitationResponse{OK: true, Status: "invited", ExpiresAt: expires}) +} + +// AcceptInvitation — POST /projects/:id/invitation/accept. Мастер принял. +// +// @Summary Принять приглашение в проект +// @Description invited → active: с этого момента мастер состоит в проекте и ему можно назначать заявки. Действует только на своё непросроченное приглашение (срок — 7 дней, см. repository.InvitationTTL). +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" +// @Failure 404 {object} handlers.ErrorResponse "приглашения нет" +// @Failure 409 {object} handlers.ErrorResponse "срок вышел или приглашение уже закрыто" +// @Router /projects/{id}/invitation/accept [post] +func (h *ProjectHandler) AcceptInvitation(c *gin.Context) { + if !h.respondInvitation(c, func(id int64, workerID string) error { + return h.repo.AcceptInvitation(c.Request.Context(), id, workerID) + }) { + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// DeclineInvitation — POST /projects/:id/invitation/decline. Мастер отказал. +// +// @Summary Отклонить приглашение в проект +// @Description invited → declined: заявок мастер не получает, клиент видит отказ в составе проекта и может пригласить повторно (строка та же, история ответа обновляется). +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" +// @Failure 404 {object} handlers.ErrorResponse "приглашения нет" +// @Failure 409 {object} handlers.ErrorResponse "приглашение уже закрыто" +// @Router /projects/{id}/invitation/decline [post] +func (h *ProjectHandler) DeclineInvitation(c *gin.Context) { + if !h.respondInvitation(c, func(id int64, workerID string) error { + return h.repo.DeclineInvitation(c.Request.Context(), id, workerID) + }) { + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// respondInvitation — общий каркас accept/decline: разбор :id, роль +// (только мастер, её ставит RequireRole), вызов репозитория и перевод +// ошибок приглашения в ответы. true — операция прошла, можно отдавать 200. +func (h *ProjectHandler) respondInvitation(c *gin.Context, run func(id int64, workerID string) error) bool { + id, ok := pathID(c) + if !ok { + return false + } + if err := run(id, c.GetString("user_id")); err != nil { + switch { + case errors.Is(err, repository.ErrInvitationExpired): + c.JSON(http.StatusConflict, gin.H{"error": "invitation expired"}) + case errors.Is(err, repository.ErrInvitationNotPending): + c.JSON(http.StatusConflict, gin.H{"error": "invitation is not pending"}) + default: + respondError(c, err) + } + return false + } + return true +} + +// ListInvitations — GET /invitations. Приглашения, ждущие решения мастера. +// +// @Summary Мои приглашения в проекты +// @Description Список приглашений для текущего мастера: по каждому — проект, кто пригласил и до какого срока. Просроченные сюда не попадают — принять их уже нельзя. +// @Tags projects +// @Produce json +// @Security Bearer +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(invited_at, expires_at, project_name) Default(invited_at) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.InvitationListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" +// @Router /invitations [get] +func (h *ProjectHandler) ListInvitations(c *gin.Context) { + page, ok := parsePage(c, repository.InvitationSorts) + if !ok { + return + } + invites, total, err := h.repo.ListInvitations(c.Request.Context(), c.GetString("user_id"), page) + if err != nil { + respondError(c, err) + return + } + if invites == nil { + invites = []models.ProjectInvitation{} + } + c.JSON(http.StatusOK, InvitationListResponse{ + Items: invites, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +// RemoveWorker — DELETE /projects/:id/workers/:worker_id. Убрать мастера. +// +// @Summary Убрать мастера из проекта +// @Description Нельзя убрать, пока за ним закреплены незакрытые заявки проекта (409) — иначе работа осталась бы без исполнителя. Заявки из истории при этом не теряются. +// @Tags projects +// @Produce json +// @Security Bearer +// @Param id path int true "ID проекта" +// @Param worker_id path string true "ID мастера (uuid)" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "проект не найден или мастер не состоит в проекте" +// @Failure 409 {object} handlers.ErrorResponse "за мастером есть незакрытые заявки" +// @Router /projects/{id}/workers/{worker_id} [delete] +func (h *ProjectHandler) RemoveWorker(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + workerID := c.Param("worker_id") + if _, err := uuid.Parse(workerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + if err := h.repo.RemoveWorker(c.Request.Context(), id, workerID); err != nil { + if errors.Is(err, repository.ErrConflict) { + c.JSON(http.StatusConflict, gin.H{"error": "worker has open tickets in this project"}) + return + } + respondError(c, err) + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} diff --git a/internal/handlers/recurring.go b/internal/handlers/recurring.go new file mode 100644 index 0000000..491ae73 --- /dev/null +++ b/internal/handlers/recurring.go @@ -0,0 +1,474 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "slices" + "strconv" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/models" + "building-mgmt/internal/repository" +) + +// RecurringHandler — правила повторяющихся заявок (миграция 0014): +// клиент заводит периодические работы в своих проектах, админ — в любых. +// Фоновое создание заявок — забота планировщика в cmd/api, здесь — +// CRUD правил, история срабатываний и ручной запуск правила. +type RecurringHandler struct { + repo *repository.RecurringRepo + projectRepo *repository.ProjectRepo + // notify — уведомление клиенту о заявке, созданной ручным запуском; + // общий хелпер с планировщиком (notifyRecurring в cmd/api). + // nil — уведомления отключены (в тестах), заявка создаётся молча. + notify func(context.Context, repository.DueRun) +} + +// NewRecurringHandler собирает хендлер: projectRepo нужен, чтобы при +// создании правила проверить владение объектом и активность проекта — +// теми же запросами, что и при создании заявки; notify — для ручного +// «Запустить сейчас» (nil, если уведомления не нужны). +func NewRecurringHandler(repo *repository.RecurringRepo, projectRepo *repository.ProjectRepo, + notify func(context.Context, repository.DueRun), +) *RecurringHandler { + return &RecurringHandler{repo: repo, projectRepo: projectRepo, notify: notify} +} + +type createRecurringReq struct { + ObjectID int64 `json:"object_id" binding:"required"` + Floor string `json:"floor"` + Category string `json:"category" binding:"required,oneof=electric plumbing climate elevator other"` + Description string `json:"description" binding:"required,min=5"` + Priority string `json:"priority" binding:"omitempty,oneof=low normal high"` + // Расписание: «каждые PeriodDays дней/недель от AnchorAt» — + // AnchorAt (RFC3339) — дата-время первого запуска, PeriodDays — + // период в днях (1..3650; недели кратны 7). + AnchorAt time.Time `json:"anchor_at" binding:"required"` + PeriodDays int `json:"period_days" binding:"required,min=1,max=3650"` +} + +// Значения категории и приоритета — те же, что в oneof-тегах +// createRecurringReq (держать в согласии). Нужны для PATCH: там +// валидация рукописная — gin-биндинг не отличает «поля нет» от +// присланной пустой строки, а править можно только присланное. +var ( + recurringCategories = []string{"electric", "plumbing", "climate", "elevator", "other"} + recurringPriorities = []string{"low", "normal", "high"} +) + +// Create — POST /recurring. Создать правило повторения. +// +// @Summary Создать правило повторяющейся заявки +// @Description Правило — шаблон заявки (объект, категория, описание, приоритет) плюс расписание: anchor_at (дата-время первого запуска) и period_days — «каждые N дней/недель». Фоновый планировщик сам создаёт заявку, когда срок наступил, — но только если прошлая по правилу уже закрыта (без дублей). Клиент создаёт правило для своего проекта, админ — для любого. Обычно правило заводится чекбоксом в POST /tickets; этот эндпоинт — для управления без заявки. +// @Tags recurring +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body createRecurringReq true "Данные правила" +// @Success 201 {object} models.RecurringRule +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "объект не найден (или чужой — неотличим)" +// @Failure 409 {object} handlers.ErrorResponse "проект или объект выключен" +// @Router /recurring [post] +func (h *RecurringHandler) Create(c *gin.Context) { + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req createRecurringReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + if req.Priority == "" { + req.Priority = "normal" + } + + // Тот же рубеж, что у заявки: объект обязан принадлежать клиенту + // (чужой неотличим от несуществующего) и быть включённым. + obj, err := h.projectRepo.OwnedObject(c.Request.Context(), req.ObjectID, scopeID) + if err != nil { + respondError(c, err) + return + } + if !obj.ProjectActive { + c.JSON(http.StatusConflict, gin.H{"error": "project is not active"}) + return + } + if !obj.IsActive { + c.JSON(http.StatusConflict, gin.H{"error": "object is not active"}) + return + } + + // Владелец будущих заявок: у клиента — он сам; админу нужно узнать + // клиента проекта (объект такой связи в себе не несёт). + clientID := scopeID + if clientID == "" { + p, err := h.projectRepo.OwnedProject(c.Request.Context(), obj.ProjectID, "") + if err != nil { + respondError(c, err) + return + } + clientID = p.ClientID + } + + rule := &models.RecurringRule{ + ClientID: clientID, + ProjectID: obj.ProjectID, + ObjectID: obj.ID, + Category: req.Category, + Description: req.Description, + Priority: req.Priority, + Floor: req.Floor, + AnchorAt: req.AnchorAt, + PeriodDays: req.PeriodDays, + IsActive: true, + CreatedBy: c.GetString("user_id"), + } + if err := h.repo.CreateRule(c.Request.Context(), rule); err != nil { + respondError(c, err) + return + } + + full, err := h.repo.GetRule(c.Request.Context(), rule.ID, "") + if err != nil { + c.JSON(http.StatusCreated, rule) + return + } + c.JSON(http.StatusCreated, full) +} + +// List — GET /recurring. Список правил, выборка режется по роли. +// +// @Summary Список правил повторения +// @Description Клиент видит только свои правила, админ — все. Ответ — страница: items плюс total/limit/offset. +// @Tags recurring +// @Produce json +// @Security Bearer +// @Param project_id query int false "Сузить до одного проекта" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(created_at, id, date) Default(created_at) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.RecurringRuleListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Router /recurring [get] +func (h *RecurringHandler) List(c *gin.Context) { + scopeID, ok := ownerScope(c) + if !ok { + return + } + page, ok := parsePage(c, repository.RecurringSorts) + if !ok { + return + } + + var projectID int64 + if v := c.Query("project_id"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project_id"}) + return + } + projectID = n + // Чужой проект неотличим от несуществующего — как везде. + if _, err := h.projectRepo.OwnedProject(c.Request.Context(), n, scopeID); err != nil { + respondError(c, err) + return + } + } + + rules, total, err := h.repo.ListRules(c.Request.Context(), scopeID, projectID, page) + if err != nil { + respondError(c, err) + return + } + if rules == nil { + rules = []models.RecurringRule{} + } + c.JSON(http.StatusOK, RecurringRuleListResponse{ + Items: rules, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +// Get — GET /recurring/:id. Детали правила. +// +// @Summary Детали правила повторения +// @Description Админ — любое правило, клиент — только своё; чужое неотличимо от несуществующего (404). +// @Tags recurring +// @Produce json +// @Security Bearer +// @Param id path int true "ID правила" +// @Success 200 {object} models.RecurringRule +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "правило не найдено" +// @Router /recurring/{id} [get] +func (h *RecurringHandler) Get(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + rule, err := h.repo.GetRule(c.Request.Context(), id, scopeID) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, rule) +} + +// updateRecurringReq — частичное обновление правила: nil означает +// «поле не трогаем», поэтому все поля — указатели (иначе gin не +// отличит «не прислали» от «прислали нулевое»). +type updateRecurringReq struct { + Floor *string `json:"floor"` + Category *string `json:"category"` + Description *string `json:"description"` + Priority *string `json:"priority"` + AnchorAt *time.Time `json:"anchor_at"` + PeriodDays *int `json:"period_days"` + IsActive *bool `json:"is_active"` +} + +// Update — PATCH /recurring/:id. Изменить правило: шаблон заявки +// (этаж, категория, описание, приоритет), расписание и включение. +// Объект не меняется — это удаление и создание правила. +// +// @Summary Изменить правило повторения +// @Description Частичное обновление: применяются только присланные поля, рамки те же, что при создании (anchor_at — любая дата-время, period_days 1..3650). object_id не меняется (перенос — через удаление и создание). Срок не сдвигается вручную: он пересчитывается от последнего срабатывания с новым расписанием. +// @Tags recurring +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID правила" +// @Param body body updateRecurringReq true "Что изменить (хотя бы одно поле)" +// @Success 200 {object} models.RecurringRule +// @Failure 400 {object} handlers.ErrorResponse "некорректный id, пустое тело или значение вне рамок" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "правило не найдено" +// @Router /recurring/{id} [patch] +func (h *RecurringHandler) Update(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + var req updateRecurringReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + // PATCH без единого поля — 400: менять нечего. + if req.Floor == nil && req.Category == nil && req.Description == nil && + req.Priority == nil && req.AnchorAt == nil && req.PeriodDays == nil && req.IsActive == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + // Ручная валидация присланных полей — в тех же рамках, что Create + // (там их закрывает oneof/min/max в тегах): чек только для + // переданных значений, «поля нет» мы не трогаем. + if req.Category != nil && !slices.Contains(recurringCategories, *req.Category) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid category"}) + return + } + if req.Description != nil && utf8.RuneCountInString(*req.Description) < 5 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid description"}) + return + } + if req.Priority != nil && !slices.Contains(recurringPriorities, *req.Priority) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid priority"}) + return + } + if req.AnchorAt != nil && req.AnchorAt.IsZero() { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid anchor_at"}) + return + } + if req.PeriodDays != nil && (*req.PeriodDays < 1 || *req.PeriodDays > 3650) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid period_days"}) + return + } + + if err := h.repo.UpdateRule(c.Request.Context(), id, scopeID, repository.RulePatch{ + Floor: req.Floor, + Category: req.Category, + Description: req.Description, + Priority: req.Priority, + AnchorAt: req.AnchorAt, + PeriodDays: req.PeriodDays, + IsActive: req.IsActive, + }); err != nil { + respondError(c, err) + return + } + rule, err := h.repo.GetRule(c.Request.Context(), id, "") + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, rule) +} + +// Delete — DELETE /recurring/:id. Удалить правило. +// +// @Summary Удалить правило повторения +// @Description Удаляется само правило, его история срабатываний (какая заявка создавалась и когда) и — каскадом — будущие заявки ещё не созданы. Созданные ранее заявки не трогаются. +// @Tags recurring +// @Produce json +// @Security Bearer +// @Param id path int true "ID правила" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "правило не найдено" +// @Router /recurring/{id} [delete] +func (h *RecurringHandler) Delete(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + if err := h.repo.DeleteRule(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// RecurringRunResponse — ответ на ручной запуск правила: заявка +// создана, вот её номер (ссылку на страницу клиент строит сам). +type RecurringRunResponse struct { + OK bool `json:"ok"` + TicketID int64 `json:"ticket_id"` +} + +// Run — POST /recurring/:id/run. Запустить правило сейчас, не дожидаясь +// интервала: ручной триггер вместо фонового срабатывания. +// +// @Summary Запустить правило сейчас +// @Description Заявка создаётся сразу, без ожидания интервала. Остальные рубежи как у планировщика: правило, проект и объект включены, прошлая заявка по правилу закрыта — иначе 409, дубль не создаём. Срок сдвигается на момент запуска (следующий фоновый тик отсчитает интервал от него), уведомление уходит тем же текстом, что при фоновом срабатывании. +// @Tags recurring +// @Produce json +// @Security Bearer +// @Param id path int true "ID правила" +// @Success 201 {object} handlers.RecurringRunResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "правило не найдено (или чужое — неотличим)" +// @Failure 409 {object} handlers.ErrorResponse "правило, проект или объект выключены, либо есть открытая заявка" +// @Router /recurring/{id}/run [post] +func (h *RecurringHandler) Run(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + + run, err := h.repo.RunRuleNow(c.Request.Context(), id, scopeID, time.Now()) + if err != nil { + // Причины конфликта — своими текстами (как в Create); + // остальное — общий маршрут ошибок. + var msg string + switch { + case errors.Is(err, repository.ErrRuleOpen): + msg = "open ticket exists" + case errors.Is(err, repository.ErrRuleNotActive): + msg = "rule is not active" + case errors.Is(err, repository.ErrProjectNotActive): + msg = "project is not active" + case errors.Is(err, repository.ErrObjectNotActive): + msg = "object is not active" + default: + respondError(c, err) + return + } + c.JSON(http.StatusConflict, gin.H{"error": msg}) + return + } + + // Заявка уже создана — уведомление best-effort и без отмены по + // завершении ответа (контекст запроса здесь умер бы мгновенно). + if h.notify != nil { + go h.notify(context.WithoutCancel(c.Request.Context()), *run) + } + c.JSON(http.StatusCreated, RecurringRunResponse{OK: true, TicketID: run.TicketID}) +} + +// Runs — GET /recurring/:id/runs. История срабатываний правила. +// +// @Summary История срабатываний правила +// @Description Какие заявки создавались по правилу и когда (аудит). Сначала свежие. Строка живёт вместе со своей заявкой: когда заявку удаляет автоочистка (TICKET_RETENTION_DAYS), строка истории уходит следом. +// @Tags recurring +// @Produce json +// @Security Bearer +// @Param id path int true "ID правила" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(run_at, id) Default(run_at) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.RecurringRunListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" +// @Failure 404 {object} handlers.ErrorResponse "правило не найдено" +// @Router /recurring/{id}/runs [get] +func (h *RecurringHandler) Runs(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + scopeID, ok := ownerScope(c) + if !ok { + return + } + page, ok := parsePage(c, repository.RecurringRunSorts) + if !ok { + return + } + + // Сначала права: чужое правило неотличимо от несуществующего. + if _, err := h.repo.GetRule(c.Request.Context(), id, scopeID); err != nil { + respondError(c, err) + return + } + + runs, total, err := h.repo.ListRuns(c.Request.Context(), id, page) + if err != nil { + respondError(c, err) + return + } + if runs == nil { + runs = []models.RecurringRun{} + } + c.JSON(http.StatusOK, RecurringRunListResponse{ + Items: runs, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} diff --git a/internal/handlers/telegram.go b/internal/handlers/telegram.go new file mode 100644 index 0000000..3d165b2 --- /dev/null +++ b/internal/handlers/telegram.go @@ -0,0 +1,430 @@ +package handlers + +import ( + "context" + "crypto/rand" + "errors" + "log/slog" + "net/http" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/crypt" + "building-mgmt/internal/ratelimit" + "building-mgmt/internal/repository" + "building-mgmt/internal/telegram" +) + +const ( + // bindCodeTTL — срок жизни кода привязки: ровно столько, чтобы + // успеть перейти по ссылке и написать боту, не дольше — код, + // лежащий в чате, бесполезен через сутки. + bindCodeTTL = 15 * time.Minute + // bindCodeLen / bindCodeAlphabet — 8 знаков из 32 (~40 бит) без + // похожих символов (0/O, 1/I): код переносят в бота руками, + // опечатка должна быть заметна. + bindCodeLen = 8 + bindCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +) + +// targetPattern — единственный формат ручной цели: @имя (группа или +// канал, куда бот добавлен) либо числовой chat_id. Личный @ник сюда +// не проходит намеренно: бот не видит человека, пока тот сам ему не +// напишет, — поэтому личная привязка идёт отдельно, через код /start. +var targetPattern = regexp.MustCompile(`^(@[A-Za-z0-9_]{5,32}|-?[0-9]{5,20})$`) + +// botTokenPattern — формат токена Bot API от @BotFather: ":<секрет>". +// Проверка до сети: мусор не доходит до getMe, а у реального токена +// формат стабилен уже много лет. Непонятное значение → 400. +var botTokenPattern = regexp.MustCompile(`^\d{5,40}:[A-Za-z0-9_-]{10,200}$`) + +// BotRegistry — выбор бота для пользователя и управление его поллером. +// Реализация — notify.Registry: собственный токен пользователя +// приоритетнее глобального TELEGRAM_BOT_TOKEN. +type BotRegistry interface { + // BotFor — личный бот пользователя, иначе глобальный (может быть + // выключенным — проверяет Enabled()). + BotFor(userID string) telegram.API + // Register поднимает поллер личного бота (замена токена гасит прежний). + Register(userID string, bot telegram.API) + // Unregister гасит поллер и убирает бота из реестра. + Unregister(userID string) +} + +// NewBot — фабрика клиента Bot API по токену: сеть поднимается отдельно +// (Init), здесь создаётся только клиент (в cmd/api — telegram.New). +type NewBot func(token string) telegram.API + +// TelegramConfig — зависимости Telegram-хендлера. +type TelegramConfig struct { + Repo *repository.TelegramRepo + // Bots — реестр: чей бот использовать (личный или глобальный) и + // как его поллер остановить. + Bots BotRegistry + // Cipher шифрует токены перед записью в БД; nil — ввод токена на + // сервере выключен (нет DATA_ENCRYPTION_KEY), ответ 400. + Cipher *crypt.Cipher + // NewBot — фабрика для проверки введённого токена. + NewBot NewBot + // CodeLimiter — лимит выдачи кодов привязки (ключ — user_id). + CodeLimiter ratelimit.Allower + // TokenLimiter — лимит сохранения токена: там сетевой getMe. + TokenLimiter ratelimit.Allower +} + +// TelegramHandler — уведомления в Telegram: собственный бот пользователя, +// привязка чата (одноразовый код), ручной ввод группы/канала, +// отключение. +type TelegramHandler struct { + repo *repository.TelegramRepo + bots BotRegistry + cipher *crypt.Cipher + newBot NewBot + codeLimiter ratelimit.Allower + tokenLimiter ratelimit.Allower +} + +// NewTelegramHandler собирает хендлер. +func NewTelegramHandler(cfg TelegramConfig) *TelegramHandler { + return &TelegramHandler{ + repo: cfg.Repo, + bots: cfg.Bots, + cipher: cfg.Cipher, + newBot: cfg.NewBot, + codeLimiter: cfg.CodeLimiter, + tokenLimiter: cfg.TokenLimiter, + } +} + +// TelegramStatusResponse — состояние уведомлений текущего пользователя. +// Токен сюда не входит никогда: по статусу видно только, есть ли он. +type TelegramStatusResponse struct { + // Enabled — может ли этот пользователь получать уведомления вообще: + // есть свой токен бота либо настроен глобальный бот сервера. + Enabled bool `json:"enabled"` + // Bound — есть ли уже цель уведомлений. + Bound bool `json:"bound"` + // Target — текущая цель: числовой chat_id либо @группа. + Target string `json:"target,omitempty"` + // BotUsername — @имя бота, через который придут уведомления и + // ссылка привязки; пусто, пока getMe не удался. + BotUsername string `json:"bot_username,omitempty"` + // HasBotToken — подключён ли пользователь свой бот (токен в БД). + HasBotToken bool `json:"has_bot_token"` + // TokenConfigurable — можно ли ввести токен прямо сейчас: false, + // если на сервере не задан DATA_ENCRYPTION_KEY. + TokenConfigurable bool `json:"token_configurable"` +} + +// Status — GET /auth/telegram. +// +// @Summary Статус уведомлений в Telegram +// @Description Куда сейчас приходят уведомления о заявках: цель (чат или группа), имя бота (свой или глобальный), подключён ли собственный токен бота (has_bot_token) и можно ли его ввести (token_configurable — зависит от DATA_ENCRYPTION_KEY на сервере). Сам токен наружу не отдаётся. +// @Tags auth +// @Produce json +// @Success 200 {object} handlers.TelegramStatusResponse +// @Failure 401 {object} handlers.ErrorResponse "нет входа" +// @Failure 404 {object} handlers.ErrorResponse "профиль не найден" +// @Router /auth/telegram [get] +func (h *TelegramHandler) Status(c *gin.Context) { + ctx := c.Request.Context() + userID := c.GetString("user_id") + + target, err := h.repo.Target(ctx, userID) + if err != nil { + respondError(c, err) + return + } + hasToken, storedName, err := h.repo.BotTokenInfo(ctx, userID) + if err != nil { + respondError(c, err) + return + } + + // Имя бота: живое из реестра (getMe при сохранении или на старте), + // иначе то, что записали в базу. + bot := h.botFor(userID) + username := bot.Username() + if username == "" { + username = storedName + } + + c.JSON(http.StatusOK, TelegramStatusResponse{ + Enabled: bot.Enabled(), + Bound: target != "", + Target: target, + BotUsername: username, + HasBotToken: hasToken, + TokenConfigurable: h.cipher != nil, + }) +} + +// BindCodeResponse — ответ на выпуск кода: сам код (его можно набрать +// в боте руками) и ссылка, открывающая бота с кодом уже подставленным. +type BindCodeResponse struct { + Code string `json:"code"` + Link string `json:"link"` + ExpiresAt time.Time `json:"expires_at"` +} + +// IssueCode — POST /auth/telegram/code. Код одноразовый: бот получает +// его как аргумент /start, поллер гасит и привязывает чат к аккаунту. +// Код выдаёт бот пользователя — свой токен либо глобальный. +// +// @Summary Выдать код привязки Telegram +// @Description Одноразовый код и deep-link t.me/{bot}?start={code}: откройте ссылку в Telegram и отправьте боту код — этот чат начнёт получать уведомления о заявках. Бот — ваш собственный токен, а если его нет — глобальный бот сервера. Код живёт 15 минут, прежние коды гаснут. Лимит: 5 выпусков за 5 минут на пользователя. +// @Tags auth +// @Accept json +// @Produce json +// @Success 200 {object} handlers.BindCodeResponse +// @Failure 400 {object} handlers.ErrorResponse "ни свой бот, ни глобальный не настроены" +// @Failure 404 {object} handlers.ErrorResponse "профиль не найден" +// @Failure 429 {object} handlers.ErrorResponse "слишком много кодов" +// @Router /auth/telegram/code [post] +func (h *TelegramHandler) IssueCode(c *gin.Context) { + ctx := c.Request.Context() + userID := c.GetString("user_id") + bot := h.botFor(userID) + if !bot.Enabled() { + c.JSON(http.StatusBadRequest, gin.H{"error": "telegram not configured"}) + return + } + if !h.codeLimiter.Allow("tg_code|" + userID) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + // Имя бота для ссылки: на старте getMe мог не пройти (сеть), + // поэтому даём ему второй шанс именно здесь, у редкого запроса. + // Проверяем раньше базы — инфраструктурная проблема не должна + // выглядеть как «профиль не найден». + if h.username(ctx, bot) == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "telegram unavailable"}) + return + } + + // Сессия могла пережить удаление профиля: код без владельца + // привязать не к кому, а отдельного «приветствия» тут не будет. + if _, err := h.repo.Target(ctx, userID); errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } else if err != nil { + respondError(c, err) + return + } + + code, err := randomCode() + if err != nil { + slog.Error("telegram bind code generation failed", "err", err, "user_id", userID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + expires, err := h.repo.CreateBindCode(ctx, userID, telegram.CodeHash(code), bindCodeTTL) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, BindCodeResponse{ + Code: code, + Link: telegram.Link(bot.Username(), code), + ExpiresAt: expires.UTC(), + }) +} + +type setTelegramReq struct { + // Target — @группа/@канал или числовой chat_id; пусто — отключить. + Target string `json:"target"` +} + +// Set — PUT /auth/telegram. Ручной ввод цели (обычно группа или канал) +// и очистка её пустым значением. Чат проверяет бот пользователя: чужая +// группа недоступна для его бота так же, как для глобального. +// +// @Summary Указать цель уведомлений вручную +// @Description Принимает @имя группы/канала (бот обязан быть в нём) или числовой chat_id; пустое значение отключает уведомления. Сначала проверяется формат, затем бот проверяет чат через getChat — чужой или недоступный чат не сохраняется. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body setTelegramReq true "Цель: @группа, chat_id или пусто" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "invalid telegram target" +// @Failure 401 {object} handlers.ErrorResponse "нет входа" +// @Router /auth/telegram [put] +func (h *TelegramHandler) Set(c *gin.Context) { + var req setTelegramReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + target := strings.TrimSpace(req.Target) + if target != "" && !targetPattern.MatchString(target) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram target"}) + return + } + + // Без включённого бота формат не с чем сверить — проверяем только + // его (иначе локальная разработка без бота ломается на своих же данных). + bot := h.botFor(c.GetString("user_id")) + if target != "" && bot.Enabled() { + if err := bot.GetChat(c.Request.Context(), target); err != nil { + // Причина не различается наружу (нет чата / бота выгнали / + // нет прав — это одно «цель не работает»), детали — в лог. + slog.Warn("telegram getChat failed", "err", err, "target", target) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram target"}) + return + } + } + if err := h.repo.SetTarget(c.Request.Context(), c.GetString("user_id"), target); err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// Unbind — DELETE /auth/telegram. Убирает цель: уведомления прекращаются. +// +// @Summary Отключить уведомления в Telegram +// @Description Очищает цель уведомлений текущего пользователя (то же, что PUT с пустым значением). Токен бота при этом не трогается — он живёт на своём эндпоинте. +// @Tags auth +// @Produce json +// @Success 200 {object} handlers.OKResponse +// @Failure 401 {object} handlers.ErrorResponse "нет входа" +// @Router /auth/telegram [delete] +func (h *TelegramHandler) Unbind(c *gin.Context) { + if err := h.repo.SetTarget(c.Request.Context(), c.GetString("user_id"), ""); err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +type setBotReq struct { + // Token — токен бота от @BotFather; пусто невалидно (нужен явный ввод). + Token string `json:"token"` +} + +// SetBot — POST /auth/telegram/bot. Сохраняет собственного бота +// пользователя: проверка токена getMe, шифрование, запись в БД и +// запуск его поллера. +// +// @Summary Подключить своего бота +// @Description Принимает токен Bot API (формат «id:secret» от @BotFather), проверяет его через getMe, шифрует и сохраняет. Дальше уведомления пользователя идут через этого бота, его поллер поднимается сразу. Лимит: 5 попыток за 5 минут. Без DATA_ENCRYPTION_KEY на сервере — 400. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body setBotReq true "Токен бота" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "токен не сохранён (формат, getMe или фича выключена)" +// @Failure 401 {object} handlers.ErrorResponse "нет входа" +// @Failure 404 {object} handlers.ErrorResponse "профиль не найден" +// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток" +// @Router /auth/telegram/bot [post] +func (h *TelegramHandler) SetBot(c *gin.Context) { + ctx := c.Request.Context() + userID := c.GetString("user_id") + + // Шифрования нет — токен сохранить некуда (секрет лежал бы открытым). + if h.cipher == nil || h.newBot == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "telegram bot token not configured"}) + return + } + var req setBotReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + token := strings.TrimSpace(req.Token) + // Формат раньше сети: мусор не доходит до getMe. + if !botTokenPattern.MatchString(token) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot token"}) + return + } + if !h.tokenLimiter.Allow("tg_token|" + userID) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + // Проверка токена: неверный или отозванный не сохраняем, чтобы + // пользователь не получил молча нерабочего бота. + bot := h.newBot(token) + if err := bot.Init(ctx); err != nil { + slog.Warn("telegram bot token rejected", "err", err, "user_id", userID) + c.JSON(http.StatusBadRequest, gin.H{"error": "bot token rejected"}) + return + } + + enc, err := h.cipher.Seal(token) + if err != nil { + slog.Error("telegram token seal failed", "err", err, "user_id", userID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + if err := h.repo.SetBotToken(ctx, userID, enc, bot.Username()); err != nil { + respondError(c, err) + return + } + // Реестр поднимает поллер бота (и гасит прежний, если токен меняли). + h.bots.Register(userID, bot) + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// DeleteBot — DELETE /auth/telegram/bot. Убирает собственного бота: +// поллер гасится, уведомления (если цель осталась) пойдут через +// глобальный бот сервера либо перестанут уходить, когда его нет. +// +// @Summary Отключить своего бота +// @Description Стирает сохранённый токен текущего пользователя и останавливает его поллер. Повторный вызов безопасен — ответ тот же 200. +// @Tags auth +// @Produce json +// @Success 200 {object} handlers.OKResponse +// @Failure 401 {object} handlers.ErrorResponse "нет входа" +// @Failure 404 {object} handlers.ErrorResponse "профиль не найден" +// @Router /auth/telegram/bot [delete] +func (h *TelegramHandler) DeleteBot(c *gin.Context) { + ctx := c.Request.Context() + userID := c.GetString("user_id") + if err := h.repo.ClearBotToken(ctx, userID); err != nil { + respondError(c, err) + return + } + h.bots.Unregister(userID) + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// botFor — бот пользователя: собственный токен, иначе глобальный. +// nil-реестр не бывает: конструктор его всегда получает. +func (h *TelegramHandler) botFor(userID string) telegram.API { + return h.bots.BotFor(userID) +} + +// username — @имя бота, с повторной попыткой getMe, если на старте +// не вышло. Пустая строка означает, что ссылку привязки не собрать. +func (h *TelegramHandler) username(ctx context.Context, bot telegram.API) string { + if bot == nil || !bot.Enabled() { + return "" + } + if name := bot.Username(); name != "" { + return name + } + if err := bot.Init(ctx); err != nil { + slog.Warn("telegram getMe failed", "err", err) + return "" + } + return bot.Username() +} + +// randomCode — код привязки: crypto/rand по алфавиту без похожих знаков. +// Алфавит 32 символа — 256 делится на 32 ровно, смещения в байте нет. +func randomCode() (string, error) { + raw := make([]byte, bindCodeLen) + if _, err := rand.Read(raw); err != nil { + return "", err + } + for i, b := range raw { + raw[i] = bindCodeAlphabet[int(b)%len(bindCodeAlphabet)] + } + return string(raw), nil +} diff --git a/internal/handlers/telegram_test.go b/internal/handlers/telegram_test.go new file mode 100644 index 0000000..d736a9d --- /dev/null +++ b/internal/handlers/telegram_test.go @@ -0,0 +1,380 @@ +package handlers + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/crypt" + "building-mgmt/internal/telegram" +) + +// newTelegramContext — контекст запроса к Telegram-хендлеру с id юзера, +// как он есть после auth.Middleware. +func newTelegramContext(t *testing.T, method, path, body string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("user_id", "11111111-1111-1111-1111-111111111111") + return c, w +} + +// fakeTelegramBot — бот без сети: записи вызовов нужны, чтобы видеть, +// что формат цели проверился до базы. +type fakeTelegramBot struct { + enabled bool + username string + initErr error + chatErr error + initCalls int + chats []string + sent []string +} + +func (f *fakeTelegramBot) Enabled() bool { return f.enabled } +func (f *fakeTelegramBot) Username() string { return f.username } + +func (f *fakeTelegramBot) Init(context.Context) error { + f.initCalls++ + if f.initErr != nil { + return f.initErr + } + if f.username == "" { + f.username = "test_bot" + } + return nil +} + +func (f *fakeTelegramBot) SendMessage(_ context.Context, chat, _ string) error { + f.sent = append(f.sent, chat) + return nil +} + +func (f *fakeTelegramBot) GetChat(_ context.Context, target string) error { + f.chats = append(f.chats, target) + return f.chatErr +} + +func (f *fakeTelegramBot) GetUpdates(context.Context, int64, time.Duration) ([]telegram.Update, error) { + return nil, nil +} + +// fakeRegistry — BotRegistry без поллеров: запасной (глобальный) бот +// плюс личные по пользователям, записи вызовов видно в tests. +type fakeRegistry struct { + global telegram.API + users map[string]telegram.API + registered []string + unregistered []string +} + +func (f *fakeRegistry) BotFor(userID string) telegram.API { + if b, ok := f.users[userID]; ok { + return b + } + return f.global +} + +func (f *fakeRegistry) Register(userID string, bot telegram.API) { + if f.users == nil { + f.users = map[string]telegram.API{} + } + f.users[userID] = bot + f.registered = append(f.registered, userID) +} + +func (f *fakeRegistry) Unregister(userID string) { + delete(f.users, userID) + f.unregistered = append(f.unregistered, userID) +} + +// fakeAllow — Allower с заданным результатом. +type fakeAllow bool + +func (f fakeAllow) Allow(string) bool { return bool(f) } + +// TestSetTargetValidation — некорректная цель не доходит ни до getChat, +// ни до базы (repo = nil упал бы): только формат и 400. +func TestSetTargetValidation(t *testing.T) { + for _, target := range []string{ + "buildings", // без @ и не число + "@ab", // короче 5 знаков + "@ссылка", // не латиница + "@group name", // пробел + "https://t.me/x", // не имя чата + "12", // короткое число + "@" + strings.Repeat("a", 33), // слишком длинное имя (>32) + } { + t.Run(target, func(t *testing.T) { + h := &TelegramHandler{bots: &fakeRegistry{global: &fakeTelegramBot{}}, codeLimiter: fakeAllow(true)} + c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram", + `{"target":"`+target+`"}`) + h.Set(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if body := w.Body.String(); !strings.Contains(body, "invalid telegram target") { + t.Errorf("body = %s", body) + } + }) + } +} + +// TestSetTargetBotCheck — включённый бот проверяет чат getChat'ом: +// недоступная цель → 400 и в базу не пишем. +func TestSetTargetBotCheck(t *testing.T) { + bot := &fakeTelegramBot{enabled: true, username: "test_bot", + chatErr: telegram.ErrChatNotFound} + h := &TelegramHandler{bots: &fakeRegistry{global: bot}, codeLimiter: fakeAllow(true)} + c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram", + `{"target":"@buildings_group"}`) + h.Set(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if len(bot.chats) != 1 || bot.chats[0] != "@buildings_group" { + t.Errorf("getChat calls = %v, want [@buildings_group]", bot.chats) + } +} + +// TestSetTargetBadBody — кривой JSON и не тот тип поля — 400 до всякой +// логики (стандартная форма ошибки binding). +func TestSetTargetBadBody(t *testing.T) { + for _, body := range []string{`{"target":`, `{"target":5}`, `[]`} { + t.Run(body, func(t *testing.T) { + h := &TelegramHandler{bots: &fakeRegistry{global: &fakeTelegramBot{}}, codeLimiter: fakeAllow(true)} + c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram", body) + h.Set(c) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + }) + } +} + +// TestIssueCodeGuards — защитные проверки до базы: фича выключена, +// лимит кодов исчерпан, getMe недоступен. +func TestIssueCodeGuards(t *testing.T) { + t.Run("бот не настроен", func(t *testing.T) { + h := &TelegramHandler{bots: &fakeRegistry{global: &fakeTelegramBot{enabled: false}}, codeLimiter: fakeAllow(true)} + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/code", `{}`) + h.IssueCode(c) + if w.Code != http.StatusBadRequest || + !strings.Contains(w.Body.String(), "telegram not configured") { + t.Errorf("status = %d, body = %s", w.Code, w.Body.String()) + } + }) + + t.Run("лимит кодов", func(t *testing.T) { + bot := &fakeTelegramBot{enabled: true, username: "test_bot"} + h := &TelegramHandler{bots: &fakeRegistry{global: bot}, codeLimiter: fakeAllow(false)} + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/code", `{}`) + h.IssueCode(c) + if w.Code != http.StatusTooManyRequests { + t.Errorf("status = %d, want 429", w.Code) + } + if bot.initCalls != 0 { + t.Errorf("Init calls = %d, want 0 (лимит раньше сети)", bot.initCalls) + } + }) + + t.Run("getMe недоступен", func(t *testing.T) { + bot := &fakeTelegramBot{enabled: true, initErr: context.DeadlineExceeded} + h := &TelegramHandler{bots: &fakeRegistry{global: bot}, codeLimiter: fakeAllow(true)} + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/code", `{}`) + h.IssueCode(c) + if w.Code != http.StatusInternalServerError || + !strings.Contains(w.Body.String(), "telegram unavailable") { + t.Errorf("status = %d, body = %s", w.Code, w.Body.String()) + } + if bot.initCalls != 1 { + t.Errorf("Init calls = %d, want 1 (второй шанс getMe)", bot.initCalls) + } + }) +} + +// testCipher — рабочий шифровальщик для тестов токена (нужен, чтобы +// хендлер вообще дошёл до формата и сети). +func testCipher(t *testing.T) *crypt.Cipher { + t.Helper() + c, err := crypt.New(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatalf("crypt.New: %v", err) + } + return c +} + +// validBotToken — формат, который проходит проверку до сети. +const validBotToken = "123456789:AAFakeTokenForTests_0123456789" + +// TestSetBotGuards — защитные проверки подключения бота: без ключа +// шифрования фича выключена, кривой формат и лимит срабатывают раньше +// сети, отвергнутый getMe не доходит ни до базы, ни до реестра +// (repo = nil упал бы). +func TestSetBotGuards(t *testing.T) { + const body = `{"token":"` + validBotToken + `"}` + + t.Run("нет ключа шифрования", func(t *testing.T) { + h := &TelegramHandler{bots: &fakeRegistry{}, tokenLimiter: fakeAllow(true)} + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", body) + h.SetBot(c) + if w.Code != http.StatusBadRequest || + !strings.Contains(w.Body.String(), "telegram bot token not configured") { + t.Errorf("status = %d, body = %s", w.Code, w.Body.String()) + } + }) + + t.Run("кривой формат токена", func(t *testing.T) { + bot := &fakeTelegramBot{enabled: true} + h := &TelegramHandler{ + bots: &fakeRegistry{}, + cipher: testCipher(t), + newBot: func(string) telegram.API { return bot }, + tokenLimiter: fakeAllow(true), + } + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", + `{"token":"не токен"}`) + h.SetBot(c) + if w.Code != http.StatusBadRequest || + !strings.Contains(w.Body.String(), "invalid bot token") { + t.Errorf("status = %d, body = %s", w.Code, w.Body.String()) + } + if bot.initCalls != 0 { + t.Errorf("Init calls = %d, want 0 (формат раньше сети)", bot.initCalls) + } + }) + + t.Run("лимит попыток", func(t *testing.T) { + bot := &fakeTelegramBot{enabled: true} + h := &TelegramHandler{ + bots: &fakeRegistry{}, + cipher: testCipher(t), + newBot: func(string) telegram.API { return bot }, + tokenLimiter: fakeAllow(false), + } + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", body) + h.SetBot(c) + if w.Code != http.StatusTooManyRequests { + t.Errorf("status = %d, want 429", w.Code) + } + if bot.initCalls != 0 { + t.Errorf("Init calls = %d, want 0 (лимит раньше сети)", bot.initCalls) + } + }) + + t.Run("getMe отверг токен", func(t *testing.T) { + bot := &fakeTelegramBot{enabled: true, initErr: context.DeadlineExceeded} + h := &TelegramHandler{ + bots: &fakeRegistry{}, + cipher: testCipher(t), + newBot: func(string) telegram.API { return bot }, + tokenLimiter: fakeAllow(true), + } + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", body) + h.SetBot(c) + if w.Code != http.StatusBadRequest || + !strings.Contains(w.Body.String(), "bot token rejected") { + t.Errorf("status = %d, body = %s", w.Code, w.Body.String()) + } + if bot.initCalls != 1 { + t.Errorf("Init calls = %d, want 1", bot.initCalls) + } + }) + + t.Run("кривое тело", func(t *testing.T) { + h := &TelegramHandler{ + bots: &fakeRegistry{}, + cipher: testCipher(t), + newBot: func(string) telegram.API { return &fakeTelegramBot{} }, + tokenLimiter: fakeAllow(true), + } + for _, raw := range []string{`{"token":`, `{"token":5}`, `[]`} { + c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", raw) + h.SetBot(c) + if w.Code != http.StatusBadRequest { + t.Errorf("body %s: status = %d, want 400", raw, w.Code) + } + } + }) +} + +// TestSetUsesPersonalBot — цель проверяет бот пользователя, а не +// глобальный: чужой для него чат должен дать 400, и ни один из ботов +// не должен молча пройти мимо проверки. +func TestSetUsesPersonalBot(t *testing.T) { + const user = "11111111-1111-1111-1111-111111111111" + mine := &fakeTelegramBot{enabled: true, username: "mine_bot", + chatErr: telegram.ErrChatNotFound} + global := &fakeTelegramBot{enabled: true, username: "global_bot"} + h := &TelegramHandler{ + bots: &fakeRegistry{global: global, users: map[string]telegram.API{user: mine}}, + codeLimiter: fakeAllow(true), + tokenLimiter: fakeAllow(true), + } + c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram", + `{"target":"@buildings_group"}`) + h.Set(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } + if len(mine.chats) != 1 || mine.chats[0] != "@buildings_group" { + t.Errorf("personal bot chats = %v, want [@buildings_group]", mine.chats) + } + if len(global.chats) != 0 { + t.Errorf("global bot chats = %v, want none (проверял личный)", global.chats) + } +} + +// TestRandomCode — код привязки: длина, алфавит без похожих символов. +func TestRandomCode(t *testing.T) { + for i := 0; i < 200; i++ { + code, err := randomCode() + if err != nil { + t.Fatalf("randomCode: %v", err) + } + if len(code) != bindCodeLen { + t.Fatalf("len = %d, want %d", len(code), bindCodeLen) + } + for _, r := range code { + if !strings.ContainsRune(bindCodeAlphabet, r) { + t.Fatalf("символ %q вне алфавита (%s)", r, code) + } + } + } +} + +// TestTargetPattern — пограничные значения формата цели. +func TestTargetPattern(t *testing.T) { + for _, tc := range []struct { + target string + valid bool + }{ + {"@buildings", true}, + {"@Build_123", true}, + {"-1001234567890", true}, // группа + {"123456789", true}, // личный chat_id + {"", true}, // пусто = отключить (проверяется отдельно) + {"@abc", false}, // слишком короткое имя + {"@_" + strings.Repeat("a", 32), false}, // 35 знаков + {"123", false}, // слишком короткое число + {"@имя", false}, // не латиница + } { + got := tc.target == "" || targetPattern.MatchString(tc.target) + if got != tc.valid { + t.Errorf("target %q: valid = %v, want %v", tc.target, got, tc.valid) + } + } +} diff --git a/internal/handlers/tickets.go b/internal/handlers/tickets.go new file mode 100644 index 0000000..209dc4d --- /dev/null +++ b/internal/handlers/tickets.go @@ -0,0 +1,740 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "building-mgmt/internal/models" + "building-mgmt/internal/repository" +) + +// TicketHandler — эндпоинты заявок: создание, выборка, переходы статусной +// машины и закрытие цикла (confirm/reject/cancel). +type TicketHandler struct { + repo *repository.TicketRepo + profileRepo *repository.ProfileRepo + projectRepo *repository.ProjectRepo +} + +// NewTicketHandler собирает хендлер: репозиторий заявок нужен всегда, +// репозиторий профилей — чтобы проверить активность worker_id в assign, +// репозиторий проектов — чтобы проверить, что мастер состоит в проекте +// заявки, а объект при создании принадлежит клиенту. +func NewTicketHandler(repo *repository.TicketRepo, profileRepo *repository.ProfileRepo, projectRepo *repository.ProjectRepo) *TicketHandler { + return &TicketHandler{repo: repo, profileRepo: profileRepo, projectRepo: projectRepo} +} + +// getTicket — разобрать :id и загрузить заявку. +// false означает, что ответ (400/404/500) уже отправлен. +func (h *TicketHandler) getTicket(c *gin.Context) (*models.Ticket, bool) { + return loadTicket(c, h.repo) +} + +// loadTicket — общий хелпер хендлеров заявки (детали, журнал, фото): +// разобрать :id и загрузить заявку. false — ответ уже отправлен. +func loadTicket(c *gin.Context, repo *repository.TicketRepo) (*models.Ticket, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return nil, false + } + t, err := repo.GetByID(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return nil, false + } + return t, true +} + +// canView — кто вообще может знать о существовании заявки. У мастера, +// кроме назначенных ему, открыты свободные (new) заявки проектов, где +// он активный участник: ровно те, что он вправе взять сам. Членство +// здесь решает БД, а не хендлер. Ошибка членства возвращает err — +// вызывающий обязан ответить ей, а не закрывать молча. +func canView(ctx context.Context, c *gin.Context, t *models.Ticket, projects *repository.ProjectRepo) (bool, error) { + switch c.GetString("role") { + case "admin": + return true, nil + case "client": + return t.ClientID == c.GetString("user_id"), nil + case "worker": + if t.WorkerID != nil && *t.WorkerID == c.GetString("user_id") { + return true, nil + } + // Чужие (не назначенные) статусы мастеру не показываем; + // new — только если он активный участник проекта заявки. + if t.Status != "new" { + return false, nil + } + st, err := projects.MembershipStatus(ctx, c.GetString("user_id"), t.ProjectID) + if err != nil { + return false, err + } + return st == "active", nil + } + return false, nil +} + +func forbidden(c *gin.Context) { + c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) +} + +// respondUpdated — перечитать заявку после изменения и отдать её. +// Сбой чтения — честный 500: изменение прошло, но показать его не удалось. +func (h *TicketHandler) respondUpdated(c *gin.Context, id int64) { + t, err := h.repo.GetByID(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, t) +} + +// recurringSchedule — «Повторяющаяся заявка» из формы новой заявки: +// точка отсчёта (дата-время первого запуска) и период в днях. +// Поле опционально: без него заявка обычная, с ним — одной операцией +// создаётся заявка и правило повторения (см. TicketRepo.CreateWithRule). +type recurringSchedule struct { + // AnchorAt — дата-время первого запуска (RFC3339): точка отсчёта + // сетки сроков, от неё идут «каждые period_days». + AnchorAt time.Time `json:"anchor_at" binding:"required"` + // PeriodDays — период в днях (1..3650; недели кратны 7). + PeriodDays int `json:"period_days" binding:"required,min=1,max=3650"` +} + +type createTicketReq struct { + // Объект, на который создаётся заявка: адрес копируется из объекта. + ObjectID int64 `json:"object_id" binding:"required"` + Floor string `json:"floor"` + Category string `json:"category" binding:"required,oneof=electric plumbing climate elevator other"` + Description string `json:"description" binding:"required,min=5"` + Priority string `json:"priority" binding:"omitempty,oneof=low normal high"` + // Recurring — задан, если заявку делают повторяющейся. + Recurring *recurringSchedule `json:"recurring,omitempty"` +} + +// Create — POST /tickets. Создаёт заявку, только для клиента. +// +// @Summary Создать заявку +// @Description Клиент создаёт заявку на объект своего проекта. Адрес берётся из объекта на момент создания, статус всегда new, мастер не назначен. Если передан recurring — той же операцией создаётся правило повторения (anchor_at — первый запуск, period_days — период в днях), связанное с заявкой: оно видно в GET /tickets/{id} в поле recurring, управление — через PATCH /recurring/{id}. +// @Tags tickets +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body createTicketReq true "Данные заявки" +// @Success 201 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль client" +// @Failure 404 {object} handlers.ErrorResponse "объект не найден (или чужой — неотличим)" +// @Failure 409 {object} handlers.ErrorResponse "проект или объект выключен" +// @Router /tickets [post] +func (h *TicketHandler) Create(c *gin.Context) { + var req createTicketReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + if req.Priority == "" { + req.Priority = "normal" + } + + clientID := c.GetString("user_id") + + // Объект обязан принадлежать клиенту: чужой неотличим от несуществующего. + obj, err := h.projectRepo.OwnedObject(c.Request.Context(), req.ObjectID, clientID) + if err != nil { + respondError(c, err) + return + } + if !obj.ProjectActive { + c.JSON(http.StatusConflict, gin.H{"error": "project is not active"}) + return + } + if !obj.IsActive { + c.JSON(http.StatusConflict, gin.H{"error": "object is not active"}) + return + } + + t := &models.Ticket{ + ClientID: clientID, + ProjectID: obj.ProjectID, + ObjectID: obj.ID, + Address: obj.Address, // снимок адреса на момент создания + Floor: req.Floor, + Category: req.Category, + Description: req.Description, + Priority: req.Priority, + } + + // Галочка «Повторяющаяся заявка»: заявка и правило создаются одной + // операцией (иначе возможна заявка без правила или правило без связи). + if req.Recurring != nil { + if _, err := h.repo.CreateWithRule(c.Request.Context(), t, + repository.Schedule{AnchorAt: req.Recurring.AnchorAt, PeriodDays: req.Recurring.PeriodDays}, + clientID); err != nil { + respondError(c, err) + return + } + } else if err := h.repo.Create(c.Request.Context(), t); err != nil { + respondError(c, err) + return + } + + // Перечитываем, чтобы вернуть полный объект с client_name + // (и связанным правилом recurring, если оно создавалось). + full, err := h.repo.GetByID(c.Request.Context(), t.ID) + if err != nil { + c.JSON(http.StatusCreated, t) + return + } + c.JSON(http.StatusCreated, full) +} + +// List — GET /tickets. Список заявок, выборка режется по роли. +// +// @Summary Список заявок +// @Description Выборка режется по роли: клиент видит свои, мастер — назначенные ему, админ — всё. Клиенту и мастеру параметры client_id/worker_id игнорируются: подсмотреть чужое нельзя. Фильтры накладываются поверх выборки по роли. Ответ — страница: items плюс total/limit/offset. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param status query string false "Фильтр по статусу" Enums(new, assigned, in_progress, done, confirmed, rejected, closed) +// @Param category query string false "Фильтр по категории" Enums(electric, plumbing, climate, elevator, other) +// @Param priority query string false "Фильтр по приоритету" Enums(low, normal, high) +// @Param client_id query string false "Фильтр по клиенту (только admin)" +// @Param worker_id query string false "Фильтр по мастеру (только admin)" +// @Param project_id query int false "Фильтр по проекту" +// @Param object_id query int false "Фильтр по объекту" +// @Param date_from query string false "created_at с, YYYY-MM-DD (включительно, с полуночи UTC)" +// @Param date_to query string false "created_at по, YYYY-MM-DD (включительно, до полуночи UTC следующего дня)" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(created_at, priority, id) Default(created_at) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.TicketListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный фильтр или параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "неизвестная роль" +// @Router /tickets [get] +func (h *TicketHandler) List(c *gin.Context) { + role := c.GetString("role") + userID := c.GetString("user_id") + + page, ok := parsePage(c, repository.TicketSorts) + if !ok { + return + } + // Фильтры по значениям — до SQL: мусор в query получает 400, + // а не «ничего не нашлось». + if !checkFilter(c, "status", models.TicketStatuses) || + !checkFilter(c, "category", models.TicketCategories) || + !checkFilter(c, "priority", models.TicketPriorities) { + return + } + dateFrom, dateTo, ok := dateRange(c) + if !ok { + return + } + + f := repository.ListFilter{ + Status: c.Query("status"), + Category: c.Query("category"), + Priority: c.Query("priority"), + DateFrom: dateFrom, + DateTo: dateTo, + Page: page, + } + + // Структурные фильтры — всем ролям: базовая выборка по роли + // накладывается всё равно, чужое через них не подсмотреть. + if v := c.Query("project_id"); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project_id"}) + return + } + f.ProjectID = id + } + if v := c.Query("object_id"); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid object_id"}) + return + } + f.ObjectID = id + } + + switch role { + case "client": + f.ClientID = userID + case "worker": + // Мастер видит свои заявки плюс свободные (new) в проектах, + // где он активный участник: такие он вправе взять сам. + f.FreeForWorker = userID + case "admin": + // Админ видит всё. Может фильтровать по клиенту/мастеру через query. + if cid := c.Query("client_id"); cid != "" { + f.ClientID = cid + } + if wid := c.Query("worker_id"); wid != "" { + f.WorkerID = wid + } + default: + c.JSON(http.StatusForbidden, gin.H{"error": "unknown role"}) + return + } + + tickets, total, err := h.repo.List(c.Request.Context(), f) + if err != nil { + respondError(c, err) + return + } + if tickets == nil { + tickets = []models.Ticket{} + } + c.JSON(http.StatusOK, TicketListResponse{ + Items: tickets, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +// Get — GET /tickets/:id. Детали; права на просмотр проверяются здесь. +// +// @Summary Детали заявки +// @Description Админ — любая заявка, клиент — только своя, мастер — назначенная ему либо свободная (new) из его проектов. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "заявка не принадлежит вам" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Router /tickets/{id} [get] +func (h *TicketHandler) Get(c *gin.Context) { + t, ok := h.getTicket(c) + if !ok { + return + } + visible, err := canView(c.Request.Context(), c, t, h.projectRepo) + if err != nil { + respondError(c, err) + return + } + if !visible { + forbidden(c) + return + } + c.JSON(http.StatusOK, t) +} + +// Events — GET /tickets/:id/events. Журнал событий: кто и когда менял +// заявку, откуда куда и с какой причиной. Видят те же, кто видит заявку. +// +// @Summary Журнал событий заявки +// @Description Хронология переходов заявки с авторами: created, assigned, started, done, confirmed, rejected, canceled. from_status у события «создана» нет. События пишутся той же SQL-операцией, что и переход, поэтому история не расходится со статусом. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(id, created_at) Default(id) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.TicketEventListResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "чужая заявка" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Router /tickets/{id}/events [get] +func (h *TicketHandler) Events(c *gin.Context) { + t, ok := h.getTicket(c) + if !ok { + return + } + visible, err := canView(c.Request.Context(), c, t, h.projectRepo) + if err != nil { + respondError(c, err) + return + } + if !visible { + forbidden(c) + return + } + + page, ok := parsePage(c, repository.EventSorts) + if !ok { + return + } + events, total, err := h.repo.ListEvents(c.Request.Context(), t.ID, page) + if err != nil { + respondError(c, err) + return + } + if events == nil { + events = []models.TicketEvent{} + } + c.JSON(http.StatusOK, TicketEventListResponse{ + Items: events, Total: total, Limit: page.Limit, Offset: page.Offset, + }) +} + +type assignReq struct { + WorkerID string `json:"worker_id" binding:"required,uuid"` +} + +// Assign — PATCH /tickets/:id/assign. Назначить мастера: админ или +// клиент-владелец заявки. Мастер обязан состоять в проекте заявки. +// +// @Summary Назначить мастера +// @Description new → assigned. Доступно админу и владельцу-клиенту. Мастер должен состоять в проекте заявки и быть активным — иначе 400/409. +// @Tags tickets +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param body body assignReq true "Мастер" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id/тело, не-мастер, неактивен, приглашение не принято или не состоит в проекте заявки" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client; чужая заявка" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "заявка уже назначена или закрыта" +// @Router /tickets/{id}/assign [patch] +func (h *TicketHandler) Assign(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var req assignReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + t, err := h.repo.GetByID(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + + // Клиенту — только своя заявка; админу — любая. + if c.GetString("role") == "client" && t.ClientID != c.GetString("user_id") { + forbidden(c) + return + } + + // Проверяем, что worker_id — реально мастер и активен + worker, err := h.profileRepo.GetByID(c.Request.Context(), req.WorkerID) + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusBadRequest, gin.H{"error": "worker not found"}) + return + } + if err != nil { + respondError(c, err) + return + } + if worker.Role != "worker" || !worker.IsActive { + c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active worker"}) + return + } + + // Мастер должен состоять в проекте заявки (и принять приглашение). + // Понятная ошибка здесь, а в UPDATE назначения та же проверка — + // уже с требованием status='active' — рубеж на уровне БД. + status, err := h.projectRepo.MembershipStatus(c.Request.Context(), req.WorkerID, t.ProjectID) + if err != nil { + respondError(c, err) + return + } + switch status { + case "active": + case "invited": + c.JSON(http.StatusBadRequest, gin.H{"error": "invitation is pending: the worker has not accepted it"}) + return + case "declined": + c.JSON(http.StatusBadRequest, gin.H{"error": "invitation was declined"}) + return + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "worker is not in the project of this ticket"}) + return + } + + actorID := c.GetString("user_id") + if err := h.repo.Assign(c.Request.Context(), id, req.WorkerID, actorID); err != nil { + respondError(c, err) + return + } + + h.respondUpdated(c, id) +} + +// Take — PATCH /tickets/:id/take. Мастер берёт свободную заявку сам: +// тело не нужно, worker_id и assigned_by — он же. Членство в проекте +// здесь, а в UPDATE назначения — та же проверка с status='active' и +// status='new' — рубеж на уровне БД: параллельное взятие вторым +// мастером не проходит и получает 409 из stateError. +// +// @Summary Взять заявку самому +// @Description new → assigned: мастер назначает себя на свободную заявку своего проекта. Дальше — «Взять в работу» (start) как обычно. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id; приглашение не принято или отклонено; не состоит в проекте заявки" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "заявка уже взята или не в статусе new" +// @Router /tickets/{id}/take [patch] +func (h *TicketHandler) Take(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + workerID := c.GetString("user_id") + + t, err := h.repo.GetByID(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + + // Та же понятная диагностика членства, что в Assign. + status, err := h.projectRepo.MembershipStatus(c.Request.Context(), workerID, t.ProjectID) + if err != nil { + respondError(c, err) + return + } + switch status { + case "active": + case "invited": + c.JSON(http.StatusBadRequest, gin.H{"error": "invitation is pending: the worker has not accepted it"}) + return + case "declined": + c.JSON(http.StatusBadRequest, gin.H{"error": "invitation was declined"}) + return + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "worker is not in the project of this ticket"}) + return + } + + if err := h.repo.Assign(c.Request.Context(), id, workerID, workerID); err != nil { + respondError(c, err) + return + } + + h.respondUpdated(c, id) +} + +// Start — PATCH /tickets/:id/start. Взять в работу, только мастер. +// +// @Summary Взять в работу +// @Description assigned → in_progress, а после отклонения (rejected) — повторный запуск. started_at первого круга сохраняется. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "не тот статус или заявка назначена другому" +// @Router /tickets/{id}/start [patch] +func (h *TicketHandler) Start(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + workerID := c.GetString("user_id") + if err := h.repo.Start(c.Request.Context(), id, workerID); err != nil { + respondError(c, err) + return + } + + h.respondUpdated(c, id) +} + +// Done — PATCH /tickets/:id/done. Завершить работу, только мастер. +// +// @Summary Завершить работу +// @Description in_progress → done. Дальше клиент подтверждает или отклоняет. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "не тот статус или заявка назначена другому" +// @Router /tickets/{id}/done [patch] +func (h *TicketHandler) Done(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + workerID := c.GetString("user_id") + if err := h.repo.Done(c.Request.Context(), id, workerID); err != nil { + respondError(c, err) + return + } + + h.respondUpdated(c, id) +} + +// reasonReq — причина отклонения/отмены. Обязательна: без неё мастер не поймёт, +// что переделывать, а админ — почему заявку закрыли. +type reasonReq struct { + Reason string `json:"reason" binding:"required,min=5"` +} + +// isOwnClient — действие доступно только владельцу-клиенту заявки. +func isOwnClient(c *gin.Context, t *models.Ticket) bool { + return c.GetString("role") == "client" && t.ClientID == c.GetString("user_id") +} + +// Confirm — PATCH /tickets/:id/confirm. Клиент принял работу. +// Заявка сразу закрывается: confirmed_at + closed_at, статус 'closed'. +// +// @Summary Подтвердить работу (финал) +// @Description done → closed с confirmed_at. Доступно только владельцу-клиенту. +// @Tags tickets +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "заявка не принадлежит вам" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "заявка не в статусе done" +// @Router /tickets/{id}/confirm [patch] +func (h *TicketHandler) Confirm(c *gin.Context) { + t, ok := h.getTicket(c) + if !ok { + return + } + if !isOwnClient(c, t) { + forbidden(c) + return + } + if err := h.repo.Confirm(c.Request.Context(), t.ID, t.ClientID); err != nil { + respondError(c, err) + return + } + h.respondUpdated(c, t.ID) +} + +// Reject — PATCH /tickets/:id/reject. Клиент отклонил работу. +// Это НЕ финал: заявка уходит в 'rejected' на доработку, счётчик +// rework_count растёт, чтобы второй круг не остался незамеченным. +// +// @Summary Отклонить работу (на доработку) +// @Description done → rejected. Не финал: мастер берёт заявку в работу повторно, rework_count увеличивается. Причина обязательна — без неё мастер не поймёт, что переделывать. +// @Tags tickets +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param body body reasonReq true "Причина отклонения, минимум 5 символов" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело: причина обязательна" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "заявка не принадлежит вам" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "заявка не в статусе done" +// @Router /tickets/{id}/reject [patch] +func (h *TicketHandler) Reject(c *gin.Context) { + var req reasonReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + t, ok := h.getTicket(c) + if !ok { + return + } + if !isOwnClient(c, t) { + forbidden(c) + return + } + if err := h.repo.Reject(c.Request.Context(), t.ID, t.ClientID, req.Reason); err != nil { + respondError(c, err) + return + } + h.respondUpdated(c, t.ID) +} + +// Cancel — PATCH /tickets/:id/cancel. Окончательный отказ или отмена заявки. +// В отличие от reject это финал: статус 'closed' с cancel_reason. +// +// @Summary Отменить заявку (финал) +// @Description Закрывает заявку с cancel_reason и closed_by. Клиент — только свои в new/assigned/done/rejected; админ — любую кроме уже закрытой, включая in_progress. Причина обязательна. +// @Tags tickets +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path int true "ID заявки" +// @Param body body reasonReq true "Причина отмены, минимум 5 символов" +// @Success 200 {object} models.Ticket +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело: причина обязательна" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "чужая заявка или роль не client/admin" +// @Failure 404 {object} handlers.ErrorResponse "заявка не найдена" +// @Failure 409 {object} handlers.ErrorResponse "нельзя закрыть из текущего статуса" +// @Router /tickets/{id}/cancel [patch] +func (h *TicketHandler) Cancel(c *gin.Context) { + var req reasonReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + t, ok := h.getTicket(c) + if !ok { + return + } + + actorID := c.GetString("user_id") + var from []string + + switch c.GetString("role") { + case "admin": + // Админ может закрыть застрявшую заявку на любом шаге. + from = []string{"new", "assigned", "in_progress", "done", "rejected"} + case "client": + if t.ClientID != actorID { + forbidden(c) + return + } + // Клиенту нельзя отменять работающую заявку — мастер уже выехал. + from = []string{"new", "assigned", "done", "rejected"} + default: + forbidden(c) + return + } + + if err := h.repo.Close(c.Request.Context(), t.ID, actorID, req.Reason, from); err != nil { + respondError(c, err) + return + } + h.respondUpdated(c, t.ID) +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go new file mode 100644 index 0000000..fa84c50 --- /dev/null +++ b/internal/handlers/types.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "time" + + "building-mgmt/internal/models" +) + +// Формы ответов API. Нужны дважды: как фактический JSON в хендлерах +// и как схема в Swagger — чтобы документация не разъезжалась с кодом. + +// ErrorResponse — единая форма ошибки. Текст фиксированный и короткий, +// детали БД в него не попадают (они только в логе). +type ErrorResponse struct { + Error string `json:"error"` +} + +// TokenResponse — ответ login, register и refresh. +type TokenResponse struct { + // Access-токен: JWT на auth.AccessTTL. + Token string `json:"token"` + // Срок жизни access-токена в секундах. + ExpiresIn int `json:"expires_in"` + User *models.Profile `json:"user"` + // Refresh-токен в JSON не отдаётся: он живёт только в httpOnly-куке + // (см. AuthHandler.setRefreshCookie) — из JavaScript ему не достаться. +} + +// OKResponse — ответ для действий без сущности на выходе (logout). +type OKResponse struct { + OK bool `json:"ok"` + // Revoked — сколько сессий закрыто; заполняется только в logout-all. + Revoked int64 `json:"revoked,omitempty"` +} + +// Формы списков. Список — это всегда страница (items + метаданные), +// а не голый массив: total считается COUNT(*) OVER() до обрезки, +// limit/offset отражают то, что реально применено. + +// TicketListResponse — страница GET /tickets. +type TicketListResponse struct { + Items []models.Ticket `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// ProjectListResponse — страница GET /projects. +type ProjectListResponse struct { + Items []models.Project `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// ObjectListResponse — страница GET /projects/{id}/objects. +type ObjectListResponse struct { + Items []models.Object `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// ProfileListResponse — страница пользователей: GET /users. +type ProfileListResponse struct { + Items []models.Profile `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// ProjectWorkerListResponse — страница состава проекта +// (GET /projects/{id}/workers): профили плюс статус членства. +type ProjectWorkerListResponse struct { + Items []models.ProjectWorker `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// InvitationListResponse — страница приглашений мастера (GET /invitations). +type InvitationListResponse struct { + Items []models.ProjectInvitation `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// TicketEventListResponse — страница журнала заявки (GET /tickets/{id}/events). +type TicketEventListResponse struct { + Items []models.TicketEvent `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// PhotoListResponse — страница списка фото (GET /tickets/{id}/photos). +type PhotoListResponse struct { + Items []models.TicketPhoto `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + // MaxPerTicket — лимит файлов на заявку (PHOTO_MAX_PER_TICKET): + // UI рисует «n / N», не зная настройки окружения. + MaxPerTicket int `json:"max_per_ticket"` +} + +// RecurringRuleListResponse — страница правил повторения (GET /recurring). +type RecurringRuleListResponse struct { + Items []models.RecurringRule `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// RecurringRunListResponse — страница истории срабатываний правила +// (GET /recurring/{id}/runs). +type RecurringRunListResponse struct { + Items []models.RecurringRun `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// InvitationResponse — ответ на POST /projects/{id}/workers: с 0005 это +// приглашение, а не готовое членство, поэтому в ответе его статус и срок. +type InvitationResponse struct { + OK bool `json:"ok"` + // Всегда "invited": стать участником мастер может только сам, через + // POST /projects/{id}/invitation/accept. + Status string `json:"status"` + // До какого срока мастер может ответить (см. repository.InvitationTTL). + ExpiresAt time.Time `json:"expires_at"` +} + +// PasswordResetResponse — ответ на POST /users/{id}/password-reset. +// Сам токен живёт только в этом ответе: в БД — лишь sha256, поэтому +// выпущенный токен нельзя подсмотреть даже с доступом к базе. +type PasswordResetResponse struct { + OK bool `json:"ok"` + Token string `json:"token"` + // См. repository.ResetTTL (24 часа). + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/internal/handlers/users.go b/internal/handlers/users.go new file mode 100644 index 0000000..ae3aa4a --- /dev/null +++ b/internal/handlers/users.go @@ -0,0 +1,517 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "building-mgmt/internal/auth" + "building-mgmt/internal/mailer" + "building-mgmt/internal/models" + "building-mgmt/internal/repository" +) + +// UserHandler — админские эндпоинты: создание пользователей, выборка +// по роли, включение/выключение аккаунтов, подтверждение регистрации, +// удаление. Клиенту отсюда доступны только мастера: создание, список +// своих (без чужих организаций), подтверждение созданного им мастера +// и выключение мастера из своих проектов. +type UserHandler struct { + repo *repository.ProfileRepo + rtRepo *repository.RefreshTokenRepo + mailer mailer.Mailer +} + +// NewUserHandler собирает хендлер: refreshRepo нужен, чтобы деактивация +// закрывала сессии пользователя немедленно, mailer — чтобы подтверждение +// слало письмо «аккаунт подтверждён» (fail-open: без SMTP работает +// как заглушка). +func NewUserHandler(repo *repository.ProfileRepo, rtRepo *repository.RefreshTokenRepo, ml mailer.Mailer) *UserHandler { + return &UserHandler{repo: repo, rtRepo: rtRepo, mailer: ml} +} + +type createUserReq struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + Name string `json:"name" binding:"required"` + Phone string `json:"phone"` + Role string `json:"role" binding:"required,oneof=worker admin client"` +} + +// Create — POST /users. Админ создаёт мастеров, админов и клиентов, +// клиент — только мастера. Правило подтверждения — в SQL CreateBy: +// созданное админом подтверждается сразу («создатель и есть +// подтверждающий»), созданное клиентом ждёт его подтверждения +// (POST /users/:id/confirm). +// +// @Summary Создать пользователя +// @Description Админ создаёт мастеров, админов и клиентов; клиент — только мастеров (для себя, далее мастер добавляется в проект через POST /projects/{id}/workers). Роли admin и client клиентом недоступны. Пароль — по политике (10..72 символа, буквы и цифры). Подтверждение: аккаунт, созданный админом, подтверждён сразу; созданный клиентом — ждёт подтверждения этим же клиентом, после чего мастеру уходит письмо. +// @Tags users +// @Accept json +// @Produce json +// @Security Bearer +// @Param body body createUserReq true "Данные пользователя" +// @Success 201 {object} models.Profile +// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса или слабый пароль" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "клиент пытается создать админа или клиента" +// @Failure 409 {object} handlers.ErrorResponse "email уже занят" +// @Router /users [post] +func (h *UserHandler) Create(c *gin.Context) { + var req createUserReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + // Из клиента ни админа, ни второго клиента не завести — + // это и есть граница полномочий: клиентов создаёт только админ. + if c.GetString("role") == "client" && req.Role != "worker" { + forbidden(c) + return + } + + // Политика пароля — после проверки полномочий: сначала «вам это + // нельзя», потом «формат запроса». + if err := auth.ValidatePassword(req.Password); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"}) + return + } + + profile := &models.Profile{ + ID: uuid.NewString(), + Email: req.Email, + PasswordHash: hash, + Name: req.Name, + Phone: req.Phone, + Role: req.Role, + IsActive: true, + } + + if err := h.repo.CreateBy(c.Request.Context(), profile, c.GetString("user_id")); err != nil { + if errors.Is(err, repository.ErrConflict) { + c.JSON(http.StatusConflict, gin.H{"error": "email already exists"}) + return + } + respondError(c, err) + return + } + + c.JSON(http.StatusCreated, profile) +} + +// Confirm — POST /users/:id/confirm. Подтвердить аккаунт. +// Право — целиком в SQL репозитория: админ подтверждает любой +// неподтверждённый аккаунт, клиент — только созданного им мастера; +// чужой и несуществующий неразличимы (404), повтор — 409. +// +// @Summary Подтвердить аккаунт +// @Description Подтверждает неподтверждённый аккаунт и шлёт пользователю письмо «аккаунт подтверждён» со ссылкой на вход. Право — в SQL: админ подтверждает любой аккаунт, клиент — только созданного им мастера (иной и несуществующий не видны — 404). Повторное подтверждение — 409. Письмо отправляется после подтверждения; недоступный SMTP не откатывает подтверждение (письмо просто не уйдёт). +// @Tags users +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "ID пользователя (uuid)" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client" +// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден (для клиента — не созданный им мастер)" +// @Failure 409 {object} handlers.ErrorResponse "аккаунт уже подтверждён" +// @Router /users/{id}/confirm [post] +func (h *UserHandler) Confirm(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + ctx := c.Request.Context() + if err := h.repo.ConfirmUser(ctx, id, c.GetString("user_id")); err != nil { + switch { + case errors.Is(err, repository.ErrAlreadyConfirmed): + c.JSON(http.StatusConflict, gin.H{"error": "already confirmed"}) + case errors.Is(err, repository.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + default: + respondError(c, err) + } + return + } + + // Письмо — после успешного подтверждения, fail-open: недоступный + // SMTP (или несработавший re-read) не должен откатывать ответ — + // подтверждение уже состоялось, в лог уходит warn. + profile, err := h.repo.GetByID(ctx, id) + if err != nil { + slog.Warn("confirmation email skipped: profile reread failed", "err", err, "user_id", id) + } else { + // ConfirmUser уже выставил confirmed_at; защищаемся от nil + // (теоретический рассинхрон репозитория) нулевым временем — + // mailer тогда просто опустит строку с датой. + var confirmedAt time.Time + if profile.ConfirmedAt != nil { + confirmedAt = *profile.ConfirmedAt + } + if err := h.mailer.SendAccountConfirmed(ctx, mailer.AccountConfirmed{ + To: profile.Email, + Name: profile.Name, + Role: profile.Role, + ConfirmedAt: confirmedAt, + }); err != nil { + slog.Warn("confirmation email failed", "err", err, "user_id", id) + } + } + + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// List — GET /users?role=worker. Админ — все пользователи по роли, +// клиент — только мастера, состоящие в его проектах. Параметр q — +// поиск по имени/email (так клиент находит существующего мастера, +// чтобы пригласить его в проект). include_inactive=1 — админский +// список показывает и отключённых (кнопка «Включить» после отключения +// иначе оказалась бы тупиком); клиент этот флаг не получает. +// +// @Summary Список пользователей по роли +// @Description Админ видит любую роль и всех; клиенту доступна только роль worker и только его собственные мастера (чужие организации не видны). С параметром q (минимум 2 символа) возвращается поиск по имени/email среди активных — так клиент пригласит в проект существующего мастера, не видя при этом полного списка. include_inactive=1 дополняет список отключёнными аккаунтами — только для админа. +// @Tags users +// @Produce json +// @Security Bearer +// @Param role query string false "Роль" Enums(worker, admin, client) Default(worker) +// @Param q query string false "Поиск по имени или email, минимум 2 символа" +// @Param include_inactive query string false "Показать и отключённых (только для админа)" Enums(0,1) Default(0) +// @Param limit query int false "Строк на странице, 1..100" default(20) +// @Param offset query int false "Сдвиг от начала выборки" default(0) +// @Param sort query string false "Поле сортировки" Enums(name, created_at, email, id) Default(name) +// @Param order query string false "Направление сортировки" Enums(asc, desc) +// @Success 200 {object} handlers.ProfileListResponse +// @Failure 400 {object} handlers.ErrorResponse "неизвестная роль, параметры страницы или короткий q" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "клиент запрашивает не мастеров" +// @Router /users [get] +func (h *UserHandler) List(c *gin.Context) { + role := c.DefaultQuery("role", "worker") + + if role != "worker" && role != "admin" && role != "client" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"}) + return + } + page, ok := parsePage(c, repository.ProfileSorts) + if !ok { + return + } + + // Поиск: короткая строка — опечатка в начале ввода, отвечаем сразу, + // не ходя в базу за «всеми подряд». Считаем символы, не байты: + // один кириллический «п» — уже два байта. + q := strings.TrimSpace(c.Query("q")) + if q != "" && utf8.RuneCountInString(q) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid q"}) + return + } + + // include_inactive: у админа — обычный список любой роли; у клиента — + // его собственные мастера (включая отключённых, чтобы «Включить» + // после отключения не оказался тупиком). Клиентский поиск q + // в ветке ниже всегда остаётся только по активным. + inactiveQ := c.Query("include_inactive") + includeInactive := inactiveQ == "1" || inactiveQ == "true" + + respond := func(profiles []models.Profile, total int) { + if profiles == nil { + profiles = []models.Profile{} + } + c.JSON(http.StatusOK, ProfileListResponse{ + Items: profiles, Total: total, Limit: page.Limit, Offset: page.Offset, + }) + } + + // Клиенту — только мастера: либо свои (без запроса), либо поиском (q). + if c.GetString("role") == "client" { + if role != "worker" { + forbidden(c) + return + } + if q != "" { + profiles, total, err := h.repo.SearchByRole(c.Request.Context(), "worker", q, false, page) + if err != nil { + respondError(c, err) + return + } + respond(profiles, total) + return + } + profiles, total, err := h.repo.ListWorkersForClient(c.Request.Context(), c.GetString("user_id"), includeInactive, page) + if err != nil { + respondError(c, err) + return + } + respond(profiles, total) + return + } + + // Админ: q сужает обычный список по роли. + if q != "" { + profiles, total, err := h.repo.SearchByRole(c.Request.Context(), role, q, includeInactive, page) + if err != nil { + respondError(c, err) + return + } + respond(profiles, total) + return + } + + profiles, total, err := h.repo.ListByRole(c.Request.Context(), role, includeInactive, page) + if err != nil { + respondError(c, err) + return + } + respond(profiles, total) +} + +type setUserReq struct { + // *bool обязателен: на bool с binding:"required" запрос + // «деактивировать» (is_active=false) не прошёл бы валидацию. + IsActive *bool `json:"is_active" binding:"required"` +} + +// Update — PATCH /users/:id. Включить или выключить аккаунт. +// Админ — любой; клиент — только мастера, который активный участник +// хотя бы одного его проекта (граница — в SQL репозитория, хендлер +// не решает). Выключенный не может войти (Login проверяет is_active) +// и теряет все открытые сессии немедленно, а не по истечении +// access-токена. Для мастера флаг общий на все его проекты. +// +// @Summary Включить/выключить аккаунт +// @Description Админ меняет любой аккаунт; клиент — только мастера, который активный участник одного из его проектов (иного клиента или чужого мастера не видит — 404). При деактивации все refresh-сессии пользователя закрываются сразу, мастер перестаёт работать во всех проектах. Выключить самого себя нельзя — вы остались бы без входа. +// @Tags users +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "ID пользователя (uuid)" +// @Param body body setUserReq true "Новый флаг" +// @Success 200 {object} models.Profile +// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело; попытка выключить себя" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client" +// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден (для клиента — не его мастер)" +// @Router /users/{id} [patch] +func (h *UserHandler) Update(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var req setUserReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + + if id == c.GetString("user_id") { + // Иначе админ может выключить самого себя и остаться без входа. + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own status"}) + return + } + + // Клиенту доступен только его мастер (проверяется в SQL: роль worker, + // активное членство в проекте этого клиента) — иному выдаётся 404, + // не 403: не раскрываем, существует ли вообще чужой аккаунт. + var err error + if c.GetString("role") == "client" { + err = h.repo.SetActiveWorkerForClient(c.Request.Context(), id, c.GetString("user_id"), *req.IsActive) + } else { + err = h.repo.SetActive(c.Request.Context(), id, *req.IsActive) + } + if err != nil { + respondError(c, err) + return + } + + if !*req.IsActive { + ctx := c.Request.Context() + n, err := h.rtRepo.RevokeAllForUser(ctx, id) + switch { + case err != nil: + slog.Warn("deactivation: failed to revoke sessions", "user_id", id, "err", err) + case n > 0: + slog.Info("deactivation: sessions revoked", "user_id", id, "count", n) + } + } + + profile, err := h.repo.GetByID(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, profile) +} + +// Delete — DELETE /users/:id. Админ — только «чистый» аккаунт: сессии и +// токены сброса уходят каскадом, но если на пользователя ссылаются заявки, +// проекты или участия — БД не даст удалить строку (409) — в этом случае +// его отключают через PATCH. Клиент — только мастера, состоящего в одном +// из его проектов: членства в проектах клиента удаляются вместе с +// аккаунтом одной транзакцией, а заявки или чужие проекты дают тот же +// 409 (откат — членства не трогаются). Со своей учётной записи +// удалиться нельзя: админ остался бы без входа. +// +// @Summary Удалить пользователя +// @Description Админ удаляет любой «чистый» аккаунт: refresh-сессии и токены сброса удаляются каскадом, но наличие заявок, проектов или участий блокирует удаление (409 user has related records) — в этом случае отключите аккаунт через PATCH /users/{id}, историю не стираем. Клиент удаляет только мастера, состоящего в одном из его проектов: членства клиента в проектах удаляются в той же транзакции, но если на мастера ссылаются заявки либо чужие проекты — тоже 409, ничего не меняется. Чужой мастер и несуществующий id неразличимы (404). Свой id удалить нельзя. +// @Tags users +// @Produce json +// @Security Bearer +// @Param id path string true "ID пользователя (uuid)" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id; нельзя удалить себя" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client" +// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден" +// @Failure 409 {object} handlers.ErrorResponse "у пользователя есть связанные записи" +// @Router /users/{id} [delete] +func (h *UserHandler) Delete(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + if id == c.GetString("user_id") { + // Иначе админ удалит сам себя и остался бы без входа. + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete yourself"}) + return + } + + // Граница клиента (мастер состоит в моём проекте), членства и + // атомарность — в SQL-транзакции репозитория; хендлер лишь выбирает + // ветку по роли. + var err error + if c.GetString("role") == "client" { + err = h.repo.DeleteWorkerForClient(c.Request.Context(), id, c.GetString("user_id")) + } else { + err = h.repo.DeleteProfile(c.Request.Context(), id) + } + if err != nil { + if errors.Is(err, repository.ErrUserHasData) { + c.JSON(http.StatusConflict, gin.H{"error": "user has related records"}) + return + } + respondError(c, err) + return + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} + +// PasswordReset — POST /users/:id/password-reset. Выпустить токен смены. +// +// @Summary Выдать токен сброса пароля +// @Description Токен возвращается в ответе — админ передаёт его пользователю сам, любым каналом. Для пользователя есть почта: POST /auth/forgot-password шлёт письмо со ссылкой. Токен одноразовый, живёт 24 часа (repository.ResetTTL); выпуск нового гасит прежние неиспользованные токены пользователя (включая свежие из писем). В БД хранится только sha256 токена. +// @Tags users +// @Produce json +// @Security Bearer +// @Param id path string true "ID пользователя (uuid)" +// @Success 200 {object} handlers.PasswordResetResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin" +// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден" +// @Router /users/{id}/password-reset [post] +func (h *UserHandler) PasswordReset(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + // Проверяем существование до выпуска: для чужого id — 404, а не + // токен на несуществующего пользователя (его бы никто не смог применить). + if _, err := h.repo.GetByID(c.Request.Context(), id); err != nil { + respondError(c, err) + return + } + + token, expires, err := h.repo.CreateResetToken(c.Request.Context(), id) + if err != nil { + respondError(c, err) + return + } + c.JSON(http.StatusOK, PasswordResetResponse{OK: true, Token: token, ExpiresAt: expires}) +} + +type setPasswordReq struct { + Password string `json:"password" binding:"required"` +} + +// SetPassword — POST /users/:id/password-set. Задать пароль напрямую. +// +// @Summary Задать пароль напрямую +// @Description Админ ставит пароль пользователю сам, без ссылки. Политика та же, что при создании и при сбросе по токену (10..72 символа, буквы и цифры). Успех закрывает все refresh-сессии пользователя и гасит неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Альтернатива — одноразовая ссылка через POST /users/{id}/password-reset. +// @Tags users +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "ID пользователя (uuid)" +// @Param body body setPasswordReq true "Новый пароль" +// @Success 200 {object} handlers.OKResponse +// @Failure 400 {object} handlers.ErrorResponse "некорректный id, тело или слабый пароль" +// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" +// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin" +// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден" +// @Router /users/{id}/password-set [post] +func (h *UserHandler) SetPassword(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var req setPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + bindingError(c, err) + return + } + if err := auth.ValidatePassword(req.Password); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"}) + return + } + + ctx := c.Request.Context() + // Смена и гашение токенов — одна операция репозитория; 404 на + // несуществующий id отдаёт respondError. + if _, err := h.repo.SetPassword(ctx, id, hash); err != nil { + respondError(c, err) + return + } + + // Сессии закрываем, как при сбросе по токену: прежний владелец не + // должен остаться в системе с refresh-сессией, а access-токен живёт + // до часа. Ошибка разлогина — не повод отдавать 500: пароль-то уже + // обновлён, деталь уходит в лог. + if n, err := h.rtRepo.RevokeAllForUser(ctx, id); err != nil { + slog.Warn("password set: failed to revoke sessions", "user_id", id, "err", err) + } else { + slog.Info("security: password set by admin", "user_id", id, + "admin_id", c.GetString("user_id"), "sessions_revoked", n) + } + c.JSON(http.StatusOK, OKResponse{OK: true}) +} diff --git a/internal/handlers/users_test.go b/internal/handlers/users_test.go new file mode 100644 index 0000000..4c07d6b --- /dev/null +++ b/internal/handlers/users_test.go @@ -0,0 +1,367 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// Наборы разбора ?q= в GET /users: короткий запрос отклоняется до базы. +// Хендлер создаётся с nil-репозиторием намеренно — если валидация +// перестанет работать, тест упадёт с паникой, а не молча уйдёт в SQL. + +func listUsersQ(t *testing.T, rawQuery, role string) (int, string) { + t.Helper() + c, w := newQueryContext(t, rawQuery) + c.Set("role", role) + c.Set("user_id", "00000000-0000-0000-0000-000000000001") + + h := &UserHandler{} + h.List(c) + return w.Code, errorBody(t, w) +} + +func TestListUsersRejectsShortQuery(t *testing.T) { + cases := []struct { + name string + query string + role string + }{ + {"один символ", "role=worker&q=x", "client"}, + {"символ после пробелов", "role=worker&q=%20x", "client"}, + {"админ с коротким запросом", "role=worker&q=п", "admin"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, errBody := listUsersQ(t, tc.query, tc.role) + if code != http.StatusBadRequest || errBody != "invalid q" { + t.Errorf("status=%d error=%q, want 400/invalid q", code, errBody) + } + }) + } +} + +// Пустой q — обычный список, а не ошибка: валидация его пропускает, +// и хендлер доходит до репозитория. Репозиторий в тесте nil — паника +// от него и есть доказательство, что ответа 400 на пустой q не было. +func TestListUsersEmptyQueryPassesValidation(t *testing.T) { + c, w := newQueryContext(t, "role=worker&q=") + c.Set("role", "client") + c.Set("user_id", "00000000-0000-0000-0000-000000000001") + + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") + } + if w.Code == http.StatusBadRequest { + t.Errorf("пустой q не должен быть 400, body %q", w.Body.String()) + } + }() + + h := &UserHandler{} + h.List(c) +} + +// --- POST /users: роль, граница полномочий и пароль — до базы --- + +// newPostContext — контекст POST с JSON-телом (для Create). +func newPostContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, w +} + +// TestCreateUserRoleRules — «клиент создать клиента/админа» — 403, +// неизвестная роль — 400, слабый пароль — 400. Все проверки идут до +// HashPassword/SQL, поэтому репозиторий здесь nil: сдвинутая проверка +// упадёт паникой, а не молча создаст пользователя. Успешный путь +// админа (role=client → 201) проверяется интеграционно в API-смоуке. +func TestCreateUserRoleRules(t *testing.T) { + cases := []struct { + name string + actor string + body string + wantCode int + wantSub string // подстрока в error, "" — не проверяем + }{ + { + "неизвестная роль", "admin", + `{"email":"a@b.co","password":"password1","name":"X","role":"manager"}`, + http.StatusBadRequest, "must be one of: worker admin client", + }, + { + "клиент создаёт клиента", "client", + `{"email":"a@b.co","password":"password1","name":"X","role":"client"}`, + http.StatusForbidden, "forbidden", + }, + { + "клиент создаёт админа", "client", + `{"email":"a@b.co","password":"password1","name":"X","role":"admin"}`, + http.StatusForbidden, "forbidden", + }, + { + // Политика пароля (мин. 10, буквы и цифры) — после границы + // полномочий и до базы: репозиторий nil. + "слабый пароль", "admin", + `{"email":"a@b.co","password":"short123","name":"X","role":"worker"}`, + http.StatusBadRequest, "password must be at least 10 characters", + }, + { + // Граница не изменилась: клиент по-прежнему создаёт только мастеров. + "клиент создаёт мастера — доходит до базы", "client", + `{"email":"a@b.co","password":"password123","name":"X","role":"worker"}`, + 0, "паника от nil-репозитория", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, w := newPostContext(t, tc.body) + c.Set("role", tc.actor) + c.Set("user_id", "00000000-0000-0000-0000-000000000001") + + h := &UserHandler{} + if tc.wantCode == 0 { + // Успешный путь: останавливает только паника nil-репо. + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") + } + }() + h.Create(c) + return + } + h.Create(c) + if w.Code != tc.wantCode { + t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) + } + if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { + t.Errorf("error = %q, want substring %q", body, tc.wantSub) + } + }) + } +} + +// --- PATCH /users/:id и DELETE /users/:id: проверки до базы --- + +// newIDContext — контекст с path-параметром :id; body пустой — без JSON. +func newIDContext(t *testing.T, method, id, body string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, "/users/"+id, strings.NewReader(body)) + if body != "" { + c.Request.Header.Set("Content-Type", "application/json") + } + c.Params = gin.Params{{Key: "id", Value: id}} + return c, w +} + +const ownID = "00000000-0000-0000-0000-000000000001" +const otherID = "00000000-0000-0000-0000-000000000002" + +// TestUpdateUserValidation — до базы: кривой id и смена своего статуса. +// Роль-ветку (клиент идёт в SetActiveWorkerForClient) проверяем отдельно: +// nil-репозиторий даёт панику — доказательство, что выбор SQL-правила +// действительно происходит по роли. +func TestUpdateUserValidation(t *testing.T) { + cases := []struct { + name string + actor string + id string + body string + wantCode int + wantSub string + }{ + {"кривой id", "admin", "not-a-uuid", `{"is_active":false}`, http.StatusBadRequest, "invalid id"}, + {"пустое тело", "admin", otherID, ``, http.StatusBadRequest, ""}, + {"свой статус менять нельзя", "admin", ownID, `{"is_active":false}`, http.StatusBadRequest, "cannot change your own status"}, + {"клиент меняет свой статус", "client", ownID, `{"is_active":false}`, http.StatusBadRequest, "cannot change your own status"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, w := newIDContext(t, http.MethodPatch, tc.id, tc.body) + c.Set("role", tc.actor) + c.Set("user_id", ownID) + + h := &UserHandler{} + h.Update(c) + if w.Code != tc.wantCode { + t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) + } + if tc.wantSub != "" { + if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { + t.Errorf("error = %q, want substring %q", body, tc.wantSub) + } + } + }) + } +} + +// TestUpdateUserRoleBranchReachesRepo — и админ, и клиент после +// проверок уходят в репозиторий: клиент — в SQL-правило «только мой +// мастер», админ — в обычное SetActive. Паника nil-репо — и есть +// доказательство; если ветка по роли сломается, упадёт тест. +func TestUpdateUserRoleBranchReachesRepo(t *testing.T) { + for _, actor := range []string{"admin", "client"} { + t.Run(actor, func(t *testing.T) { + c, _ := newIDContext(t, http.MethodPatch, otherID, `{"is_active":false}`) + c.Set("role", actor) + c.Set("user_id", ownID) + + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, роль-ветка не дошла до SQL") + } + }() + h := &UserHandler{} + h.Update(c) + }) + } +} + +// TestDeleteUserValidation — до базы: кривой id и удаление самого себя. +func TestDeleteUserValidation(t *testing.T) { + cases := []struct { + name string + id string + wantCode int + wantSub string + }{ + {"кривой id", "not-a-uuid", http.StatusBadRequest, "invalid id"}, + {"себя удалить нельзя", ownID, http.StatusBadRequest, "cannot delete yourself"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, w := newIDContext(t, http.MethodDelete, tc.id, "") + c.Set("role", "admin") + c.Set("user_id", ownID) + + h := &UserHandler{} + h.Delete(c) + if w.Code != tc.wantCode { + t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) + } + if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { + t.Errorf("error = %q, want substring %q", body, tc.wantSub) + } + }) + } +} + +// TestDeleteUserReachesRepo — валидный id после проверок уходит в БД, +// независимо от роли: у админа ветка DeleteProfile, у клиента — +// DeleteWorkerForClient (граница и транзакция — в SQL). Паника +// nil-репозитория — доказательство; успешный путь и 409 «есть история» +// проверяются интеграционно в API-смоуке и репозиториных тестах. +func TestDeleteUserReachesRepo(t *testing.T) { + for _, r := range []string{"admin", "client"} { + t.Run(r, func(t *testing.T) { + c, _ := newIDContext(t, http.MethodDelete, otherID, "") + c.Set("role", r) + c.Set("user_id", ownID) + + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") + } + }() + h := &UserHandler{} + h.Delete(c) + }) + } +} + +// --- POST /users/:id/confirm: проверки до базы --- + +// TestConfirmValidation — до базы: кривой id отбрасывается сразу, валидный +// доходит до репозитория (паника nil-репозитория — доказательство). +// Матрица прав подтверждения — TestUserConfirmationRules в репозитории, +// письмо — в mailer-тестах: здесь только разбор параметра. +func TestConfirmValidation(t *testing.T) { + t.Run("кривой id", func(t *testing.T) { + c, w := newIDContext(t, http.MethodPost, "not-a-uuid", "") + c.Set("role", "admin") + c.Set("user_id", ownID) + + h := &UserHandler{} + h.Confirm(c) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (body %q)", w.Code, w.Body.String()) + } + if body := errorBody(t, w); !strings.Contains(body, "invalid id") { + t.Errorf("error = %q, want \"invalid id\"", body) + } + }) + + t.Run("валидный id доходит в репозиторий", func(t *testing.T) { + c, _ := newIDContext(t, http.MethodPost, otherID, "") + c.Set("role", "admin") + c.Set("user_id", ownID) + + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") + } + }() + h := &UserHandler{} + h.Confirm(c) + }) +} + +// --- POST /users/:id/password-set: проверки до базы --- + +// TestSetPasswordValidation — до базы: кривой id, пустое тело и слабый +// пароль отклоняются до SQL. Валидный запрос доходит до репозитория +// (паника nil-репозитория — доказательство); успешный путь админа +// (200, вход новым паролем, старые сессии отозваны) проверяется +// интеграционно в API-смоуке. +func TestSetPasswordValidation(t *testing.T) { + cases := []struct { + name string + id string + body string + wantCode int + wantSub string + }{ + {"кривой id", "not-a-uuid", `{"password":"password123"}`, http.StatusBadRequest, "invalid id"}, + {"пустое тело", otherID, ``, http.StatusBadRequest, "invalid request body"}, + {"нет пароля", otherID, `{}`, http.StatusBadRequest, "is required"}, + {"слабый пароль", otherID, `{"password":"short123"}`, http.StatusBadRequest, "password must be at least 10 characters"}, + {"валидный запрос идёт в базу", otherID, `{"password":"password123"}`, 0, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, w := newIDContext(t, http.MethodPost, tc.id, tc.body) + c.Set("role", "admin") + c.Set("user_id", ownID) + + h := &UserHandler{} + if tc.wantCode == 0 { + defer func() { + if recover() == nil { + t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") + } + }() + h.SetPassword(c) + return + } + h.SetPassword(c) + if w.Code != tc.wantCode { + t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) + } + if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { + t.Errorf("error = %q, want substring %q", body, tc.wantSub) + } + }) + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..aa6b2c5 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,44 @@ +// Package logging настраивает структурное логирование через log/slog. +// +// Логгер ставится дефолтным, чтобы пакеты писали slog.Info(...) без +// протаскивания логгера через все сигнатуры — в Go это принятое решение. +package logging + +import ( + "log/slog" + "os" + "strings" +) + +// Setup создаёт корневой логгер и делает его дефолтным. +// +// LOG_LEVEL — debug | info | warn | error (по умолчанию info); +// LOG_FORMAT — text | json (по умолчанию text: читаемо в консоли, +// json выбирают, когда логи собирает агрегатор). +func Setup() *slog.Logger { + opts := &slog.HandlerOptions{Level: levelFromEnv()} + + var handler slog.Handler + if strings.EqualFold(strings.TrimSpace(os.Getenv("LOG_FORMAT")), "json") { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + + logger := slog.New(handler) + slog.SetDefault(logger) + return logger +} + +func levelFromEnv() slog.Level { + switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..3d7f67d --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,444 @@ +// Package mailer — отправка писем по SMTP: библиотека +// github.com/emersion/go-smtp (MIT) вместо замороженного net/smtp. +// +// Письма сейчас два — «аккаунт подтверждён» (POST /users/:id/confirm) +// и «сброс пароля» (POST /auth/forgot-password): multipart/alternative +// (текст + HTML), тема в encoded-word (RFC 2047), тела в base64 +// (RFC 2045). Отправка fail-open: подтверждение и сброс не +// откатываются из-за недоступной почты — ошибка уходит в warn-лог. +// +// Настройки читает cmd/api из env: +// +// SMTP_HOST — хост SMTP; пусто — письма не шлются (dev без почты) +// SMTP_PORT — порт; пусто — 587 +// SMTP_USER — пользователь; пусто — без аутентификации (Mailpit) +// SMTP_PASSWORD — пароль +// SMTP_FROM — адрес отправителя; пусто — берётся SMTP_USER +// SMTP_TLS — tls | starttls | none; пусто — tls для 465, иначе starttls +// APP_BASE_URL — база ссылок письма; пусто — http://localhost:3000 +package mailer + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "html" + "log/slog" + "mime" + "net" + "net/url" + "strconv" + "strings" + "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +// Режимы SMTP_TLS (и режимы по умолчанию, когда переменная пуста). +const ( + tlsImplicit = "tls" // порт 465: TLS с самого начала соединения + tlsStart = "starttls" // порт 587: обычное соединение + STARTTLS + tlsNone = "none" // без шифрования: Mailpit в локальной разработке +) + +// sendTimeout — общий дедлайн на письмо: соединение, аутентификация +// и отправка должны уложиться в него, иначе ответ админу (.fail-open) +// не должен ждать зависший SMTP дольше. +const sendTimeout = 10 * time.Second + +// altBoundary — разделитель частей multipart/alternative. В base64 +// символов «_» нет, поэтому граница не может встретиться в телах. +const altBoundary = "_=_BuildingMgmt_alternative_=_" + +// Mailer — то, что видят handlers: письма и точка отказа в тестах +// (fake реализует тот же интерфейс, сеть не нужна). +type Mailer interface { + // SendAccountConfirmed — письмо «аккаунт подтверждён, можно войти». + SendAccountConfirmed(ctx context.Context, a AccountConfirmed) error + // SendPasswordReset — письмо «сброс пароля» со ссылкой на форму. + SendPasswordReset(ctx context.Context, p PasswordReset) error +} + +// AccountConfirmed — данные письма после подтверждения: handlers +// передаёт профиль, тему/шаблоны собирает mailer. +type AccountConfirmed struct { + To string // адрес получателя + Name string // имя аккаунта — им и обращаемся + Role string // client | worker | admin + ConfirmedAt time.Time // момент подтверждения (zero — строку опускаем) +} + +// PasswordReset — данные письма «забыли пароль»: токен handlers уже +// выпустил в базе, mailer только строит ссылку (у него BaseURL) и шлёт. +type PasswordReset struct { + To string // адрес получателя + Name string // имя аккаунта + Token string // одноразовый токен — в ссылке, в базе только sha256 + ExpiresAt time.Time // срок жизни ссылки (из CreateResetToken) +} + +// Config — настройки SMTP из env (см. комментарий пакета). +type Config struct { + Host string + Port string + User string + Password string + From string + TLS string + BaseURL string +} + +// New собирает mailer: пустой Host — заглушка без отправки (dev), +// иначе SMTP-клиент. Некорректные порт/режим/адрес — ошибка на старте, +// а не сюрприз на первом подтверждении. +func New(cfg Config, log *slog.Logger) (Mailer, error) { + if cfg.Host == "" { + log.Info("smtp disabled: SMTP_HOST is empty, confirmation emails are skipped") + return disabledMailer{log: log}, nil + } + if cfg.Port == "" { + cfg.Port = "587" + } + if _, err := strconv.Atoi(cfg.Port); err != nil { + return nil, fmt.Errorf("smtp: invalid SMTP_PORT %q", cfg.Port) + } + if cfg.From == "" { + cfg.From = cfg.User + } + if cfg.From == "" { + return nil, fmt.Errorf("smtp: SMTP_FROM and SMTP_USER are both empty") + } + if cfg.BaseURL == "" { + cfg.BaseURL = "http://localhost:3000" + } + mode := cfg.TLS + if mode == "" { + // Дефолт по порту: 465 — implicit TLS, остальные — STARTTLS. + if cfg.Port == "465" { + mode = tlsImplicit + } else { + mode = tlsStart + } + } + if mode != tlsImplicit && mode != tlsStart && mode != tlsNone { + return nil, fmt.Errorf("smtp: invalid SMTP_TLS %q (want tls, starttls or none)", cfg.TLS) + } + // Пароль намеренно не логируем. + log.Info("smtp configured", "host", cfg.Host, "port", cfg.Port, "tls", mode, "user", cfg.User) + return &smtpMailer{cfg: cfg, mode: mode, timeout: sendTimeout}, nil +} + +type smtpMailer struct { + cfg Config + mode string + timeout time.Duration +} + +// SendAccountConfirmed — письмо после подтверждения: тема и обе версии +// тела (текст для почтовиков без HTML, HTML — для людей) собираются +// здесь, handlers передаёт только данные аккаунта. +func (m *smtpMailer) SendAccountConfirmed(ctx context.Context, a AccountConfirmed) error { + const subject = "Аккаунт подтверждён" + return m.deliver(ctx, a.To, subject, + confirmedText(a, m.cfg.BaseURL), confirmedHTML(a, m.cfg.BaseURL)) +} + +// SendPasswordReset — письмо «забыли пароль»: ссылка на форму сброса +// (токен в query) собирается здесь же — BaseURL живёт в конфиге mailer. +func (m *smtpMailer) SendPasswordReset(ctx context.Context, p PasswordReset) error { + const subject = "Сброс пароля" + return m.deliver(ctx, p.To, subject, + resetText(p, m.cfg.BaseURL), resetHTML(p, m.cfg.BaseURL)) +} + +// deliver — общий дедлайн вокруг сетевой части: go-smtp работает без +// ctx, поэтому ждём результат select'ом — упавший SMTP не подвешивает +// ответ хендлера дольше sendTimeout. +func (m *smtpMailer) deliver(ctx context.Context, to, subject, textBody, htmlBody string) error { + done := make(chan error, 1) + go func() { done <- m.deliverOnce(to, subject, textBody, htmlBody) }() + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + case <-time.After(m.timeout): + return fmt.Errorf("smtp: no result in %s", m.timeout) + } +} + +// deliverOnce — одно соединение: dial (по режиму TLS), опциональная +// аутентификация (пустой User — сервер без неё, как Mailpit), +// отправка, выход. Сертификат проверяется по имени хоста (ServerName). +func (m *smtpMailer) deliverOnce(to, subject, textBody, htmlBody string) error { + addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) + tlsCfg := &tls.Config{ServerName: m.cfg.Host} + + var ( + c *smtp.Client + err error + ) + switch m.mode { + case tlsImplicit: + c, err = smtp.DialTLS(addr, tlsCfg) + case tlsStart: + c, err = smtp.DialStartTLS(addr, tlsCfg) + default: // tlsNone + c, err = smtp.Dial(addr) + } + if err != nil { + return fmt.Errorf("smtp dial %s: %w", addr, err) + } + // Закрытие — best-effort: после Quit ошибка на Close ничего не значит. + defer func() { _ = c.Close() }() + c.CommandTimeout = m.timeout + c.SubmissionTimeout = m.timeout + + if m.cfg.User != "" { + if err := c.Auth(sasl.NewPlainClient("", m.cfg.User, m.cfg.Password)); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + msg := buildMessage(m.cfg.From, to, subject, textBody, htmlBody) + if err := c.SendMail(m.cfg.From, []string{to}, strings.NewReader(msg)); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + if err := c.Quit(); err != nil { + return fmt.Errorf("smtp quit: %w", err) + } + return nil +} + +// loginURL — ссылка на вход письма: база из APP_BASE_URL + хеш-роут. +func loginURL(baseURL string) string { + return strings.TrimRight(baseURL, "/") + "/#/login" +} + +// roleLabel — роль по-человечески для письма; неизвестная роль +// остаётся как есть (не валим технические значения в текст письма). +func roleLabel(role string) string { + switch role { + case "client": + return "Клиент" + case "worker": + return "Мастер" + case "admin": + return "Администратор" + default: + return role + } +} + +// confirmedText — текстовая версия письма: тот же набор данных, что +// и в HTML, для почтовиков без поддержки разметки. +func confirmedText(a AccountConfirmed, baseURL string) string { + var b strings.Builder + fmt.Fprintf(&b, "Здравствуйте, %s!\n\n", a.Name) + b.WriteString("Аккаунт подтверждён — теперь можно войти в систему.\n\n") + fmt.Fprintf(&b, "Email аккаунта: %s\n", a.To) + fmt.Fprintf(&b, "Роль: %s\n", roleLabel(a.Role)) + if !a.ConfirmedAt.IsZero() { + fmt.Fprintf(&b, "Подтверждено: %s\n", a.ConfirmedAt.Format("02.01.2006 15:04")) + } + fmt.Fprintf(&b, "Вход: %s\n", loginURL(baseURL)) + b.WriteString("\nЕсли это письмо пришло вам по ошибке, просто проигнорируйте его —\n") + b.WriteString("пароль в письмах мы никогда не указываем.\n\n") + b.WriteString("— Заявки в здании. Автоматическое письмо, отвечать на него не нужно.\n") + return b.String() +} + +// confirmedHTML — HTML-версия: карточка с заголовком, таблицей данных +// и кнопкой входа. Только инлайн-стили и table-вёрстка — почтовики +// (в т.ч. мобильные) отрезают