commit 519903b9301bce2f0f49dd9672178ece5a9c2dee Author: edadmin Date: Sun Oct 18 22:07:25 2026 +0300 Initial commit: backend structure 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-вёрстка — почтовики +// (в т.ч. мобильные) отрезают