Initial commit: backend structure
This commit is contained in:
commit
519903b930
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -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
|
||||||
67
.drone.yml
Normal file
67
.drone.yml
Normal file
@ -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
|
||||||
124
.env.example
Normal file
124
.env.example
Normal file
@ -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
|
||||||
95
.env.prod.example
Normal file
95
.env.prod.example
Normal file
@ -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
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
/tmp/
|
||||||
39
.golangci.yml
Normal file
39
.golangci.yml
Normal file
@ -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]
|
||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@ -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"]
|
||||||
46
Makefile
Normal file
46
Makefile
Normal file
@ -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
|
||||||
736
cmd/api/main.go
Normal file
736
cmd/api/main.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
cmd/migrate/main.go
Normal file
53
cmd/migrate/main.go
Normal file
@ -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
|
||||||
|
}
|
||||||
343
cmd/seed/main.go
Normal file
343
cmd/seed/main.go
Normal file
@ -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).")
|
||||||
|
}
|
||||||
160
compose.prod.yaml
Normal file
160
compose.prod.yaml
Normal file
@ -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:
|
||||||
74
compose.yaml
Normal file
74
compose.yaml
Normal file
@ -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:
|
||||||
70
deploy/drone/compose.yaml
Normal file
70
deploy/drone/compose.yaml
Normal file
@ -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:
|
||||||
26
deploy/minio/Dockerfile
Normal file
26
deploy/minio/Dockerfile
Normal file
@ -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.<tag>.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"]
|
||||||
4787
docs/docs.go
Normal file
4787
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
4766
docs/swagger.json
Normal file
4766
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
3343
docs/swagger.yaml
Normal file
3343
docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
82
go.mod
Normal file
82
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
230
go.sum
Normal file
230
go.sum
Normal file
@ -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=
|
||||||
85
internal/auth/jwt.go
Normal file
85
internal/auth/jwt.go
Normal file
@ -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
|
||||||
|
}
|
||||||
104
internal/auth/jwt_test.go
Normal file
104
internal/auth/jwt_test.go
Normal file
@ -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, ""}, ".")
|
||||||
|
}
|
||||||
43
internal/auth/middleware.go
Normal file
43
internal/auth/middleware.go
Normal file
@ -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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/auth/password.go
Normal file
68
internal/auth/password.go
Normal file
@ -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
|
||||||
|
}
|
||||||
34
internal/auth/password_test.go
Normal file
34
internal/auth/password_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/auth/tokens.go
Normal file
28
internal/auth/tokens.go
Normal file
@ -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[:])
|
||||||
|
}
|
||||||
114
internal/crypt/cipher.go
Normal file
114
internal/crypt/cipher.go
Normal file
@ -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
|
||||||
|
}
|
||||||
128
internal/crypt/cipher_test.go
Normal file
128
internal/crypt/cipher_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/db/db.go
Normal file
77
internal/db/db.go
Normal file
@ -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
|
||||||
|
}
|
||||||
62
internal/db/db_test.go
Normal file
62
internal/db/db_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/db/migrate.go
Normal file
169
internal/db/migrate.go
Normal file
@ -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"
|
||||||
|
|
||||||
|
// Имя файла: <version>_<name>.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 <version>_<name>.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
|
||||||
|
}
|
||||||
129
internal/db/migrations/0001_init.sql
Normal file
129
internal/db/migrations/0001_init.sql
Normal file
@ -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);
|
||||||
44
internal/db/migrations/0002_ticket_resolution.sql
Normal file
44
internal/db/migrations/0002_ticket_resolution.sql
Normal file
@ -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);
|
||||||
30
internal/db/migrations/0003_hardening.sql
Normal file
30
internal/db/migrations/0003_hardening.sql
Normal file
@ -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;
|
||||||
104
internal/db/migrations/0004_projects.sql
Normal file
104
internal/db/migrations/0004_projects.sql
Normal file
@ -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);
|
||||||
60
internal/db/migrations/0005_project_invitations.sql
Normal file
60
internal/db/migrations/0005_project_invitations.sql
Normal file
@ -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);
|
||||||
55
internal/db/migrations/0006_ticket_events.sql
Normal file
55
internal/db/migrations/0006_ticket_events.sql
Normal file
@ -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);
|
||||||
42
internal/db/migrations/0007_password_reset.sql
Normal file
42
internal/db/migrations/0007_password_reset.sql
Normal file
@ -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);
|
||||||
65
internal/db/migrations/0008_ticket_photos.sql
Normal file
65
internal/db/migrations/0008_ticket_photos.sql
Normal file
@ -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'));
|
||||||
32
internal/db/migrations/0009_rate_limits.sql
Normal file
32
internal/db/migrations/0009_rate_limits.sql
Normal file
@ -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);
|
||||||
31
internal/db/migrations/0010_user_confirmation.sql
Normal file
31
internal/db/migrations/0010_user_confirmation.sql
Normal file
@ -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;
|
||||||
48
internal/db/migrations/0011_telegram_notifications.sql
Normal file
48
internal/db/migrations/0011_telegram_notifications.sql
Normal file
@ -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';
|
||||||
25
internal/db/migrations/0012_user_bot_tokens.sql
Normal file
25
internal/db/migrations/0012_user_bot_tokens.sql
Normal file
@ -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;
|
||||||
|
|
||||||
|
-- Отбор пользователей со своим ботом при старте процесса (реестр ботов
|
||||||
|
-- и их поллеры): колонка трогается редко, индекс не нужен.
|
||||||
30
internal/db/migrations/0013_project_delete_cascade.sql
Normal file
30
internal/db/migrations/0013_project_delete_cascade.sql
Normal file
@ -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;
|
||||||
81
internal/db/migrations/0014_recurring_rules.sql
Normal file
81
internal/db/migrations/0014_recurring_rules.sql
Normal file
@ -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);
|
||||||
35
internal/db/migrations/0015_recurring_annual.sql
Normal file
35
internal/db/migrations/0015_recurring_annual.sql
Normal file
@ -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);
|
||||||
35
internal/db/migrations/0016_recurring_period.sql
Normal file
35
internal/db/migrations/0016_recurring_period.sql
Normal file
@ -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;
|
||||||
459
internal/handlers/auth.go
Normal file
459
internal/handlers/auth.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
213
internal/handlers/auth_test.go
Normal file
213
internal/handlers/auth_test.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
46
internal/handlers/binding.go
Normal file
46
internal/handlers/binding.go
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
internal/handlers/filters.go
Normal file
56
internal/handlers/filters.go
Normal file
@ -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
|
||||||
|
}
|
||||||
42
internal/handlers/httperr.go
Normal file
42
internal/handlers/httperr.go
Normal file
@ -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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
167
internal/handlers/list_test.go
Normal file
167
internal/handlers/list_test.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
53
internal/handlers/pagination.go
Normal file
53
internal/handlers/pagination.go
Normal file
@ -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
|
||||||
|
}
|
||||||
374
internal/handlers/photos.go
Normal file
374
internal/handlers/photos.go
Normal file
@ -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
|
||||||
|
}
|
||||||
84
internal/handlers/photos_test.go
Normal file
84
internal/handlers/photos_test.go
Normal file
@ -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(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), "", ""},
|
||||||
|
{"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
785
internal/handlers/projects.go
Normal file
785
internal/handlers/projects.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
474
internal/handlers/recurring.go
Normal file
474
internal/handlers/recurring.go
Normal file
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
430
internal/handlers/telegram.go
Normal file
430
internal/handlers/telegram.go
Normal file
@ -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: "<id>:<секрет>".
|
||||||
|
// Проверка до сети: мусор не доходит до 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
|
||||||
|
}
|
||||||
380
internal/handlers/telegram_test.go
Normal file
380
internal/handlers/telegram_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
740
internal/handlers/tickets.go
Normal file
740
internal/handlers/tickets.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
144
internal/handlers/types.go
Normal file
144
internal/handlers/types.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
517
internal/handlers/users.go
Normal file
517
internal/handlers/users.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
367
internal/handlers/users_test.go
Normal file
367
internal/handlers/users_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/logging/logging.go
Normal file
44
internal/logging/logging.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
444
internal/mailer/mailer.go
Normal file
444
internal/mailer/mailer.go
Normal file
@ -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-вёрстка — почтовики
|
||||||
|
// (в т.ч. мобильные) отрезают <style> и не всегда понимают flex.
|
||||||
|
// Всё пользовательское (имя, email, роль) прогоняется через
|
||||||
|
// html.EscapeString — имя в аккаунте вводит сам пользователь.
|
||||||
|
func confirmedHTML(a AccountConfirmed, baseURL string) string {
|
||||||
|
var rows strings.Builder
|
||||||
|
details := [][2]string{
|
||||||
|
{"Email аккаунта", a.To},
|
||||||
|
{"Роль", roleLabel(a.Role)},
|
||||||
|
}
|
||||||
|
if !a.ConfirmedAt.IsZero() {
|
||||||
|
details = append(details, [2]string{"Подтверждено", a.ConfirmedAt.Format("02.01.2006 15:04")})
|
||||||
|
}
|
||||||
|
for i, kv := range details {
|
||||||
|
border := "border-bottom:1px solid #e9edf5;"
|
||||||
|
if i == len(details)-1 {
|
||||||
|
border = ""
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&rows,
|
||||||
|
"<tr>"+
|
||||||
|
"<td style=\"padding:10px 16px;%s color:#8a94a6;font-size:14px;\">%s</td>"+
|
||||||
|
"<td style=\"padding:10px 16px;%s color:#16233a;font-size:14px;text-align:right;\">%s</td>"+
|
||||||
|
"</tr>\n",
|
||||||
|
border, html.EscapeString(kv[0]), border, html.EscapeString(kv[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<!DOCTYPE html>\n" +
|
||||||
|
"<html lang=\"ru\"><body style=\"margin:0;padding:0;background:#eef1f6;\">" +
|
||||||
|
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#eef1f6;\"><tr>" +
|
||||||
|
"<td align=\"center\" style=\"padding:28px 12px;\">" +
|
||||||
|
"<table role=\"presentation\" width=\"560\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:100%;max-width:560px;background:#ffffff;border-radius:14px;border:1px solid #e4e9f2;\">" +
|
||||||
|
"<tr><td style=\"padding:30px 32px 6px;font-family:Arial,Helvetica,sans-serif;\">" +
|
||||||
|
"<div style=\"font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:#8a94a6;\">Заявки в здании</div>" +
|
||||||
|
"<div style=\"height:3px;width:44px;background:#2f6bff;border-radius:2px;margin:12px 0 18px;\"></div>" +
|
||||||
|
"<div style=\"font-size:24px;font-weight:bold;color:#16233a;margin:0 0 18px;\">Аккаунт подтверждён</div>" +
|
||||||
|
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 12px;\">Здравствуйте, " +
|
||||||
|
html.EscapeString(a.Name) + "!</div>" +
|
||||||
|
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 22px;\">" +
|
||||||
|
"Ваш аккаунт подтверждён — теперь можно войти в систему.</div>" +
|
||||||
|
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" " +
|
||||||
|
"style=\"border-collapse:collapse;margin:0 0 24px;background:#f7f9fc;border-radius:8px;\">" +
|
||||||
|
rows.String() +
|
||||||
|
"</table>" +
|
||||||
|
"<a href=\"" + html.EscapeString(loginURL(baseURL)) + "\" style=\"display:inline-block;" +
|
||||||
|
"background:#2f6bff;color:#ffffff;text-decoration:none;font-size:15px;font-weight:bold;" +
|
||||||
|
"padding:13px 28px;border-radius:8px;font-family:Arial,Helvetica,sans-serif;\">" +
|
||||||
|
"Войти в систему</a>" +
|
||||||
|
"</td></tr>" +
|
||||||
|
"<tr><td style=\"padding:14px 32px 28px;font-family:Arial,Helvetica,sans-serif;\">" +
|
||||||
|
"<div style=\"height:1px;background:#e9edf5;margin:0 0 14px;\"></div>" +
|
||||||
|
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0 0 6px;\">" +
|
||||||
|
"Если это письмо пришло вам по ошибке, просто проигнорируйте его — " +
|
||||||
|
"пароль в письмах мы никогда не указываем.</div>" +
|
||||||
|
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0;\">" +
|
||||||
|
"Заявки в здании · " + html.EscapeString(strings.TrimRight(baseURL, "/")) +
|
||||||
|
" · Автоматическое письмо, отвечать на него не нужно.</div>" +
|
||||||
|
"</td></tr>" +
|
||||||
|
"</table>" +
|
||||||
|
"</td></tr></table>" +
|
||||||
|
"</body></html>\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetURL — ссылка на форму сброса: база из APP_BASE_URL + хеш-роут
|
||||||
|
// с токеном. Токен hex (URL-безопасен), но экранируем на будущее —
|
||||||
|
// формат токена может измениться.
|
||||||
|
func resetURL(baseURL, token string) string {
|
||||||
|
return strings.TrimRight(baseURL, "/") + "/#/reset-password?token=" + url.QueryEscape(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetText — текстовая версия письма сброса.
|
||||||
|
func resetText(p PasswordReset, baseURL string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Здравствуйте, %s!\n\n", p.Name)
|
||||||
|
b.WriteString("Кто-то (надеемся, вы) запросил сброс пароля для вашего\n")
|
||||||
|
b.WriteString("аккаунта в системе «Заявки в здании».\n\n")
|
||||||
|
fmt.Fprintf(&b, "Email аккаунта: %s\n", p.To)
|
||||||
|
fmt.Fprintf(&b, "Ссылка для смены пароля: %s\n", resetURL(baseURL, p.Token))
|
||||||
|
fmt.Fprintf(&b, "Ссылка одноразовая и действует до %s.\n",
|
||||||
|
p.ExpiresAt.Format("02.01.2006 15:04"))
|
||||||
|
b.WriteString("\nЕсли вы не просили сброс — просто проигнорируйте это письмо:\n")
|
||||||
|
b.WriteString("пароль останется прежним, а ссылка перестанет работать по истечении\n")
|
||||||
|
b.WriteString("срока. Пароль в письмах мы никогда не указываем.\n\n")
|
||||||
|
b.WriteString("— Заявки в здании. Автоматическое письмо, отвечать на него не нужно.\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetHTML — HTML-версия письма сброса: та же карточка, что и в
|
||||||
|
// письме подтверждения, с кнопкой «Задать новый пароль». Инлайн-стили
|
||||||
|
// и table-вёрстка — почтовики отрезают <style> и не всегда понимают
|
||||||
|
// flex; имя/email экранируются (html.EscapeString).
|
||||||
|
func resetHTML(p PasswordReset, baseURL string) string {
|
||||||
|
return "<!DOCTYPE html>\n" +
|
||||||
|
"<html lang=\"ru\"><body style=\"margin:0;padding:0;background:#eef1f6;\">" +
|
||||||
|
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#eef1f6;\"><tr>" +
|
||||||
|
"<td align=\"center\" style=\"padding:28px 12px;\">" +
|
||||||
|
"<table role=\"presentation\" width=\"560\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:100%;max-width:560px;background:#ffffff;border-radius:14px;border:1px solid #e4e9f2;\">" +
|
||||||
|
"<tr><td style=\"padding:30px 32px 6px;font-family:Arial,Helvetica,sans-serif;\">" +
|
||||||
|
"<div style=\"font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:#8a94a6;\">Заявки в здании</div>" +
|
||||||
|
"<div style=\"height:3px;width:44px;background:#2f6bff;border-radius:2px;margin:12px 0 18px;\"></div>" +
|
||||||
|
"<div style=\"font-size:24px;font-weight:bold;color:#16233a;margin:0 0 18px;\">Сброс пароля</div>" +
|
||||||
|
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 12px;\">Здравствуйте, " +
|
||||||
|
html.EscapeString(p.Name) + "!</div>" +
|
||||||
|
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 22px;\">" +
|
||||||
|
"Кто-то (надеемся, вы) запросил сброс пароля для аккаунта <b>" +
|
||||||
|
html.EscapeString(p.To) + "</b>. Задайте новый пароль по кнопке ниже.</div>" +
|
||||||
|
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" " +
|
||||||
|
"style=\"border-collapse:collapse;margin:0 0 24px;background:#f7f9fc;border-radius:8px;\">" +
|
||||||
|
"<tr>" +
|
||||||
|
"<td style=\"padding:10px 16px;border-bottom:1px solid #e9edf5;color:#8a94a6;font-size:14px;\">Действует до</td>" +
|
||||||
|
"<td style=\"padding:10px 16px;border-bottom:1px solid #e9edf5;color:#16233a;font-size:14px;text-align:right;\">" +
|
||||||
|
html.EscapeString(p.ExpiresAt.Format("02.01.2006 15:04")) + "</td>" +
|
||||||
|
"</tr>" +
|
||||||
|
"<tr>" +
|
||||||
|
"<td style=\"padding:10px 16px;color:#8a94a6;font-size:14px;\">Ссылка</td>" +
|
||||||
|
"<td style=\"padding:10px 16px;color:#16233a;font-size:14px;text-align:right;\">одноразовая</td>" +
|
||||||
|
"</tr>" +
|
||||||
|
"</table>" +
|
||||||
|
"<a href=\"" + html.EscapeString(resetURL(baseURL, p.Token)) + "\" style=\"display:inline-block;" +
|
||||||
|
"background:#2f6bff;color:#ffffff;text-decoration:none;font-size:15px;font-weight:bold;" +
|
||||||
|
"padding:13px 28px;border-radius:8px;font-family:Arial,Helvetica,sans-serif;\">" +
|
||||||
|
"Задать новый пароль</a>" +
|
||||||
|
"</td></tr>" +
|
||||||
|
"<tr><td style=\"padding:14px 32px 28px;font-family:Arial,Helvetica,sans-serif;\">" +
|
||||||
|
"<div style=\"height:1px;background:#e9edf5;margin:0 0 14px;\"></div>" +
|
||||||
|
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0 0 6px;\">" +
|
||||||
|
"Если вы не просили сброс — проигнорируйте письмо: пароль останется прежним, " +
|
||||||
|
"а ссылка перестанет работать по истечении срока. Пароль в письмах мы никогда не указываем.</div>" +
|
||||||
|
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0;\">" +
|
||||||
|
"Заявки в здании · " + html.EscapeString(strings.TrimRight(baseURL, "/")) +
|
||||||
|
" · Автоматическое письмо, отвечать на него не нужно.</div>" +
|
||||||
|
"</td></tr>" +
|
||||||
|
"</table>" +
|
||||||
|
"</td></tr></table>" +
|
||||||
|
"</body></html>\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessage — письмо RFC 5322 c multipart/alternative: текст и HTML
|
||||||
|
// идут отдельными частями — почтовик выбирает, что показать. Тема —
|
||||||
|
// MIME encoded-word (RFC 2047): кириллицу без кодировки почтовики
|
||||||
|
// понимают по-разному, а SMTPUTF8 поддерживают не все серверы. Тела —
|
||||||
|
// base64 с переносом каждые 76 символов (RFC 2045).
|
||||||
|
func buildMessage(from, to, subject, textBody, htmlBody string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("From: " + from + "\r\n")
|
||||||
|
b.WriteString("To: " + to + "\r\n")
|
||||||
|
b.WriteString("Subject: " + mime.BEncoding.Encode("UTF-8", subject) + "\r\n")
|
||||||
|
b.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
b.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString("--" + altBoundary + "\r\n")
|
||||||
|
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
|
b.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(wrapBase64(textBody))
|
||||||
|
b.WriteString("--" + altBoundary + "\r\n")
|
||||||
|
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||||
|
b.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(wrapBase64(htmlBody))
|
||||||
|
b.WriteString("--" + altBoundary + "--\r\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapBase64 — тело в base64 с длиной строки 76 (RFC 2045).
|
||||||
|
func wrapBase64(s string) string {
|
||||||
|
enc := base64.StdEncoding.EncodeToString([]byte(s))
|
||||||
|
var lines []string
|
||||||
|
for len(enc) > 76 {
|
||||||
|
lines = append(lines, enc[:76])
|
||||||
|
enc = enc[76:]
|
||||||
|
}
|
||||||
|
if enc != "" {
|
||||||
|
lines = append(lines, enc)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\r\n") + "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabledMailer — SMTP не настроен: письма не шлются, вызов успешен,
|
||||||
|
// в лог уходит debug — подтверждение работает и без почты.
|
||||||
|
type disabledMailer struct {
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d disabledMailer) SendAccountConfirmed(_ context.Context, a AccountConfirmed) error {
|
||||||
|
d.log.Debug("smtp disabled: confirmation email skipped", "to", a.To)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d disabledMailer) SendPasswordReset(_ context.Context, p PasswordReset) error {
|
||||||
|
d.log.Debug("smtp disabled: password reset email skipped", "to", p.To)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
227
internal/mailer/mailer_test.go
Normal file
227
internal/mailer/mailer_test.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodePart — вырезает часть multipart по её Content-Type и декодирует
|
||||||
|
// base64-тело обратно в строку.
|
||||||
|
func decodePart(t *testing.T, msg, contentType string) string {
|
||||||
|
t.Helper()
|
||||||
|
idx := strings.Index(msg, "Content-Type: "+contentType)
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("no part %q in message:\n%s", contentType, msg)
|
||||||
|
}
|
||||||
|
rest := msg[idx:]
|
||||||
|
sep := strings.Index(rest, "\r\n\r\n")
|
||||||
|
if sep < 0 {
|
||||||
|
t.Fatalf("no body separator after %q", contentType)
|
||||||
|
}
|
||||||
|
body := rest[sep+4:]
|
||||||
|
if end := strings.Index(body, "\r\n--"+altBoundary); end >= 0 {
|
||||||
|
body = body[:end]
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(body, "\r\n", ""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("part %q decode: %v", contentType, err)
|
||||||
|
}
|
||||||
|
return string(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildMessage — из письма не должен сыпаться UTF-8: тема в
|
||||||
|
// encoded-word (RFC 2047), обе части тела в base64 (RFC 2045),
|
||||||
|
// разделители CRLF, multipart/alternative (текст + HTML).
|
||||||
|
func TestBuildMessage(t *testing.T) {
|
||||||
|
const text = "Здравствуйте, Иван!\n"
|
||||||
|
const htmlBody = "<p>Здравствуйте, Иван!</p>\n"
|
||||||
|
msg := buildMessage("from@example.test", "to@example.test", "Аккаунт подтверждён", text, htmlBody)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(msg, "From: from@example.test\r\nTo: to@example.test\r\n") {
|
||||||
|
t.Errorf("envelope headers:\n%q", msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "\r\nSubject: =?UTF-8?B?") &&
|
||||||
|
!strings.Contains(msg, "\r\nSubject: =?UTF-8?b?") {
|
||||||
|
t.Errorf("subject must be encoded-word (RFC 2047): %q", msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "Content-Type: multipart/alternative; boundary=\""+altBoundary+"\"") {
|
||||||
|
t.Errorf("missing multipart/alternative header: %q", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обе части на месте и декодируются обратно без потерь.
|
||||||
|
if got := decodePart(t, msg, "text/plain; charset=UTF-8"); got != text {
|
||||||
|
t.Errorf("text part = %q, want %q", got, text)
|
||||||
|
}
|
||||||
|
if got := decodePart(t, msg, "text/html; charset=UTF-8"); got != htmlBody {
|
||||||
|
t.Errorf("html part = %q, want %q", got, htmlBody)
|
||||||
|
}
|
||||||
|
// Последняя граница закрыта.
|
||||||
|
if !strings.HasSuffix(msg, "--"+altBoundary+"--\r\n") {
|
||||||
|
t.Errorf("multipart not closed: %q", msg[len(msg)-80:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmedContent — состав письма: ссылка на вход, роль
|
||||||
|
// по-человечески, дата подтверждения. Имя/email пользователя —
|
||||||
|
// данные из аккаунта, в HTML они обязаны быть экранированы.
|
||||||
|
func TestConfirmedContent(t *testing.T) {
|
||||||
|
a := AccountConfirmed{
|
||||||
|
To: "user@example.test",
|
||||||
|
Name: `Иван <script>alert(1)</script>`,
|
||||||
|
Role: "worker",
|
||||||
|
ConfirmedAt: time.Date(2026, 10, 6, 19, 4, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
const base = "https://example.test/"
|
||||||
|
|
||||||
|
text := confirmedText(a, base)
|
||||||
|
for _, want := range []string{
|
||||||
|
"Email аккаунта: user@example.test",
|
||||||
|
"Роль: Мастер",
|
||||||
|
"Подтверждено: 06.10.2026 19:04",
|
||||||
|
"Вход: https://example.test/#/login",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("text part misses %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := confirmedHTML(a, base)
|
||||||
|
if strings.Contains(page, "<script>") {
|
||||||
|
t.Errorf("HTML must escape the name:\n%s", page)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, "Иван <script>") {
|
||||||
|
t.Errorf("escaped name not found:\n%s", page)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`href="https://example.test/#/login"`,
|
||||||
|
"Мастер",
|
||||||
|
"user@example.test",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(page, want) {
|
||||||
|
t.Errorf("HTML misses %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без даты (нулевое время) строка «Подтверждено» опускается.
|
||||||
|
noDate := confirmedHTML(
|
||||||
|
AccountConfirmed{To: "a@b.c", Name: "N", Role: "client"},
|
||||||
|
"http://localhost:3000")
|
||||||
|
if strings.Contains(noDate, "Подтверждено") {
|
||||||
|
t.Errorf("zero ConfirmedAt must omit the date row:\n%s", noDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResetContent — состав письма сброса: ссылка с токеном, срок
|
||||||
|
// действия, email. Имя/адрес — данные пользователя, в HTML обязаны
|
||||||
|
// быть экранированы.
|
||||||
|
func TestResetContent(t *testing.T) {
|
||||||
|
p := PasswordReset{
|
||||||
|
To: "user@example.test",
|
||||||
|
Name: `Иван <script>alert(1)</script>`,
|
||||||
|
Token: "abc123TOKEN",
|
||||||
|
ExpiresAt: time.Date(2026, 10, 7, 12, 30, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
const base = "https://example.test/"
|
||||||
|
|
||||||
|
text := resetText(p, base)
|
||||||
|
for _, want := range []string{
|
||||||
|
"Email аккаунта: user@example.test",
|
||||||
|
"https://example.test/#/reset-password?token=abc123TOKEN",
|
||||||
|
"до 07.10.2026 12:30",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("text part misses %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := resetHTML(p, base)
|
||||||
|
if strings.Contains(page, "<script>") {
|
||||||
|
t.Errorf("HTML must escape the name:\n%s", page)
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, "Иван <script>") {
|
||||||
|
t.Errorf("escaped name not found:\n%s", page)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`href="https://example.test/#/reset-password?token=abc123TOKEN"`,
|
||||||
|
"Задать новый пароль",
|
||||||
|
"user@example.test",
|
||||||
|
"07.10.2026 12:30",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(page, want) {
|
||||||
|
t.Errorf("HTML misses %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrapBase64 — длина строки 76 символов (RFC 2045), содержимое
|
||||||
|
// сохраняется при склейке.
|
||||||
|
func TestWrapBase64(t *testing.T) {
|
||||||
|
in := strings.Repeat("ы", 300)
|
||||||
|
out := wrapBase64(in)
|
||||||
|
|
||||||
|
for i, line := range strings.Split(strings.TrimRight(out, "\r\n"), "\r\n") {
|
||||||
|
if len(line) > 76 {
|
||||||
|
t.Errorf("line %d: %d bytes, want <= 76", i, len(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
joined := strings.ReplaceAll(out, "\r\n", "")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(joined)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if string(decoded) != in {
|
||||||
|
t.Error("content lost after wrap/unwrap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewValidatesConfig — фейл-фаст на старте: пустой хост — заглушка,
|
||||||
|
// кривые порт/режим/адрес — ошибка, а не сюрприз на первом письме.
|
||||||
|
func TestNewValidatesConfig(t *testing.T) {
|
||||||
|
if _, err := New(Config{}, testLogger()); err != nil {
|
||||||
|
t.Errorf("empty host must be disabled mailer, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := New(Config{Host: "smtp.example", Port: "abc", From: "a@b.c"}, testLogger()); err == nil {
|
||||||
|
t.Error("invalid SMTP_PORT must fail")
|
||||||
|
}
|
||||||
|
if _, err := New(Config{Host: "smtp.example", Port: "587", TLS: "bogus", From: "a@b.c"}, testLogger()); err == nil {
|
||||||
|
t.Error("invalid SMTP_TLS must fail")
|
||||||
|
}
|
||||||
|
if _, err := New(Config{Host: "smtp.example", Port: "587"}, testLogger()); err == nil {
|
||||||
|
t.Error("empty From and User must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewDefaults — дефолты: порт 587, режим по порту (465 → implicit TLS),
|
||||||
|
// адрес отправителя берётся из пользователя.
|
||||||
|
func TestNewDefaults(t *testing.T) {
|
||||||
|
m, err := New(Config{Host: "smtp.example", User: "me@example.test"}, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
sm, ok := m.(*smtpMailer)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("type = %T, want *smtpMailer", m)
|
||||||
|
}
|
||||||
|
if sm.cfg.Port != "587" || sm.mode != tlsStart {
|
||||||
|
t.Errorf("defaults: port=%s mode=%s, want 587/starttls", sm.cfg.Port, sm.mode)
|
||||||
|
}
|
||||||
|
if sm.cfg.From != "me@example.test" {
|
||||||
|
t.Errorf("from = %q, want SMTP_USER", sm.cfg.From)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err = New(Config{Host: "smtp.example", Port: "465", From: "a@b.c"}, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new 465: %v", err)
|
||||||
|
}
|
||||||
|
if sm := m.(*smtpMailer); sm.mode != tlsImplicit {
|
||||||
|
t.Errorf("port 465 mode = %s, want %s", sm.mode, tlsImplicit)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/middleware/accesslog.go
Normal file
46
internal/middleware/accesslog.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Package middleware — общие gin-middleware: request-id, access-log
|
||||||
|
// и восстановление после паники. Ставятся в cmd/api в таком порядке,
|
||||||
|
// где request_id идёт первым — остальные пишут его в свои записи.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessLog пишет по одной записи на запрос: метод, путь, статус,
|
||||||
|
// длительность, адрес клиента и request_id.
|
||||||
|
//
|
||||||
|
// Уровень выбирается по коду ответа: 5xx — error, 4xx — warn, остальное —
|
||||||
|
// info. Так обычный трафик не засоряет ошибки, а реальные падения видно
|
||||||
|
// фильтром уровня.
|
||||||
|
func AccessLog() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
attrs := []any{
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", c.Request.URL.Path,
|
||||||
|
"status", c.Writer.Status(),
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
"client_ip", c.ClientIP(),
|
||||||
|
"request_id", RequestIDFrom(c),
|
||||||
|
}
|
||||||
|
// Кто именно дёргал — важно для аудита, но не все запросы авторизованы.
|
||||||
|
if userID := c.GetString("user_id"); userID != "" {
|
||||||
|
attrs = append(attrs, "user_id", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status := c.Writer.Status(); {
|
||||||
|
case status >= 500:
|
||||||
|
slog.Error("request", attrs...)
|
||||||
|
case status >= 400:
|
||||||
|
slog.Warn("request", attrs...)
|
||||||
|
default:
|
||||||
|
slog.Info("request", attrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
internal/middleware/cors.go
Normal file
66
internal/middleware/cors.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseOrigins — разобрать CORS_ORIGINS: источники через запятую,
|
||||||
|
// пустые куски и пробелы отбрасываются. Пустая строка — пустой список,
|
||||||
|
// то есть ни один кросс-доменный запрос не получит заголовков.
|
||||||
|
func ParseOrigins(raw string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
if o := strings.TrimSpace(part); o != "" {
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS — заголовки для кросс-доменных запросов (микрофронт и разработка
|
||||||
|
// с отдельного origin).
|
||||||
|
//
|
||||||
|
// Безопасный дефолт: список разрешённых источников приходит из
|
||||||
|
// CORS_ORIGINS, и если он пуст — ничего не выдаём вовсе. Фронту на том же
|
||||||
|
// домене CORS не нужен, а чужому домену его просто не должно быть.
|
||||||
|
//
|
||||||
|
// Разрешённый origin получает точный свой адрес в Allow-Origin
|
||||||
|
// (не `*`: рядом Authorization, звёздочка запрещена спецификацией),
|
||||||
|
// Vary: Origin — чтобы кэши не смешали ответы для разных источников.
|
||||||
|
//
|
||||||
|
// Preflight-OPTIONS отвечает 204 и обрывается ДО авторизации: браузер
|
||||||
|
// спрашивает «можно ли» заранее, запроса с токеном ещё нет.
|
||||||
|
func CORS(allowed []string) gin.HandlerFunc {
|
||||||
|
allowedSet := make(map[string]bool, len(allowed))
|
||||||
|
for _, o := range allowed {
|
||||||
|
allowedSet[o] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if origin == "" || !allowedSet[origin] {
|
||||||
|
// Без CORS-заголовков браузер сам закроет ответ — серверу
|
||||||
|
// ничего не нужно делать.
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h := c.Writer.Header()
|
||||||
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
h.Add("Vary", "Origin")
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id")
|
||||||
|
h.Set("Access-Control-Expose-Headers", "X-Request-Id")
|
||||||
|
// Кэш preflight: браузер не будет переспрашивать каждую минуту.
|
||||||
|
h.Set("Access-Control-Max-Age", "600")
|
||||||
|
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
103
internal/middleware/cors_test.go
Normal file
103
internal/middleware/cors_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты CORS: разрешённый origin получает точные заголовки, чужой и
|
||||||
|
// пустой список — ничего, preflight отвечает 204 до авторизации.
|
||||||
|
|
||||||
|
// TestParseOrigins — список источников через запятую, мусор отбрасывается.
|
||||||
|
func TestParseOrigins(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"", 0},
|
||||||
|
{"http://localhost:3000", 1},
|
||||||
|
{"http://a.test, http://b.test", 2},
|
||||||
|
{" http://a.test ,, ,http://b.test ", 2},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ParseOrigins(c.raw); len(got) != c.want {
|
||||||
|
t.Errorf("ParseOrigins(%q) = %v, want %d origins", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCORSRouter — роутер с CORS и одним эндпоинтом.
|
||||||
|
func newCORSRouter(allowed ...string) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(CORS(allowed))
|
||||||
|
r.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve выполняет запрос и возвращает запись ответа.
|
||||||
|
func serve(r *gin.Engine, method, origin string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(method, "/x", nil)
|
||||||
|
if origin != "" {
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSAllowedOrigin(t *testing.T) {
|
||||||
|
r := newCORSRouter("http://localhost:3000")
|
||||||
|
|
||||||
|
// Настоящий запрос: заголовок ровно для своего origin + Vary: Origin.
|
||||||
|
w := serve(r, http.MethodGet, "http://localhost:3000")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
|
||||||
|
t.Errorf("Allow-Origin = %q, want the exact origin", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(w.Header()["Vary"], ","), "Origin") {
|
||||||
|
t.Error("no Vary: Origin — кэши смешают ответы разных источников")
|
||||||
|
}
|
||||||
|
if h := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(h, "Authorization") {
|
||||||
|
t.Errorf("Allow-Headers = %q, want Authorization", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight: 204 и без тела — браузер спрашивает до запроса с токеном.
|
||||||
|
w2 := serve(r, http.MethodOptions, "http://localhost:3000")
|
||||||
|
if w2.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("preflight code = %d, want 204", w2.Code)
|
||||||
|
}
|
||||||
|
if m := w2.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(m, "PATCH") {
|
||||||
|
t.Errorf("Allow-Methods = %q, want PATCH", m)
|
||||||
|
}
|
||||||
|
if w2.Body.Len() != 0 {
|
||||||
|
t.Errorf("preflight body = %q, want empty", w2.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSDisallowedOrigin(t *testing.T) {
|
||||||
|
// Чужой origin в списке разрешённых — заголовков нет, браузер закроет.
|
||||||
|
r := newCORSRouter("http://localhost:3000")
|
||||||
|
w := serve(r, http.MethodGet, "http://evil.test")
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("Allow-Origin for foreign origin = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой список (CORS_ORIGINS не задан) — заголовков нет вообще.
|
||||||
|
r2 := newCORSRouter()
|
||||||
|
w2 := serve(r2, http.MethodGet, "http://localhost:3000")
|
||||||
|
if got := w2.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("Allow-Origin with empty list = %q, want empty", got)
|
||||||
|
}
|
||||||
|
// Запрос без Origin (тот же домен) живёт независимо от списка.
|
||||||
|
w3 := serve(r2, http.MethodGet, "")
|
||||||
|
if w3.Code != http.StatusOK {
|
||||||
|
t.Errorf("same-origin request code = %d, want 200", w3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/middleware/recovery.go
Normal file
31
internal/middleware/recovery.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recovery перехватывает панику в хендлере: клиент получает честный
|
||||||
|
// 500 с фиксированным текстом, а в лог уходят причина и стек —
|
||||||
|
// стек наружу не уходит, но без него панику не воспроизвести.
|
||||||
|
func Recovery() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
slog.Error("panic recovered",
|
||||||
|
"error", rec,
|
||||||
|
"request_id", RequestIDFrom(c),
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", c.Request.URL.Path,
|
||||||
|
"stack", string(debug.Stack()),
|
||||||
|
)
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError,
|
||||||
|
gin.H{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/middleware/requestid.go
Normal file
37
internal/middleware/requestid.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requestIDKey — ключ, под которым id лежит в контексте gin.
|
||||||
|
const requestIDKey = "request_id"
|
||||||
|
|
||||||
|
// incomingID — допустимый входящий X-Request-Id: до 64 символов из
|
||||||
|
// [A-Za-z0-9._-]. Посторонние символы (переводы строк, кавычки) в логах
|
||||||
|
// выглядели бы как подделка записей, поэтому чужой заголовок отбрасываем.
|
||||||
|
var incomingID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
|
||||||
|
|
||||||
|
// RequestID берёт id из заголовка X-Request-Id (если он осмысленный)
|
||||||
|
// или генерирует свой, кладёт в контекст и возвращает клиенту тем же
|
||||||
|
// заголовком. Дальше этот id подхватывают access-log и логи безопасности:
|
||||||
|
// один запрос проходится по логам одной строкой.
|
||||||
|
func RequestID() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.GetHeader("X-Request-Id")
|
||||||
|
if !incomingID.MatchString(id) {
|
||||||
|
id = uuid.NewString()
|
||||||
|
}
|
||||||
|
c.Set(requestIDKey, id)
|
||||||
|
c.Header("X-Request-Id", id)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestIDFrom возвращает id текущего запроса; "" вне middleware.
|
||||||
|
func RequestIDFrom(c *gin.Context) string {
|
||||||
|
return c.GetString(requestIDKey)
|
||||||
|
}
|
||||||
254
internal/models/models.go
Normal file
254
internal/models/models.go
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
// Package models — предметные сущности API. Только структуры и теги JSON,
|
||||||
|
// без логики и без зависимостей: ими пользуются handlers, repository и docs.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Допустимые значения полей заявки. Это третий по счёту источник тех же
|
||||||
|
// списков (после binding oneof при создании и CHECK-ограничений в 0001):
|
||||||
|
// он нужен фильтрам списка, чтобы мусор в ?status= получал 400, а не
|
||||||
|
// пустую страницу. Меняете значения — правьте все три места.
|
||||||
|
var (
|
||||||
|
// TicketStatuses включает 'confirmed': такой статус разрешён
|
||||||
|
// констрейнтом схемы, хотя боевая машина статусов его не выставляет.
|
||||||
|
TicketStatuses = []string{"new", "assigned", "in_progress", "done", "confirmed", "rejected", "closed"}
|
||||||
|
TicketCategories = []string{"electric", "plumbing", "climate", "elevator", "other"}
|
||||||
|
TicketPriorities = []string{"low", "normal", "high"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile — пользователь: клиент, мастер или админ.
|
||||||
|
// PasswordHash помечен json:"-" и наружу никогда не уходит.
|
||||||
|
type Profile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"-"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
// ConfirmedAt — момент подтверждения аккаунта; nil (null в JSON)
|
||||||
|
// — ожидает подтверждения, вход до этого запрещён (403
|
||||||
|
// account not confirmed). Существующие пользователи подтверждены
|
||||||
|
// бэкфиллом миграции 0010.
|
||||||
|
ConfirmedAt *time.Time `json:"confirmed_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project — проект клиента: контур, внутри которого живут объекты
|
||||||
|
// (здания с адресами) и мастера. Проект создаёт клиент или админ.
|
||||||
|
type Project struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Денормализация для UI: чей проект (админ видит все сразу).
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object — объект, входящий в проект: здание/корпус с адресом.
|
||||||
|
// Адрес заявки копируется из объекта на момент создания заявки.
|
||||||
|
type Object struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Денормализации для UI.
|
||||||
|
ProjectName string `json:"project_name,omitempty"`
|
||||||
|
// Служебное: активен ли проект-владелец — для проверки при создании
|
||||||
|
// заявки. В JSON не уходит.
|
||||||
|
ProjectActive bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket — заявка на обслуживание со всей её историей в одном ряду:
|
||||||
|
// какой-то статус машины состояний и таймстампы всех достигнутых этапов.
|
||||||
|
type Ticket struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
WorkerID *string `json:"worker_id,omitempty"`
|
||||||
|
AssignedBy *string `json:"assigned_by,omitempty"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Floor string `json:"floor,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
AssignedAt *time.Time `json:"assigned_at,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
DoneAt *time.Time `json:"done_at,omitempty"`
|
||||||
|
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
||||||
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||||
|
|
||||||
|
// Принадлежность: заявка живёт в объекте проекта клиента.
|
||||||
|
// Проверяется составными FK в БД (миграция 0004).
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
ObjectID int64 `json:"object_id"`
|
||||||
|
|
||||||
|
// Закрытие цикла: rejected — это доработка, closed — финал.
|
||||||
|
RejectedAt *time.Time `json:"rejected_at,omitempty"`
|
||||||
|
RejectReason string `json:"reject_reason,omitempty"`
|
||||||
|
CancelReason string `json:"cancel_reason,omitempty"`
|
||||||
|
ClosedBy *string `json:"closed_by,omitempty"`
|
||||||
|
// Сколько раз клиент отклонял работу — чтобы доработки не бесконечны
|
||||||
|
// были незаметны.
|
||||||
|
ReworkCount int `json:"rework_count"`
|
||||||
|
|
||||||
|
// Денормализованные поля для UI — подтягиваются JOIN'ом
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
WorkerName string `json:"worker_name,omitempty"`
|
||||||
|
ProjectName string `json:"project_name,omitempty"`
|
||||||
|
ObjectName string `json:"object_name,omitempty"`
|
||||||
|
|
||||||
|
// Recurring — правило повторения, если заявка связана с ним:
|
||||||
|
// создана вместе с правилом (чекбокс «Новая заявка») или самим
|
||||||
|
// планировщиком. Заполняется только в детали (GetByID) — спискам
|
||||||
|
// блок на странице заявки не нужен.
|
||||||
|
Recurring *TicketRecurring `json:"recurring,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketRecurring — что показать в блоке «Повторяющаяся заявка» на
|
||||||
|
// странице заявки: расписание правила, его состояние и ближайший запуск.
|
||||||
|
type TicketRecurring struct {
|
||||||
|
RuleID int64 `json:"rule_id"`
|
||||||
|
// Расписание: «каждые PeriodDays дней/недель от AnchorAt» —
|
||||||
|
// точка отсчёта плюс период в днях.
|
||||||
|
AnchorAt time.Time `json:"anchor_at"`
|
||||||
|
PeriodDays int `json:"period_days"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
// NextRunAt — ближайший запуск, вычисляется при чтении
|
||||||
|
// (см. repository.nextDue); у выключенного правила — когда включат.
|
||||||
|
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecurringRule — правило повторяющейся заявки (миграции 0014/0016):
|
||||||
|
// шаблон будущей заявки (объект, категория, описание, приоритет) плюс
|
||||||
|
// расписание — точка отсчёта и период в днях. Фоновый планировщик сам
|
||||||
|
// создаёт заявку, когда срок наступил, — но только если прошлая по
|
||||||
|
// этому правилу уже закрыта (без дублей).
|
||||||
|
type RecurringRule struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
// ClientID — владелец будущих заявок (клиент проекта).
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
ObjectID int64 `json:"object_id"`
|
||||||
|
|
||||||
|
// Шаблон заявки — те же поля, что у tickets.
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Floor string `json:"floor,omitempty"`
|
||||||
|
// Расписание: «каждые PeriodDays дней/недель от AnchorAt» —
|
||||||
|
// AnchorAt — дата-время первого запуска (точка отсчёта сетки
|
||||||
|
// сроков), PeriodDays — период в днях (1..3650, недели кратны 7).
|
||||||
|
AnchorAt time.Time `json:"anchor_at"`
|
||||||
|
PeriodDays int `json:"period_days"`
|
||||||
|
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
// LastRunAt — когда последний раз срабатывали; null (в JSON — null)
|
||||||
|
// означает «ещё ни разу», следующий запуск считается от created_at.
|
||||||
|
LastRunAt *time.Time `json:"last_run_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// NextRunAt — ближайший запуск после последнего срабатывания
|
||||||
|
// (вычисляется при чтении; у выключенного правила — когда включат).
|
||||||
|
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||||||
|
// CreatedBy — кто завёл правило (админ или клиент); в журнале
|
||||||
|
// созданной заявки это же лицо будет автором события «created».
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
|
||||||
|
// Денормализации для UI.
|
||||||
|
ProjectName string `json:"project_name,omitempty"`
|
||||||
|
ObjectName string `json:"object_name,omitempty"`
|
||||||
|
// Адрес объекта — снимок, как у заявки: правило показывает, куда едут.
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
// Кто завёл правило (админ или сам клиент) — для аудита, в JSON идёт имя.
|
||||||
|
CreatedByName string `json:"created_by_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecurringRun — история срабатывания правила (миграция 0014): какая
|
||||||
|
// заявка создана по какому правилу и когда. Аудит «кто и когда».
|
||||||
|
type RecurringRun struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RuleID int64 `json:"rule_id"`
|
||||||
|
// TicketID — созданная заявка (её номер = id); null, если её удалили
|
||||||
|
// каскадом вместе с проектом.
|
||||||
|
TicketID *int64 `json:"ticket_id"`
|
||||||
|
RunAt time.Time `json:"run_at"`
|
||||||
|
// Адрес и категория — из заявки на момент создания (для списка истории).
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectWorker — мастер в составе проекта: профиль плюс состояние
|
||||||
|
// членства (миграция 0005). invited — приглашение отправлено, но ещё
|
||||||
|
// не принято: такой мастер заявок не получает.
|
||||||
|
type ProjectWorker struct {
|
||||||
|
Profile
|
||||||
|
|
||||||
|
// invited | active | declined (см. 0005_project_invitations.sql).
|
||||||
|
Status string `json:"status"`
|
||||||
|
// RespondedAt — когда мастер принял/отклонил; пусто, пока не ответил.
|
||||||
|
RespondedAt *time.Time `json:"responded_at,omitempty"`
|
||||||
|
// ExpiresAt — крайний срок ответа; заполнен у приглашений.
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectInvitation — приглашение в проект, ожидающее решения мастера
|
||||||
|
// (то, что видит сам мастер в GET /invitations).
|
||||||
|
type ProjectInvitation struct {
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
ClientName string `json:"client_name"`
|
||||||
|
InvitedBy string `json:"invited_by"` // id пригласившего
|
||||||
|
InvitedName string `json:"invited_by_name"` // его имя для UI
|
||||||
|
InvitedAt time.Time `json:"invited_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketEvent — запись журнала заявки (миграция 0006): кто, что сделал,
|
||||||
|
// от какого статуса к какому и когда. Пишется в той же SQL-операции,
|
||||||
|
// что и переход, поэтому история не может разойтись со статусом.
|
||||||
|
type TicketEvent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TicketID int64 `json:"ticket_id"`
|
||||||
|
// "" — автор удалён (событие сохранится, имя нет).
|
||||||
|
ActorID string `json:"actor_id,omitempty"`
|
||||||
|
ActorName string `json:"actor_name,omitempty"`
|
||||||
|
// created | assigned | started | done | confirmed | rejected |
|
||||||
|
// canceled | photo_added | photo_removed.
|
||||||
|
Action string `json:"action"`
|
||||||
|
// "" — у события «создана» не было предыдущего статуса.
|
||||||
|
// У photo_added/photo_removed статус не менялся: оба равны текущему.
|
||||||
|
FromStatus string `json:"from_status,omitempty"`
|
||||||
|
ToStatus string `json:"to_status"`
|
||||||
|
// Причина для rejected/canceled, имя файла для photo_*; иначе пусто.
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketPhoto — фотоотчёт к заявке (миграция 0008): в БД только
|
||||||
|
// метаданные, байты живут в объектном хранилище (S3-совместимом:
|
||||||
|
// локально MinIO, в проде — любой S3). Ссылка на чтение — временная
|
||||||
|
// (presigned), поэтому сам объект остаётся приватным.
|
||||||
|
type TicketPhoto struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TicketID int64 `json:"ticket_id"`
|
||||||
|
// "" — аккаунт загрузившего удалён, файл остаётся.
|
||||||
|
UploaderID string `json:"uploader_id,omitempty"`
|
||||||
|
UploaderName string `json:"uploader_name,omitempty"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
// Имя, которое дал файл при загрузке: лежит и в журнале (comment).
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// URL — presigned GET (срок жизни ограничен); заполняется только
|
||||||
|
// в ответах API. ObjectKey — внутренняя деталь, в JSON не уходит.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
ObjectKey string `json:"-"`
|
||||||
|
}
|
||||||
197
internal/notify/notify.go
Normal file
197
internal/notify/notify.go
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
// Package notify — фоновые задачи Telegram: напоминание клиенту о заявке,
|
||||||
|
// которая простояла в статусе 'new' дольше порога, и длинный поллинг
|
||||||
|
// ботов для привязки чата по одноразовому коду. Каким ботом слать —
|
||||||
|
// решает Registry: собственный токен пользователя, иначе глобальный.
|
||||||
|
//
|
||||||
|
// Обе задачи живут до выхода процесса и зависят только от ctx — так же,
|
||||||
|
// как чистка лимитов (ratelimit.RunCleanup) и чистка заявок в cmd/api.
|
||||||
|
// Отправка fail-open: недоступный Telegram не роняет API, ошибка уходит
|
||||||
|
// в warn-лог, а заявка остаётся кандидатом на следующий тик.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/repository"
|
||||||
|
"building-mgmt/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// batchLimit — сколько заявок за один тик; больше не нужно:
|
||||||
|
// опоздавших единицы, и запас на всплеск после простоя API.
|
||||||
|
batchLimit = 100
|
||||||
|
// pollTimeout — удержание соединения getUpdates: Telegram отвечает
|
||||||
|
// сразу, если апдейтов нет, либо держит соединение до таймаута.
|
||||||
|
pollTimeout = 30 * time.Second
|
||||||
|
// minBackoff/maxBackoff — пауза между неудачными поллингами
|
||||||
|
// (экспоненциально, чтобы не долбить API в минуты сбоя).
|
||||||
|
minBackoff = time.Second
|
||||||
|
maxBackoff = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store — доступ к базе, нужный фоновым задачам (в тестах — фейк).
|
||||||
|
type Store interface {
|
||||||
|
StaleTickets(ctx context.Context, cutoff time.Time, limit int) ([]repository.StaleTicket, error)
|
||||||
|
MarkNotified(ctx context.Context, ticketID int64) error
|
||||||
|
ConsumeBindCode(ctx context.Context, codeHash string) (string, error)
|
||||||
|
SetTarget(ctx context.Context, userID, target string) error
|
||||||
|
CleanupBindCodes(ctx context.Context, before time.Time) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run — цикл уведомлений: сразу прогон, дальше тик каждые tick.
|
||||||
|
// after — возраст заявки для первого напоминания; повтор уходит раз в
|
||||||
|
// сутки (столько же отсчитывает StaleTickets от tg_notified_at) и
|
||||||
|
// прекращается, как только заявку взяли. bots выбирает, через какого
|
||||||
|
// бота слать: личный токен клиента или глобальный бот сервера.
|
||||||
|
func Run(ctx context.Context, store Store, bots Bots, after, tick time.Duration, baseURL string, log *slog.Logger) {
|
||||||
|
if tick <= 0 {
|
||||||
|
tick = time.Minute
|
||||||
|
}
|
||||||
|
t := time.NewTicker(tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
tickOnce(ctx, store, bots, after, baseURL, log)
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickOnce — один проход: кандидаты → отправка → фиксация. Ошибка на
|
||||||
|
// одной заявке не останавливает остальные, а неотправленные остаются
|
||||||
|
// кандидатами (tg_notified_at не тронут) и вернутся на следующем тике.
|
||||||
|
func tickOnce(ctx context.Context, store Store, bots Bots, after time.Duration, baseURL string, log *slog.Logger) {
|
||||||
|
// Просроченные коды привязки чистим здесь же: тик раз в минуту,
|
||||||
|
// лишняя пара DELETE никого не заденет, таблица остаётся маленькой.
|
||||||
|
if _, err := store.CleanupBindCodes(ctx, time.Now().Add(-time.Hour)); err != nil {
|
||||||
|
log.Warn("telegram bind codes cleanup failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := store.StaleTickets(ctx, time.Now().Add(-after), batchLimit)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("telegram notify: candidates failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// У каждого клиента может быть свой бот, а может и не быть —
|
||||||
|
// тогда слать нечем. Не фиксируем: заявка дождётся подключения
|
||||||
|
// бота и уведомление уйдёт, а не потеряется.
|
||||||
|
bot := bots.BotFor(it.ClientID)
|
||||||
|
if bot == nil || !bot.Enabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := bot.SendMessage(ctx, it.Target, message(it, after, baseURL))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
// Успех: фиксируем — со следующего тика заявка молчит сутки.
|
||||||
|
case telegram.Permanent(err):
|
||||||
|
// Чат недоступен (бота выгнали, человек не писал боту):
|
||||||
|
// повтор не поможет, помечаем отправленным, чтобы не
|
||||||
|
// долбить лог каждую минуту.
|
||||||
|
log.Warn("telegram notify: chat unavailable",
|
||||||
|
"ticket_id", it.ID, "err", err)
|
||||||
|
default:
|
||||||
|
log.Warn("telegram notify: send failed",
|
||||||
|
"ticket_id", it.ID, "err", err)
|
||||||
|
continue // без фиксации — попробуем на следующем тике
|
||||||
|
}
|
||||||
|
if err := store.MarkNotified(ctx, it.ID); err != nil &&
|
||||||
|
!isNotFound(err) {
|
||||||
|
log.Warn("telegram notify: mark failed",
|
||||||
|
"ticket_id", it.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// message — текст напоминания. HTML-режим, поэтому весь пользовательский
|
||||||
|
// текст (адрес, категория) экранируется; ссылка — на страницу заявки.
|
||||||
|
func message(t repository.StaleTicket, after time.Duration, baseURL string) string {
|
||||||
|
link := baseURL + "/#/tickets/" + strconv.FormatInt(t.ID, 10)
|
||||||
|
return "<b>Заявка №" + strconv.FormatInt(t.ID, 10) +
|
||||||
|
" — без движения более " + durationLabel(after) + "</b>\n" +
|
||||||
|
categoryLabel(t.Category) + " · " + htmlEscape(t.Address) + "\n" +
|
||||||
|
"Приоритет: " + priorityLabel(t.Priority) + "\n" +
|
||||||
|
`<a href="` + htmlEscape(link) + `">Открыть заявку</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecurringMessage — текст уведомления о заявке, которую фоновый
|
||||||
|
// планировщик создал сам по правилу повторения («осмотр вентиляции
|
||||||
|
// раз в 3 месяца»). От обычного создания отличается заголовком:
|
||||||
|
// клиент заявку не создавал, ему важно понять, откуда она взялась.
|
||||||
|
// Тот же HTML-режим и то же экранирование, что у message.
|
||||||
|
func RecurringMessage(t repository.DueRun, baseURL string) string {
|
||||||
|
link := baseURL + "/#/tickets/" + strconv.FormatInt(t.TicketID, 10)
|
||||||
|
return "<b>Создана заявка по плану: №" + strconv.FormatInt(t.TicketID, 10) + "</b>\n" +
|
||||||
|
categoryLabel(t.Category) + " · " + htmlEscape(t.Address) + "\n" +
|
||||||
|
htmlEscape(t.Description) + "\n" +
|
||||||
|
"Приоритет: " + priorityLabel(t.Priority) + "\n" +
|
||||||
|
`<a href="` + htmlEscape(link) + `">Открыть заявку</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationLabel — «24 ч.» из настроенного порога; меньше часа — минуты.
|
||||||
|
func durationLabel(d time.Duration) string {
|
||||||
|
if h := int(d.Hours()); h > 0 {
|
||||||
|
return strconv.Itoa(h) + " ч."
|
||||||
|
}
|
||||||
|
return strconv.Itoa(int(d.Minutes())) + " мин."
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryLabel / priorityLabel — подпись поля в сообщении; неизвестное
|
||||||
|
// значение отдаём как есть, чтобы напоминание не потеряло информацию.
|
||||||
|
func categoryLabel(v string) string {
|
||||||
|
if s, ok := categoryName[v]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorityLabel(v string) string {
|
||||||
|
if s, ok := priorityName[v]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlEscape — экранирование для parse_mode=HTML: Telegram знает только
|
||||||
|
// свой набор тегов (b/i/a/code), поэтому &, <, > и кавычки в
|
||||||
|
// пользовательском тексте (адрес объекта) уходят сущностями.
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
return strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
).Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNotFound — заявку успели удалить, пока мы готовили сообщение.
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
return errors.Is(err, repository.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryName / priorityName — переводим коды полей в текст (тот же
|
||||||
|
// словарь, что на фронте в DICT).
|
||||||
|
var (
|
||||||
|
categoryName = map[string]string{
|
||||||
|
"electric": "электрика",
|
||||||
|
"plumbing": "сантехника",
|
||||||
|
"climate": "климат",
|
||||||
|
"elevator": "лифт",
|
||||||
|
"other": "прочее",
|
||||||
|
}
|
||||||
|
priorityName = map[string]string{
|
||||||
|
"low": "низкий",
|
||||||
|
"normal": "обычный",
|
||||||
|
"high": "высокий",
|
||||||
|
}
|
||||||
|
)
|
||||||
482
internal/notify/notify_test.go
Normal file
482
internal/notify/notify_test.go
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/repository"
|
||||||
|
"building-mgmt/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- фейки -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// fakeStore — база в памяти: записи вызовов нужны, чтобы видеть
|
||||||
|
// дедуп (MarkNotified) и гашение кодов.
|
||||||
|
type fakeStore struct {
|
||||||
|
items []repository.StaleTicket
|
||||||
|
staleErr error
|
||||||
|
|
||||||
|
marked []int64
|
||||||
|
markErr error
|
||||||
|
|
||||||
|
codes map[string]string // sha256 → user_id
|
||||||
|
targets map[string]string // user_id → chat_id
|
||||||
|
|
||||||
|
cleaned []time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore() *fakeStore {
|
||||||
|
return &fakeStore{codes: map[string]string{}, targets: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) StaleTickets(context.Context, time.Time, int) ([]repository.StaleTicket, error) {
|
||||||
|
if f.staleErr != nil {
|
||||||
|
return nil, f.staleErr
|
||||||
|
}
|
||||||
|
return f.items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) MarkNotified(_ context.Context, id int64) error {
|
||||||
|
if f.markErr != nil {
|
||||||
|
return f.markErr
|
||||||
|
}
|
||||||
|
f.marked = append(f.marked, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ConsumeBindCode(_ context.Context, hash string) (string, error) {
|
||||||
|
userID, ok := f.codes[hash]
|
||||||
|
if !ok {
|
||||||
|
return "", repository.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.codes, hash) // одноразовость — на уровне фейка тоже
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetTarget(_ context.Context, userID, target string) error {
|
||||||
|
f.targets[userID] = target
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CleanupBindCodes(_ context.Context, before time.Time) (int64, error) {
|
||||||
|
f.cleaned = append(f.cleaned, before)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sentMsg — одно отправленное сообщение.
|
||||||
|
type sentMsg struct{ chat, text string }
|
||||||
|
|
||||||
|
// fakeBot — Telegram без сети: ошибки отправки задаются по чату,
|
||||||
|
// поллинг отдаёт заготовленные батчи, потом встаёт до остановки.
|
||||||
|
type fakeBot struct {
|
||||||
|
enabled bool
|
||||||
|
username string
|
||||||
|
|
||||||
|
sendErr map[string]error
|
||||||
|
sent []sentMsg
|
||||||
|
|
||||||
|
updates [][]telegram.Update
|
||||||
|
getErr error
|
||||||
|
idle chan struct{} // закрывается перед первым «висящим» вызовом
|
||||||
|
// stopped — закрывается, когда GetUpdates вышел по отмене контекста:
|
||||||
|
// по нему видно, что поллер реально остановился, а не висит.
|
||||||
|
stopped chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
offsets []int64
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBot) Enabled() bool { return f.enabled }
|
||||||
|
func (f *fakeBot) Username() string { return f.username }
|
||||||
|
func (f *fakeBot) Init(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (f *fakeBot) SendMessage(_ context.Context, chat, text string) error {
|
||||||
|
if err, ok := f.sendErr[chat]; ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.sent = append(f.sent, sentMsg{chat: chat, text: text})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBot) GetChat(context.Context, string) error { return nil }
|
||||||
|
|
||||||
|
func (f *fakeBot) GetUpdates(ctx context.Context, offset int64, _ time.Duration) ([]telegram.Update, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.offsets = append(f.offsets, offset)
|
||||||
|
n := f.calls
|
||||||
|
f.calls++
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
if f.getErr != nil && n == 0 {
|
||||||
|
return nil, f.getErr
|
||||||
|
}
|
||||||
|
if n < len(f.updates) {
|
||||||
|
return f.updates[n], nil
|
||||||
|
}
|
||||||
|
// Батчи кончились — «висим», как настоящий длинный поллинг.
|
||||||
|
if f.idle != nil && n == len(f.updates) {
|
||||||
|
close(f.idle)
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
if f.stopped != nil {
|
||||||
|
select {
|
||||||
|
case <-f.stopped:
|
||||||
|
default:
|
||||||
|
close(f.stopped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBot) seenOffsets() []int64 {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return append([]int64(nil), f.offsets...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeBots — реализация Bots для тестов уведомлений: один запасной бот
|
||||||
|
// на всех (как раньше был глобальный) плюс личные по пользователю.
|
||||||
|
type fakeBots struct {
|
||||||
|
fallback telegram.API
|
||||||
|
byUser map[string]telegram.API
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBots) BotFor(userID string) telegram.API {
|
||||||
|
if b, ok := f.byUser[userID]; ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return f.fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLog() *slog.Logger {
|
||||||
|
return slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- уведомления -----------------------------------------------------------
|
||||||
|
|
||||||
|
// TestTickOnceNotifies — обе заявки ушли, текст собран, отправка
|
||||||
|
// зафиксирована (дедуп на следующих тиках), коды почищены.
|
||||||
|
func TestTickOnceNotifies(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.items = []repository.StaleTicket{
|
||||||
|
{ID: 12, Address: "ул. Ленина, 5", Category: "plumbing",
|
||||||
|
Priority: "high", ClientName: "Иван", Target: "@buildings"},
|
||||||
|
{ID: 13, Address: "пр. Мира, 1", Category: "electric",
|
||||||
|
Priority: "normal", ClientName: "Иван", Target: "-100500"},
|
||||||
|
}
|
||||||
|
bot := &fakeBot{enabled: true, username: "test_bot"}
|
||||||
|
|
||||||
|
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://localhost:3000", testLog())
|
||||||
|
|
||||||
|
if len(bot.sent) != 2 {
|
||||||
|
t.Fatalf("sent = %d, want 2", len(bot.sent))
|
||||||
|
}
|
||||||
|
first := bot.sent[0]
|
||||||
|
if first.chat != "@buildings" {
|
||||||
|
t.Errorf("chat = %q, want @buildings", first.chat)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Заявка №12", "без движения более 24 ч.", "сантехника",
|
||||||
|
"ул. Ленина, 5", "высокий",
|
||||||
|
`<a href="http://localhost:3000/#/tickets/12">Открыть заявку</a>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(first.text, want) {
|
||||||
|
t.Errorf("message misses %q:\n%s", want, first.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(store.marked) != 2 || store.marked[0] != 12 || store.marked[1] != 13 {
|
||||||
|
t.Errorf("marked = %v, want [12 13]", store.marked)
|
||||||
|
}
|
||||||
|
if len(store.cleaned) == 0 {
|
||||||
|
t.Error("bind codes cleanup did not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickOnceSendFailure — временная ошибка (сеть): заявка остаётся
|
||||||
|
// кандидатом, повтор будет на следующем тике.
|
||||||
|
func TestTickOnceSendFailure(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.items = []repository.StaleTicket{{ID: 1, Target: "@buildings"}}
|
||||||
|
bot := &fakeBot{enabled: true, sendErr: map[string]error{
|
||||||
|
"@buildings": errors.New("connection reset"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://x", testLog())
|
||||||
|
|
||||||
|
if len(store.marked) != 0 {
|
||||||
|
t.Errorf("marked = %v, want none (ошибка временная)", store.marked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickOncePermanentError — чат недоступен: повтор бессмыслен,
|
||||||
|
// помечаем отправленным, чтобы не долбить лог каждую минуту.
|
||||||
|
func TestTickOncePermanentError(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.items = []repository.StaleTicket{{ID: 1, Target: "@gone"}}
|
||||||
|
bot := &fakeBot{enabled: true, sendErr: map[string]error{
|
||||||
|
"@gone": telegram.ErrChatNotFound,
|
||||||
|
}}
|
||||||
|
|
||||||
|
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://x", testLog())
|
||||||
|
|
||||||
|
if len(store.marked) != 1 {
|
||||||
|
t.Errorf("marked = %v, want [1]", store.marked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickOnceStaleError — база недоступна: тихо выходим, ничего не шлём.
|
||||||
|
func TestTickOnceStaleError(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.staleErr = errors.New("db down")
|
||||||
|
bot := &fakeBot{enabled: true}
|
||||||
|
|
||||||
|
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://x", testLog())
|
||||||
|
|
||||||
|
if len(bot.sent) != 0 {
|
||||||
|
t.Errorf("sent = %d, want 0", len(bot.sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickOnceChoosesBot — у клиентов разные боты: сообщение уходит
|
||||||
|
// через личный токен, а без него — через запасной (глобальный).
|
||||||
|
func TestTickOnceChoosesBot(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.items = []repository.StaleTicket{
|
||||||
|
{ID: 1, Target: "@for_mine", ClientID: "user-1"},
|
||||||
|
{ID: 2, Target: "@for_other", ClientID: "user-2"},
|
||||||
|
}
|
||||||
|
mine := &fakeBot{enabled: true, username: "mine_bot"}
|
||||||
|
global := &fakeBot{enabled: true, username: "global_bot"}
|
||||||
|
bots := &fakeBots{
|
||||||
|
fallback: global,
|
||||||
|
byUser: map[string]telegram.API{"user-1": mine},
|
||||||
|
}
|
||||||
|
|
||||||
|
tickOnce(context.Background(), store, bots, 24*time.Hour, "http://x", testLog())
|
||||||
|
|
||||||
|
if len(mine.sent) != 1 || mine.sent[0].chat != "@for_mine" {
|
||||||
|
t.Errorf("mine sent = %+v, want [@for_mine]", mine.sent)
|
||||||
|
}
|
||||||
|
if len(global.sent) != 1 || global.sent[0].chat != "@for_other" {
|
||||||
|
t.Errorf("global sent = %+v, want [@for_other]", global.sent)
|
||||||
|
}
|
||||||
|
if len(store.marked) != 2 {
|
||||||
|
t.Errorf("marked = %v, want обе", store.marked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickOnceWithoutBot — ни своего бота, ни включённого глобального:
|
||||||
|
// слать нечем, заявка остаётся кандидатом — уведомление уйдёт, как
|
||||||
|
// только пользователь подключит бота, а не потеряется.
|
||||||
|
func TestTickOnceWithoutBot(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.items = []repository.StaleTicket{{ID: 5, Target: "@x", ClientID: "user-1"}}
|
||||||
|
|
||||||
|
tickOnce(context.Background(), store, &fakeBots{fallback: &fakeBot{enabled: false}},
|
||||||
|
24*time.Hour, "http://x", testLog())
|
||||||
|
|
||||||
|
if len(store.marked) != 0 {
|
||||||
|
t.Errorf("marked = %v, want none (бота нет — не фиксируем)", store.marked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMessageEscapesHTML — пользовательский текст не ломает разметку
|
||||||
|
// (адрес с <, > и & — обычное дело).
|
||||||
|
func TestMessageEscapesHTML(t *testing.T) {
|
||||||
|
tk := repository.StaleTicket{ID: 7,
|
||||||
|
Address: `ул. <Ленина> & "дом"`,
|
||||||
|
Category: "other", Priority: "low"}
|
||||||
|
text := message(tk, 24*time.Hour, "http://localhost:3000")
|
||||||
|
|
||||||
|
if strings.Contains(text, "<Ленина>") {
|
||||||
|
t.Errorf("address is not escaped:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "<Ленина>") {
|
||||||
|
t.Errorf("want escaped address:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "&") {
|
||||||
|
t.Errorf("want escaped ampersand:\n%s", text)
|
||||||
|
}
|
||||||
|
// Ссылка остаётся рабочей.
|
||||||
|
if !strings.Contains(text, `href="http://localhost:3000/#/tickets/7"`) {
|
||||||
|
t.Errorf("link broken:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDurationLabel — порог в сообщении: часы, либо минуты (< 1 ч).
|
||||||
|
func TestDurationLabel(t *testing.T) {
|
||||||
|
if got := durationLabel(24 * time.Hour); got != "24 ч." {
|
||||||
|
t.Errorf("24h = %q", got)
|
||||||
|
}
|
||||||
|
if got := durationLabel(48 * time.Hour); got != "48 ч." {
|
||||||
|
t.Errorf("48h = %q", got)
|
||||||
|
}
|
||||||
|
if got := durationLabel(30 * time.Minute); got != "30 мин." {
|
||||||
|
t.Errorf("30m = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- поллер ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestStartArg — разбор команд: /start, /start@bot, посторонний текст.
|
||||||
|
func TestStartArg(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
arg string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"/start ABCD2345", "ABCD2345", true},
|
||||||
|
{"/start@my_bot ABCD2345", "ABCD2345", true},
|
||||||
|
{"/start", "", true},
|
||||||
|
{"/start ", "", true},
|
||||||
|
{"/Start abcd", "abcd", true}, // регистр не важен
|
||||||
|
{"/help", "", false},
|
||||||
|
{"привет", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
{"/startx ABC", "", false},
|
||||||
|
} {
|
||||||
|
arg, ok := startArg(tc.text)
|
||||||
|
if arg != tc.arg || ok != tc.ok {
|
||||||
|
t.Errorf("startArg(%q) = (%q, %v), want (%q, %v)",
|
||||||
|
tc.text, arg, ok, tc.arg, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleStartBinds — /start с валидным кодом: чат записан владельцу,
|
||||||
|
// человеку ответили подтверждением, код сгорел.
|
||||||
|
func TestHandleStartBinds(t *testing.T) {
|
||||||
|
const code = "ABCD2345"
|
||||||
|
store := newFakeStore()
|
||||||
|
store.codes[telegram.CodeHash(code)] = "user-1"
|
||||||
|
bot := &fakeBot{enabled: true, username: "test_bot"}
|
||||||
|
|
||||||
|
handleStart(context.Background(), store, bot, &telegram.Message{
|
||||||
|
Text: "/start " + code,
|
||||||
|
Chat: telegram.Chat{ID: 777, Type: "private"},
|
||||||
|
}, testLog())
|
||||||
|
|
||||||
|
if store.targets["user-1"] != "777" {
|
||||||
|
t.Errorf("targets = %v, want user-1 → 777", store.targets)
|
||||||
|
}
|
||||||
|
if len(bot.sent) != 1 || bot.sent[0].chat != "777" ||
|
||||||
|
bot.sent[0].text != boundText {
|
||||||
|
t.Errorf("sent = %+v, want подтверждение в 777", bot.sent)
|
||||||
|
}
|
||||||
|
// Код одноразовый: повторное сообщение — «не найден».
|
||||||
|
handleStart(context.Background(), store, bot, &telegram.Message{
|
||||||
|
Text: "/start " + code,
|
||||||
|
Chat: telegram.Chat{ID: 888, Type: "private"},
|
||||||
|
}, testLog())
|
||||||
|
if len(bot.sent) != 2 || bot.sent[1].text != codeBadText {
|
||||||
|
t.Errorf("second attempt: sent = %+v", bot.sent)
|
||||||
|
}
|
||||||
|
if _, ok := store.targets[""]; ok {
|
||||||
|
t.Error("target записан пустому пользователю")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleStartIgnoresNoise — боту пишут что угодно: реагируем только
|
||||||
|
// на /start, помощь — только после чистого /start.
|
||||||
|
func TestHandleStartIgnoresNoise(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
bot := &fakeBot{enabled: true}
|
||||||
|
|
||||||
|
for _, text := range []string{"", "привет", "/help", "/status"} {
|
||||||
|
handleStart(context.Background(), store, bot,
|
||||||
|
&telegram.Message{Text: text, Chat: telegram.Chat{ID: 5}},
|
||||||
|
testLog())
|
||||||
|
}
|
||||||
|
if len(bot.sent) != 0 {
|
||||||
|
t.Errorf("sent = %v, want none", bot.sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /start без кода — подсказка, где взять код.
|
||||||
|
handleStart(context.Background(), store, bot,
|
||||||
|
&telegram.Message{Text: "/start", Chat: telegram.Chat{ID: 5}},
|
||||||
|
testLog())
|
||||||
|
if len(bot.sent) != 1 || bot.sent[0].text != helpText {
|
||||||
|
t.Errorf("sent = %+v, want подсказка", bot.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPollerOffsets — offset сдвигается всегда, включая сообщения,
|
||||||
|
// которые не наша команда: иначе одно такое встало бы в очередь навечно.
|
||||||
|
func TestRunPollerOffsets(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
store.codes[telegram.CodeHash("ABCD2345")] = "user-1"
|
||||||
|
bot := &fakeBot{
|
||||||
|
enabled: true,
|
||||||
|
username: "test_bot",
|
||||||
|
idle: make(chan struct{}),
|
||||||
|
updates: [][]telegram.Update{{
|
||||||
|
{UpdateID: 1, Message: &telegram.Message{
|
||||||
|
Text: "/start ABCD2345", Chat: telegram.Chat{ID: 777}}},
|
||||||
|
{UpdateID: 2, Message: &telegram.Message{
|
||||||
|
Text: "спам", Chat: telegram.Chat{ID: 777}}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
RunPoller(ctx, store, bot, testLog())
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-bot.idle // батч обработан, поллер ушёл в ожидание апдейтов
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if got, want := bot.seenOffsets(), []int64{0, 3}; len(got) != len(want) ||
|
||||||
|
got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Errorf("offsets = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if store.targets["user-1"] != "777" {
|
||||||
|
t.Errorf("targets = %v, want user-1 → 777", store.targets)
|
||||||
|
}
|
||||||
|
// Спам-сообщение проигнорировано (никаких ответов на него).
|
||||||
|
if len(bot.sent) != 1 || bot.sent[0].text != boundText {
|
||||||
|
t.Errorf("sent = %+v, want только подтверждение привязки", bot.sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPollerBackoff — ошибка поллинга: сначала повтор, при остановке —
|
||||||
|
// выход без зависания.
|
||||||
|
func TestRunPollerBackoff(t *testing.T) {
|
||||||
|
bot := &fakeBot{enabled: true, getErr: errors.New("network down")}
|
||||||
|
store := newFakeStore()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
RunPoller(ctx, store, bot, testLog())
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("RunPoller не остановился по контексту")
|
||||||
|
}
|
||||||
|
if len(bot.seenOffsets()) == 0 {
|
||||||
|
t.Error("poller не пытался получить апдейты")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPollerDisabled — без токена поллер не стартует (его и не должны
|
||||||
|
// запускать в cmd/api, но и тут — защита от дурака).
|
||||||
|
func TestRunPollerDisabled(t *testing.T) {
|
||||||
|
bot := &fakeBot{enabled: false, idle: make(chan struct{})}
|
||||||
|
RunPoller(context.Background(), newFakeStore(), bot, testLog())
|
||||||
|
if len(bot.seenOffsets()) != 0 {
|
||||||
|
t.Errorf("offsets = %v, want none", bot.seenOffsets())
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/notify/poll.go
Normal file
146
internal/notify/poll.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Подсказки боту — по-русски, как и весь интерфейс приложения.
|
||||||
|
const (
|
||||||
|
helpText = "Привязка уведомлений: в приложении откройте раздел «Настройки», " +
|
||||||
|
"нажмите «Получить код» и пришлите его сюда."
|
||||||
|
boundText = "Готово — уведомления о заявках будут приходить в этот чат."
|
||||||
|
codeBadText = "Код не найден или истёк — запросите новый в разделе «Настройки»."
|
||||||
|
bindFailText = "Не удалось привязать — запросите код заново."
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunPoller — длинный поллинг бота, пока жив контекст: очередь апдейтов
|
||||||
|
// разбирается на месте, offset сдвигается всегда. Один процесс — один
|
||||||
|
// поллер (в контейнере их один), иначе два инстанса драли бы одну
|
||||||
|
// очередь; на ошибках — экспоненциальный backoff, а не мгновенный
|
||||||
|
// повтор, чтобы не долбить API в минуты сбоя.
|
||||||
|
func RunPoller(ctx context.Context, store Store, bot telegram.API, log *slog.Logger) {
|
||||||
|
if !bot.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var offset int64
|
||||||
|
backoff := minBackoff
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
updates, err := bot.GetUpdates(ctx, offset, pollTimeout)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return // остановка процесса, не сбой
|
||||||
|
}
|
||||||
|
log.Warn("telegram poll failed", "err", err, "retry_in", backoff)
|
||||||
|
if !sleep(ctx, backoff) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backoff = minBackoff
|
||||||
|
|
||||||
|
for _, u := range updates {
|
||||||
|
// offset двигаем до разбора: сообщение, которое мы не смогли
|
||||||
|
// обработать, не должно вечно стоять впереди очереди —
|
||||||
|
// иначе один битый апдейт остановит привязку навсегда.
|
||||||
|
offset = u.UpdateID + 1
|
||||||
|
if u.Message == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handleStart(ctx, store, bot, u.Message, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStart — обработка сообщения боту. Единственное, на что он
|
||||||
|
// реагирует, — команда /start [код]: код гасится одним UPDATE (по нему
|
||||||
|
// и находится владелец), в его чат пишется chat_id, человеку отвечает
|
||||||
|
// подтверждение. Всё прочее (спор, случайные сообщения) молчим.
|
||||||
|
func handleStart(ctx context.Context, store Store, bot telegram.API, m *telegram.Message, log *slog.Logger) {
|
||||||
|
chat := strconv.FormatInt(m.Chat.ID, 10)
|
||||||
|
|
||||||
|
arg, isStart := startArg(m.Text)
|
||||||
|
if !isStart {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if arg == "" {
|
||||||
|
if err := bot.SendMessage(ctx, chat, helpText); err != nil {
|
||||||
|
log.Warn("telegram poll: help failed", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// В базе лежит sha256 кода — по нему и ищем; сам код в логи не пишем.
|
||||||
|
userID, err := store.ConsumeBindCode(ctx, telegram.CodeHash(arg))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case isNotFound(err):
|
||||||
|
if err := bot.SendMessage(ctx, chat, codeBadText); err != nil {
|
||||||
|
log.Warn("telegram poll: reply failed", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Warn("telegram poll: consume code failed", "err", err)
|
||||||
|
if err := bot.SendMessage(ctx, chat, bindFailText); err != nil {
|
||||||
|
log.Warn("telegram poll: reply failed", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Числовой chat_id (личка — положительный, группа — отрицательный)
|
||||||
|
// пишется напрямую: ручная валидация формата тут не проходит.
|
||||||
|
if err := store.SetTarget(ctx, userID, chat); err != nil {
|
||||||
|
// Код уже сгорел, поэтому подсказываем запросить новый, а не
|
||||||
|
// «повторите»: повторить то нечего.
|
||||||
|
log.Warn("telegram poll: save target failed", "err", err, "user_id", userID)
|
||||||
|
if err := bot.SendMessage(ctx, chat, bindFailText); err != nil {
|
||||||
|
log.Warn("telegram poll: reply failed", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("telegram bound", "user_id", userID, "chat_id", chat)
|
||||||
|
if err := bot.SendMessage(ctx, chat, boundText); err != nil {
|
||||||
|
log.Warn("telegram poll: reply failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startArg — разбор «/start [код]»: вариант с @имя_бота (так приходит
|
||||||
|
// команда в группе) приводится к /start; не наша команда — false.
|
||||||
|
func startArg(text string) (arg string, ok bool) {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(text))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cmd := fields[0]
|
||||||
|
if i := strings.IndexByte(cmd, '@'); i >= 0 {
|
||||||
|
cmd = cmd[:i]
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(cmd, "/start") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
return fields[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleep — пауза, прерываемая остановкой контекста.
|
||||||
|
func sleep(ctx context.Context, d time.Duration) bool {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/notify/registry.go
Normal file
110
internal/notify/registry.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"building-mgmt/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bots — выбор бота для пользователя: собственный токен приоритетнее
|
||||||
|
// глобального TELEGRAM_BOT_TOKEN. Именно так нотификатор решает, через
|
||||||
|
// кого слать, — интерфейс, чтобы в тестах подставить карту без поллеров.
|
||||||
|
type Bots interface {
|
||||||
|
BotFor(userID string) telegram.API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry — боты пользователей и их личные поллеры.
|
||||||
|
//
|
||||||
|
// Роль процесса здесь одна: на каждый сохранённый токен нужен свой
|
||||||
|
// длинный поллинг (getUpdates у Telegram — очередь на бота, чужой её
|
||||||
|
// не заберёт). Реестр владеет дочерними контекстами от parent, поэтому
|
||||||
|
// остановка процесса гасит все поллеры разом (defer tgCancel в cmd/api).
|
||||||
|
//
|
||||||
|
// Потокобезопасен: BotFor вызывают нотификатор и хендлеры из разных
|
||||||
|
// горутин, Register/Unregister — в запросах на сохранение и удаление
|
||||||
|
// токена.
|
||||||
|
type Registry struct {
|
||||||
|
parent context.Context
|
||||||
|
store Store
|
||||||
|
// global — бот сервера (TELEGRAM_BOT_TOKEN); может быть выключенным,
|
||||||
|
// тогда BotFor отдаёт его же, а вызывающий проверяет Enabled().
|
||||||
|
global telegram.API
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
users map[string]*userBot
|
||||||
|
}
|
||||||
|
|
||||||
|
// userBot — один личный бот: клиент API плюс отмена его поллера.
|
||||||
|
type userBot struct {
|
||||||
|
bot telegram.API
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry создаёт реестр поверх родительского контекста.
|
||||||
|
func NewRegistry(ctx context.Context, store Store, global telegram.API, log *slog.Logger) *Registry {
|
||||||
|
return &Registry{
|
||||||
|
parent: ctx,
|
||||||
|
store: store,
|
||||||
|
global: global,
|
||||||
|
log: log,
|
||||||
|
users: make(map[string]*userBot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotFor — бот пользователя: свой токен, иначе глобальный (может быть
|
||||||
|
// выключенным — проверяет вызывающий).
|
||||||
|
func (r *Registry) BotFor(userID string) telegram.API {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if u, ok := r.users[userID]; ok {
|
||||||
|
return u.bot
|
||||||
|
}
|
||||||
|
return r.global
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register поднимает поллер личного бота. Повторный вызов (пользователь
|
||||||
|
// заменил токен) сначала гасит прежний поллер: две горутины не должны
|
||||||
|
// тянуть одну очередь.
|
||||||
|
func (r *Registry) Register(userID string, bot telegram.API) {
|
||||||
|
if bot == nil || !bot.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if old, ok := r.users[userID]; ok {
|
||||||
|
old.cancel()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(r.parent)
|
||||||
|
r.users[userID] = &userBot{bot: bot, cancel: cancel}
|
||||||
|
// Имя бота на старте процесса ещё пустое: getMe не делаем, чтобы не
|
||||||
|
// тормозить запуск (оно лежит в БД и в статусе). Поле добавляем
|
||||||
|
// только когда оно есть — пустое bot="" лишь путает в логе.
|
||||||
|
attrs := []any{"user_id", userID}
|
||||||
|
if name := bot.Username(); name != "" {
|
||||||
|
attrs = append(attrs, "bot", name)
|
||||||
|
}
|
||||||
|
r.log.Info("telegram user bot registered", attrs...)
|
||||||
|
go RunPoller(ctx, r.store, bot, r.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister гасит поллер и забывает бота: дальше BotFor отдаёт
|
||||||
|
// глобального (или выключенного, если его нет).
|
||||||
|
func (r *Registry) Unregister(userID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if u, ok := r.users[userID]; ok {
|
||||||
|
u.cancel()
|
||||||
|
delete(r.users, userID)
|
||||||
|
r.log.Info("telegram user bot removed", "user_id", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users — сколько личных ботов сейчас поднято (для лога на старте).
|
||||||
|
func (r *Registry) Users() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return len(r.users)
|
||||||
|
}
|
||||||
158
internal/notify/registry_test.go
Normal file
158
internal/notify/registry_test.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты реестра ботов (миграция 0012): личный токен приоритетнее
|
||||||
|
// глобального, на каждый личный бот — свой поллер, остановка гасит его.
|
||||||
|
|
||||||
|
// newRegistryBot — бот с каналами, чтобы дождаться старта поллера и его
|
||||||
|
// выхода (idle — поллинг начался, stopped — поллер остановился).
|
||||||
|
func newRegistryBot() *fakeBot {
|
||||||
|
return &fakeBot{
|
||||||
|
enabled: true,
|
||||||
|
username: "test_bot",
|
||||||
|
idle: make(chan struct{}),
|
||||||
|
stopped: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitStopped — ждёт остановки поллера, но не вечно.
|
||||||
|
func waitStopped(t *testing.T, bot *fakeBot) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-bot.stopped:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("поллер не остановился")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryBotFor — без своего токена отдаётся глобальный бот;
|
||||||
|
// со своим — он, глобальный не задействуется.
|
||||||
|
func TestRegistryBotFor(t *testing.T) {
|
||||||
|
global := &fakeBot{enabled: false, username: "global_bot"}
|
||||||
|
r := NewRegistry(context.Background(), newFakeStore(), global, testLog())
|
||||||
|
|
||||||
|
if got := r.BotFor("user-1"); got != telegram.API(global) {
|
||||||
|
t.Errorf("BotFor = %v, want global fallback", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
mine := newRegistryBot()
|
||||||
|
r.Register("user-1", mine)
|
||||||
|
if got := r.BotFor("user-1"); got != telegram.API(mine) {
|
||||||
|
t.Errorf("BotFor = %v, want personal bot", got)
|
||||||
|
}
|
||||||
|
// Чужой пользователь по-прежнему получает глобального.
|
||||||
|
if got := r.BotFor("user-2"); got != telegram.API(global) {
|
||||||
|
t.Errorf("BotFor другого = %v, want global", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Unregister("user-1")
|
||||||
|
if got := r.BotFor("user-1"); got != telegram.API(global) {
|
||||||
|
t.Errorf("BotFor после удаления = %v, want global", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryPollerLifecycle — Register поднимает поллер, Unregister
|
||||||
|
// останавливает: без этого процесс накапливал бы горутины на каждый
|
||||||
|
// ввод и удаление токена.
|
||||||
|
func TestRegistryPollerLifecycle(t *testing.T) {
|
||||||
|
r := NewRegistry(context.Background(), newFakeStore(), &fakeBot{enabled: false}, testLog())
|
||||||
|
|
||||||
|
bot := newRegistryBot()
|
||||||
|
r.Register("user-1", bot)
|
||||||
|
if r.Users() != 1 {
|
||||||
|
t.Fatalf("Users = %d, want 1", r.Users())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-bot.idle: // поллинг пошёл
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("поллер не стартовал")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Unregister("user-1")
|
||||||
|
if r.Users() != 0 {
|
||||||
|
t.Errorf("Users = %d, want 0", r.Users())
|
||||||
|
}
|
||||||
|
waitStopped(t, bot)
|
||||||
|
|
||||||
|
// Повторное удаление безопасно (идемпотентно).
|
||||||
|
r.Unregister("user-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryReRegister — смена токена гасит прежний поллер: две
|
||||||
|
// горутины не должны драть одну очередь getUpdates.
|
||||||
|
func TestRegistryReRegister(t *testing.T) {
|
||||||
|
r := NewRegistry(context.Background(), newFakeStore(), &fakeBot{enabled: false}, testLog())
|
||||||
|
|
||||||
|
first := newRegistryBot()
|
||||||
|
r.Register("user-1", first)
|
||||||
|
select {
|
||||||
|
case <-first.idle:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("первый поллер не стартовал")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := newRegistryBot()
|
||||||
|
r.Register("user-1", second)
|
||||||
|
waitStopped(t, first) // прежний остановлен
|
||||||
|
|
||||||
|
if got := r.BotFor("user-1"); got != telegram.API(second) {
|
||||||
|
t.Errorf("BotFor = %v, want второй бот", got)
|
||||||
|
}
|
||||||
|
if r.Users() != 1 {
|
||||||
|
t.Errorf("Users = %d, want 1", r.Users())
|
||||||
|
}
|
||||||
|
// Дожидаемся, пока новый поллер повиснет в getUpdates, и гасим:
|
||||||
|
// до этого момента его отмена — штатный выход без вызова API.
|
||||||
|
select {
|
||||||
|
case <-second.idle:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("второй поллер не стартовал")
|
||||||
|
}
|
||||||
|
r.Unregister("user-1")
|
||||||
|
waitStopped(t, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryParentContext — остановка процесса (отмена родительского
|
||||||
|
// контекста) гасит все личные поллеры разом.
|
||||||
|
func TestRegistryParentContext(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r := NewRegistry(ctx, newFakeStore(), &fakeBot{enabled: false}, testLog())
|
||||||
|
|
||||||
|
first, second := newRegistryBot(), newRegistryBot()
|
||||||
|
r.Register("user-1", first)
|
||||||
|
r.Register("user-2", second)
|
||||||
|
// Оба должны повиснуть в getUpdates — иначе отмена пройдёт мимо и
|
||||||
|
// поллер выйдет, не вызв API (тест не увидит stopped).
|
||||||
|
for _, bot := range []*fakeBot{first, second} {
|
||||||
|
select {
|
||||||
|
case <-bot.idle:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("поллер не стартовал")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
waitStopped(t, first)
|
||||||
|
waitStopped(t, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistryIgnoresDisabled — выключенный бот (пустой токен) поллер
|
||||||
|
// не поднимает: держать горутину, которая сразу вернёт ErrDisabled,
|
||||||
|
// незачем.
|
||||||
|
func TestRegistryIgnoresDisabled(t *testing.T) {
|
||||||
|
r := NewRegistry(context.Background(), newFakeStore(), &fakeBot{enabled: false}, testLog())
|
||||||
|
|
||||||
|
r.Register("user-1", &fakeBot{enabled: false, idle: make(chan struct{})})
|
||||||
|
if r.Users() != 0 {
|
||||||
|
t.Errorf("Users = %d, want 0", r.Users())
|
||||||
|
}
|
||||||
|
if got := r.BotFor("user-1"); got.Enabled() {
|
||||||
|
t.Error("BotFor = включённый бот, want выключенный fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/ratelimit/db.go
Normal file
88
internal/ratelimit/db.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Querier — всё, что лимитеру нужно от pgx: пул (pgxpool.Pool) или
|
||||||
|
// транзакция (pgx.Tx). Интерфейс свой, а не из repository: пакет
|
||||||
|
// лимитера не должен тянуть за собой весь слой репозиториев.
|
||||||
|
type Querier interface {
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBLimiter — лимитер на Postgres (таблица rate_limits, миграция 0009).
|
||||||
|
// В отличие от in-memory Limiter, счётчик переживает рестарт процесса
|
||||||
|
// и общий для нескольких инстансов API. Ключ хранится как «имя|IP»:
|
||||||
|
// у каждого лимита своё имя, поэтому потолки разных эндпоинтов
|
||||||
|
// не складываются в одну кучу.
|
||||||
|
type DBLimiter struct {
|
||||||
|
q Querier
|
||||||
|
name string
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDB создаёт лимитер name на limit запросов за window по одному ключу.
|
||||||
|
// Имена — «login», «refresh», «register», «reset»: видно в таблице,
|
||||||
|
// какой эндпоинт упёрся в потолок.
|
||||||
|
func NewDB(q Querier, name string, limit int, window time.Duration) *DBLimiter {
|
||||||
|
return &DBLimiter{q: q, name: name, limit: limit, window: window}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertSQL засчитывает попытку одним round-trip'ом: окно кончилось —
|
||||||
|
// счётчик и окно начинаются заново, нет — инкремент. Возвращается
|
||||||
|
// счётчик ПОСЛЕ засчёта; гонки двух параллельных запросов не боятся:
|
||||||
|
// ON CONFLICT сериализует обновление одной строки.
|
||||||
|
// clock_timestamp(), а не now(): now() в Postgres — время начала
|
||||||
|
// транзакции, внутри длинной транзакции окно не сдвинулось бы.
|
||||||
|
const upsertSQL = `
|
||||||
|
INSERT INTO rate_limits (key, window_start, count)
|
||||||
|
VALUES ($1, clock_timestamp(), 1)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
count = CASE WHEN rate_limits.window_start + make_interval(secs => $2) > clock_timestamp()
|
||||||
|
THEN rate_limits.count + 1 ELSE 1 END,
|
||||||
|
window_start = CASE WHEN rate_limits.window_start + make_interval(secs => $2) > clock_timestamp()
|
||||||
|
THEN rate_limits.window_start ELSE clock_timestamp() END
|
||||||
|
RETURNING count`
|
||||||
|
|
||||||
|
// Allow засчитывает попытку и говорит, разрешена ли она.
|
||||||
|
// Ошибка БД не блокирует запрос (fail-open): лимитер — не то место,
|
||||||
|
// из-за которого падает вход; доступ к базе и так нужен самому логину,
|
||||||
|
// а ошибку видно в логе.
|
||||||
|
func (l *DBLimiter) Allow(key string) bool {
|
||||||
|
var count int
|
||||||
|
err := l.q.QueryRow(context.Background(), upsertSQL, l.name+"|"+key, float64(l.window.Seconds())).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("rate limit: db check failed, request allowed", "limiter", l.name, "err", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return count <= l.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCleanup периодически выбрасывает протухшие строки: самое длинное
|
||||||
|
// окно — «3 письма в сутки» на forgotten-пароли (24 часа), поэтому
|
||||||
|
// строки старше 25 часов (час запаса) не понадобятся никогда. Короткие
|
||||||
|
// минутные окна живут дольше нужного — для шкалы rate_limits это
|
||||||
|
// копейки (строка на IP/ящик), а чистка одна на все лимиты.
|
||||||
|
// Останавливается по отмене ctx (graceful shutdown в cmd/api).
|
||||||
|
func RunCleanup(ctx context.Context, q Querier, every time.Duration) {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if _, err := q.Exec(ctx, `DELETE FROM rate_limits WHERE window_start < now() - interval '25 hours'`); err != nil {
|
||||||
|
slog.Warn("rate limit: cleanup failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
internal/ratelimit/db_test.go
Normal file
205
internal/ratelimit/db_test.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestQuerier — паттерн из repository: транзакция поверх DATABASE_URL
|
||||||
|
// с откатом в конце теста — проверяем на реальной БД, данные не остаются.
|
||||||
|
// Без DATABASE_URL (или недоступной базы) тест пропускается.
|
||||||
|
func newTestQuerier(t *testing.T) (Querier, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_ = godotenv.Load("../../.env")
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("DATABASE_URL is not set — integration test skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
t.Skipf("database is not reachable: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin tx: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = tx.Rollback(ctx) })
|
||||||
|
|
||||||
|
return tx, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireRateLimits — таблица из миграции 0009. Появляется после первого
|
||||||
|
// запуска API (db.Migrate на старте) либо go run ./cmd/migrate.
|
||||||
|
func requireRateLimits(t *testing.T, q Querier, ctx context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
var name *string
|
||||||
|
if err := q.QueryRow(ctx, `SELECT to_regclass('rate_limits')::text`).Scan(&name); err != nil {
|
||||||
|
t.Fatalf("проверка таблицы rate_limits: %v", err)
|
||||||
|
}
|
||||||
|
if name == nil {
|
||||||
|
t.Fatal("таблицы rate_limits нет — примени миграции: go run ./cmd/migrate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDBLimiter — счётчик в БД: потолок держится, ключи независимы,
|
||||||
|
// окно сбрасывается по времени.
|
||||||
|
func TestDBLimiter(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
requireRateLimits(t, q, ctx)
|
||||||
|
|
||||||
|
l := NewDB(q, "test-login", 2, time.Minute)
|
||||||
|
ip := uuid.NewString() // уникальный ключ: параллельные тесты не пересекаются
|
||||||
|
|
||||||
|
if !l.Allow(ip) {
|
||||||
|
t.Fatal("1-я попытка: want allowed")
|
||||||
|
}
|
||||||
|
if !l.Allow(ip) {
|
||||||
|
t.Fatal("2-я попытка: want allowed")
|
||||||
|
}
|
||||||
|
if l.Allow(ip) {
|
||||||
|
t.Error("3-я попытка: want denied")
|
||||||
|
}
|
||||||
|
// Ключи независимы: чужой бюджет не задет.
|
||||||
|
if !l.Allow(uuid.NewString()) {
|
||||||
|
t.Error("другой ключ: want allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Окно: кончилось — счётчик начинается заново.
|
||||||
|
w := NewDB(q, "test-window", 1, 50*time.Millisecond)
|
||||||
|
k := uuid.NewString()
|
||||||
|
if !w.Allow(k) {
|
||||||
|
t.Fatal("1-я попытка в окне: want allowed")
|
||||||
|
}
|
||||||
|
if w.Allow(k) {
|
||||||
|
t.Error("2-я попытка в окне: want denied")
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
if !w.Allow(k) {
|
||||||
|
t.Error("после окна: want allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDBLimiterFailOpen — БД недоступна → запрос разрешён (fail-open)
|
||||||
|
// и вход не встаёт вместе с базой.
|
||||||
|
func TestDBLimiterFailOpen(t *testing.T) {
|
||||||
|
l := NewDB(deadQuerier{}, "test-dead", 1, time.Minute)
|
||||||
|
if !l.Allow("any") {
|
||||||
|
t.Error("при ошибке БД лимитер должен разрешить запрос")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadQuerier — «база умерла»: любая операция возвращает ошибку.
|
||||||
|
type deadQuerier struct{}
|
||||||
|
|
||||||
|
func (deadQuerier) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
|
||||||
|
return pgconn.CommandTag{}, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
func (deadQuerier) QueryRow(context.Context, string, ...any) pgx.Row {
|
||||||
|
return errRow{context.DeadlineExceeded}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRow — pgx.Row, у которого всегда ошибка.
|
||||||
|
type errRow struct{ err error }
|
||||||
|
|
||||||
|
func (r errRow) Scan(...any) error { return r.err }
|
||||||
|
|
||||||
|
// TestRunCleanup — протухшие строки вычищаются, свежие остаются.
|
||||||
|
func TestRunCleanup(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
requireRateLimits(t, q, ctx)
|
||||||
|
|
||||||
|
fresh := "cleanup-fresh|" + uuid.NewString()
|
||||||
|
stale := "cleanup-stale|" + uuid.NewString()
|
||||||
|
// Порог чистки — 25 часов (самое длинное окно лимитов — сутки):
|
||||||
|
// протухшей считается строка старше порога, свежая — минутная.
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`INSERT INTO rate_limits (key, window_start, count) VALUES ($1, now(), 1), ($2, now() - interval '26 hours', 1)`,
|
||||||
|
fresh, stale); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Опрос и чистильщик ходят в один tx из разных горутин, а pgx.Tx
|
||||||
|
// (одно соединение) параллельных запросов не переносит —
|
||||||
|
// сериализуем мьютексом через адаптер (см. syncQuerier).
|
||||||
|
sq := &syncQuerier{q: q}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go RunCleanup(runCtx, sq, 20*time.Millisecond)
|
||||||
|
|
||||||
|
// Ждём не больше секунды, пока тикер удалит протухшую строку.
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
var n int
|
||||||
|
if err := sq.QueryRow(ctx, `SELECT count(*) FROM rate_limits WHERE key = $1`, stale).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("проверка: %v", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("протухшая строка не вычищена за 1s")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежая строка не задета.
|
||||||
|
var n int
|
||||||
|
if err := sq.QueryRow(ctx, `SELECT count(*) FROM rate_limits WHERE key = $1`, fresh).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("проверка свежей: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("свежая строка: count = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncQuerier — сериализует запросы к тестовому tx: чистильщик и опрос
|
||||||
|
// идут из разных горутин, а pgx.Tx параллельных запросов не переносит
|
||||||
|
// («conn busy»). Мьютекс держится до конца запроса: QueryRow откладывает
|
||||||
|
// реальный вызов до Scan — только там строка реально выполняется.
|
||||||
|
type syncQuerier struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
q Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *syncQuerier) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.q.Exec(ctx, sql, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *syncQuerier) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||||
|
return &syncRow{s: s, ctx: ctx, sql: sql, args: args}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncRow — Scan держит мьютекс адаптера на всё время запроса.
|
||||||
|
type syncRow struct {
|
||||||
|
s *syncQuerier
|
||||||
|
ctx context.Context
|
||||||
|
sql string
|
||||||
|
args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *syncRow) Scan(dest ...any) error {
|
||||||
|
r.s.mu.Lock()
|
||||||
|
defer r.s.mu.Unlock()
|
||||||
|
return r.s.q.QueryRow(r.ctx, r.sql, r.args...).Scan(dest...)
|
||||||
|
}
|
||||||
97
internal/ratelimit/ratelimit.go
Normal file
97
internal/ratelimit/ratelimit.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// Package ratelimit — брутфорс-лимиты «оконный счётчик» по ключу
|
||||||
|
// (обычно IP). Два хранилища за общим интерфейсом Allower:
|
||||||
|
// - DBLimiter (db.go) — в проде: счётчик в БД переживает рестарт
|
||||||
|
// и общий для нескольких инстансов;
|
||||||
|
// - Limiter (этот файл) — in-memory, для юнит-тестов без базы.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
count int
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allower — то, что умеет Middleware: засчитать попытку по ключу
|
||||||
|
// и сказать, разрешена ли она. *Limiter и *DBLimiter оба его реализуют.
|
||||||
|
type Allower interface {
|
||||||
|
Allow(key string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter считает обращения по ключу в пределах скользящего окна.
|
||||||
|
// Значение ограничений задаётся при создании — там же, где оно
|
||||||
|
// наиболее очевидно читателю.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
buckets map[string]*bucket
|
||||||
|
lastCleanup time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New создаёт лимитер на limit запросов за каждые window по одному ключу.
|
||||||
|
func New(limit int, window time.Duration) *Limiter {
|
||||||
|
return &Limiter{
|
||||||
|
limit: limit,
|
||||||
|
window: window,
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow засчитывает попытку и говорит, разрешена ли она.
|
||||||
|
func (l *Limiter) Allow(key string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
l.cleanupLocked(now)
|
||||||
|
|
||||||
|
b := l.buckets[key]
|
||||||
|
if b == nil || now.Sub(b.start) >= l.window {
|
||||||
|
l.buckets[key] = &bucket{count: 1, start: now}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if b.count >= l.limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupLocked выбрасывает протухшие ключи — карта не должна расти
|
||||||
|
// бесконечно под потоком разных IP. Чистка раз в окно, а не на каждый вызов.
|
||||||
|
func (l *Limiter) cleanupLocked(now time.Time) {
|
||||||
|
if now.Sub(l.lastCleanup) < l.window {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.lastCleanup = now
|
||||||
|
for k, b := range l.buckets {
|
||||||
|
if now.Sub(b.start) >= l.window {
|
||||||
|
delete(l.buckets, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByIP — ключ лимита: адрес клиента. Просто и без чтения тела запроса.
|
||||||
|
func ByIP(c *gin.Context) string {
|
||||||
|
return c.ClientIP()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware ограничивает количество запросов на ключ (обычно — IP)
|
||||||
|
// и отвечает 429, не давая запросу дойти до хендлера.
|
||||||
|
func Middleware(l Allower, key func(*gin.Context) string, message string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !l.Allow(key(c)) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": message})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/ratelimit/ratelimit_test.go
Normal file
102
internal/ratelimit/ratelimit_test.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// performRequest — минимальный прогон запроса через gin-роутер.
|
||||||
|
func performRequest(r *gin.Engine, method, path string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimiterAllow(t *testing.T) {
|
||||||
|
l := New(3, time.Minute)
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
if !l.Allow("ip-1") {
|
||||||
|
t.Fatalf("attempt %d: want allowed", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.Allow("ip-1") {
|
||||||
|
t.Error("4th attempt: want denied")
|
||||||
|
}
|
||||||
|
// Ключи независимы: чужой бюджет не задет.
|
||||||
|
if !l.Allow("ip-2") {
|
||||||
|
t.Error("other key: want allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimiterWindowReset(t *testing.T) {
|
||||||
|
l := New(1, 30*time.Millisecond)
|
||||||
|
|
||||||
|
if !l.Allow("k") {
|
||||||
|
t.Fatal("first attempt: want allowed")
|
||||||
|
}
|
||||||
|
if l.Allow("k") {
|
||||||
|
t.Error("second attempt: want denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(40 * time.Millisecond)
|
||||||
|
|
||||||
|
if !l.Allow("k") {
|
||||||
|
t.Error("after window: want allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLimiterCleanup — после чистки карта не тащит протухшие ключи,
|
||||||
|
// иначе под потоком разных адресов она росла бы без предела.
|
||||||
|
func TestLimiterCleanup(t *testing.T) {
|
||||||
|
l := New(5, 20*time.Millisecond)
|
||||||
|
|
||||||
|
l.Allow("old")
|
||||||
|
time.Sleep(30 * time.Millisecond) // ключ протух, но чистки ещё не было
|
||||||
|
|
||||||
|
l.Allow("fresh") // триггерит cleanupLocked
|
||||||
|
if _, exists := l.buckets["old"]; exists {
|
||||||
|
t.Error("stale key survived cleanup")
|
||||||
|
}
|
||||||
|
if _, exists := l.buckets["fresh"]; !exists {
|
||||||
|
t.Error("fresh key missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareAborts(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
l := New(1, time.Minute)
|
||||||
|
h := Middleware(l, ByIP, "too many attempts")
|
||||||
|
|
||||||
|
var passed int
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(func(c *gin.Context) { // абстрактный «хендлер», который просто считает проходы
|
||||||
|
h(c)
|
||||||
|
if c.IsAborted() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passed++
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
w := performRequest(r, "POST", "/x")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first request: status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
w = performRequest(r, "POST", "/x")
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second request: status = %d, want 429", w.Code)
|
||||||
|
}
|
||||||
|
if body := w.Body.String(); body != `{"error":"too many attempts"}` {
|
||||||
|
t.Errorf("body = %s", body)
|
||||||
|
}
|
||||||
|
if passed != 1 {
|
||||||
|
t.Errorf("handler ran %d times, want 1", passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/repository/db.go
Normal file
30
internal/repository/db.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Package repository — доступ к данным: репозитории профилей, заявок
|
||||||
|
// и refresh-сессий. Всё общение с PostgreSQL живёт здесь; ошибки
|
||||||
|
// pgx превращаются в три доменных класса (ErrNotFound/ErrConflict/ErrInvalid),
|
||||||
|
// детали которых наружу не уходят.
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Querier — минимальный набор запросов, который нужен репозиториям.
|
||||||
|
// Реализуют и *pgxpool.Pool (боевой путь), и pgx.Tx (тесты: выполняют
|
||||||
|
// сценарий и откатывают транзакцию, не оставляя данных в БД).
|
||||||
|
type Querier interface {
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beginer — то, что умеет начать транзакцию: *pgxpool.Pool (боевой путь)
|
||||||
|
// и pgx.Tx (в тестах — SAVEPOINT внутри тестовой транзакции). Нужен
|
||||||
|
// репозиториям, чьи шаги обязаны выполниться атомарно: например,
|
||||||
|
// клиентское удаление мастера (членства + профиль) не должно остаться
|
||||||
|
// наполовину, если второй шаг упёрся в FK.
|
||||||
|
type Beginer interface {
|
||||||
|
Begin(ctx context.Context) (pgx.Tx, error)
|
||||||
|
}
|
||||||
112
internal/repository/errors.go
Normal file
112
internal/repository/errors.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Классы ошибок, которые понимают handlers.
|
||||||
|
//
|
||||||
|
// ErrNotFound / ErrConflict / ErrInvalid — ошибки предметной области,
|
||||||
|
// им можно отдавать клиенту код и сообщение.
|
||||||
|
// Всё остальное — инфраструктура (сеть, авторизация, падение БД):
|
||||||
|
// наружу уходит только «internal server error», детали пишутся в лог.
|
||||||
|
var (
|
||||||
|
// ErrNotFound — сущность не найдена → 404.
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// ErrConflict — нарушение уникальности или конфликт состояния → 409.
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
|
||||||
|
// ErrInvalid — нарушение ограничений целостности (FK, CHECK, NOT NULL) → 422.
|
||||||
|
ErrInvalid = errors.New("invalid")
|
||||||
|
|
||||||
|
// Ниже — специализированные конфликты приглашений (миграция 0005).
|
||||||
|
// Каждый заворачивается в ErrConflict (→ 409), но хендлеру даёт
|
||||||
|
// точный текст для клиента: «уже состоит» и «ждём ответа» — разные
|
||||||
|
// ситуации, хотя обе это 409.
|
||||||
|
//
|
||||||
|
// ErrMembershipActive — мастер уже активно состоит в проекте.
|
||||||
|
ErrMembershipActive = errors.New("worker already active in project")
|
||||||
|
// ErrInvitationPending — приглашение отправлено и ещё не принято.
|
||||||
|
ErrInvitationPending = errors.New("invitation already pending")
|
||||||
|
// ErrInvitationExpired — срок ответа истёк, приглашение мертво.
|
||||||
|
ErrInvitationExpired = errors.New("invitation expired")
|
||||||
|
// ErrInvitationNotPending — строка есть, но ждать решения уже нечего
|
||||||
|
// (мастер ответил или строка устарела на момент операции).
|
||||||
|
ErrInvitationNotPending = errors.New("invitation is not pending")
|
||||||
|
|
||||||
|
// ErrResetToken — токен сброса пароля неизвестен, уже использован или
|
||||||
|
// истёк. Наружу уходит одним текстом (400 invalid token): различать
|
||||||
|
// причины — помощь переборщику.
|
||||||
|
ErrResetToken = errors.New("invalid reset token")
|
||||||
|
|
||||||
|
// ErrUserHasData — аккаунт нельзя удалить: на него ссылается история —
|
||||||
|
// заявки, проекты или участия (FK без каскада). Хендлер отвечает 409
|
||||||
|
// и предлагает отключить аккаунт вместо удаления — историю не стираем.
|
||||||
|
ErrUserHasData = errors.New("user has related records")
|
||||||
|
|
||||||
|
// ErrAlreadyConfirmed — подтверждать уже нечего: аккаунт подтверждён
|
||||||
|
// раньше (повторный POST /users/:id/confirm). Хендлер ветвится сам
|
||||||
|
// и отвечает 409 already confirmed.
|
||||||
|
ErrAlreadyConfirmed = errors.New("already confirmed")
|
||||||
|
|
||||||
|
// Ниже — специализированные конфликты фотоотчёта (миграция 0008).
|
||||||
|
// Оба заворачиваются в ErrConflict (→ 409), но хендлеру дают
|
||||||
|
// точный текст: «слишком много файлов» и «нельзя удалить» —
|
||||||
|
// разные ситуации, хотя обе это 409.
|
||||||
|
ErrTooManyPhotos = errors.New("too many photos")
|
||||||
|
// ErrPhotoNotRemovable — чужое фото или заявка уже закрыта.
|
||||||
|
ErrPhotoNotRemovable = errors.New("photo cannot be removed")
|
||||||
|
|
||||||
|
// Ниже — рубежи правила повторяющейся заявки: фоновый планировщик
|
||||||
|
// пропускает такое правило молча (создания заявки нет), а ручной
|
||||||
|
// запуск (POST /recurring/:id/run) получает конфликт с причиной.
|
||||||
|
// Каждый заворачивается в ErrConflict (→ 409).
|
||||||
|
ErrRuleNotActive = errors.New("rule is not active")
|
||||||
|
ErrRuleOpen = errors.New("open ticket exists")
|
||||||
|
ErrProjectNotActive = errors.New("project is not active")
|
||||||
|
ErrObjectNotActive = errors.New("object is not active")
|
||||||
|
)
|
||||||
|
|
||||||
|
// classify превращает ошибку pgx в ошибку одного из трёх классов.
|
||||||
|
// Инфраструктурные ошибки возвращаются как есть, чтобы handlers не
|
||||||
|
// приняли обрыв соединения за «запись не нашлась».
|
||||||
|
// Контекст вызывающего («ticket 12») сохраняется — он нужен для лога,
|
||||||
|
// наружу он не уходит: handlers отдают только фиксированные тексты.
|
||||||
|
func classify(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch {
|
||||||
|
case pgErr.Code == pgUniqueViolation: // 23505
|
||||||
|
return fmt.Errorf("%w: %w", err, ErrConflict)
|
||||||
|
case strings.HasPrefix(pgErr.Code, "23"): // 23xxx — integrity constraint
|
||||||
|
return fmt.Errorf("%w: %w", err, ErrInvalid)
|
||||||
|
}
|
||||||
|
// Прочие коды (08xxx сеть, 40001 deadlock, 42xxx права) — инфраструктура.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return fmt.Errorf("%w: %w", err, ErrNotFound)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
pgUniqueViolation = "23505"
|
||||||
|
pgFKViolation = "23503"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notFound — обёртка, чтобы сообщение об ошибке было информативным в логе.
|
||||||
|
func notFound(format string, args ...any) error {
|
||||||
|
return fmt.Errorf("%w: %s", ErrNotFound, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
142
internal/repository/page.go
Normal file
142
internal/repository/page.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Пагинация списков — общий контракт для всех GET-списков
|
||||||
|
// (/tickets, /projects, объекты, мастера, пользователи).
|
||||||
|
//
|
||||||
|
// Хендлер разбирает query и валидирует его против тех же словарей,
|
||||||
|
// что и здесь (400 с человеческим текстом), а репозиторий не доверяет
|
||||||
|
// даже ему: limit подрезается, а в SQL уходит только колонка
|
||||||
|
// из словаря — пользовательская строка никогда не конкатенируется.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultLimit — строк на странице, если ?limit= не задан.
|
||||||
|
DefaultLimit = 20
|
||||||
|
// MaxLimit — потолок: столько максимум попадает в ответ.
|
||||||
|
MaxLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// Page — параметры одной страницы.
|
||||||
|
type Page struct {
|
||||||
|
Limit int // строк на странице (нормализуется в normalize)
|
||||||
|
Offset int // сдвиг от начала выборки
|
||||||
|
Sort string // ключ из словаря сортировок; "" — сортировка по умолчанию
|
||||||
|
Order string // "asc" | "desc"; "" — направление по умолчанию ключа
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortSpec — колонка сортировки и её направление по умолчанию.
|
||||||
|
// Col — готовое выражение ORDER BY, собирается только здесь.
|
||||||
|
type SortSpec struct {
|
||||||
|
Col string
|
||||||
|
Desc bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Словари сортировок. Единственный источник: по ним хендлер отвечает
|
||||||
|
// 400 на неизвестный ?sort=, а orderBy берёт колонку для SQL.
|
||||||
|
var (
|
||||||
|
// TicketSorts: priority — «сначала важные», поэтому колонка
|
||||||
|
// это CASE, а не текст (по алфавиту high оказался бы в середине).
|
||||||
|
TicketSorts = map[string]SortSpec{
|
||||||
|
"created_at": {Col: "t.created_at", Desc: true},
|
||||||
|
"priority": {Col: "CASE t.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END", Desc: false},
|
||||||
|
"id": {Col: "t.id", Desc: true},
|
||||||
|
}
|
||||||
|
ProjectSorts = map[string]SortSpec{
|
||||||
|
"created_at": {Col: "p.created_at", Desc: true},
|
||||||
|
"name": {Col: "p.name", Desc: false},
|
||||||
|
"id": {Col: "p.id", Desc: true},
|
||||||
|
}
|
||||||
|
ObjectSorts = map[string]SortSpec{
|
||||||
|
"name": {Col: "o.name", Desc: false},
|
||||||
|
"created_at": {Col: "o.created_at", Desc: true},
|
||||||
|
"id": {Col: "o.id", Desc: true},
|
||||||
|
}
|
||||||
|
// ProfileSorts общий для /users и мастеров проекта: оба запроса
|
||||||
|
// используют алиас u (см. ListByRole/ListWorkersForClient/ListWorkers).
|
||||||
|
ProfileSorts = map[string]SortSpec{
|
||||||
|
"name": {Col: "u.name", Desc: false},
|
||||||
|
"created_at": {Col: "u.created_at", Desc: true},
|
||||||
|
"email": {Col: "u.email", Desc: false},
|
||||||
|
"id": {Col: "u.id", Desc: true},
|
||||||
|
}
|
||||||
|
// InvitationSorts — приглашения мастера (GET /invitations): по
|
||||||
|
// умолчанию сначала свежие; для алиасов см. ListInvitations.
|
||||||
|
InvitationSorts = map[string]SortSpec{
|
||||||
|
"invited_at": {Col: "pw.invited_at", Desc: true},
|
||||||
|
"expires_at": {Col: "pw.expires_at", Desc: false},
|
||||||
|
"project_name": {Col: "p.name", Desc: false},
|
||||||
|
}
|
||||||
|
// EventSorts — журнал заявки: хронология (id ASC) по умолчанию,
|
||||||
|
// алиас e — из ListEvents.
|
||||||
|
EventSorts = map[string]SortSpec{
|
||||||
|
"id": {Col: "e.id", Desc: false},
|
||||||
|
"created_at": {Col: "e.created_at", Desc: false},
|
||||||
|
}
|
||||||
|
// PhotoSorts — список фото заявки: тоже хронология (id ASC),
|
||||||
|
// алиас ph — из ListPhotos.
|
||||||
|
PhotoSorts = map[string]SortSpec{
|
||||||
|
"id": {Col: "ph.id", Desc: false},
|
||||||
|
"created_at": {Col: "ph.created_at", Desc: false},
|
||||||
|
}
|
||||||
|
// RecurringSorts — правила повторяющихся заявок (алиас r из
|
||||||
|
// ListRules): по умолчанию сначала свежие; «date» — по точке
|
||||||
|
// отсчёта расписания (ближайший первый запуск вперёд).
|
||||||
|
RecurringSorts = map[string]SortSpec{
|
||||||
|
"created_at": {Col: "r.created_at", Desc: true},
|
||||||
|
"id": {Col: "r.id", Desc: true},
|
||||||
|
"date": {Col: "r.anchor_at", Desc: false},
|
||||||
|
}
|
||||||
|
// RecurringRunSorts — история срабатываний правила (алиас rr из
|
||||||
|
// ListRuns): по умолчанию сначала свежие запуски.
|
||||||
|
RecurringRunSorts = map[string]SortSpec{
|
||||||
|
"run_at": {Col: "rr.run_at", Desc: true},
|
||||||
|
"id": {Col: "rr.id", Desc: true},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalize подрезает страницу в допустимые границы — второй рубеж
|
||||||
|
// после валидации в хендлере: лишних строк в ответ не уйдёт в любом случае.
|
||||||
|
func (p Page) normalize() Page {
|
||||||
|
if p.Limit <= 0 || p.Limit > MaxLimit {
|
||||||
|
p.Limit = DefaultLimit
|
||||||
|
}
|
||||||
|
if p.Offset < 0 {
|
||||||
|
p.Offset = 0
|
||||||
|
}
|
||||||
|
if p.Order != "asc" && p.Order != "desc" {
|
||||||
|
p.Order = ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderBy — фрагмент ORDER BY: ключ из словаря (или сортировка по
|
||||||
|
// умолчанию списка), направление из запроса или дефолт ключа.
|
||||||
|
// tiebreak — добавляемая последней колонка (обычно id): без неё
|
||||||
|
// одинаковые значения колонки «перетасовывались» бы между страницами.
|
||||||
|
func orderBy(p Page, sorts map[string]SortSpec, defaultKey, tiebreak string) (string, error) {
|
||||||
|
key := p.Sort
|
||||||
|
if key == "" {
|
||||||
|
key = defaultKey
|
||||||
|
}
|
||||||
|
spec, ok := sorts[key]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("sort %q: %w", key, ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := spec.Desc
|
||||||
|
switch p.Order {
|
||||||
|
case "":
|
||||||
|
case "asc":
|
||||||
|
desc = false
|
||||||
|
case "desc":
|
||||||
|
desc = true
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("order %q: %w", p.Order, ErrInvalid)
|
||||||
|
}
|
||||||
|
dir := "ASC"
|
||||||
|
if desc {
|
||||||
|
dir = "DESC"
|
||||||
|
}
|
||||||
|
return " ORDER BY " + spec.Col + " " + dir + ", " + tiebreak, nil
|
||||||
|
}
|
||||||
305
internal/repository/page_test.go
Normal file
305
internal/repository/page_test.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- чистые функции (база не нужна) ---
|
||||||
|
|
||||||
|
func TestPageNormalize(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in Page
|
||||||
|
want Page
|
||||||
|
}{
|
||||||
|
{"дефолт", Page{}, Page{Limit: DefaultLimit}},
|
||||||
|
{"нормальный лимит", Page{Limit: 10}, Page{Limit: 10}},
|
||||||
|
{"лимит за потолком", Page{Limit: 500}, Page{Limit: DefaultLimit}},
|
||||||
|
{"нулевой лимит", Page{Limit: 0}, Page{Limit: DefaultLimit}},
|
||||||
|
{"отрицательный сдвиг", Page{Limit: 5, Offset: -3}, Page{Limit: 5, Offset: 0}},
|
||||||
|
{"порядок сохраняется", Page{Limit: 5, Order: "asc"}, Page{Limit: 5, Order: "asc"}},
|
||||||
|
{"мусорный порядок сбрасывается", Page{Limit: 5, Order: "up"}, Page{Limit: 5}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := tc.in.normalize()
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("normalize(%+v) = %+v, want %+v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderBy(t *testing.T) {
|
||||||
|
// Словарь с ключами по умолчанию: created_at — по убыванию, name — по возрастанию.
|
||||||
|
sorts := map[string]SortSpec{
|
||||||
|
"created_at": {Col: "t.created_at", Desc: true},
|
||||||
|
"name": {Col: "t.name", Desc: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("дефолт списка", func(t *testing.T) {
|
||||||
|
got, err := orderBy(Page{}, sorts, "created_at", "t.id DESC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if want := " ORDER BY t.created_at DESC, t.id DESC"; got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("направление из запроса", func(t *testing.T) {
|
||||||
|
got, err := orderBy(Page{Sort: "name", Order: "asc"}, sorts, "created_at", "t.id DESC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if want := " ORDER BY t.name ASC, t.id DESC"; got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("desc переопределяет дефолт", func(t *testing.T) {
|
||||||
|
got, err := orderBy(Page{Sort: "name", Order: "desc"}, sorts, "created_at", "t.id DESC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if want := " ORDER BY t.name DESC, t.id DESC"; got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("неизвестный ключ — ошибка, а не дефолт", func(t *testing.T) {
|
||||||
|
if _, err := orderBy(Page{Sort: "password_hash"}, sorts, "created_at", "t.id"); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("мусорное направление — ошибка", func(t *testing.T) {
|
||||||
|
if _, err := orderBy(Page{Sort: "name", Order: "sideways"}, sorts, "created_at", "t.id"); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- интеграционные (нужна живая БД, без DATABASE_URL скипаются) ---
|
||||||
|
|
||||||
|
// TestTicketListPagination — страницы, total и фильтры по полям заявки.
|
||||||
|
// Ключевое: total считается до обрезки, а offset за концом не ошибка,
|
||||||
|
// а пустая страница с прежним total.
|
||||||
|
func TestTicketListPagination(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
|
||||||
|
// Пять заявок: категории и приоритеты чередуются, чтобы проверить
|
||||||
|
// сортировку и фильтры независимо от порядка вставки.
|
||||||
|
specs := []struct{ category, priority string }{
|
||||||
|
{"plumbing", "high"},
|
||||||
|
{"electric", "normal"},
|
||||||
|
{"plumbing", "low"},
|
||||||
|
{"climate", "high"},
|
||||||
|
{"plumbing", "normal"},
|
||||||
|
}
|
||||||
|
var created int
|
||||||
|
for _, s := range specs {
|
||||||
|
tk := &models.Ticket{
|
||||||
|
ClientID: client.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: s.category,
|
||||||
|
Description: "описание " + s.category,
|
||||||
|
Priority: s.priority,
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, tk); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
if created != len(specs) {
|
||||||
|
t.Fatalf("создано %d заявок, want %d", created, len(specs))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("total считается до обрезки", func(t *testing.T) {
|
||||||
|
items, total, err := tickets.List(ctx, ListFilter{ClientID: client.ID, Page: Page{Limit: 2}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 || total != 5 {
|
||||||
|
t.Errorf("len=%d total=%d, want 2 и 5", len(items), total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("страницы идут подряд без пересечений", func(t *testing.T) {
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
offset := 0
|
||||||
|
for page := 1; page <= 3; page++ {
|
||||||
|
items, total, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID,
|
||||||
|
Page: Page{Limit: 2, Offset: offset},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("page %d: %v", page, err)
|
||||||
|
}
|
||||||
|
if total != 5 {
|
||||||
|
t.Errorf("page %d total = %d, want 5", page, total)
|
||||||
|
}
|
||||||
|
if page < 3 && len(items) != 2 {
|
||||||
|
t.Errorf("page %d len = %d, want 2", page, len(items))
|
||||||
|
}
|
||||||
|
if page == 3 && len(items) != 1 {
|
||||||
|
t.Errorf("page 3 len = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
for _, tk := range items {
|
||||||
|
if seen[tk.ID] {
|
||||||
|
t.Errorf("ticket %d выдан дважды", tk.ID)
|
||||||
|
}
|
||||||
|
seen[tk.ID] = true
|
||||||
|
}
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
if len(seen) != 5 {
|
||||||
|
t.Errorf("уникальных заявок за 3 страницы = %d, want 5", len(seen))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("offset за концом — пустая страница, total цел", func(t *testing.T) {
|
||||||
|
items, total, err := tickets.List(ctx, ListFilter{ClientID: client.ID, Page: Page{Limit: 2, Offset: 100}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 0 || total != 5 {
|
||||||
|
t.Errorf("len=%d total=%d, want 0 и 5", len(items), total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("лимит вне границ подрезается, а не падает", func(t *testing.T) {
|
||||||
|
items, _, err := tickets.List(ctx, ListFilter{ClientID: client.ID, Page: Page{Limit: 500}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 5 {
|
||||||
|
t.Errorf("len = %d, want 5 (лимит 500 → 100 → все)", len(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("сортировка по приоритету: сначала важные", func(t *testing.T) {
|
||||||
|
items, _, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID,
|
||||||
|
Page: Page{Limit: MaxLimit, Sort: "priority", Order: "asc"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 5 {
|
||||||
|
t.Fatalf("len = %d, want 5", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Priority != "high" || items[len(items)-1].Priority != "low" {
|
||||||
|
t.Errorf("порядок приоритетов: %v", priorities(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("неизвестное поле сортировки — ErrInvalid", func(t *testing.T) {
|
||||||
|
if _, _, err := tickets.List(ctx, ListFilter{Page: Page{Sort: "description"}}); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("фильтр по категории", func(t *testing.T) {
|
||||||
|
items, total, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID, Category: "plumbing",
|
||||||
|
Page: Page{Limit: MaxLimit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 3 || len(items) != 3 {
|
||||||
|
t.Errorf("total=%d len=%d, want 3 и 3", total, len(items))
|
||||||
|
}
|
||||||
|
for _, tk := range items {
|
||||||
|
if tk.Category != "plumbing" {
|
||||||
|
t.Errorf("фильтр category пропустил %q", tk.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("фильтр по приоритету", func(t *testing.T) {
|
||||||
|
_, total, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID, Priority: "high",
|
||||||
|
Page: Page{Limit: MaxLimit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Errorf("total = %d, want 2", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("фильтры комбинируются", func(t *testing.T) {
|
||||||
|
_, total, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID, Category: "plumbing", Priority: "high",
|
||||||
|
Page: Page{Limit: MaxLimit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Errorf("total = %d, want 1 (только plumbing+high)", total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("диапазон дат", func(t *testing.T) {
|
||||||
|
today := time.Now().AddDate(0, 0, -1)
|
||||||
|
tomorrow := today.AddDate(0, 0, 2)
|
||||||
|
|
||||||
|
_, total, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID,
|
||||||
|
DateFrom: &today, DateTo: &tomorrow,
|
||||||
|
Page: Page{Limit: MaxLimit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 5 {
|
||||||
|
t.Errorf("диапазон, покрывающий сегодняшний день: total = %d, want 5", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
onlyFuture := tomorrow.AddDate(0, 0, 10)
|
||||||
|
items, total, err := tickets.List(ctx, ListFilter{
|
||||||
|
ClientID: client.ID,
|
||||||
|
DateFrom: &onlyFuture,
|
||||||
|
Page: Page{Limit: MaxLimit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 || len(items) != 0 {
|
||||||
|
t.Errorf("будущий диапазон: total=%d len=%d, want 0 и 0", total, len(items))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("пустая выборка возвращает total=0", func(t *testing.T) {
|
||||||
|
items, total, err := tickets.List(ctx, ListFilter{ClientID: "00000000-0000-0000-0000-000000000000"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 0 || total != 0 {
|
||||||
|
t.Errorf("len=%d total=%d, want 0 и 0", len(items), total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// priorities — приоритеты страницы по порядку, для сообщений об ошибках.
|
||||||
|
func priorities(items []models.Ticket) []string {
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, tk := range items {
|
||||||
|
out = append(out, tk.Priority)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
124
internal/repository/password_reset.go
Normal file
124
internal/repository/password_reset.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сброс пароля без SMTP (миграция 0007): токен выдаёт админ, в базе
|
||||||
|
// остаётся только его sha256, применяется он одной SQL-операцией —
|
||||||
|
// токен нельзя потратить дважды даже при параллельных запросах.
|
||||||
|
|
||||||
|
// ResetTTL — срок жизни токена сброса. Единственный источник: и в SQL
|
||||||
|
// (expires_at), и в текстах Swagger/README.
|
||||||
|
const ResetTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
// CreateResetToken — выпустить одноразовый токен смены пароля для
|
||||||
|
// пользователя. Возвращает сам токен (дальше он нигде не хранится —
|
||||||
|
// восстановить его из базы нельзя) и срок истечения.
|
||||||
|
//
|
||||||
|
// Прежние неиспользованные токены пользователя гасятся той же операцией:
|
||||||
|
// действует ровно один свежий, старый токен из письма/переписки умирает.
|
||||||
|
func (r *ProfileRepo) CreateResetToken(ctx context.Context, userID string) (string, time.Time, error) {
|
||||||
|
token, err := newResetToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
var expires time.Time
|
||||||
|
err = r.db.QueryRow(ctx,
|
||||||
|
`WITH dead AS (
|
||||||
|
UPDATE password_reset_tokens
|
||||||
|
SET used_at = now()
|
||||||
|
WHERE user_id = $1 AND used_at IS NULL
|
||||||
|
)
|
||||||
|
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, now() + make_interval(secs => $3))
|
||||||
|
RETURNING expires_at`,
|
||||||
|
userID, hashResetToken(token), ResetTTL.Seconds(),
|
||||||
|
).Scan(&expires)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, classify(err)
|
||||||
|
}
|
||||||
|
return token, expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseResetToken — применить токен: сменить пароль пользователя и закрыть
|
||||||
|
// сам токен. Возвращает id пользователя, чтобы вызывающий закрыл его
|
||||||
|
// refresh-сессии.
|
||||||
|
//
|
||||||
|
// Обе части — один UPDATE-CTE: токен помечается использованным и пароль
|
||||||
|
// меняется атомарно, а WHERE used_at IS NULL делает второе применение
|
||||||
|
// невозможным (конкурирующий запрос дождётся строки и увидит used_at).
|
||||||
|
func (r *ProfileRepo) UseResetToken(ctx context.Context, token, newHash string) (string, error) {
|
||||||
|
var userID string
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`WITH tok AS (
|
||||||
|
UPDATE password_reset_tokens
|
||||||
|
SET used_at = now()
|
||||||
|
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
RETURNING user_id
|
||||||
|
), upd AS (
|
||||||
|
UPDATE profiles
|
||||||
|
SET password_hash = $2
|
||||||
|
WHERE id = (SELECT user_id FROM tok)
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
-- ::text обязателен: пустой литерал в контексте uuid — SQLSTATE 22P02
|
||||||
|
SELECT COALESCE((SELECT id FROM upd)::text, '')`,
|
||||||
|
hashResetToken(token), newHash,
|
||||||
|
).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", classify(err)
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
// Неизвестен, уже потрачен или истёк — клиенту всё равно одно.
|
||||||
|
return "", ErrResetToken
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword — прямая смена пароля админом (POST /users/:id/password-set):
|
||||||
|
// один UPDATE-CTE ставит новый хеш и гасит все неиспользованные токены
|
||||||
|
// сброса пользователя — после смены действующей ссылки из письма или
|
||||||
|
// переписки не остаётся. Возвращает id сменённого пользователя;
|
||||||
|
// несуществующий id — ErrNotFound (наружу 404).
|
||||||
|
func (r *ProfileRepo) SetPassword(ctx context.Context, userID, newHash string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`WITH dead AS (
|
||||||
|
UPDATE password_reset_tokens
|
||||||
|
SET used_at = now()
|
||||||
|
WHERE user_id = $1 AND used_at IS NULL
|
||||||
|
)
|
||||||
|
UPDATE profiles
|
||||||
|
SET password_hash = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id::text`,
|
||||||
|
userID, newHash,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return "", classify(err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newResetToken — 32 байта криптостойкого шума в hex (64 символа):
|
||||||
|
// не угадать, а из базы не восстановить — там только sha256.
|
||||||
|
func newResetToken() (string, error) {
|
||||||
|
var b [32]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("generate reset token: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashResetToken — sha256 в hex, как у refresh-токенов в auth.HashToken.
|
||||||
|
// Своя копия здесь, чтобы репозиторий не зависел от auth-пакета.
|
||||||
|
func hashResetToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
148
internal/repository/password_reset_test.go
Normal file
148
internal/repository/password_reset_test.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты сброса пароля (миграция 0007): токен одноразовый, прежние гасятся
|
||||||
|
// при выпуске нового, просроченный не работает, пароль реально меняется.
|
||||||
|
|
||||||
|
// TestPasswordReset — полный цикл: выпуск, замена токена, применение.
|
||||||
|
func TestPasswordReset(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
p := createProfile(t, ctx, q, "client")
|
||||||
|
before, err := repo.GetByID(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выпуск: токен — длинная случайная строка, срок в будущем (ResetTTL).
|
||||||
|
token, expires, err := repo.CreateResetToken(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reset token: %v", err)
|
||||||
|
}
|
||||||
|
if len(token) < 32 {
|
||||||
|
t.Errorf("token = %q, want long random string", token)
|
||||||
|
}
|
||||||
|
if !expires.After(time.Now().Add(ResetTTL - time.Minute)) {
|
||||||
|
t.Errorf("expires_at = %v, want ~%v from now", expires, ResetTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй выпуск гасит первый: действует ровно один свежий токен.
|
||||||
|
token2, _, err := repo.CreateResetToken(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second token: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.UseResetToken(ctx, token, "$2a$12$old-hash"); !errors.Is(err, ErrResetToken) {
|
||||||
|
t.Errorf("superseded token: err = %v, want ErrResetToken", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применение: возвращает владельца и меняет хеш пароля.
|
||||||
|
const newHash = "$2a$12$new-hash-for-test"
|
||||||
|
userID, err := repo.UseResetToken(ctx, token2, newHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("use reset token: %v", err)
|
||||||
|
}
|
||||||
|
if userID != p.ID {
|
||||||
|
t.Errorf("user id = %s, want %s", userID, p.ID)
|
||||||
|
}
|
||||||
|
after, err := repo.GetByID(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get profile after reset: %v", err)
|
||||||
|
}
|
||||||
|
if after.PasswordHash != newHash {
|
||||||
|
t.Errorf("password hash = %q, want %q", after.PasswordHash, newHash)
|
||||||
|
}
|
||||||
|
if before.PasswordHash == after.PasswordHash {
|
||||||
|
t.Error("password hash did not change")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Токен одноразовый: второе применение — ErrResetToken.
|
||||||
|
if _, err := repo.UseResetToken(ctx, token2, "$2a$12$third"); !errors.Is(err, ErrResetToken) {
|
||||||
|
t.Errorf("reuse: err = %v, want ErrResetToken", err)
|
||||||
|
}
|
||||||
|
// Неизвестный токен — та же ошибка, без подсказок.
|
||||||
|
if _, err := repo.UseResetToken(ctx, "deadbeef", newHash); !errors.Is(err, ErrResetToken) {
|
||||||
|
t.Errorf("unknown token: err = %v, want ErrResetToken", err)
|
||||||
|
}
|
||||||
|
// Несуществующему пользователю токен не выпускается → ErrInvalid
|
||||||
|
// (наружу это 422, хендлер заранее отдаёт 404 по GetByID).
|
||||||
|
if _, _, err := repo.CreateResetToken(ctx, "00000000-0000-0000-0000-000000000000"); !errors.Is(err, ErrInvalid) {
|
||||||
|
t.Errorf("token for missing user: err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetPasswordDirect — прямая смена пароля админом: пароль реально
|
||||||
|
// меняется, токены, выпущенные до смены, гаснут, неизвестный id — 404.
|
||||||
|
func TestSetPasswordDirect(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
p := createProfile(t, ctx, q, "worker")
|
||||||
|
token, _, err := repo.CreateResetToken(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reset token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newHash = "$2a$12$admin-set-password"
|
||||||
|
userID, err := repo.SetPassword(ctx, p.ID, newHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set password: %v", err)
|
||||||
|
}
|
||||||
|
if userID != p.ID {
|
||||||
|
t.Errorf("user id = %s, want %s", userID, p.ID)
|
||||||
|
}
|
||||||
|
got, err := repo.GetByID(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get profile: %v", err)
|
||||||
|
}
|
||||||
|
if got.PasswordHash != newHash {
|
||||||
|
t.Errorf("password hash = %q, want %q", got.PasswordHash, newHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежая ссылка, выданная до смены, больше не работает.
|
||||||
|
if _, err := repo.UseResetToken(ctx, token, "$2a$12$stale"); !errors.Is(err, ErrResetToken) {
|
||||||
|
t.Errorf("token issued before reset: err = %v, want ErrResetToken", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Несуществующий пользователь — ErrNotFound (наружу 404).
|
||||||
|
if _, err := repo.SetPassword(ctx, "00000000-0000-0000-0000-000000000000", newHash); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("missing user: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordResetExpired — просроченный токен не применяется и пароль
|
||||||
|
// при этом не трогается (реально ждать сутки не будем — устареваем сами).
|
||||||
|
func TestPasswordResetExpired(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
p := createProfile(t, ctx, q, "worker")
|
||||||
|
token, _, err := repo.CreateResetToken(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reset token: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE password_reset_tokens
|
||||||
|
SET created_at = now() - interval '25 hours',
|
||||||
|
expires_at = now() - interval '1 minute'
|
||||||
|
WHERE user_id = $1 AND used_at IS NULL`,
|
||||||
|
p.ID); err != nil {
|
||||||
|
t.Fatalf("expire token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.UseResetToken(ctx, token, "$2a$12$expired"); !errors.Is(err, ErrResetToken) {
|
||||||
|
t.Errorf("expired token: err = %v, want ErrResetToken", err)
|
||||||
|
}
|
||||||
|
got, err := repo.GetByID(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get profile: %v", err)
|
||||||
|
}
|
||||||
|
if got.PasswordHash == "$2a$12$expired" {
|
||||||
|
t.Error("password changed with an expired token")
|
||||||
|
}
|
||||||
|
}
|
||||||
257
internal/repository/photo.go
Normal file
257
internal/repository/photo.go
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhotoRepo — фотоотчёт заявки (миграция 0008): метаданные в ticket_photos,
|
||||||
|
// байты — в объектном хранилище (см. package storage). Как и переходы
|
||||||
|
// статусов, все правила живут в самом SQL: статус заявки, членство
|
||||||
|
// в проекте, лимит файлов и событие в журнале проверяются и пишутся
|
||||||
|
// одной операцией — «фото записано, а журнал потерялся» невозможно.
|
||||||
|
type PhotoRepo struct {
|
||||||
|
db Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPhotoRepo создаёт репозиторий поверх Querier (пул или транзакция).
|
||||||
|
func NewPhotoRepo(db Querier) *PhotoRepo {
|
||||||
|
return &PhotoRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhotoMeta — то, что хендлер уже проверил до обращения к БД: тип файла
|
||||||
|
// (белый список по сигнатурам) и размер. Права, статус и лимит здесь —
|
||||||
|
// в SQL: им нельзя доверять хендлеру, их ловит только БД.
|
||||||
|
type PhotoMeta struct {
|
||||||
|
TicketID int64
|
||||||
|
UploaderID string
|
||||||
|
ObjectKey string
|
||||||
|
ContentType string
|
||||||
|
SizeBytes int64
|
||||||
|
OriginalName string
|
||||||
|
// MaxPerTicket — потолок файлов на заявку (PHOTO_MAX_PER_TICKET):
|
||||||
|
// счётчик считается в той же CTE, что и вставка, поэтому гонку
|
||||||
|
// «двое залезли в последнее свободное место» ловит SQL.
|
||||||
|
MaxPerTicket int
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoColumns — проекция строки фото. Один источник для AddPhoto
|
||||||
|
// и ListPhotos: порядок обязан совпадать со сканом.
|
||||||
|
const photoColumns = `
|
||||||
|
ph.id, ph.ticket_id, COALESCE(ph.uploaded_by::text, ''),
|
||||||
|
COALESCE(p.name, ''), ph.object_key, ph.content_type,
|
||||||
|
ph.size_bytes, ph.original_name, ph.created_at`
|
||||||
|
|
||||||
|
// scanPhoto читает строку в модель; extra — доп. назначения в конце
|
||||||
|
// (например COUNT(*) OVER() у списка).
|
||||||
|
func scanPhoto(s scanner, extra ...any) (models.TicketPhoto, error) {
|
||||||
|
var ph models.TicketPhoto
|
||||||
|
dest := []any{
|
||||||
|
&ph.ID, &ph.TicketID, &ph.UploaderID,
|
||||||
|
&ph.UploaderName, &ph.ObjectKey, &ph.ContentType,
|
||||||
|
&ph.SizeBytes, &ph.OriginalName, &ph.CreatedAt,
|
||||||
|
}
|
||||||
|
dest = append(dest, extra...)
|
||||||
|
err := s.Scan(dest...)
|
||||||
|
return ph, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPhoto — вставить фото и событие photo_added одной операцией.
|
||||||
|
//
|
||||||
|
// Вставка разрешена, только если всё вместе истинно (см. WHERE):
|
||||||
|
// - заявка существует и в статусе in_progress: после «Работа
|
||||||
|
// выполнена» (done) отчёт мастера заморожен — и на доработке
|
||||||
|
// (rejected) он снова откроется вместе со стартом;
|
||||||
|
// - загрузивший — админ, либо активный мастер проекта заявки
|
||||||
|
// (тот же приём, что Assign в 0004/0005);
|
||||||
|
// - файлов на заявке меньше лимита.
|
||||||
|
//
|
||||||
|
// role — роль из токена: админу членство в проекте не нужно, но
|
||||||
|
// статус заявки обязан быть рабочим и у него.
|
||||||
|
func (r *PhotoRepo) AddPhoto(ctx context.Context, m PhotoMeta, role string) (*models.TicketPhoto, error) {
|
||||||
|
ph, err := scanPhoto(r.db.QueryRow(ctx,
|
||||||
|
`WITH ins AS (
|
||||||
|
INSERT INTO ticket_photos
|
||||||
|
(ticket_id, uploaded_by, object_key, content_type, size_bytes, original_name)
|
||||||
|
SELECT t.id, $2, $3, $4, $5, $6
|
||||||
|
FROM tickets t
|
||||||
|
WHERE t.id = $1
|
||||||
|
AND t.status = 'in_progress'
|
||||||
|
AND (
|
||||||
|
$7::text = 'admin'
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM project_workers pw
|
||||||
|
WHERE pw.worker_id = $2 AND pw.project_id = t.project_id
|
||||||
|
AND pw.status = 'active')
|
||||||
|
)
|
||||||
|
AND (SELECT COUNT(*) FROM ticket_photos ph
|
||||||
|
WHERE ph.ticket_id = t.id) < $8
|
||||||
|
RETURNING id, ticket_id, uploaded_by, object_key,
|
||||||
|
content_type, size_bytes, original_name, created_at
|
||||||
|
), ev AS (
|
||||||
|
-- Событие той же операцией, что и вставка (как в 0006):
|
||||||
|
-- статус не менялся, поэтому from_status = to_status = текущий.
|
||||||
|
INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, action, from_status, to_status, comment)
|
||||||
|
SELECT i.ticket_id, $2, 'photo_added', t.status, t.status, i.original_name
|
||||||
|
FROM ins i JOIN tickets t ON t.id = i.ticket_id
|
||||||
|
)
|
||||||
|
SELECT i.id, i.ticket_id, COALESCE(i.uploaded_by::text, ''),
|
||||||
|
COALESCE(p.name, ''), i.object_key, i.content_type,
|
||||||
|
i.size_bytes, i.original_name, i.created_at
|
||||||
|
FROM ins i
|
||||||
|
LEFT JOIN profiles p ON p.id = i.uploaded_by`,
|
||||||
|
m.TicketID, m.UploaderID, m.ObjectKey, m.ContentType,
|
||||||
|
m.SizeBytes, m.OriginalName, role, m.MaxPerTicket,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, r.addPhotoError(ctx, m)
|
||||||
|
}
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
return &ph, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPhotoError — вставка не применилась: разделяем «заявки нет» (404),
|
||||||
|
// «лимит исчерпан» (409 too many photos) и «не тот статус/членство» (409),
|
||||||
|
// не выдавая наружу текстов ошибок БД.
|
||||||
|
func (r *PhotoRepo) addPhotoError(ctx context.Context, m PhotoMeta) error {
|
||||||
|
var status string
|
||||||
|
var count int
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT t.status,
|
||||||
|
(SELECT COUNT(*) FROM ticket_photos ph WHERE ph.ticket_id = t.id)
|
||||||
|
FROM tickets t WHERE t.id = $1`,
|
||||||
|
m.TicketID,
|
||||||
|
).Scan(&status, &count)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return notFound("ticket %d", m.TicketID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if count >= m.MaxPerTicket {
|
||||||
|
return fmt.Errorf("%w: %w: ticket %d already has %d photos",
|
||||||
|
ErrConflict, ErrTooManyPhotos, m.TicketID, count)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: ticket %d must be in 'in_progress' and the uploader must be an active member of the project (status=%s)",
|
||||||
|
ErrConflict, m.TicketID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPhotos — сколько файлов уже на заявке. Дешёвый precheck хендлера
|
||||||
|
// до заливки в S3: спорная гонка всё равно решается в AddPhoto.
|
||||||
|
func (r *PhotoRepo) CountPhotos(ctx context.Context, ticketID int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`, ticketID,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
return 0, classify(err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPhotos — страница фото заявки в хронологии (id ASC по умолчанию).
|
||||||
|
// Имя загрузившего — LEFT JOIN: аккаунт могли удалить, файл останется.
|
||||||
|
func (r *PhotoRepo) ListPhotos(ctx context.Context, ticketID int64, page Page) ([]models.TicketPhoto, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, PhotoSorts, "id", "ph.id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT `+photoColumns+`, COUNT(*) OVER() AS total
|
||||||
|
FROM ticket_photos ph
|
||||||
|
LEFT JOIN profiles p ON p.id = ph.uploaded_by
|
||||||
|
WHERE ph.ticket_id = $1`+order+`
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
ticketID, page.Limit, page.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var photos []models.TicketPhoto
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
ph, err := scanPhoto(rows, &total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
photos = append(photos, ph)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(photos) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`,
|
||||||
|
ticketID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return photos, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePhoto — убрать фото: своё и только пока заявка в работе
|
||||||
|
// (in_progress — отчёт мастера замораживается в done), либо любое
|
||||||
|
// у админа (роль приходит из токена). Событие photo_removed
|
||||||
|
// пишется той же операцией. Возвращает object_key удалённой строки —
|
||||||
|
// вызывающий ещё удалит сам объект из хранилища.
|
||||||
|
//
|
||||||
|
// ticket_id в условиях обязателен: :photo_id из чужой заявки должен
|
||||||
|
// быть неотличим от несуществующего, даже если дошли до этого роута.
|
||||||
|
func (r *PhotoRepo) DeletePhoto(ctx context.Context, ticketID, photoID int64, requesterID, role string) (string, error) {
|
||||||
|
var key string
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`WITH del AS (
|
||||||
|
DELETE FROM ticket_photos ph
|
||||||
|
WHERE ph.ticket_id = $1 AND ph.id = $2
|
||||||
|
AND (
|
||||||
|
$4::text = 'admin'
|
||||||
|
OR (ph.uploaded_by = $3
|
||||||
|
AND EXISTS (SELECT 1 FROM tickets t
|
||||||
|
WHERE t.id = ph.ticket_id
|
||||||
|
AND t.status = 'in_progress'))
|
||||||
|
)
|
||||||
|
RETURNING ph.ticket_id, ph.object_key, ph.original_name
|
||||||
|
), ev AS (
|
||||||
|
INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, action, from_status, to_status, comment)
|
||||||
|
SELECT d.ticket_id, $3, 'photo_removed', t.status, t.status, d.original_name
|
||||||
|
FROM del d JOIN tickets t ON t.id = d.ticket_id
|
||||||
|
)
|
||||||
|
SELECT object_key FROM del`,
|
||||||
|
ticketID, photoID, requesterID, role,
|
||||||
|
).Scan(&key)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", r.deletePhotoError(ctx, ticketID, photoID)
|
||||||
|
}
|
||||||
|
return "", classify(err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletePhotoError — DELETE не применился: «фото нет» (404) или
|
||||||
|
// «есть, но трогать нельзя» (409, свой текст для хендлера).
|
||||||
|
func (r *PhotoRepo) deletePhotoError(ctx context.Context, ticketID, photoID int64) error {
|
||||||
|
var ok bool
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM ticket_photos WHERE ticket_id = $1 AND id = $2)`,
|
||||||
|
ticketID, photoID).Scan(&ok); err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return notFound("photo %d", photoID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %w: ticket %d is not in progress or the photo is not yours",
|
||||||
|
ErrConflict, ErrPhotoNotRemovable, ticketID)
|
||||||
|
}
|
||||||
292
internal/repository/photo_test.go
Normal file
292
internal/repository/photo_test.go
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// photoMeta — каркас метаданных для теста: каждый кейс меняет
|
||||||
|
// только то, что проверяет.
|
||||||
|
func photoMeta(ticketID int64, uploaderID string, n int, maxPer int) PhotoMeta {
|
||||||
|
return PhotoMeta{
|
||||||
|
TicketID: ticketID,
|
||||||
|
UploaderID: uploaderID,
|
||||||
|
ObjectKey: fmt.Sprintf("tickets/%d/%s.jpg", ticketID, uuid.NewString()),
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
SizeBytes: int64(100 + n),
|
||||||
|
OriginalName: fmt.Sprintf("photo-%d.jpg", n),
|
||||||
|
MaxPerTicket: maxPer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhotoReportLifecycle — сквозной сценарий фотоотчёта: запрет до
|
||||||
|
// старта работы, загрузка мастером, лимит, журнал, удаление и финальная
|
||||||
|
// стадия заявки. Все правила — SQL, поэтому проверяем именно их.
|
||||||
|
func TestPhotoReportLifecycle(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
photos := NewPhotoRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
otherWorker := createProfile(t, ctx, q, "worker")
|
||||||
|
admin := createProfile(t, ctx, q, "admin")
|
||||||
|
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||||||
|
|
||||||
|
ticket := &models.Ticket{
|
||||||
|
ClientID: client.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: "electric",
|
||||||
|
Description: "не горит лампочка",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, ticket); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Заявка в new: загрузка запрещена даже админу ---
|
||||||
|
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, admin.ID, 0, 5), "admin"); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("upload in status new: err = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tickets.Assign(ctx, ticket.ID, worker.ID, client.ID); err != nil {
|
||||||
|
t.Fatalf("assign: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Несуществующая заявка — 404, а не конфликт ---
|
||||||
|
if _, err := photos.AddPhoto(ctx, photoMeta(999999999, worker.ID, 0, 5), "worker"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("upload to missing ticket: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Мастер проекта загружает в работу: строка + событие ---
|
||||||
|
m1 := photoMeta(ticket.ID, worker.ID, 1, 3)
|
||||||
|
p1, err := photos.AddPhoto(ctx, m1, "worker")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload by worker: %v", err)
|
||||||
|
}
|
||||||
|
if p1.ID == 0 || p1.TicketID != ticket.ID {
|
||||||
|
t.Errorf("photo = %+v, want id>0 and ticket %d", p1, ticket.ID)
|
||||||
|
}
|
||||||
|
if p1.ObjectKey != m1.ObjectKey || p1.ContentType != "image/jpeg" {
|
||||||
|
t.Errorf("photo meta mismatch: %+v vs %+v", p1, m1)
|
||||||
|
}
|
||||||
|
if p1.UploaderID != worker.ID || p1.UploaderName != worker.Name {
|
||||||
|
t.Errorf("uploader = %q/%q, want %q/%q", p1.UploaderID, p1.UploaderName, worker.ID, worker.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, _, err := tickets.ListEvents(ctx, ticket.ID, Page{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list events: %v", err)
|
||||||
|
}
|
||||||
|
var added *models.TicketEvent
|
||||||
|
for i := range events {
|
||||||
|
if events[i].Action == "photo_added" {
|
||||||
|
added = &events[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if added == nil {
|
||||||
|
t.Fatal("photo_added event not found")
|
||||||
|
}
|
||||||
|
if added.ActorID != worker.ID || added.FromStatus != "in_progress" || added.ToStatus != "in_progress" {
|
||||||
|
t.Errorf("event = %+v, want actor=%s in_progress→in_progress", added, worker.ID)
|
||||||
|
}
|
||||||
|
if added.Comment != m1.OriginalName {
|
||||||
|
t.Errorf("event comment = %q, want %q", added.Comment, m1.OriginalName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Чужой мастер (не в проекте заявки) — конфликт ---
|
||||||
|
mo := photoMeta(ticket.ID, otherWorker.ID, 90, 3)
|
||||||
|
if _, err := photos.AddPhoto(ctx, mo, "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||||||
|
t.Errorf("upload by stranger: err = %v, want plain ErrConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Лимит файлов: до потолка — ок, дальше — ErrTooManyPhotos ---
|
||||||
|
m2 := photoMeta(ticket.ID, worker.ID, 2, 3)
|
||||||
|
p2, err := photos.AddPhoto(ctx, m2, "worker")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second upload: %v", err)
|
||||||
|
}
|
||||||
|
ma := photoMeta(ticket.ID, admin.ID, 3, 3)
|
||||||
|
pa, err := photos.AddPhoto(ctx, ma, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin upload: %v", err)
|
||||||
|
}
|
||||||
|
// Четвёртый упирается в лимит: и у мастера, и у админа.
|
||||||
|
mw := photoMeta(ticket.ID, worker.ID, 4, 3)
|
||||||
|
if _, err := photos.AddPhoto(ctx, mw, "worker"); !errors.Is(err, ErrTooManyPhotos) {
|
||||||
|
t.Errorf("upload over limit: err = %v, want ErrTooManyPhotos", err)
|
||||||
|
}
|
||||||
|
ml := photoMeta(ticket.ID, admin.ID, 5, 3)
|
||||||
|
if _, err := photos.AddPhoto(ctx, ml, "admin"); !errors.Is(err, ErrTooManyPhotos) {
|
||||||
|
t.Errorf("admin upload over limit: err = %v, want ErrTooManyPhotos", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := photos.CountPhotos(ctx, ticket.ID); err != nil || n != 3 {
|
||||||
|
t.Errorf("count = %d, %v; want 3", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Список: хронология и имена ---
|
||||||
|
list, total, err := photos.ListPhotos(ctx, ticket.ID, Page{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list photos: %v", err)
|
||||||
|
}
|
||||||
|
if total != 3 || len(list) != 3 {
|
||||||
|
t.Fatalf("list = %d photos, total %d; want 3/3", len(list), total)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(list); i++ {
|
||||||
|
if list[i-1].ID >= list[i].ID {
|
||||||
|
t.Errorf("photos not in id ASC order: %d then %d", list[i-1].ID, list[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if list[0].UploaderName == "" {
|
||||||
|
t.Error("uploader name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. Удаление: своё — можно, повторно — 404, чужая заявка — 404 ---
|
||||||
|
key, err := photos.DeletePhoto(ctx, ticket.ID, p1.ID, worker.ID, "worker")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete own photo: %v", err)
|
||||||
|
}
|
||||||
|
if key != m1.ObjectKey {
|
||||||
|
t.Errorf("deleted key = %q, want %q", key, m1.ObjectKey)
|
||||||
|
}
|
||||||
|
if _, err := photos.DeletePhoto(ctx, ticket.ID, p1.ID, worker.ID, "worker"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("second delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := photos.DeletePhoto(ctx, 999999999, pa.ID, admin.ID, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("cross-ticket delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// Чужое фото (мастер чужое не удаляет): осталось фото админа.
|
||||||
|
if _, err := photos.DeletePhoto(ctx, ticket.ID, pa.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
|
||||||
|
t.Errorf("delete admin photo by worker: err = %v, want ErrPhotoNotRemovable", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8. Работа выполнена (done): отчёт заморожен ---
|
||||||
|
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("done: %v", err)
|
||||||
|
}
|
||||||
|
// Ни мастер, ни админ больше не загружают: лимит тут не причина
|
||||||
|
// (maxPer 10 > 3 уже загруженных) — именно статус решает.
|
||||||
|
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 6, 10), "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||||||
|
t.Errorf("worker upload in done: err = %v, want plain ErrConflict", err)
|
||||||
|
}
|
||||||
|
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, admin.ID, 7, 10), "admin"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||||||
|
t.Errorf("admin upload in done: err = %v, want plain ErrConflict", err)
|
||||||
|
}
|
||||||
|
// Мастер в done не удаляет даже своё — админ может (роль выше статуса).
|
||||||
|
if _, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
|
||||||
|
t.Errorf("worker delete own photo in done: err = %v, want ErrPhotoNotRemovable", err)
|
||||||
|
}
|
||||||
|
if _, err := photos.DeletePhoto(ctx, ticket.ID, pa.ID, admin.ID, "admin"); err != nil {
|
||||||
|
t.Errorf("admin delete in done: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9. Заявка закрыта: мастеру нельзя, админу можно ---
|
||||||
|
if err := tickets.Confirm(ctx, ticket.ID, client.ID); err != nil {
|
||||||
|
t.Fatalf("confirm: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, worker.ID, "worker"); !errors.Is(err, ErrPhotoNotRemovable) {
|
||||||
|
t.Errorf("delete on closed ticket: err = %v, want ErrPhotoNotRemovable", err)
|
||||||
|
}
|
||||||
|
closedKey, err := photos.DeletePhoto(ctx, ticket.ID, p2.ID, admin.ID, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin delete on closed ticket: %v", err)
|
||||||
|
}
|
||||||
|
if closedKey == "" {
|
||||||
|
t.Error("admin delete returned empty key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Журнал: у фото статус не менялся — from = to у каждого
|
||||||
|
// photo_added/photo_removed, на какой бы статус ни попало событие.
|
||||||
|
events, _, err = tickets.ListEvents(ctx, ticket.ID, Page{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list events: %v", err)
|
||||||
|
}
|
||||||
|
addedN, removedN := 0, 0
|
||||||
|
for i := range events {
|
||||||
|
switch events[i].Action {
|
||||||
|
case "photo_added":
|
||||||
|
addedN++
|
||||||
|
case "photo_removed":
|
||||||
|
removedN++
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if events[i].FromStatus != events[i].ToStatus {
|
||||||
|
t.Errorf("%s status = %q→%q, want equal",
|
||||||
|
events[i].Action, events[i].FromStatus, events[i].ToStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addedN != 3 {
|
||||||
|
t.Errorf("photo_added events = %d, want 3", addedN)
|
||||||
|
}
|
||||||
|
if removedN != 3 {
|
||||||
|
t.Errorf("photo_removed events = %d, want 3", removedN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPhotoReportAfterReject — отклонённая заявка (rejected) фото не
|
||||||
|
// принимает, но повторный старт открывает отчёт снова: заморозка
|
||||||
|
// действует ровно в статусах вне in_progress.
|
||||||
|
func TestPhotoReportAfterReject(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
photos := NewPhotoRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
admin := createProfile(t, ctx, q, "admin")
|
||||||
|
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
joinProject(t, ctx, NewProjectRepo(q), projectID, worker.ID, admin.ID)
|
||||||
|
|
||||||
|
ticket := &models.Ticket{
|
||||||
|
ClientID: client.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: "electric",
|
||||||
|
Description: "течёт кран",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, ticket); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Assign(ctx, ticket.ID, worker.ID, client.ID); err != nil {
|
||||||
|
t.Fatalf("assign: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Done(ctx, ticket.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("done: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Reject(ctx, ticket.ID, client.ID, "не тот цвет"); err != nil {
|
||||||
|
t.Fatalf("reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// В rejected загрузка закрыта (maxPer 10 — причина именно статус).
|
||||||
|
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 1, 10), "worker"); !errors.Is(err, ErrConflict) || errors.Is(err, ErrTooManyPhotos) {
|
||||||
|
t.Errorf("upload in rejected: err = %v, want plain ErrConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный старт (rejected → in_progress) открывает отчёт.
|
||||||
|
if err := tickets.Start(ctx, ticket.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("restart: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := photos.AddPhoto(ctx, photoMeta(ticket.ID, worker.ID, 2, 10), "worker"); err != nil {
|
||||||
|
t.Errorf("upload after rework start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
472
internal/repository/profile.go
Normal file
472
internal/repository/profile.go
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileRepo — CRUD профилей: регистрация, вход, выборки по роли,
|
||||||
|
// включение/выключение аккаунтов, удаление «чистых» профилей.
|
||||||
|
type ProfileRepo struct {
|
||||||
|
db Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileRepo создаёт репозиторий поверх Querier (пул или транзакция).
|
||||||
|
func NewProfileRepo(db Querier) *ProfileRepo {
|
||||||
|
return &ProfileRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeEmail — email храним канонически: без обрамляющих пробелов
|
||||||
|
// и в нижнем регистре. Admin@x.ru и admin@x.ru — один адрес:
|
||||||
|
// уникальность держит индекс profiles_email_lower_key (миграция 0003),
|
||||||
|
// поэтому вставляется уже нормализованное значение.
|
||||||
|
func normalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create вставляет профиль без создателя (created_by NULL): email
|
||||||
|
// нормализуется, роль и id задаёт вызывающий, аккаунт остаётся
|
||||||
|
// неподтверждённым — до POST /users/:id/confirm войти нельзя.
|
||||||
|
// HTTP-пути за методом нет (его давно не было, кроме снятой
|
||||||
|
// саморегистрации): так заводят демо-данные seed и тесты.
|
||||||
|
// Дубликат адреса (в любом регистре) → ErrConflict.
|
||||||
|
func (r *ProfileRepo) Create(ctx context.Context, p *models.Profile) error {
|
||||||
|
return r.CreateBy(ctx, p, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBy — создание существующим пользователем (POST /users):
|
||||||
|
// created_by = актор, а правило «создатель и есть подтверждающий»
|
||||||
|
// исполняется тем же INSERT: админ при создании сразу подтверждает
|
||||||
|
// аккаунт (confirmed_at/confirmed_by), у остальных создателей поля
|
||||||
|
// остаются NULL — клиент ждёт подтверждения своего мастера.
|
||||||
|
// Пустой actorID — вставка без актора (см. Create): CTE actor пуст
|
||||||
|
// и даёт NULL-ветки.
|
||||||
|
// RETURNING created_at, confirmed_at — иначе в ответе уйдёт нулевое
|
||||||
|
// время (0001-01-01T00:00:00Z) и неверный статус подтверждения.
|
||||||
|
func (r *ProfileRepo) CreateBy(ctx context.Context, p *models.Profile, actorID string) error {
|
||||||
|
p.Email = normalizeEmail(p.Email) // правим и структуру: ответ покажет сохранённый вид
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`WITH actor AS (
|
||||||
|
SELECT id, role FROM profiles WHERE id = NULLIF($8, '')::uuid
|
||||||
|
), ins AS (
|
||||||
|
INSERT INTO profiles (id, email, password_hash, name, phone, role, is_active,
|
||||||
|
created_by, confirmed_at, confirmed_by)
|
||||||
|
SELECT $1, $2, $3, $4, $5, $6, $7,
|
||||||
|
NULLIF($8, '')::uuid,
|
||||||
|
CASE WHEN a.role = 'admin' THEN now() END,
|
||||||
|
CASE WHEN a.role = 'admin' THEN a.id END
|
||||||
|
FROM (SELECT 1) one LEFT JOIN actor a ON true
|
||||||
|
RETURNING created_at, confirmed_at
|
||||||
|
)
|
||||||
|
SELECT created_at, confirmed_at FROM ins`,
|
||||||
|
p.ID, p.Email, p.PasswordHash, p.Name, p.Phone, p.Role, p.IsActive, actorID,
|
||||||
|
).Scan(&p.CreatedAt, &p.ConfirmedAt)
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmail ищет пользователя по адресу без учёта регистра —
|
||||||
|
// через индекс по lower(email); не нашлось → ErrNotFound.
|
||||||
|
func (r *ProfileRepo) GetByEmail(ctx context.Context, email string) (*models.Profile, error) {
|
||||||
|
var p models.Profile
|
||||||
|
// lower() на обеих сторонах: поймает и ненормализованный ввод,
|
||||||
|
// и старые строки с верхним регистром, и использует индекс по lower(email).
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT id, email, password_hash, name, COALESCE(phone, ''), role, is_active, confirmed_at, created_at
|
||||||
|
FROM profiles WHERE lower(email) = lower($1)`, email,
|
||||||
|
).Scan(&p.ID, &p.Email, &p.PasswordHash, &p.Name, &p.Phone, &p.Role, &p.IsActive, &p.ConfirmedAt, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
// Не раскрываем, что именно не нашлось: наружу уйдёт общий текст.
|
||||||
|
return nil, classify(fmt.Errorf("profile by email: %w", err))
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID возвращает профиль по uuid; не нашлось → ErrNotFound.
|
||||||
|
func (r *ProfileRepo) GetByID(ctx context.Context, id string) (*models.Profile, error) {
|
||||||
|
var p models.Profile
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT id, email, password_hash, name, COALESCE(phone, ''), role, is_active, confirmed_at, created_at
|
||||||
|
FROM profiles WHERE id = $1`, id,
|
||||||
|
).Scan(&p.ID, &p.Email, &p.PasswordHash, &p.Name, &p.Phone, &p.Role, &p.IsActive, &p.ConfirmedAt, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(fmt.Errorf("profile by id: %w", err))
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanProfileRows читает строки в модели; порядок Scan обязан совпадать
|
||||||
|
// с SELECT во всех List* этого файла (project.go ListWorkers сканирует
|
||||||
|
// сам — у него дополнительные колонки членства).
|
||||||
|
// Все выборки-списки заканчиваются COUNT(*) OVER() — последним он и
|
||||||
|
// читается, поэтому функция возвращает и total (0 у пустой страницы,
|
||||||
|
// если total не досчитан отдельным запросом).
|
||||||
|
func scanProfileRows(rows pgx.Rows) ([]models.Profile, int, error) {
|
||||||
|
var profiles []models.Profile
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.Profile
|
||||||
|
if err := rows.Scan(&p.ID, &p.Email, &p.PasswordHash, &p.Name, &p.Phone,
|
||||||
|
&p.Role, &p.IsActive, &p.ConfirmedAt, &p.CreatedAt, &total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
profiles = append(profiles, p)
|
||||||
|
}
|
||||||
|
return profiles, total, classify(rows.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByRole — страница пользователей одной роли (для выпадающих списков:
|
||||||
|
// кого назначить мастером). По умолчанию только активные — так отключённые
|
||||||
|
// не мешают приглашениям; includeInactive=true показывает и выключенных
|
||||||
|
// (админские списки «Клиенты»/«Мастера»: кнопка «Включить» не должна
|
||||||
|
// быть тупиком). Алиас u — общий со всеми выборками профилей
|
||||||
|
// (см. ProfileSorts в page.go).
|
||||||
|
func (r *ProfileRepo) ListByRole(ctx context.Context, role string, includeInactive bool, page Page) ([]models.Profile, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = `u.role = $1 AND ($2 OR u.is_active = TRUE)`
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT u.id, u.email, u.password_hash, u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.confirmed_at, u.created_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM profiles u
|
||||||
|
WHERE `+where+order+`
|
||||||
|
LIMIT $3 OFFSET $4`,
|
||||||
|
role, includeInactive, page.Limit, page.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
profiles, total, err := scanProfileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM profiles u WHERE `+where,
|
||||||
|
role, includeInactive).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWorkersForClient — страница «свои мастера» клиента: активный
|
||||||
|
// участник хотя бы одного его проекта (членство status='active':
|
||||||
|
// приглашённый, но не ответивший ещё не «свой мастер») либо созданный
|
||||||
|
// им самим — только что заведённый мастер иначе не виден нигде,
|
||||||
|
// и подтверждать было бы не из чего. Чужие организации через этот
|
||||||
|
// запрос не подсмотреть.
|
||||||
|
// include_inactive — как у админа: отключённые свои мастера остаются
|
||||||
|
// в списке (иначе «Включить» после отключения оказался бы тупиком);
|
||||||
|
// поиск q у клиента в хендлере всегда идёт только по активным.
|
||||||
|
func (r *ProfileRepo) ListWorkersForClient(ctx context.Context, clientID string, includeInactive bool, page Page) ([]models.Profile, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownWorker = `u.role = 'worker'
|
||||||
|
AND ($4::bool OR u.is_active)
|
||||||
|
AND (
|
||||||
|
u.created_by = $1
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM project_workers pw
|
||||||
|
JOIN projects p ON p.id = pw.project_id
|
||||||
|
WHERE pw.worker_id = u.id
|
||||||
|
AND p.client_id = $1
|
||||||
|
AND pw.status = 'active'
|
||||||
|
)
|
||||||
|
)`
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT u.id, u.email, u.password_hash, u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.confirmed_at, u.created_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM profiles u
|
||||||
|
WHERE `+ownWorker+order+`
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
clientID, page.Limit, page.Offset, includeInactive,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
profiles, total, err := scanProfileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM profiles u WHERE `+ownWorker,
|
||||||
|
clientID, includeInactive).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// likePattern — шаблон подстрочного поиска: экранируем %, _ и \,
|
||||||
|
// чтобы введённый пользователем «100%» искал буквально, а не раздувал
|
||||||
|
// выборку. В SQL экранирование — стандартное для ILIKE (бэкслэш).
|
||||||
|
func likePattern(q string) string {
|
||||||
|
repl := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||||
|
return "%" + repl.Replace(q) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchByRole — поиск пользователей роли по email/имени (поле «найти
|
||||||
|
// мастера» при приглашении в проект): подстрока без учёта регистра.
|
||||||
|
// По умолчанию только активные; includeInactive=true — для админского
|
||||||
|
// списка, где нужно найти и отключённого (и включить обратно).
|
||||||
|
// Клиенту список без запроса не отдаётся (см. ListWorkersForClient) —
|
||||||
|
// через поиск он находит только того, чей адрес или имя ввёл сам.
|
||||||
|
func (r *ProfileRepo) SearchByRole(ctx context.Context, role, q string, includeInactive bool, page Page) ([]models.Profile, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := likePattern(q)
|
||||||
|
const where = `u.role = $1 AND ($3 OR u.is_active = TRUE)
|
||||||
|
AND (u.email ILIKE $2 OR u.name ILIKE $2)`
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT u.id, u.email, u.password_hash, u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.confirmed_at, u.created_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM profiles u
|
||||||
|
WHERE `+where+order+`
|
||||||
|
LIMIT $4 OFFSET $5`,
|
||||||
|
role, pattern, includeInactive, page.Limit, page.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
profiles, total, err := scanProfileRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM profiles u WHERE `+where,
|
||||||
|
role, pattern, includeInactive).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive — включить/выключить аккаунт (админ, любой пользователь).
|
||||||
|
// Выключенный не может войти (Login проверяет is_active) и не сможет
|
||||||
|
// получить новую сессию через refresh.
|
||||||
|
func (r *ProfileRepo) SetActive(ctx context.Context, id string, active bool) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE profiles SET is_active = $2 WHERE id = $1`, id, active)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("profile %s", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveWorkerForClient — клиент включает/выключает своего мастера:
|
||||||
|
// активный участник хотя бы одного его проекта либо созданный им самим
|
||||||
|
// («свой» ровно как в списке ListWorkersForClient — созданный, но ещё не
|
||||||
|
// приглашённый, иначе кнопка в его строке оказалась бы тупиком).
|
||||||
|
// Правило целиком в SQL: роль, членство/создание и владелец проекта
|
||||||
|
// проверяются в WHERE — хендлер ничего не решает. Чужой мастер, другой
|
||||||
|
// клиент и несуществующий id неразличимы: все три дают «строку не
|
||||||
|
// найдено» (404), не раскрывая чужие данные.
|
||||||
|
// Побочный эффект (осознанный): is_active общий на все проекты мастера —
|
||||||
|
// выключенный уходит со всех работ и теряет сессии.
|
||||||
|
func (r *ProfileRepo) SetActiveWorkerForClient(ctx context.Context, id, clientID string, active bool) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE profiles p SET is_active = $3
|
||||||
|
WHERE p.id = $1
|
||||||
|
AND p.role = 'worker'
|
||||||
|
AND (
|
||||||
|
p.created_by = $2
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM projects pr
|
||||||
|
JOIN project_workers pw ON pw.project_id = pr.id
|
||||||
|
WHERE pr.client_id = $2
|
||||||
|
AND pw.worker_id = p.id
|
||||||
|
AND pw.status = 'active')
|
||||||
|
)`,
|
||||||
|
id, clientID, active)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("profile %s", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmUser — подтвердить аккаунт (POST /users/:id/confirm).
|
||||||
|
// Правило целиком в WHERE самого UPDATE: подтверждает админ — любой
|
||||||
|
// неподтверждённый аккаунт; клиент — только созданного им мастера.
|
||||||
|
// Нулевой RowsAffected различается отдельным SELECT уже после попытки
|
||||||
|
// (чисто для корректной ошибки, не для решения): право есть — значит,
|
||||||
|
// подтверждать уже нечего → ErrAlreadyConfirmed (409), иначе чужой или
|
||||||
|
// несуществующий → ErrNotFound (404): чужие аккаунты не подсматриваются.
|
||||||
|
// Гонка «два подтверждения одновременно» безопасна: WHERE confirmed_at
|
||||||
|
// IS NULL пропускает ровно одну операцию.
|
||||||
|
func (r *ProfileRepo) ConfirmUser(ctx context.Context, targetID, actorID string) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE profiles
|
||||||
|
SET confirmed_at = now(), confirmed_by = $2
|
||||||
|
WHERE id = $1
|
||||||
|
AND confirmed_at IS NULL
|
||||||
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM profiles a
|
||||||
|
WHERE a.id = $2 AND a.role = 'admin')
|
||||||
|
OR EXISTS (SELECT 1 FROM profiles t
|
||||||
|
WHERE t.id = $1
|
||||||
|
AND t.role = 'worker'
|
||||||
|
AND t.created_by = $2)
|
||||||
|
)`,
|
||||||
|
targetID, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return r.confirmDenyReason(ctx, targetID, actorID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmDenyReason — почему UPDATE не тронул строку. Вызывается только
|
||||||
|
// при RowsAffected()==0 и ничего не решает: классификация нужна лишь
|
||||||
|
// для ответа клиенту.
|
||||||
|
func (r *ProfileRepo) confirmDenyReason(ctx context.Context, targetID, actorID string) error {
|
||||||
|
var canConfirm bool
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM profiles t
|
||||||
|
WHERE t.id = $1
|
||||||
|
AND (EXISTS (SELECT 1 FROM profiles a
|
||||||
|
WHERE a.id = $2 AND a.role = 'admin')
|
||||||
|
OR (t.role = 'worker' AND t.created_by = $2))
|
||||||
|
)`,
|
||||||
|
targetID, actorID).Scan(&canConfirm)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if canConfirm {
|
||||||
|
return ErrAlreadyConfirmed
|
||||||
|
}
|
||||||
|
return notFound("profile %s", targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProfile — удалить профиль. Решение принимает БД: refresh-сессии
|
||||||
|
// и токены сброса уходят каскадом, но заявки, проекты и участия ссылаются
|
||||||
|
// без каскада — их наличие даёт FK-отказ 23503, который здесь
|
||||||
|
// превращается в ErrUserHasData (409: велеть отключить аккаунт, а не
|
||||||
|
// стирать историю). Нет строки — ErrNotFound (404).
|
||||||
|
func (r *ProfileRepo) DeleteProfile(ctx context.Context, id string) error {
|
||||||
|
tag, err := r.db.Exec(ctx, `DELETE FROM profiles WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == pgFKViolation {
|
||||||
|
return fmt.Errorf("%w: %w", err, ErrUserHasData)
|
||||||
|
}
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("profile %s", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWorkerForClient — клиент удаляет своего мастера: состоит в одном
|
||||||
|
// из его проектов (любой статус членства) либо создан им самим — гвард
|
||||||
|
// ровно тот же, что в списке и включение/выключение («свой мастер»).
|
||||||
|
// Три шага в одной транзакции:
|
||||||
|
// 1. guard: цель — мастер и мой (участие или создание); иначе ErrNotFound —
|
||||||
|
// чужой мастер и несуществующий id неразличимы (как в
|
||||||
|
// SetActiveWorkerForClient);
|
||||||
|
// 2. членства удаляются только в моих проектах — чужие не трогаем
|
||||||
|
// (у созданного, но не приглашённого, членств нет — шаг пустой);
|
||||||
|
// 3. DELETE profiles: если на мастера ссылаются заявки либо чужие
|
||||||
|
// проекты — FK-отказ 23503 → ErrUserHasData (409 «отключите вместо
|
||||||
|
// удаления»), и вся транзакция откатывается: членства из шага 2
|
||||||
|
// остаются на месте, аккаунт — тоже.
|
||||||
|
func (r *ProfileRepo) DeleteWorkerForClient(ctx context.Context, id, clientID string) error {
|
||||||
|
b, ok := r.db.(Beginer)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("repository: querier cannot begin a transaction")
|
||||||
|
}
|
||||||
|
tx, err := b.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }() // после Commit — безопасный no-op
|
||||||
|
|
||||||
|
var mine bool
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM profiles p
|
||||||
|
WHERE p.id = $1
|
||||||
|
AND p.role = 'worker'
|
||||||
|
AND (
|
||||||
|
p.created_by = $2
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM projects pr
|
||||||
|
JOIN project_workers pw ON pw.project_id = pr.id
|
||||||
|
WHERE pr.client_id = $2 AND pw.worker_id = p.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`, id, clientID).Scan(&mine); err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if !mine {
|
||||||
|
return notFound("profile %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM project_workers
|
||||||
|
WHERE worker_id = $1
|
||||||
|
AND project_id IN (SELECT id FROM projects WHERE client_id = $2)`,
|
||||||
|
id, clientID); err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `DELETE FROM profiles WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == pgFKViolation {
|
||||||
|
return fmt.Errorf("%w: %w", err, ErrUserHasData)
|
||||||
|
}
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("profile %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
684
internal/repository/profile_test.go
Normal file
684
internal/repository/profile_test.go
Normal file
@ -0,0 +1,684 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProfileEmailNormalization — email хранится и ищется без учёта регистра
|
||||||
|
// (индекс profiles_email_lower_key из миграции 0003).
|
||||||
|
func TestProfileEmailNormalization(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
p := &models.Profile{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Email: " Mixed.Case@Example.TEST ",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Name: "mixed",
|
||||||
|
Role: "client",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repo.Create(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if p.Email != "mixed.case@example.test" {
|
||||||
|
t.Errorf("stored email = %q, want normalized %q", p.Email, "mixed.case@example.test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск с другим регистром находит того же пользователя.
|
||||||
|
got, err := repo.GetByEmail(ctx, "MIXED.CASE@EXAMPLE.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by email (other case): %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != p.ID {
|
||||||
|
t.Errorf("got profile %s, want %s", got.ID, p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дубликат в другом регистре — тот же адрес, 409.
|
||||||
|
dup := &models.Profile{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Email: "mixed.case@example.test",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Name: "dup",
|
||||||
|
Role: "client",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
expectError(t, ctx, q, "duplicate email (other case)", func() error {
|
||||||
|
return repo.Create(ctx, dup)
|
||||||
|
}, ErrConflict)
|
||||||
|
|
||||||
|
// Неизвестный адрес — не 409, а ровно «не найдено».
|
||||||
|
if _, err := repo.GetByEmail(ctx, "nobody@example.test"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("unknown email: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSearchByRole — поиск мастера для приглашения в проект: подстрока
|
||||||
|
// без учёта регистра, спецсимволы ILIKE ищутся буквально, выключенные
|
||||||
|
// аккаунты и другие роли в результат не попадают.
|
||||||
|
func TestSearchByRole(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
pro := func(name, email, role string, active bool) {
|
||||||
|
t.Helper()
|
||||||
|
p := &models.Profile{
|
||||||
|
ID: uuid.NewString(), Email: email, PasswordHash: "test-hash",
|
||||||
|
Name: name, Role: role, IsActive: active,
|
||||||
|
}
|
||||||
|
if err := repo.Create(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pro("Петр Сидоров", "sidorov@site.test", "worker", true)
|
||||||
|
pro("Petr Upper", "petr-upper@site.test", "worker", true)
|
||||||
|
pro("Петр Выкл", "petr-off@site.test", "worker", false) // выключен — мимо
|
||||||
|
pro("Петр Клиент", "petr-client@site.test", "client", true) // чужая роль — мимо
|
||||||
|
pro("Мастер 100%", "percent@site.test", "worker", true)
|
||||||
|
|
||||||
|
t.Run("кириллица без учёта регистра", func(t *testing.T) {
|
||||||
|
got, total, err := repo.SearchByRole(ctx, "worker", "петр", false, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(got) != 1 || got[0].Email != "sidorov@site.test" {
|
||||||
|
t.Errorf("got %d/%d %+v, want ровно sidorov (выключенный и клиент скрыты)",
|
||||||
|
len(got), total, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("латиница в другом регистре", func(t *testing.T) {
|
||||||
|
got, total, err := repo.SearchByRole(ctx, "worker", "PETR-UPPER", false, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(got) != 1 || got[0].Name != "Petr Upper" {
|
||||||
|
t.Errorf("got %d/%d %+v, want Petr Upper", len(got), total, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("процент в запросе ищется буквально", func(t *testing.T) {
|
||||||
|
got, total, err := repo.SearchByRole(ctx, "worker", "100%", false, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
// Без экранирования шаблон %100%% совпал бы со всеми подряд.
|
||||||
|
if total != 1 || len(got) != 1 || got[0].Name != "Мастер 100%" {
|
||||||
|
t.Errorf("got %d/%d %+v, want «Мастер 100%%»", len(got), total, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("пустое окно считает total", func(t *testing.T) {
|
||||||
|
got, total, err := repo.SearchByRole(ctx, "worker", "петр", false, Page{Limit: MaxLimit, Offset: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 || total != 1 {
|
||||||
|
t.Errorf("got %d строк total=%d, want 0 строк total=1", len(got), total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ничего не найдено", func(t *testing.T) {
|
||||||
|
got, total, err := repo.SearchByRole(ctx, "worker", "нет-такого-мастера", false, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 || total != 0 {
|
||||||
|
t.Errorf("got %d строк total=%d, want пусто", len(got), total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileSetActive(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
p := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
if err := repo.SetActive(ctx, p.ID, false); err != nil {
|
||||||
|
t.Fatalf("deactivate: %v", err)
|
||||||
|
}
|
||||||
|
got, err := repo.GetByID(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.IsActive {
|
||||||
|
t.Error("is_active = true after deactivation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetActive(ctx, p.ID, true); err != nil {
|
||||||
|
t.Fatalf("activate: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := repo.GetByID(ctx, p.ID); !got.IsActive {
|
||||||
|
t.Error("is_active = false after activation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetActive(ctx, uuid.NewString(), false); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("unknown id: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Удаление профиля и клиентское отключение ---
|
||||||
|
|
||||||
|
// TestDeleteProfileRules — «чистый» профиль удаляется, причём его
|
||||||
|
// refresh-сессии уходят каскадом (миграция 0003). Профиль, на который
|
||||||
|
// ссылаются проекты или участия (FK без каскада), БД не отдаёт:
|
||||||
|
// ErrUserHasData → 409 «отключите вместо удаления». Несуществующий id —
|
||||||
|
// ErrNotFound (404).
|
||||||
|
func TestDeleteProfileRules(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
profiles := NewProfileRepo(q)
|
||||||
|
projects := NewProjectRepo(q)
|
||||||
|
tokens := NewRefreshTokenRepo(q)
|
||||||
|
|
||||||
|
// Чистый: сам профиль + одна живая сессия — уходят обе строки.
|
||||||
|
clean := createProfile(t, ctx, q, "worker")
|
||||||
|
hash := "hash-" + uuid.NewString()
|
||||||
|
if err := tokens.Create(ctx, uuid.NewString(), clean.ID, hash,
|
||||||
|
time.Now().Add(time.Hour), "ua", "127.0.0.1"); err != nil {
|
||||||
|
t.Fatalf("create refresh token: %v", err)
|
||||||
|
}
|
||||||
|
if err := profiles.DeleteProfile(ctx, clean.ID); err != nil {
|
||||||
|
t.Fatalf("clean delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := profiles.GetByID(ctx, clean.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("после удаления: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := tokens.GetByHash(ctx, hash); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("сессия удалённого: err = %v, want ErrNotFound (каскад)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := profiles.DeleteProfile(ctx, uuid.NewString()); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("несуществующий id: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клиент с проектом: FK без каскада не даёт стереть владельца.
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Отказ", IsActive: true}
|
||||||
|
if err := projects.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
expectError(t, ctx, q, "delete client with project",
|
||||||
|
func() error { return profiles.DeleteProfile(ctx, owner.ID) }, ErrUserHasData)
|
||||||
|
|
||||||
|
// Мастер с активным членством — тоже история, а не «чистый» аккаунт.
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
if _, err := projects.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("invite worker: %v", err)
|
||||||
|
}
|
||||||
|
if err := projects.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
expectError(t, ctx, q, "delete worker with membership",
|
||||||
|
func() error { return profiles.DeleteProfile(ctx, worker.ID) }, ErrUserHasData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetActiveWorkerForClientRules — правило клиента целиком в SQL:
|
||||||
|
// включить/выключить можно только мастера, активного участника своих
|
||||||
|
// проектов. До принятия приглашения мастер ещё «не свой»; чужой мастер,
|
||||||
|
// другой клиент и несуществующий id неразличимы — все ErrNotFound.
|
||||||
|
func TestSetActiveWorkerForClientRules(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
profiles := NewProfileRepo(q)
|
||||||
|
projects := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
stranger := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
bystander := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Правило", IsActive: true}
|
||||||
|
if err := projects.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := projects.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("invite worker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приглашение не принято — активного членства нет, трогать пока нечего.
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, false); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("pending invitation: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := projects.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свой мастер: выключается, повторный флаг возвращает — включается.
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, false); err != nil {
|
||||||
|
t.Fatalf("deactivate own worker: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := profiles.GetByID(ctx, worker.ID); err != nil || got.IsActive {
|
||||||
|
t.Errorf("после выключения: is_active=%v err=%v, want false/nil", got.IsActive, err)
|
||||||
|
}
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, worker.ID, owner.ID, true); err != nil {
|
||||||
|
t.Fatalf("reactivate own worker: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := profiles.GetByID(ctx, worker.ID); err != nil || !got.IsActive {
|
||||||
|
t.Errorf("после включения: is_active=%v err=%v, want true/nil", got.IsActive, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Созданный мной, но ещё не приглашённый: гвард «свой» ровно тот же,
|
||||||
|
// что в списке ListWorkersForClient — кнопка в его строке не тупиковая.
|
||||||
|
created := &models.Profile{
|
||||||
|
ID: uuid.NewString(), Email: uuid.NewString() + "@example.test",
|
||||||
|
PasswordHash: "hash", Name: "created-worker", Role: "worker", IsActive: true,
|
||||||
|
}
|
||||||
|
if err := profiles.CreateBy(ctx, created, owner.ID); err != nil {
|
||||||
|
t.Fatalf("create by owner: %v", err)
|
||||||
|
}
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, created.ID, owner.ID, false); err != nil {
|
||||||
|
t.Fatalf("deactivate created (not member) worker: %v", err)
|
||||||
|
}
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, created.ID, stranger.ID, false); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("чужой клиент жмёт созданного: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, created.ID, owner.ID, true); err != nil {
|
||||||
|
t.Fatalf("reactivate created worker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Границы: чужой мастер, другой клиент, чужая роль, несуществующий id.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
label, target, client string
|
||||||
|
}{
|
||||||
|
{"мастер вне проектов", bystander.ID, owner.ID},
|
||||||
|
{"чужой клиент жмёт нашего мастера", worker.ID, stranger.ID},
|
||||||
|
{"клиент жмёт профиль клиента", stranger.ID, owner.ID},
|
||||||
|
{"несуществующий id", uuid.NewString(), owner.ID},
|
||||||
|
} {
|
||||||
|
if err := profiles.SetActiveWorkerForClient(ctx, tc.target, tc.client, false); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("%s: err = %v, want ErrNotFound", tc.label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteWorkerForClientRules — клиентское удаление мастера целиком
|
||||||
|
// в SQL-транзакции: граница «состоит в моём проекте» (чужой мастер и
|
||||||
|
// несуществующий id неразличимы), членства удаляются только в моих
|
||||||
|
// проектах, а FK-отказ от чужих проектов откатывает всё: профиль и все
|
||||||
|
// членства остаются на месте → ErrUserHasData (409 «отключите вместо
|
||||||
|
// удаления»).
|
||||||
|
func TestDeleteWorkerForClientRules(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
profiles := NewProfileRepo(q)
|
||||||
|
projects := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
stranger := createProfile(t, ctx, q, "client")
|
||||||
|
bystander := createProfile(t, ctx, q, "worker")
|
||||||
|
wInvite := createProfile(t, ctx, q, "worker")
|
||||||
|
wBusy := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
memberships := func(workerID string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := q.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM project_workers WHERE worker_id = $1`,
|
||||||
|
workerID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count memberships: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Удаление", IsActive: true}
|
||||||
|
if err := projects.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Границы до членств: чужой и несуществующий неразличимы.
|
||||||
|
for _, tc := range []struct{ label, target, client string }{
|
||||||
|
{"несуществующий id", uuid.NewString(), owner.ID},
|
||||||
|
{"мастер вне проектов", bystander.ID, owner.ID},
|
||||||
|
{"клиент удаляет профиль клиента", stranger.ID, owner.ID},
|
||||||
|
} {
|
||||||
|
if err := profiles.DeleteWorkerForClient(ctx, tc.target, tc.client); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("%s: err = %v, want ErrNotFound", tc.label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приглашение не принято — но членство уже есть, значит «свой»:
|
||||||
|
// аккаунт и членство уходят одной транзакцией.
|
||||||
|
if _, err := projects.InviteWorker(ctx, p.ID, wInvite.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("invite worker: %v", err)
|
||||||
|
}
|
||||||
|
if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("чужой клиент: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("delete own invited worker: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := profiles.GetByID(ctx, wInvite.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("после удаления: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if n := memberships(wInvite.ID); n != 0 {
|
||||||
|
t.Errorf("членство удалённого: %d строк, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Созданный мной, но ещё не приглашённый: «свой» — удаляется
|
||||||
|
// (членств нет, шаг 2 пустой), чужой клиент — 404.
|
||||||
|
created := &models.Profile{
|
||||||
|
ID: uuid.NewString(), Email: uuid.NewString() + "@example.test",
|
||||||
|
PasswordHash: "hash", Name: "created-worker", Role: "worker", IsActive: true,
|
||||||
|
}
|
||||||
|
if err := profiles.CreateBy(ctx, created, owner.ID); err != nil {
|
||||||
|
t.Fatalf("create by owner: %v", err)
|
||||||
|
}
|
||||||
|
if err := profiles.DeleteWorkerForClient(ctx, created.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("чужой клиент удаляет созданного: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := profiles.DeleteWorkerForClient(ctx, created.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("delete created (not member) worker: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := profiles.GetByID(ctx, created.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("после удаления созданного: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мастер состоит и в моём, и в чужом проекте: шаг 2 убирает моё
|
||||||
|
// членство, но FK от чужого даёт 23503 → откат всей транзакции —
|
||||||
|
// профиль и оба членства остаются.
|
||||||
|
if _, err := projects.InviteWorker(ctx, p.ID, wBusy.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("invite worker (mine): %v", err)
|
||||||
|
}
|
||||||
|
if err := projects.AcceptInvitation(ctx, p.ID, wBusy.ID); err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
sp := &models.Project{ClientID: stranger.ID, Name: "Чужой ЖК", IsActive: true}
|
||||||
|
if err := projects.CreateProject(ctx, sp); err != nil {
|
||||||
|
t.Fatalf("create stranger project: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := projects.InviteWorker(ctx, sp.ID, wBusy.ID, stranger.ID); err != nil {
|
||||||
|
t.Fatalf("invite worker (foreign): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectError(t, ctx, q, "delete worker with foreign membership",
|
||||||
|
func() error { return profiles.DeleteWorkerForClient(ctx, wBusy.ID, owner.ID) }, ErrUserHasData)
|
||||||
|
|
||||||
|
if _, err := profiles.GetByID(ctx, wBusy.ID); err != nil {
|
||||||
|
t.Errorf("профиль после отката: err = %v, want существующий", err)
|
||||||
|
}
|
||||||
|
if n := memberships(wBusy.ID); n != 2 {
|
||||||
|
t.Errorf("членств после отката: %d, want 2 (моё и чужое)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уже удалённый — тот же 404, что и чужой.
|
||||||
|
if err := profiles.DeleteWorkerForClient(ctx, wInvite.ID, owner.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("повторное удаление: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListByRoleIncludeInactive — include_inactive показывает и
|
||||||
|
// выключенных (нужно админу: кнопка «Включить» после отключения не
|
||||||
|
// должна быть тупиком); по умолчанию — только активные.
|
||||||
|
func TestListByRoleIncludeInactive(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
active := createProfile(t, ctx, q, "worker")
|
||||||
|
off := createProfile(t, ctx, q, "worker")
|
||||||
|
if err := repo.SetActive(ctx, off.ID, false); err != nil {
|
||||||
|
t.Fatalf("deactivate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// В базе есть и другие мастера (посев), поэтому проверяем конкретные
|
||||||
|
// id на страницах, а не абсолютные счётчики.
|
||||||
|
got, _, err := repo.ListByRole(ctx, "worker", false, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list active only: %v", err)
|
||||||
|
}
|
||||||
|
if containsID(got, off.ID) {
|
||||||
|
t.Error("без флага выключенный мастер не должен попадать в список")
|
||||||
|
}
|
||||||
|
if !containsID(got, active.ID) {
|
||||||
|
t.Error("активный мастер пропал из списка без флага")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _, err = repo.ListByRole(ctx, "worker", true, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list with inactive: %v", err)
|
||||||
|
}
|
||||||
|
if !containsID(got, active.ID) || !containsID(got, off.ID) {
|
||||||
|
t.Error("с флагом должны быть оба: активный и выключенный")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsID — есть ли профиль с данным id на странице.
|
||||||
|
func containsID(profiles []models.Profile, id string) bool {
|
||||||
|
for _, p := range profiles {
|
||||||
|
if p.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshTokens — жизнь сессии: выпуск, поиск по хешу, отзыв одной,
|
||||||
|
// отзыв всех и чистка протухших.
|
||||||
|
func TestRefreshTokens(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRefreshTokenRepo(q)
|
||||||
|
profile := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
hash := uuid.NewString // «хеш» — достаточно уникальной строки
|
||||||
|
|
||||||
|
t.Run("выпуск и поиск по хешу", func(t *testing.T) {
|
||||||
|
h := hash()
|
||||||
|
if err := repo.Create(ctx, uuid.NewString(), profile.ID, h,
|
||||||
|
time.Now().Add(time.Hour), "test-agent", "127.0.0.1"); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.GetByHash(ctx, h)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by hash: %v", err)
|
||||||
|
}
|
||||||
|
if got.UserID != profile.ID {
|
||||||
|
t.Errorf("user_id = %s, want %s", got.UserID, profile.ID)
|
||||||
|
}
|
||||||
|
if got.RevokedAt != nil {
|
||||||
|
t.Error("fresh token is revoked")
|
||||||
|
}
|
||||||
|
if got.UserAgent != "test-agent" || got.IP != "127.0.0.1" {
|
||||||
|
t.Errorf("agent=%q ip=%q", got.UserAgent, got.IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.GetByHash(ctx, hash()); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("unknown hash: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("отзыв идемпотентен", func(t *testing.T) {
|
||||||
|
p := createProfile(t, ctx, q, "client")
|
||||||
|
h := hash()
|
||||||
|
if err := repo.Create(ctx, uuid.NewString(), p.ID, h,
|
||||||
|
time.Now().Add(time.Hour), "", ""); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := repo.RevokeByHash(ctx, h)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("first revoke: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
ok, err = repo.RevokeByHash(ctx, h)
|
||||||
|
if err != nil || ok {
|
||||||
|
t.Errorf("second revoke: ok=%v err=%v, want false,nil", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.GetByHash(ctx, h)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.RevokedAt == nil {
|
||||||
|
t.Error("revoked_at not set")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("revoked_at не трогает активные", func(t *testing.T) {
|
||||||
|
// Своя пара «клиент — сессии», чтобы прошлые подтесты не влияли
|
||||||
|
// на счётчик: RevokeAllForUser считает только активные записи.
|
||||||
|
p := createProfile(t, ctx, q, "client")
|
||||||
|
live := hash()
|
||||||
|
if err := repo.Create(ctx, uuid.NewString(), p.ID, live,
|
||||||
|
time.Now().Add(time.Hour), "", ""); err != nil {
|
||||||
|
t.Fatalf("create live: %v", err)
|
||||||
|
}
|
||||||
|
dead := hash()
|
||||||
|
if err := repo.Create(ctx, uuid.NewString(), p.ID, dead,
|
||||||
|
time.Now().Add(time.Hour), "", ""); err != nil {
|
||||||
|
t.Fatalf("create dead: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.RevokeByHash(ctx, dead); err != nil {
|
||||||
|
t.Fatalf("revoke: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := repo.RevokeAllForUser(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("revoke all: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("revoked %d session(s), want 1 (уже отозванная не считается)", n)
|
||||||
|
}
|
||||||
|
if got, _ := repo.GetByHash(ctx, live); got.RevokedAt == nil {
|
||||||
|
t.Error("live token not revoked by logout-all")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("чистка протухших", func(t *testing.T) {
|
||||||
|
expired := hash()
|
||||||
|
if err := repo.Create(ctx, uuid.NewString(), profile.ID, expired,
|
||||||
|
time.Now().Add(-time.Hour), "", ""); err != nil {
|
||||||
|
t.Fatalf("create expired: %v", err)
|
||||||
|
}
|
||||||
|
fresh := hash()
|
||||||
|
if err := repo.Create(ctx, uuid.NewString(), profile.ID, fresh,
|
||||||
|
time.Now().Add(time.Hour), "", ""); err != nil {
|
||||||
|
t.Fatalf("create fresh: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
removed, err := repo.DeleteExpired(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete expired: %v", err)
|
||||||
|
}
|
||||||
|
if removed < 1 {
|
||||||
|
t.Errorf("removed %d, want at least the expired one", removed)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetByHash(ctx, expired); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("expired token survived: err = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetByHash(ctx, fresh); err != nil {
|
||||||
|
t.Errorf("fresh token removed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUserConfirmationRules — матрица подтверждений (миграция 0010):
|
||||||
|
// - вставка без актора (Create) — pending;
|
||||||
|
// - создание админом (CreateBy) — подтверждение сразу, тем же INSERT;
|
||||||
|
// - создание клиентом — pending, ждёт подтверждения этим клиентом;
|
||||||
|
// - подтверждает админ (любого) или клиент (только созданного им мастера);
|
||||||
|
// - повтор — ErrAlreadyConfirmed, чужой/неподходящий/неизвестный — 404.
|
||||||
|
func TestUserConfirmationRules(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProfileRepo(q)
|
||||||
|
|
||||||
|
mkProfile := func(role string) *models.Profile {
|
||||||
|
return &models.Profile{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Email: uuid.NewString() + "@example.test",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Name: role + "-pending",
|
||||||
|
Role: role,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := createProfile(t, ctx, q, "admin")
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
otherClient := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
// Вставка без актора — подтверждения нет.
|
||||||
|
noActor := mkProfile("client")
|
||||||
|
if err := repo.Create(ctx, noActor); err != nil {
|
||||||
|
t.Fatalf("create without actor: %v", err)
|
||||||
|
}
|
||||||
|
if noActor.ConfirmedAt != nil {
|
||||||
|
t.Error("create without actor must be pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание админом = подтверждение сразу («создатель и есть подтверждающий»).
|
||||||
|
byAdmin := mkProfile("worker")
|
||||||
|
if err := repo.CreateBy(ctx, byAdmin, admin.ID); err != nil {
|
||||||
|
t.Fatalf("create by admin: %v", err)
|
||||||
|
}
|
||||||
|
if byAdmin.ConfirmedAt == nil {
|
||||||
|
t.Error("admin-created worker must be confirmed at creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание клиентом — pending: клиент подтвердит сам.
|
||||||
|
byClient := mkProfile("worker")
|
||||||
|
if err := repo.CreateBy(ctx, byClient, client.ID); err != nil {
|
||||||
|
t.Fatalf("create by client: %v", err)
|
||||||
|
}
|
||||||
|
if byClient.ConfirmedAt != nil {
|
||||||
|
t.Error("client-created worker must be pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Админ подтверждает профиль без создателя, confirmed_by = админ.
|
||||||
|
if err := repo.ConfirmUser(ctx, noActor.ID, admin.ID); err != nil {
|
||||||
|
t.Fatalf("admin confirms: %v", err)
|
||||||
|
}
|
||||||
|
got, err := repo.GetByID(ctx, noActor.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reread: %v", err)
|
||||||
|
}
|
||||||
|
if got.ConfirmedAt == nil {
|
||||||
|
t.Error("must be confirmed after ConfirmUser")
|
||||||
|
}
|
||||||
|
var confirmedBy string
|
||||||
|
if err := q.QueryRow(ctx,
|
||||||
|
`SELECT confirmed_by FROM profiles WHERE id = $1`, noActor.ID,
|
||||||
|
).Scan(&confirmedBy); err != nil {
|
||||||
|
t.Fatalf("read confirmed_by: %v", err)
|
||||||
|
}
|
||||||
|
if confirmedBy != admin.ID {
|
||||||
|
t.Errorf("confirmed_by = %s, want %s", confirmedBy, admin.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторное подтверждение — 409.
|
||||||
|
expectError(t, ctx, q, "confirm twice",
|
||||||
|
func() error { return repo.ConfirmUser(ctx, noActor.ID, admin.ID) },
|
||||||
|
ErrAlreadyConfirmed)
|
||||||
|
|
||||||
|
// Клиент подтверждает созданного им мастера.
|
||||||
|
if err := repo.ConfirmUser(ctx, byClient.ID, client.ID); err != nil {
|
||||||
|
t.Fatalf("client confirms own worker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чужого (не созданного этим клиентом) мастера клиент не подтверждает.
|
||||||
|
foreign := mkProfile("worker")
|
||||||
|
if err := repo.Create(ctx, foreign); err != nil {
|
||||||
|
t.Fatalf("foreign worker: %v", err)
|
||||||
|
}
|
||||||
|
expectError(t, ctx, q, "client confirms foreign worker",
|
||||||
|
func() error { return repo.ConfirmUser(ctx, foreign.ID, client.ID) },
|
||||||
|
ErrNotFound)
|
||||||
|
|
||||||
|
// Другого клиента клиент не подтверждает (роль не worker → 404).
|
||||||
|
expectError(t, ctx, q, "client confirms another client",
|
||||||
|
func() error { return repo.ConfirmUser(ctx, otherClient.ID, client.ID) },
|
||||||
|
ErrNotFound)
|
||||||
|
|
||||||
|
// Мастер не подтверждает никого — 404.
|
||||||
|
expectError(t, ctx, q, "worker confirms someone",
|
||||||
|
func() error { return repo.ConfirmUser(ctx, foreign.ID, byClient.ID) },
|
||||||
|
ErrNotFound)
|
||||||
|
|
||||||
|
// Несуществующий id — 404, а не ошибка типа или 409.
|
||||||
|
expectError(t, ctx, q, "confirm unknown id",
|
||||||
|
func() error { return repo.ConfirmUser(ctx, uuid.NewString(), admin.ID) },
|
||||||
|
ErrNotFound)
|
||||||
|
}
|
||||||
586
internal/repository/project.go
Normal file
586
internal/repository/project.go
Normal file
@ -0,0 +1,586 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectRepo — проекты клиентов, объекты внутри проектов и мастера,
|
||||||
|
// добавленные в проект. Схема — миграция 0004.
|
||||||
|
//
|
||||||
|
// Проверка владельца встроена в запросы: OwnedProject/OwnedObject принимают
|
||||||
|
// clientID, и чужой проект неотличим от несуществующего (404, не 403).
|
||||||
|
type ProjectRepo struct {
|
||||||
|
db Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProjectRepo создаёт репозиторий поверх Querier (пул или транзакция).
|
||||||
|
func NewProjectRepo(db Querier) *ProjectRepo {
|
||||||
|
return &ProjectRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectColumns и projectFrom — одна проекция для всех выборок проекта.
|
||||||
|
const projectColumns = `
|
||||||
|
p.id, p.client_id, p.name, p.is_active, p.created_at,
|
||||||
|
c.name AS client_name`
|
||||||
|
const projectFrom = `
|
||||||
|
FROM projects p
|
||||||
|
JOIN profiles c ON c.id = p.client_id`
|
||||||
|
|
||||||
|
// scanProject читает строку в модель; порядок Scan = projectColumns.
|
||||||
|
// extra — доп. назначения в конце SELECT (COUNT(*) OVER() у списка).
|
||||||
|
func scanProject(s scanner, extra ...any) (models.Project, error) {
|
||||||
|
var p models.Project
|
||||||
|
dest := []any{&p.ID, &p.ClientID, &p.Name, &p.IsActive, &p.CreatedAt, &p.ClientName}
|
||||||
|
dest = append(dest, extra...)
|
||||||
|
err := s.Scan(dest...)
|
||||||
|
return p, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProject — новый проект. Одинаковое имя у одного клиента → ErrConflict.
|
||||||
|
func (r *ProjectRepo) CreateProject(ctx context.Context, p *models.Project) error {
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`INSERT INTO projects (client_id, name, is_active)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
p.ClientID, p.Name, p.IsActive,
|
||||||
|
).Scan(&p.ID, &p.CreatedAt)
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnedProject — проект по id; clientID != "" ограничивает выборку
|
||||||
|
// проектами этого клиента.
|
||||||
|
func (r *ProjectRepo) OwnedProject(ctx context.Context, id int64, clientID string) (*models.Project, error) {
|
||||||
|
query := `SELECT ` + projectColumns + projectFrom + ` WHERE p.id = $1`
|
||||||
|
args := []any{id}
|
||||||
|
if clientID != "" {
|
||||||
|
query += ` AND p.client_id = $2`
|
||||||
|
args = append(args, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := scanProject(r.db.QueryRow(ctx, query, args...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("project %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjects — страница проектов: все (clientID пуст, для админа)
|
||||||
|
// или только свои. Возвращает страницу и total (сколько подходило
|
||||||
|
// без обрезки) — по нему фронт считает число страниц.
|
||||||
|
func (r *ProjectRepo) ListProjects(ctx context.Context, clientID string, page Page) ([]models.Project, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, ProjectSorts, "created_at", "p.id DESC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
where := `1=1`
|
||||||
|
var whereArgs []any
|
||||||
|
if clientID != "" {
|
||||||
|
where += ` AND p.client_id = $1`
|
||||||
|
whereArgs = append(whereArgs, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `SELECT ` + projectColumns + `, COUNT(*) OVER() AS total` + projectFrom + `
|
||||||
|
WHERE ` + where + order +
|
||||||
|
` LIMIT $` + itoa(len(whereArgs)+1) + ` OFFSET $` + itoa(len(whereArgs)+2)
|
||||||
|
args := append(append([]any{}, whereArgs...), page.Limit, page.Offset)
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var projects []models.Project
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanProject(rows, &total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(projects) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*)`+projectFrom+` WHERE `+where, whereArgs...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projects, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProject — переименование и/или переключение is_active.
|
||||||
|
// nil-аргумент означает «поле не трогаем» (COALESCE в самом UPDATE).
|
||||||
|
func (r *ProjectRepo) UpdateProject(ctx context.Context, id int64, name *string, active *bool) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE projects
|
||||||
|
SET name = COALESCE($2, name),
|
||||||
|
is_active = COALESCE($3, is_active)
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, name, active)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("project %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProject — удалить проект целиком (только админ, хендлер).
|
||||||
|
// Три шага в одной транзакции (Beginer, как в DeleteWorkerForClient):
|
||||||
|
// 1. снять object_key фото заявок проекта — после каскадов строк не будет;
|
||||||
|
// 2. удалить заявки проекта: события журнала и фото уходят каскадом
|
||||||
|
// (0006/0008), явный DELETE делает порядок читаемым, а каскады 0013
|
||||||
|
// остаются страховкой на уровне БД;
|
||||||
|
// 3. удалить проект: объекты и участия — каскад 0004.
|
||||||
|
//
|
||||||
|
// Проекта нет (повторное удаление) → ErrNotFound. Возвращает ключи
|
||||||
|
// объектов фото — их вызывающая сторона удаляет из хранилища после
|
||||||
|
// коммита, сбой — в лог (порядок «сначала строка, потом объект»,
|
||||||
|
// как в TicketRepo.DeleteExpired).
|
||||||
|
func (r *ProjectRepo) DeleteProject(ctx context.Context, id int64) ([]string, error) {
|
||||||
|
b, ok := r.db.(Beginer)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("repository: querier cannot begin a transaction")
|
||||||
|
}
|
||||||
|
tx, err := b.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }() // после Commit — безопасный no-op
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT ph.object_key
|
||||||
|
FROM ticket_photos ph
|
||||||
|
JOIN tickets t ON t.id = ph.ticket_id
|
||||||
|
WHERE t.project_id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
if err := rows.Scan(&key); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM tickets WHERE project_id = $1`, id); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `DELETE FROM projects WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, notFound("project %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ объекты ============
|
||||||
|
|
||||||
|
const objectColumns = `
|
||||||
|
o.id, o.project_id, o.name, o.address, o.is_active, o.created_at,
|
||||||
|
p.name AS project_name, p.is_active AS project_active`
|
||||||
|
const objectFrom = `
|
||||||
|
FROM project_objects o
|
||||||
|
JOIN projects p ON p.id = o.project_id`
|
||||||
|
|
||||||
|
func scanObject(s scanner, extra ...any) (models.Object, error) {
|
||||||
|
var o models.Object
|
||||||
|
dest := []any{&o.ID, &o.ProjectID, &o.Name, &o.Address, &o.IsActive, &o.CreatedAt,
|
||||||
|
&o.ProjectName, &o.ProjectActive}
|
||||||
|
dest = append(dest, extra...)
|
||||||
|
err := s.Scan(dest...)
|
||||||
|
return o, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateObject — новый объект в проекте. Дубликат имени в проекте → ErrConflict.
|
||||||
|
// Вызывающий обязан уже проверить, что проект принадлежит ему (OwnedProject).
|
||||||
|
func (r *ProjectRepo) CreateObject(ctx context.Context, o *models.Object) error {
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`INSERT INTO project_objects (project_id, name, address, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
o.ProjectID, o.Name, o.Address, o.IsActive,
|
||||||
|
).Scan(&o.ID, &o.CreatedAt)
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnedObject — объект по id вместе с его проектом; clientID != "" ограничивает
|
||||||
|
// объектами проектов этого клиента. Ключевой метод для создания заявки:
|
||||||
|
// он же даёт адрес и принадлежность одним запросом.
|
||||||
|
func (r *ProjectRepo) OwnedObject(ctx context.Context, id int64, clientID string) (*models.Object, error) {
|
||||||
|
query := `SELECT ` + objectColumns + objectFrom + ` WHERE o.id = $1`
|
||||||
|
args := []any{id}
|
||||||
|
if clientID != "" {
|
||||||
|
query += ` AND p.client_id = $2`
|
||||||
|
args = append(args, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
o, err := scanObject(r.db.QueryRow(ctx, query, args...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("object %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListObjects — страница объектов проекта (по умолчанию по имени —
|
||||||
|
// так и раньше выглядел список, только теперь он ещё и пагинируется).
|
||||||
|
func (r *ProjectRepo) ListObjects(ctx context.Context, projectID int64, page Page) ([]models.Object, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, ObjectSorts, "name", "o.id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT `+objectColumns+`, COUNT(*) OVER() AS total`+objectFrom+`
|
||||||
|
WHERE o.project_id = $1`+order+`
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
projectID, page.Limit, page.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var objects []models.Object
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
o, err := scanObject(rows, &total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
objects = append(objects, o)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(objects) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*)`+objectFrom+` WHERE o.project_id = $1`, projectID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return objects, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateObject — правка объекта; nil-аргумент = «поле не трогаем».
|
||||||
|
// Адрес правим только у объекта: у уже созданных заявок остаётся адрес
|
||||||
|
// на момент создания — это осознанно.
|
||||||
|
func (r *ProjectRepo) UpdateObject(ctx context.Context, id int64, name, address *string, active *bool) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE project_objects
|
||||||
|
SET name = COALESCE($2, name),
|
||||||
|
address = COALESCE($3, address),
|
||||||
|
is_active = COALESCE($4, is_active)
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, name, address, active)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("object %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ мастера проекта (приглашения, миграция 0005) ============
|
||||||
|
|
||||||
|
// InvitationTTL — сколько живёт приглашение. Единственный источник:
|
||||||
|
// и в SQL (expires_at), и в текстах Swagger/README. По истечении
|
||||||
|
// мастер принять его уже не может (409 invitation expired), а клиент
|
||||||
|
// может отозвать строку обычным DELETE.
|
||||||
|
const InvitationTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// ListWorkers — страница состава проекта: профили мастеров плюс состояние
|
||||||
|
// членства (invited ещё не работает по заявкам). Сортировка — ProfileSorts,
|
||||||
|
// алиасы u/pw как в запросе.
|
||||||
|
func (r *ProjectRepo) ListWorkers(ctx context.Context, projectID int64, page Page) ([]models.ProjectWorker, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, ProfileSorts, "name", "u.id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT u.id, u.email, '', u.name, COALESCE(u.phone, ''), u.role, u.is_active, u.created_at,
|
||||||
|
pw.status, pw.responded_at, pw.expires_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM project_workers pw
|
||||||
|
JOIN profiles u ON u.id = pw.worker_id
|
||||||
|
WHERE pw.project_id = $1`+order+`
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
projectID, page.Limit, page.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var workers []models.ProjectWorker
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var w models.ProjectWorker
|
||||||
|
if err := rows.Scan(&w.ID, &w.Email, &w.PasswordHash, &w.Name, &w.Phone,
|
||||||
|
&w.Role, &w.IsActive, &w.CreatedAt,
|
||||||
|
&w.Status, &w.RespondedAt, &w.ExpiresAt, &total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
workers = append(workers, w)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(workers) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM project_workers pw WHERE pw.project_id = $1`,
|
||||||
|
projectID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return workers, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteWorker — пригласить мастера в проект: единственный путь членства.
|
||||||
|
// Возвращает срок ответа (expires_at), чтобы клиент знал дедлайн.
|
||||||
|
//
|
||||||
|
// Дубликаты не теряются: активное членство и уже висящее приглашение —
|
||||||
|
// это ErrConflict с уточнением (ErrMembershipActive / ErrInvitationPending),
|
||||||
|
// а отклонённое приглашение перезапускается (строка одна, история — та же).
|
||||||
|
// Пригласить можно только подтверждённого мастера (миграция 0010):
|
||||||
|
// SELECT-форма INSERT не вставит строку для неподтверждённого —
|
||||||
|
// неподтверждённый и несуществующий неразличимы (404).
|
||||||
|
func (r *ProjectRepo) InviteWorker(ctx context.Context, projectID int64, workerID, invitedBy string) (time.Time, error) {
|
||||||
|
var expires time.Time
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`INSERT INTO project_workers (worker_id, project_id, invited_by, status, invited_at, expires_at)
|
||||||
|
SELECT $1, $2, $3, 'invited', now(), now() + make_interval(secs => $4)
|
||||||
|
FROM profiles w
|
||||||
|
WHERE w.id = $1 AND w.role = 'worker' AND w.confirmed_at IS NOT NULL
|
||||||
|
ON CONFLICT (worker_id, project_id) DO UPDATE
|
||||||
|
SET status = 'invited',
|
||||||
|
invited_by = EXCLUDED.invited_by,
|
||||||
|
invited_at = EXCLUDED.invited_at,
|
||||||
|
responded_at = NULL,
|
||||||
|
expires_at = EXCLUDED.expires_at
|
||||||
|
WHERE project_workers.status = 'declined'
|
||||||
|
RETURNING expires_at`,
|
||||||
|
workerID, projectID, invitedBy, InvitationTTL.Seconds(),
|
||||||
|
).Scan(&expires)
|
||||||
|
if err == nil {
|
||||||
|
return expires, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return time.Time{}, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строку не тронули — выясняем, что уже стоит, чтобы ответ был точным.
|
||||||
|
var status string
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT status FROM project_workers WHERE worker_id = $1 AND project_id = $2`,
|
||||||
|
workerID, projectID).Scan(&status); err != nil {
|
||||||
|
return time.Time{}, classify(err)
|
||||||
|
}
|
||||||
|
if status == "invited" {
|
||||||
|
return time.Time{}, fmt.Errorf("%w: %w", ErrConflict, ErrInvitationPending)
|
||||||
|
}
|
||||||
|
if status == "active" {
|
||||||
|
return time.Time{}, fmt.Errorf("%w: %w", ErrConflict, ErrMembershipActive)
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("%w: membership status %q", ErrConflict, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptInvitation — мастер принял приглашение: invited → active.
|
||||||
|
// Просроченное принять нельзя — строка устарела, нужен новый вызов
|
||||||
|
// InviteWorker от клиента.
|
||||||
|
func (r *ProjectRepo) AcceptInvitation(ctx context.Context, projectID int64, workerID string) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE project_workers
|
||||||
|
SET status = 'active', responded_at = now()
|
||||||
|
WHERE worker_id = $1 AND project_id = $2
|
||||||
|
AND status = 'invited' AND expires_at > now()`,
|
||||||
|
workerID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.invitationStateError(ctx, projectID, workerID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclineInvitation — мастер отказал: invited → declined. Отказ разрешён
|
||||||
|
// и по просроченному приглашению — устаревшая строка всё равно мертва,
|
||||||
|
// а клиент увидит в списке состава, что ждать уже нечего.
|
||||||
|
func (r *ProjectRepo) DeclineInvitation(ctx context.Context, projectID int64, workerID string) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE project_workers
|
||||||
|
SET status = 'declined', responded_at = now()
|
||||||
|
WHERE worker_id = $1 AND project_id = $2
|
||||||
|
AND status = 'invited'`,
|
||||||
|
workerID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.invitationStateError(ctx, projectID, workerID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invitationStateError — почему accept/decline не нашёл строки: её нет (404)
|
||||||
|
// или ждать решения уже нечего (409 с точной причиной). checkExpiry —
|
||||||
|
// считать ли просрочку ошибкой: для отказа — нет (см. DeclineInvitation).
|
||||||
|
func (r *ProjectRepo) invitationStateError(ctx context.Context, projectID int64, workerID string, checkExpiry bool) error {
|
||||||
|
var status string
|
||||||
|
var expires *time.Time
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT status, expires_at FROM project_workers
|
||||||
|
WHERE worker_id = $1 AND project_id = $2`,
|
||||||
|
workerID, projectID).Scan(&status, &expires)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return notFound("invitation for worker %s in project %d", workerID, projectID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if checkExpiry && status == "invited" && expires != nil && !expires.After(time.Now()) {
|
||||||
|
return fmt.Errorf("%w: %w", ErrConflict, ErrInvitationExpired)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %w", ErrConflict, ErrInvitationNotPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvitations — приглашения, ожидающие решения этого мастера.
|
||||||
|
// Просроченные не отдаются: принять их нельзя, а для мастера они — шум.
|
||||||
|
func (r *ProjectRepo) ListInvitations(ctx context.Context, workerID string, page Page) ([]models.ProjectInvitation, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, InvitationSorts, "invited_at", "pw.project_id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = `pw.worker_id = $1 AND pw.status = 'invited' AND pw.expires_at > now()`
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT pw.project_id, p.name, c.name, pw.invited_by, COALESCE(inv.name, ''),
|
||||||
|
pw.invited_at, pw.expires_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM project_workers pw
|
||||||
|
JOIN projects p ON p.id = pw.project_id
|
||||||
|
JOIN profiles c ON c.id = p.client_id
|
||||||
|
LEFT JOIN profiles inv ON inv.id = pw.invited_by
|
||||||
|
WHERE `+active+order+`
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
workerID, page.Limit, page.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var invites []models.ProjectInvitation
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var v models.ProjectInvitation
|
||||||
|
if err := rows.Scan(&v.ProjectID, &v.ProjectName, &v.ClientName,
|
||||||
|
&v.InvitedBy, &v.InvitedName, &v.InvitedAt, &v.ExpiresAt, &total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
invites = append(invites, v)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
// Пустое окно: window не вернул total — досчитываем тем же WHERE.
|
||||||
|
if len(invites) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM project_workers pw WHERE `+active,
|
||||||
|
workerID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invites, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveWorker — убрать мастера из проекта.
|
||||||
|
//
|
||||||
|
// Условие «нет незакрытых заявок, назначенных ему» стоит прямо в DELETE:
|
||||||
|
// так проверка и сам отзыв атомарны, гонка между ними исключена.
|
||||||
|
// Неназначенная заявка (worker_id IS NULL) мастера не удерживает —
|
||||||
|
// исполнитель у неё и так не он. Если строк не ушло —
|
||||||
|
// различаем «не состоит в проекте» (404) и «есть открытые заявки» (409).
|
||||||
|
func (r *ProjectRepo) RemoveWorker(ctx context.Context, projectID int64, workerID string) error {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`DELETE FROM project_workers
|
||||||
|
WHERE worker_id = $1 AND project_id = $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tickets
|
||||||
|
WHERE worker_id = $1 AND project_id = $2
|
||||||
|
AND status <> 'closed')`,
|
||||||
|
workerID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ни одной строки не ушло — выясняем причину, чтобы ответ был честным.
|
||||||
|
var busy bool
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM tickets
|
||||||
|
WHERE worker_id = $1 AND project_id = $2
|
||||||
|
AND status <> 'closed')`, workerID, projectID).Scan(&busy); err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if busy {
|
||||||
|
return fmt.Errorf("%w: worker %s still has open tickets in project %d",
|
||||||
|
ErrConflict, workerID, projectID)
|
||||||
|
}
|
||||||
|
return notFound("worker %s in project %d", workerID, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MembershipStatus — состояние членства мастера в проекте: "" — строки
|
||||||
|
// нет, иначе invited/active/declined. Нужно назначению: «ждём ответа»
|
||||||
|
// и «вообще не состоит» — разные ошибки для клиента, а решающим рубежом
|
||||||
|
// в самом UPDATE остаётся требование status='active'.
|
||||||
|
func (r *ProjectRepo) MembershipStatus(ctx context.Context, workerID string, projectID int64) (string, error) {
|
||||||
|
var status string
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT status FROM project_workers
|
||||||
|
WHERE worker_id = $1 AND project_id = $2`,
|
||||||
|
workerID, projectID).Scan(&status)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", classify(err)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
584
internal/repository/project_test.go
Normal file
584
internal/repository/project_test.go
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты модели «проект → объекты → мастера → заявки» (миграция 0004).
|
||||||
|
// Проверяем то, что держится на SQL, а не на хендлерах: чужие сущности
|
||||||
|
// неотличимы от несуществующих, членство мастера и принадлежность объекта
|
||||||
|
// ловятся репозиторием/БД.
|
||||||
|
|
||||||
|
// TestProjectOwnership — проект виден только своему клиенту, админской
|
||||||
|
// «пустой» выборке — любой; чужой неотличим от несуществующего.
|
||||||
|
func TestProjectOwnership(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
stranger := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Север", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дубликат имени у того же клиента — конфликт (под savepoint:
|
||||||
|
// ожидаемая ошибка не должна отравить остальную транзакцию теста).
|
||||||
|
dup := &models.Project{ClientID: owner.ID, Name: "ЖК Север", IsActive: true}
|
||||||
|
expectError(t, ctx, q, "duplicate project name",
|
||||||
|
func() error { return repo.CreateProject(ctx, dup) }, ErrConflict)
|
||||||
|
// То же имя у другого клиента — норма (свои пространства имён).
|
||||||
|
other := &models.Project{ClientID: stranger.ID, Name: "ЖК Север", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, other); err != nil {
|
||||||
|
t.Errorf("same name for another client: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Своё лицо — находит, чужое — нет, админская выборка — находит.
|
||||||
|
if _, err := repo.OwnedProject(ctx, p.ID, owner.ID); err != nil {
|
||||||
|
t.Errorf("owner access: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.OwnedProject(ctx, p.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.OwnedProject(ctx, p.ID, ""); err != nil {
|
||||||
|
t.Errorf("admin access: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Списки: клиент видит только своё.
|
||||||
|
own, total, err := repo.ListProjects(ctx, owner.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list own: %v", err)
|
||||||
|
}
|
||||||
|
if len(own) != 1 || own[0].ID != p.ID {
|
||||||
|
t.Errorf("owner list = %v, want only project %d", projectIDs(own), p.ID)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Errorf("owner total = %d, want 1", total)
|
||||||
|
}
|
||||||
|
otherList, _, err := repo.ListProjects(ctx, stranger.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list stranger: %v", err)
|
||||||
|
}
|
||||||
|
if len(otherList) != 1 || otherList[0].ID != other.ID {
|
||||||
|
t.Errorf("stranger list = %v, want only own project", projectIDs(otherList))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestObjectBelongsToProject — объект живёт в проекте: чужой клиент его не
|
||||||
|
// читает, а составной FK не даёт сшить заявку из объекта и проекта
|
||||||
|
// разных контуров.
|
||||||
|
func TestObjectBelongsToProject(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
stranger := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "БЦ Альфа", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
o := &models.Object{ProjectID: p.ID, Name: "Корпус 1", Address: "ул. Мира, 5", IsActive: true}
|
||||||
|
if err := repo.CreateObject(ctx, o); err != nil {
|
||||||
|
t.Fatalf("create object: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дубликат имени объекта в проекте — конфликт (под savepoint).
|
||||||
|
dup := &models.Object{ProjectID: p.ID, Name: "Корпус 1", Address: "ул. Мира, 6", IsActive: true}
|
||||||
|
expectError(t, ctx, q, "duplicate object name",
|
||||||
|
func() error { return repo.CreateObject(ctx, dup) }, ErrConflict)
|
||||||
|
|
||||||
|
if _, err := repo.OwnedObject(ctx, o.ID, owner.ID); err != nil {
|
||||||
|
t.Errorf("owner access: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.OwnedObject(ctx, o.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правка адреса меняет объект; новые заявки возьмут новый адрес.
|
||||||
|
newAddr := "ул. Мира, 5а"
|
||||||
|
if err := repo.UpdateObject(ctx, o.ID, nil, &newAddr, nil); err != nil {
|
||||||
|
t.Fatalf("update object: %v", err)
|
||||||
|
}
|
||||||
|
got, err := repo.OwnedObject(ctx, o.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get object: %v", err)
|
||||||
|
}
|
||||||
|
if got.Address != newAddr {
|
||||||
|
t.Errorf("address = %q, want %q", got.Address, newAddr)
|
||||||
|
}
|
||||||
|
if !got.ProjectActive {
|
||||||
|
t.Error("project_active = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Составной FK: объект чужого проекта в заявку не проходит.
|
||||||
|
other := &models.Project{ClientID: owner.ID, Name: "БЦ Бета", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, other); err != nil {
|
||||||
|
t.Fatalf("create other project: %v", err)
|
||||||
|
}
|
||||||
|
expectInvalid(t, ctx, q, "object from another project", `
|
||||||
|
INSERT INTO tickets (client_id, project_id, object_id, address, category, description)
|
||||||
|
VALUES ($1, $2, $3, 'addr', 'other', 'desc')`,
|
||||||
|
owner.ID, other.ID, o.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProjectWorkers — мастера проекта: членство только по приглашению
|
||||||
|
// (invite → accept), дубликаты с точной причиной и отзыв.
|
||||||
|
func TestProjectWorkers(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
strangerWorker := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Восток", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// До приглашения членства нет вообще — назначить такого мастера нельзя.
|
||||||
|
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "" {
|
||||||
|
t.Errorf("membership before invite: status=%q err=%v, want empty/nil", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приглашение: строка появляется сразу, но рабочей она ещё не.
|
||||||
|
expires, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invite: %v", err)
|
||||||
|
}
|
||||||
|
if !expires.After(time.Now()) {
|
||||||
|
t.Errorf("expires_at = %v, want in the future", expires)
|
||||||
|
}
|
||||||
|
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "invited" {
|
||||||
|
t.Errorf("membership after invite: status=%q err=%v, want invited/nil", status, err)
|
||||||
|
}
|
||||||
|
// Повторное приглашение по висящему — конфликт с точной причиной
|
||||||
|
// (под savepoint, чтобы не отравить транзакцию теста).
|
||||||
|
expectError(t, ctx, q, "duplicate invite",
|
||||||
|
func() error { _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); return err },
|
||||||
|
ErrInvitationPending)
|
||||||
|
|
||||||
|
// Мастер принял — членство активное.
|
||||||
|
if err := repo.AcceptInvitation(ctx, p.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "active" {
|
||||||
|
t.Errorf("membership after accept: status=%q err=%v, want active/nil", status, err)
|
||||||
|
}
|
||||||
|
// Активного мастера заново не зовут и повторно не принимают.
|
||||||
|
expectError(t, ctx, q, "invite active member",
|
||||||
|
func() error { _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); return err },
|
||||||
|
ErrMembershipActive)
|
||||||
|
expectError(t, ctx, q, "accept twice",
|
||||||
|
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
|
||||||
|
ErrInvitationNotPending)
|
||||||
|
|
||||||
|
// Чужой мастер здесь не состоит.
|
||||||
|
if status, err := repo.MembershipStatus(ctx, strangerWorker.ID, p.ID); err != nil || status != "" {
|
||||||
|
t.Errorf("foreign membership: status=%q err=%v, want empty/nil", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Состав проекта: профиль плюс статус, без хеша пароля.
|
||||||
|
workers, total, err := repo.ListWorkers(ctx, p.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list workers: %v", err)
|
||||||
|
}
|
||||||
|
if len(workers) != 1 || workers[0].ID != worker.ID || workers[0].Status != "active" {
|
||||||
|
t.Errorf("workers = %v (status %q), want only %s and active",
|
||||||
|
workerIDs(workers), workers[0].Status, worker.ID)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Errorf("workers total = %d, want 1", total)
|
||||||
|
}
|
||||||
|
if workers[0].PasswordHash != "" {
|
||||||
|
t.Error("password hash leaked into worker list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отзыв без открытых заявок — чисто.
|
||||||
|
if err := repo.RemoveWorker(ctx, p.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("remove worker: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.RemoveWorker(ctx, p.ID, worker.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("second remove: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitationDeclineAndExpiry — отказ, просрочка и повторное приглашение.
|
||||||
|
// Всё живёт на уровне SQL: обход хендлера статус не поменяет.
|
||||||
|
func TestInvitationDeclineAndExpiry(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
other := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Закат", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приглашение видно мастеру сразу: проект, кто пригласил, срок.
|
||||||
|
if _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("invite: %v", err)
|
||||||
|
}
|
||||||
|
invites, total, err := repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list invitations: %v", err)
|
||||||
|
}
|
||||||
|
if len(invites) != 1 || total != 1 {
|
||||||
|
t.Fatalf("invitations = %d/%d, want 1/1", len(invites), total)
|
||||||
|
}
|
||||||
|
got := invites[0]
|
||||||
|
if got.ProjectID != p.ID || got.ProjectName != p.Name ||
|
||||||
|
got.InvitedBy != owner.ID || got.InvitedName != owner.Name {
|
||||||
|
t.Errorf("invitation = %+v, want project %d and inviter %s", got, p.ID, owner.ID)
|
||||||
|
}
|
||||||
|
if !got.ExpiresAt.After(time.Now()) || !got.InvitedAt.Before(got.ExpiresAt) {
|
||||||
|
t.Errorf("invitation times: invited=%v expires=%v, want a future deadline", got.InvitedAt, got.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мастер отклонил: строка помечена, ждать решения больше нечего.
|
||||||
|
if err := repo.DeclineInvitation(ctx, p.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("decline: %v", err)
|
||||||
|
}
|
||||||
|
if status, err := repo.MembershipStatus(ctx, worker.ID, p.ID); err != nil || status != "declined" {
|
||||||
|
t.Errorf("membership after decline: status=%q err=%v, want declined/nil", status, err)
|
||||||
|
}
|
||||||
|
expectError(t, ctx, q, "accept declined",
|
||||||
|
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
|
||||||
|
ErrInvitationNotPending)
|
||||||
|
// Отказавшему приглашения не видно.
|
||||||
|
invites, total, err = repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list after decline: %v", err)
|
||||||
|
}
|
||||||
|
if len(invites) != 0 || total != 0 {
|
||||||
|
t.Errorf("invitations after decline = %d/%d, want empty", len(invites), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторное приглашение перезапускает ту же строку — не дубль.
|
||||||
|
if _, err := repo.InviteWorker(ctx, p.ID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Errorf("re-invite after decline: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Просрочка: принять нельзя (409 invitation expired), но строка
|
||||||
|
// остаётся «висячей», пока клиент не отзовёт её DELETE'ом.
|
||||||
|
if err := expireInvitations(ctx, q, p.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("expire: %v", err)
|
||||||
|
}
|
||||||
|
expectError(t, ctx, q, "accept expired",
|
||||||
|
func() error { return repo.AcceptInvitation(ctx, p.ID, worker.ID) },
|
||||||
|
ErrInvitationExpired)
|
||||||
|
// Просроченное не отдаётся в списке: принимать уже нечего.
|
||||||
|
invites, total, err = repo.ListInvitations(ctx, worker.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list after expiry: %v", err)
|
||||||
|
}
|
||||||
|
if len(invites) != 0 || total != 0 {
|
||||||
|
t.Errorf("expired invitation listed: %d/%d, want empty", len(invites), total)
|
||||||
|
}
|
||||||
|
// Отказ по просрочке проходит — устаревшая строка и так мертва.
|
||||||
|
if err := repo.DeclineInvitation(ctx, p.ID, worker.ID); err != nil {
|
||||||
|
t.Errorf("decline expired: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кому приглашения не было — отвечать нечего: 404.
|
||||||
|
if err := repo.AcceptInvitation(ctx, p.ID, other.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("accept without invitation: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListWorkersEmptyFallback — пустое окно списка состава: total
|
||||||
|
// досчитывается отдельным COUNT, а не остаётся нулём.
|
||||||
|
func TestListWorkersEmptyFallback(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
p := &models.Project{ClientID: owner.ID, Name: "ЖК Поле", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, p); err != nil {
|
||||||
|
t.Fatalf("create project: %v", err)
|
||||||
|
}
|
||||||
|
joinProject(t, ctx, repo, p.ID, worker.ID, owner.ID)
|
||||||
|
|
||||||
|
workers, total, err := repo.ListWorkers(ctx, p.ID, Page{Limit: MaxLimit, Offset: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list beyond the end: %v", err)
|
||||||
|
}
|
||||||
|
if len(workers) != 0 || total != 1 {
|
||||||
|
t.Errorf("page beyond the end = %d items/%d total, want 0 items/1 total", len(workers), total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinProject — мастер входит в проект: с 0005 членство даёт только
|
||||||
|
// приглашение плюс согласие самого мастера (invite → accept).
|
||||||
|
func joinProject(t *testing.T, ctx context.Context, repo *ProjectRepo, projectID int64, workerID, invitedBy string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := repo.InviteWorker(ctx, projectID, workerID, invitedBy); err != nil {
|
||||||
|
t.Fatalf("invite worker: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.AcceptInvitation(ctx, projectID, workerID); err != nil {
|
||||||
|
t.Fatalf("accept invitation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireInvitations — убить срок действия приглашения (для теста просрочки).
|
||||||
|
func expireInvitations(ctx context.Context, q Querier, projectID int64, workerID string) error {
|
||||||
|
_, err := q.Exec(ctx,
|
||||||
|
`UPDATE project_workers SET expires_at = now() - interval '1 minute'
|
||||||
|
WHERE worker_id = $1 AND project_id = $2`,
|
||||||
|
workerID, projectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWorkerBlockedByTickets — мастера нельзя убрать из проекта,
|
||||||
|
// пока за ним не закрыты заявки: иначе работа осталась бы без исполнителя.
|
||||||
|
// После закрытия отзыв проходит, история при этом не теряется.
|
||||||
|
func TestRemoveWorkerBlockedByTickets(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
|
||||||
|
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
|
||||||
|
|
||||||
|
tk := &models.Ticket{
|
||||||
|
ClientID: owner.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: "other",
|
||||||
|
Description: "test ticket",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, tk); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Назначаем заявку мастеру: пока она не закрыта, убрать его нельзя —
|
||||||
|
// иначе работа осталась бы без исполнителя.
|
||||||
|
if err := tickets.Assign(ctx, tk.ID, worker.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("assign: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.RemoveWorker(ctx, projectID, worker.ID); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("remove with open ticket: err = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем заявку — отзыв разблокируется, заявка остаётся с мастером.
|
||||||
|
if err := tickets.Close(ctx, tk.ID, owner.ID, "передумал", []string{"new", "assigned", "done", "rejected"}); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.RemoveWorker(ctx, projectID, worker.ID); err != nil {
|
||||||
|
t.Errorf("remove after close: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := tickets.GetByID(ctx, tk.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get ticket: %v", err)
|
||||||
|
}
|
||||||
|
if got.WorkerID == nil || *got.WorkerID != worker.ID {
|
||||||
|
t.Errorf("worker_id = %v, want %s kept in history", got.WorkerID, worker.ID)
|
||||||
|
}
|
||||||
|
if got.ProjectID != projectID || got.ObjectID != objectID {
|
||||||
|
t.Errorf("project/object = %d/%d, want %d/%d", got.ProjectID, got.ObjectID, projectID, objectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неназначенная заявка (worker_id IS NULL) мастера не удерживает:
|
||||||
|
// исполнитель у неё и так не он. Членство набирается заново —
|
||||||
|
// invite → accept, строка новая (старую удалили).
|
||||||
|
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
|
||||||
|
free := &models.Ticket{
|
||||||
|
ClientID: owner.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: "other",
|
||||||
|
Description: "free ticket",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, free); err != nil {
|
||||||
|
t.Fatalf("create free ticket: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.RemoveWorker(ctx, projectID, worker.ID); err != nil {
|
||||||
|
t.Errorf("remove with only unassigned ticket: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAssignRequiresProjectMembership — «чужого» мастера не назначить:
|
||||||
|
// проверка стоит внутри UPDATE, поэтому обход хендлера не поможет.
|
||||||
|
func TestAssignRequiresProjectMembership(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
|
||||||
|
tk := &models.Ticket{
|
||||||
|
ClientID: owner.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: "other",
|
||||||
|
Description: "test ticket",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, tk); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Мастер не состоит в проекте — назначение не применяется.
|
||||||
|
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("assign foreign worker: err = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
got := mustGetTicket(t, ctx, tickets, tk.ID)
|
||||||
|
if got.WorkerID != nil || got.Status != "new" {
|
||||||
|
t.Errorf("ticket = %q/%v, want new/nil", got.Status, got.WorkerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Приглашён, но не принял — всё равно не назначается: рубеж в самом
|
||||||
|
// UPDATE (status = 'active'), обход хендлера не поможет.
|
||||||
|
if _, err := repo.InviteWorker(ctx, projectID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("invite: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Errorf("assign invited worker: err = %v, want ErrConflict", err)
|
||||||
|
}
|
||||||
|
got = mustGetTicket(t, ctx, tickets, tk.ID)
|
||||||
|
if got.WorkerID != nil || got.Status != "new" {
|
||||||
|
t.Errorf("ticket after invited assign = %q/%v, want new/nil", got.Status, got.WorkerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Принял приглашение — назначение проходит.
|
||||||
|
if err := repo.AcceptInvitation(ctx, projectID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("accept: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("assign member: %v", err)
|
||||||
|
}
|
||||||
|
assigned := mustGetTicket(t, ctx, tickets, tk.ID)
|
||||||
|
if assigned.WorkerID == nil || *assigned.WorkerID != worker.ID {
|
||||||
|
t.Errorf("worker_id = %v, want %s", assigned.WorkerID, worker.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteProjectCascade — удаление проекта: заявки, объекты, участия
|
||||||
|
// и журнал уходят каскадом (0004/0006/0008/0013), ключи фото возвращаются
|
||||||
|
// вызывающей стороне, повторное удаление — 404. Чужой проект не трогаем.
|
||||||
|
func TestDeleteProjectCascade(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewProjectRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
photos := NewPhotoRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, owner.ID)
|
||||||
|
joinProject(t, ctx, repo, projectID, worker.ID, owner.ID)
|
||||||
|
|
||||||
|
tk := &models.Ticket{
|
||||||
|
ClientID: owner.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Category: "other",
|
||||||
|
Description: "заявка проекта",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, tk); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Assign(ctx, tk.ID, worker.ID, owner.ID); err != nil {
|
||||||
|
t.Fatalf("assign: %v", err)
|
||||||
|
}
|
||||||
|
// Фотоотчёт открывается только после старта работы (статус in_progress).
|
||||||
|
if err := tickets.Start(ctx, tk.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
meta := photoMeta(tk.ID, worker.ID, 1, 10)
|
||||||
|
if _, err := photos.AddPhoto(ctx, meta, "worker"); err != nil {
|
||||||
|
t.Fatalf("add photo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Соседний проект того же клиента: после удаления должен остаться.
|
||||||
|
neighbor := &models.Project{ClientID: owner.ID, Name: "Сосед", IsActive: true}
|
||||||
|
if err := repo.CreateProject(ctx, neighbor); err != nil {
|
||||||
|
t.Fatalf("create neighbor: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление: возвращаются ключи объектов фото (их чистит хендлер),
|
||||||
|
// все вложенные строки исчезают каскадом.
|
||||||
|
keys, err := repo.DeleteProject(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete project: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != 1 || keys[0] != meta.ObjectKey {
|
||||||
|
t.Errorf("keys = %v, want [%s]", keys, meta.ObjectKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.OwnedProject(ctx, projectID, ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("project after delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.OwnedObject(ctx, objectID, ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("object after delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := tickets.GetByID(ctx, tk.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("ticket after delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if status, err := repo.MembershipStatus(ctx, worker.ID, projectID); err != nil || status != "" {
|
||||||
|
t.Errorf("membership after delete: status=%q err=%v, want empty/nil", status, err)
|
||||||
|
}
|
||||||
|
// Строки фото и событий журнала ушли каскадом вместе с заявкой.
|
||||||
|
var photosLeft, eventsLeft int
|
||||||
|
if err := q.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM ticket_photos WHERE ticket_id = $1`, tk.ID).Scan(&photosLeft); err != nil {
|
||||||
|
t.Fatalf("count photos: %v", err)
|
||||||
|
}
|
||||||
|
if err := q.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM ticket_events WHERE ticket_id = $1`, tk.ID).Scan(&eventsLeft); err != nil {
|
||||||
|
t.Fatalf("count events: %v", err)
|
||||||
|
}
|
||||||
|
if photosLeft != 0 || eventsLeft != 0 {
|
||||||
|
t.Errorf("photo rows = %d, event rows = %d, want 0/0", photosLeft, eventsLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторное удаление — проект не существует (404).
|
||||||
|
if _, err := repo.DeleteProject(ctx, projectID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("second delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// Чужой проект не пострадал.
|
||||||
|
if _, err := repo.OwnedProject(ctx, neighbor.ID, owner.ID); err != nil {
|
||||||
|
t.Errorf("neighbor project: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectIDs(projects []models.Project) []int64 {
|
||||||
|
out := make([]int64, 0, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
out = append(out, p.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func workerIDs(workers []models.ProjectWorker) []string {
|
||||||
|
out := make([]string, 0, len(workers))
|
||||||
|
for _, w := range workers {
|
||||||
|
out = append(out, w.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
579
internal/repository/recurring.go
Normal file
579
internal/repository/recurring.go
Normal file
@ -0,0 +1,579 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecurringRepo — правила повторяющихся заявок и их срабатывание
|
||||||
|
// (миграции 0014/0016). Правило — шаблон будущей заявки (объект,
|
||||||
|
// категория, описание, приоритет) плюс расписание «каждые period_days
|
||||||
|
// дней/недель от anchor_at»; фоновый планировщик в cmd/api зовёт
|
||||||
|
// RunDueRules и создаёт заявки по расписанию.
|
||||||
|
//
|
||||||
|
// Проверка владельца встроена в запросы, как у проектов: clientID != ""
|
||||||
|
// ограничивает выборку, чужое правило неотличимо от несуществующего.
|
||||||
|
type RecurringRepo struct {
|
||||||
|
db Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecurringRepo создаёт репозиторий поверх Querier (пул или транзакция).
|
||||||
|
func NewRecurringRepo(db Querier) *RecurringRepo {
|
||||||
|
return &RecurringRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDue — ближайший запуск правила: наименьшая точка расписания
|
||||||
|
// anchor + k·period (k ≥ 0), строго после отправной точки ref.
|
||||||
|
//
|
||||||
|
// Сетка сроков строится от anchor_at и не «плывёт» после каждого
|
||||||
|
// срабатывания: опоздание на тик не накапливается. Отправная точка —
|
||||||
|
// COALESCE(last_run_at, created_at): у правила, которое ещё ни разу не
|
||||||
|
// срабатывало, первая заявка — первая точка сетки после создания
|
||||||
|
// (anchor в будущем — это сам anchor: пользователь выбрал первый запуск).
|
||||||
|
//
|
||||||
|
// periodDays ≥ 1 держится CHECK'ом в БД; защита ниже — чтобы деление
|
||||||
|
// на ноль при битых данных не уронило планировщик.
|
||||||
|
func nextDue(anchor time.Time, periodDays int, ref time.Time) time.Time {
|
||||||
|
if periodDays < 1 {
|
||||||
|
periodDays = 1
|
||||||
|
}
|
||||||
|
a, r := anchor.UTC(), ref.UTC()
|
||||||
|
if r.Before(a) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
period := time.Duration(periodDays) * 24 * time.Hour
|
||||||
|
// Сколько полных периодов прошло между anchor и ref, плюс один:
|
||||||
|
// срок обязан быть строго после ref (ровно на ref — уже прошло).
|
||||||
|
return a.Add(time.Duration(int64(r.Sub(a)/period)+1) * period)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recurringColumns и recurringFrom — одна проекция для всех выборок:
|
||||||
|
// правило плюс денормализации для UI (проект, объект, адрес, имена).
|
||||||
|
const recurringColumns = `
|
||||||
|
r.id, r.client_id, r.project_id, r.object_id,
|
||||||
|
r.category, r.description, r.priority, COALESCE(r.floor, ''),
|
||||||
|
r.anchor_at, r.period_days, r.is_active, r.last_run_at, r.created_at,
|
||||||
|
COALESCE(r.created_by::text, ''),
|
||||||
|
p.name, o.name, o.address,
|
||||||
|
COALESCE(c.name, ''), COALESCE(cb.name, '')`
|
||||||
|
const recurringFrom = `
|
||||||
|
FROM recurring_rules r
|
||||||
|
JOIN projects p ON p.id = r.project_id
|
||||||
|
JOIN project_objects o ON o.id = r.object_id
|
||||||
|
LEFT JOIN profiles c ON c.id = r.client_id
|
||||||
|
LEFT JOIN profiles cb ON cb.id = r.created_by`
|
||||||
|
|
||||||
|
// scanRule читает строку в модель; порядок Scan = recurringColumns.
|
||||||
|
// extra — доп. назначения в конце SELECT (COUNT(*) OVER() у списка).
|
||||||
|
func scanRule(s scanner, extra ...any) (models.RecurringRule, error) {
|
||||||
|
var rule models.RecurringRule
|
||||||
|
dest := []any{
|
||||||
|
&rule.ID, &rule.ClientID, &rule.ProjectID, &rule.ObjectID,
|
||||||
|
&rule.Category, &rule.Description, &rule.Priority, &rule.Floor,
|
||||||
|
&rule.AnchorAt, &rule.PeriodDays, &rule.IsActive, &rule.LastRunAt, &rule.CreatedAt,
|
||||||
|
&rule.CreatedBy,
|
||||||
|
&rule.ProjectName, &rule.ObjectName, &rule.Address,
|
||||||
|
&rule.ClientName, &rule.CreatedByName,
|
||||||
|
}
|
||||||
|
dest = append(dest, extra...)
|
||||||
|
if err := s.Scan(dest...); err != nil {
|
||||||
|
return rule, classify(err)
|
||||||
|
}
|
||||||
|
// Ближайший запуск — чистая функция расписания и отправной точки,
|
||||||
|
// поэтому считается здесь, а не хранится (миграция бы устарела
|
||||||
|
// при правке периода или после каждого срабатывания).
|
||||||
|
next := nextDue(rule.AnchorAt, rule.PeriodDays, dueRef(rule))
|
||||||
|
rule.NextRunAt = &next
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueRef — отправная точка срока правила: последнее срабатывание
|
||||||
|
// или создание, если оно ещё ни разу не срабатывало.
|
||||||
|
func dueRef(rule models.RecurringRule) time.Time {
|
||||||
|
if rule.LastRunAt != nil {
|
||||||
|
return *rule.LastRunAt
|
||||||
|
}
|
||||||
|
return rule.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRule — новое правило. Вызывающий обязан уже проверить владение
|
||||||
|
// объектом (OwnedObject) и активность проекта/объекта — как при создании
|
||||||
|
// заявки (handlers.TicketHandler.Create).
|
||||||
|
func (r *RecurringRepo) CreateRule(ctx context.Context, rule *models.RecurringRule) error {
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`INSERT INTO recurring_rules
|
||||||
|
(client_id, project_id, object_id, category, description,
|
||||||
|
priority, floor, anchor_at, period_days, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NULLIF($7, ''), $8, $9, NULLIF($10, '')::uuid)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
rule.ClientID, rule.ProjectID, rule.ObjectID, rule.Category,
|
||||||
|
rule.Description, rule.Priority, rule.Floor, rule.AnchorAt,
|
||||||
|
rule.PeriodDays, rule.CreatedBy,
|
||||||
|
).Scan(&rule.ID, &rule.CreatedAt)
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRule — правило по id; clientID != "" ограничивает выборку
|
||||||
|
// правилами этого клиента (чужое → ErrNotFound).
|
||||||
|
func (r *RecurringRepo) GetRule(ctx context.Context, id int64, clientID string) (*models.RecurringRule, error) {
|
||||||
|
query := `SELECT ` + recurringColumns + recurringFrom + ` WHERE r.id = $1`
|
||||||
|
args := []any{id}
|
||||||
|
if clientID != "" {
|
||||||
|
query += ` AND r.client_id = $2`
|
||||||
|
args = append(args, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := scanRule(r.db.QueryRow(ctx, query, args...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recurring rule %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRules — страница правил: все (clientID пуст, для админа) или только
|
||||||
|
// свои; projectID != 0 сужает до одного проекта (секция в проекте).
|
||||||
|
// Пустое окно досчитывается тем же WHERE: окно total его не знает.
|
||||||
|
func (r *RecurringRepo) ListRules(ctx context.Context, clientID string, projectID int64, page Page) ([]models.RecurringRule, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, RecurringSorts, "created_at", "r.id DESC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
where := `1=1`
|
||||||
|
var whereArgs []any
|
||||||
|
if clientID != "" {
|
||||||
|
where += ` AND r.client_id = $` + itoa(len(whereArgs)+1)
|
||||||
|
whereArgs = append(whereArgs, clientID)
|
||||||
|
}
|
||||||
|
if projectID != 0 {
|
||||||
|
where += ` AND r.project_id = $` + itoa(len(whereArgs)+1)
|
||||||
|
whereArgs = append(whereArgs, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `SELECT ` + recurringColumns + `, COUNT(*) OVER() AS total` + recurringFrom + `
|
||||||
|
WHERE ` + where + order +
|
||||||
|
` LIMIT $` + itoa(len(whereArgs)+1) + ` OFFSET $` + itoa(len(whereArgs)+2)
|
||||||
|
args := append(append([]any{}, whereArgs...), page.Limit, page.Offset)
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var rules []models.RecurringRule
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
rule, err := scanRule(rows, &total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*)`+recurringFrom+` WHERE `+where, whereArgs...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RulePatch — изменяемые поля правила в UpdateRule: nil означает
|
||||||
|
// «поле не трогаем» (COALESCE в самом UPDATE). Объект (object_id) не
|
||||||
|
// входит: перенос правила в другой проект — это удаление и создание,
|
||||||
|
// иначе легко увести правило в чужой проект.
|
||||||
|
type RulePatch struct {
|
||||||
|
Floor *string
|
||||||
|
Category *string
|
||||||
|
Description *string
|
||||||
|
Priority *string
|
||||||
|
AnchorAt *time.Time
|
||||||
|
PeriodDays *int
|
||||||
|
IsActive *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRule — частичное обновление правила: применятся только
|
||||||
|
// переданные поля. Чужое или несуществующее → ErrNotFound: выборка
|
||||||
|
// уже подрезана clientID. Срок не трогается — он пересчитывается сам
|
||||||
|
// от COALESCE(last_run_at, created_at) с новым расписанием (nextDue).
|
||||||
|
func (r *RecurringRepo) UpdateRule(ctx context.Context, id int64, clientID string, patch RulePatch) error {
|
||||||
|
query := `UPDATE recurring_rules
|
||||||
|
SET floor = NULLIF(COALESCE($2, floor), ''),
|
||||||
|
category = COALESCE($3, category),
|
||||||
|
description = COALESCE($4, description),
|
||||||
|
priority = COALESCE($5, priority),
|
||||||
|
anchor_at = COALESCE($6, anchor_at),
|
||||||
|
period_days = COALESCE($7, period_days),
|
||||||
|
is_active = COALESCE($8, is_active)
|
||||||
|
WHERE id = $1`
|
||||||
|
args := []any{id, patch.Floor, patch.Category, patch.Description,
|
||||||
|
patch.Priority, patch.AnchorAt, patch.PeriodDays, patch.IsActive}
|
||||||
|
if clientID != "" {
|
||||||
|
query += ` AND client_id = $9`
|
||||||
|
args = append(args, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := r.db.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("recurring rule %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRule — удалить правило; чужое или несуществующее → ErrNotFound.
|
||||||
|
// История срабатываний уходит каскадом (0014).
|
||||||
|
func (r *RecurringRepo) DeleteRule(ctx context.Context, id int64, clientID string) error {
|
||||||
|
query := `DELETE FROM recurring_rules WHERE id = $1`
|
||||||
|
args := []any{id}
|
||||||
|
if clientID != "" {
|
||||||
|
query += ` AND client_id = $2`
|
||||||
|
args = append(args, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := r.db.Exec(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return notFound("recurring rule %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ история срабатываний ============
|
||||||
|
|
||||||
|
// ListRuns — история срабатываний правила (сначала свежие). Заявку
|
||||||
|
// показываем с адресом/категорией на момент создания; если её удалили
|
||||||
|
// каскадом (проект), строка истории остаётся, поля пустые (LEFT JOIN).
|
||||||
|
// Владение проверяет вызывающий — GetRule до этого, здесь только правило.
|
||||||
|
func (r *RecurringRepo) ListRuns(ctx context.Context, ruleID int64, page Page) ([]models.RecurringRun, int, error) {
|
||||||
|
page = page.normalize()
|
||||||
|
order, err := orderBy(page, RecurringRunSorts, "run_at", "rr.id DESC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT rr.id, rr.rule_id, rr.ticket_id, rr.run_at,
|
||||||
|
COALESCE(t.address, ''), COALESCE(t.category, ''),
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM recurring_runs rr
|
||||||
|
LEFT JOIN tickets t ON t.id = rr.ticket_id
|
||||||
|
WHERE rr.rule_id = $1`+order+`
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
ruleID, page.Limit, page.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var runs []models.RecurringRun
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var run models.RecurringRun
|
||||||
|
if err := rows.Scan(&run.ID, &run.RuleID, &run.TicketID, &run.RunAt,
|
||||||
|
&run.Address, &run.Category, &total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
runs = append(runs, run)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
if len(runs) == 0 {
|
||||||
|
if err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM recurring_runs WHERE rule_id = $1`,
|
||||||
|
ruleID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, classify(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ планировщик ============
|
||||||
|
|
||||||
|
// DueRun — результат срабатывания одного правила: что создалось и куда
|
||||||
|
// слать уведомление (Target — profiles.telegram_target, "" — не настроено).
|
||||||
|
type DueRun struct {
|
||||||
|
RuleID int64
|
||||||
|
TicketID int64
|
||||||
|
ClientID string
|
||||||
|
Target string
|
||||||
|
Address string
|
||||||
|
Category string
|
||||||
|
// Description — текст правила: в уведомлении он объясняет, зачем
|
||||||
|
// заявка появилась («осмотр вентиляции по графику»).
|
||||||
|
Description string
|
||||||
|
Priority string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDueRules — главный метод планировщика: создаёт заявки по всем
|
||||||
|
// правилам, у которых наступил срок (точка сетки anchor_at + k·period).
|
||||||
|
//
|
||||||
|
// Условия срабатывания (всё перепроверяется под блокировкой строки
|
||||||
|
// правила, поэтому параллельный прогон не создаст дубль):
|
||||||
|
// - правило активно, проект и объект включены;
|
||||||
|
// - срок наступил: nextDue(anchor_at, period_days,
|
||||||
|
// COALESCE(last_run_at, created_at)) <= now — отправная точка —
|
||||||
|
// создание, поэтому первая заявка появится первой точкой сетки
|
||||||
|
// после заведения правила, а не сразу;
|
||||||
|
// - прошлая заявка по этому правилу закрыта — пока открыта, новую не
|
||||||
|
// создаём (ждём закрытия), срок при этом не сдвигается: после
|
||||||
|
// закрытия следующий тик создаст заявку сразу.
|
||||||
|
//
|
||||||
|
// Каждое правило обрабатывается в своей транзакции: ошибка одного не
|
||||||
|
// откатывает остальные. Возвращает созданные заявки и первую ошибку
|
||||||
|
// (если была) — вызывающий шлёт уведомления по created, ошибки идут в лог.
|
||||||
|
func (r *RecurringRepo) RunDueRules(ctx context.Context, now time.Time) ([]DueRun, error) {
|
||||||
|
b, ok := r.db.(Beginer)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("repository: querier cannot begin a transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := r.dueRuleIDs(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var runs []DueRun
|
||||||
|
var firstErr error
|
||||||
|
for _, id := range ids {
|
||||||
|
run, err := r.runRule(ctx, b, id, "", false, now)
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if run != nil {
|
||||||
|
runs = append(runs, *run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runs, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRuleNow — ручной запуск одного правила (POST /recurring/:id/run).
|
||||||
|
// То же срабатывание, что у планировщика, но без ожидания срока:
|
||||||
|
// заявка создаётся сразу. Остальные рубежи остаются — правило, проект
|
||||||
|
// и объект включены, прошлая заявка закрыта (иначе дубль): нарушение —
|
||||||
|
// ErrConflict с причиной, чужое правило — ErrNotFound (clientID).
|
||||||
|
// last_run_at сдвигается как обычно — следующий срок отсчитывается
|
||||||
|
// от ручного запуска.
|
||||||
|
func (r *RecurringRepo) RunRuleNow(ctx context.Context, id int64, clientID string, now time.Time) (*DueRun, error) {
|
||||||
|
b, ok := r.db.(Beginer)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("repository: querier cannot begin a transaction")
|
||||||
|
}
|
||||||
|
return r.runRule(ctx, b, id, clientID, true, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueRuleIDs — кандидаты на срабатывание: активные правила, чей проект
|
||||||
|
// и объект включены, у которых срок наступил (созревание —
|
||||||
|
// в Go: сетку сроков anchor_at + k·period не выразить в SQL без
|
||||||
|
// прижатия дней). Проверка «нет открытой заявки» стоит в runRule —
|
||||||
|
// там она атомарна вместе с созданием.
|
||||||
|
func (r *RecurringRepo) dueRuleIDs(ctx context.Context, now time.Time) ([]int64, error) {
|
||||||
|
rows, err := r.db.Query(ctx,
|
||||||
|
`SELECT r.id, r.anchor_at, r.period_days,
|
||||||
|
COALESCE(r.last_run_at, r.created_at)
|
||||||
|
FROM recurring_rules r
|
||||||
|
JOIN projects p ON p.id = r.project_id
|
||||||
|
JOIN project_objects o ON o.id = r.object_id
|
||||||
|
WHERE r.is_active AND p.is_active AND o.is_active
|
||||||
|
ORDER BY r.id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var anchor, ref time.Time
|
||||||
|
var period int
|
||||||
|
if err := rows.Scan(&id, &anchor, &period, &ref); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
if !nextDue(anchor, period, ref).After(now) {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRule — срабатывание одного правила в транзакции.
|
||||||
|
//
|
||||||
|
// Условия рубежей общие для планировщика и ручного запуска, реакция —
|
||||||
|
// разная: планировщик (force=false) пропускает правило молча (nil, nil),
|
||||||
|
// срок не сдвигается, следующий тик проверит снова; ручной запуск
|
||||||
|
// (force=true) получает ErrNotFound или ErrConflict с причиной — её
|
||||||
|
// хендлер отдаёт клиенту.
|
||||||
|
//
|
||||||
|
// «Срок ещё не наступил» — условие только планировщика
|
||||||
|
// (nextDue(...) <= now), поэтому ручной запуск созревания не ждёт.
|
||||||
|
// Отправная точка — COALESCE(last_run_at, created_at): правило, которое
|
||||||
|
// ещё ни разу не срабатывало, создаёт первую заявку первой точкой
|
||||||
|
// сетки после создания.
|
||||||
|
//
|
||||||
|
// clientID != "" ограничивает выборку правилами клиента — чужое
|
||||||
|
// неотличимо от несуществующего на этом шаге, а не «прочитал и потом
|
||||||
|
// запустил». FOR UPDATE сериализует параллельные запуски (второй
|
||||||
|
// инстанс, два ручных запроса): проверка «нет открытой заявки» и
|
||||||
|
// создание заявки — одна атомарная операция.
|
||||||
|
func (r *RecurringRepo) runRule(ctx context.Context, b Beginer, ruleID int64, clientID string, force bool, now time.Time) (*DueRun, error) {
|
||||||
|
tx, err := b.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }() // после Commit — безопасный no-op
|
||||||
|
|
||||||
|
// Причина конфликта: планировщику — молчаливый пропуск, ручному
|
||||||
|
// запуску — ErrConflict (→ 409) с текстом причины.
|
||||||
|
conflict := func(reason error) (*DueRun, error) {
|
||||||
|
if !force {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrConflict, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `SELECT r.id, r.client_id, r.project_id, r.object_id,
|
||||||
|
r.category, r.description, r.priority, COALESCE(r.floor, ''),
|
||||||
|
COALESCE(r.created_by::text, ''), o.address,
|
||||||
|
COALESCE(pf.telegram_target, ''),
|
||||||
|
r.is_active, p.is_active, o.is_active,
|
||||||
|
r.anchor_at, r.period_days,
|
||||||
|
COALESCE(r.last_run_at, r.created_at)
|
||||||
|
FROM recurring_rules r
|
||||||
|
JOIN projects p ON p.id = r.project_id
|
||||||
|
JOIN project_objects o ON o.id = r.object_id
|
||||||
|
LEFT JOIN profiles pf ON pf.id = r.client_id
|
||||||
|
WHERE r.id = $1`
|
||||||
|
args := []any{ruleID}
|
||||||
|
if clientID != "" {
|
||||||
|
query += ` AND r.client_id = $` + itoa(len(args)+1)
|
||||||
|
args = append(args, clientID)
|
||||||
|
}
|
||||||
|
query += ` FOR UPDATE OF r`
|
||||||
|
|
||||||
|
var run DueRun
|
||||||
|
var projectID, objectID int64
|
||||||
|
var floor, createdBy string
|
||||||
|
var ruleActive, projectActive, objectActive bool
|
||||||
|
var anchor, ref time.Time
|
||||||
|
var period int
|
||||||
|
err = tx.QueryRow(ctx, query, args...).
|
||||||
|
Scan(&run.RuleID, &run.ClientID, &projectID, &objectID,
|
||||||
|
&run.Category, &run.Description, &run.Priority, &floor,
|
||||||
|
&createdBy, &run.Address, &run.Target,
|
||||||
|
&ruleActive, &projectActive, &objectActive,
|
||||||
|
&anchor, &period, &ref)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Планировщику: правила нет или чужое (для него созревание
|
||||||
|
// уже отфильтровано в dueRuleIDs).
|
||||||
|
if !force {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, notFound("recurring rule %d", ruleID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рубежи активности: правило выключено — пауза, проект/объект
|
||||||
|
// выключены — заявки некуда ставить.
|
||||||
|
if !ruleActive {
|
||||||
|
return conflict(ErrRuleNotActive)
|
||||||
|
}
|
||||||
|
if !projectActive {
|
||||||
|
return conflict(ErrProjectNotActive)
|
||||||
|
}
|
||||||
|
if !objectActive {
|
||||||
|
return conflict(ErrObjectNotActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Срок не наступил — планировщик ждёт (ручной запуск эту
|
||||||
|
// проверку обходит: force). Проверка под блокировкой строки —
|
||||||
|
// после сдвига срока другим инстансом она не «протухает».
|
||||||
|
if !force && nextDue(anchor, period, ref).After(now) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прошлая заявка по правилу ещё открыта: ждём её закрытия — без
|
||||||
|
// дублей. Срок при этом не сдвигается: после закрытия следующий
|
||||||
|
// тик создаст заявку сразу же.
|
||||||
|
var open bool
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM recurring_runs rr
|
||||||
|
JOIN tickets t ON t.id = rr.ticket_id
|
||||||
|
WHERE rr.rule_id = $1 AND t.status <> 'closed')`,
|
||||||
|
ruleID).Scan(&open); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
if open {
|
||||||
|
return conflict(ErrRuleOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка: снимок адреса с объекта, статус new (дефолт колонки).
|
||||||
|
var ticketID int64
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`INSERT INTO tickets
|
||||||
|
(client_id, project_id, object_id, address, floor,
|
||||||
|
category, description, priority)
|
||||||
|
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
run.ClientID, projectID, objectID, run.Address, floor,
|
||||||
|
run.Category, run.Description, run.Priority,
|
||||||
|
).Scan(&ticketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
run.TicketID = ticketID
|
||||||
|
|
||||||
|
// Журнал: автор — тот, кто завёл правило (fallback — владелец),
|
||||||
|
// чтобы «кто создал» читалось в истории заявки.
|
||||||
|
actor := createdBy
|
||||||
|
if actor == "" {
|
||||||
|
actor = run.ClientID
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO ticket_events (ticket_id, actor_id, action, from_status, to_status)
|
||||||
|
VALUES ($1, $2, 'created', NULL, 'new')`,
|
||||||
|
ticketID, actor); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// История срабатывания и сдвиг срока — в той же транзакции, что и
|
||||||
|
// заявка: «заявка есть, а правило думает, что не запускалось»
|
||||||
|
// невозможно.
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO recurring_runs (rule_id, ticket_id, run_at)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
ruleID, ticketID, now); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET last_run_at = $2 WHERE id = $1`,
|
||||||
|
ruleID, now); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, classify(err)
|
||||||
|
}
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
685
internal/repository/recurring_test.go
Normal file
685
internal/repository/recurring_test.go
Normal file
@ -0,0 +1,685 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты правил повторяющихся заявок (миграции 0014/0016). Проверяем то,
|
||||||
|
// что держится на SQL: созревание по периоду (anchor_at + k·period),
|
||||||
|
// блокировка незакрытой заявкой, владение правилом и каскад при
|
||||||
|
// удалении проекта.
|
||||||
|
|
||||||
|
// TestNextDue — срок правила (чистая функция, без БД): наименьшая точка
|
||||||
|
// сетки anchor + k·period строго после отправной точки; точка в
|
||||||
|
// будущем — сама точка (пользователь выбрал первый запуск).
|
||||||
|
func TestNextDue(t *testing.T) {
|
||||||
|
anchor := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
day := func(n int) time.Duration { return time.Duration(n) * 24 * time.Hour }
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
anchor time.Time
|
||||||
|
period int
|
||||||
|
ref time.Time
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{"отправная точка раньше сетки", anchor, 7,
|
||||||
|
anchor.Add(-day(12)), anchor},
|
||||||
|
{"ref ровно на точке — следующий период", anchor, 7,
|
||||||
|
anchor, anchor.Add(day(7))},
|
||||||
|
{"через секунду после точки", anchor, 7,
|
||||||
|
anchor.Add(time.Second), anchor.Add(day(7))},
|
||||||
|
{"между точками — ближайшая будущая", anchor, 7,
|
||||||
|
anchor.Add(day(2)), anchor.Add(day(7))},
|
||||||
|
{"пропущено много периодов", anchor, 30,
|
||||||
|
anchor.Add(day(100)), anchor.Add(day(120))},
|
||||||
|
{"недельный период (14 дней)", anchor, 14,
|
||||||
|
anchor.Add(day(16)), anchor.Add(day(28))},
|
||||||
|
{"годовой период (365 дней)", anchor, 365,
|
||||||
|
anchor.Add(day(370)), anchor.Add(day(730))},
|
||||||
|
{"битый нулевой период не роняет расчёт", anchor, 0,
|
||||||
|
anchor, anchor.Add(day(1))},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := nextDue(tc.anchor, tc.period, tc.ref)
|
||||||
|
if !got.Equal(tc.want) {
|
||||||
|
t.Errorf("nextDue(%v, %d, %v) = %v, want %v",
|
||||||
|
tc.anchor, tc.period, tc.ref, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecurringLifecycle — сквозной сценарий планировщика: правило не
|
||||||
|
// созрело до срока, создаёт заявку по созревании, ждёт закрытия
|
||||||
|
// прошлой и создаёт следующую, когда её закрыли.
|
||||||
|
func TestRecurringLifecycle(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
admin := createProfile(t, ctx, q, "admin")
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
|
||||||
|
rule := &models.RecurringRule{
|
||||||
|
ClientID: client.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Category: "climate",
|
||||||
|
Description: "Осмотр вентиляции",
|
||||||
|
Priority: "normal",
|
||||||
|
Floor: "3",
|
||||||
|
AnchorAt: time.Now().Add(-40 * 24 * time.Hour).UTC().Truncate(time.Second),
|
||||||
|
PeriodDays: 30,
|
||||||
|
IsActive: true,
|
||||||
|
CreatedBy: admin.ID,
|
||||||
|
}
|
||||||
|
if err := repo.CreateRule(ctx, rule); err != nil {
|
||||||
|
t.Fatalf("create rule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.ID == 0 || rule.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("rule not filled: id=%d created_at=%v", rule.ID, rule.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Свежее правило не созрело: первая точка сетки ещё впереди ---
|
||||||
|
created, err := repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run (fresh rule): %v", err)
|
||||||
|
}
|
||||||
|
if n := len(dueFor(created, rule.ID)); n != 0 {
|
||||||
|
t.Fatalf("fresh rule created %d tickets, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Подвигаем created_at в прошлое — правило созрело ---
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET created_at = now() - make_interval(years => 2)
|
||||||
|
WHERE id = $1`, rule.ID); err != nil {
|
||||||
|
t.Fatalf("backdate created_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err = repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run (due rule): %v", err)
|
||||||
|
}
|
||||||
|
created = dueFor(created, rule.ID)
|
||||||
|
if len(created) != 1 {
|
||||||
|
t.Fatalf("due rule created %d tickets, want 1", len(created))
|
||||||
|
}
|
||||||
|
got := created[0]
|
||||||
|
if got.ClientID != client.ID || got.Address != address ||
|
||||||
|
got.Category != "climate" || got.Description != "Осмотр вентиляции" ||
|
||||||
|
got.Priority != "normal" {
|
||||||
|
t.Errorf("DueRun = %+v, want client/address/category from rule", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка создана с адресом-снимком, статусом new и журналом created.
|
||||||
|
tk, err := tickets.GetByID(ctx, got.TicketID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get created ticket: %v", err)
|
||||||
|
}
|
||||||
|
if tk.Status != "new" || tk.Address != address || tk.Floor != "3" ||
|
||||||
|
tk.ProjectID != projectID || tk.ObjectID != objectID {
|
||||||
|
t.Errorf("ticket = status %q floor %q project %d object %d, want new rule copy",
|
||||||
|
tk.Status, tk.Floor, tk.ProjectID, tk.ObjectID)
|
||||||
|
}
|
||||||
|
events, _, err := tickets.ListEvents(ctx, got.TicketID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("events: %v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0].Action != "created" || events[0].ActorID != admin.ID {
|
||||||
|
t.Errorf("events = %+v, want one created by rule author", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// last_run_at сдвинулся, история записана.
|
||||||
|
rule, err = repo.GetRule(ctx, rule.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get rule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.LastRunAt == nil {
|
||||||
|
t.Fatal("last_run_at is nil after run, want set")
|
||||||
|
}
|
||||||
|
runs, total, err := repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list runs: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(runs) != 1 || runs[0].TicketID == nil || *runs[0].TicketID != got.TicketID {
|
||||||
|
t.Errorf("runs = %+v (total %d), want one row with ticket %d", runs, total, got.TicketID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Повторный прогон сразу — срок от запуска не наступил ---
|
||||||
|
if created, err = repo.RunDueRules(ctx, time.Now()); err != nil {
|
||||||
|
t.Fatalf("immediate re-run: %v", err)
|
||||||
|
}
|
||||||
|
if n := len(dueFor(created, rule.ID)); n != 0 {
|
||||||
|
t.Fatalf("immediate re-run created %d tickets, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Срок наступил, но прошлая заявка открыта — новую не создаём ---
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET last_run_at = now() - make_interval(years => 2)
|
||||||
|
WHERE id = $1`, rule.ID); err != nil {
|
||||||
|
t.Fatalf("backdate last_run_at: %v", err)
|
||||||
|
}
|
||||||
|
created, err = repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run (open ticket): %v", err)
|
||||||
|
}
|
||||||
|
if n := len(dueFor(created, rule.ID)); n != 0 {
|
||||||
|
t.Fatalf("open ticket blocked nothing: created %d, want 0", n)
|
||||||
|
}
|
||||||
|
// Срок не сдвинулся — после закрытия заявка создастся сразу.
|
||||||
|
rule, err = repo.GetRule(ctx, rule.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get rule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.LastRunAt == nil || !rule.LastRunAt.Before(time.Now().Add(-3*24*time.Hour)) {
|
||||||
|
t.Errorf("last_run_at = %v, want still backdated (skip must not advance)", rule.LastRunAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Закрыли — следующий тик создаёт следующую заявку ---
|
||||||
|
if err := tickets.Close(ctx, got.TicketID, client.ID, "осмотр перенесён", []string{"new"}); err != nil {
|
||||||
|
t.Fatalf("close ticket: %v", err)
|
||||||
|
}
|
||||||
|
created, err = repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run (closed ticket): %v", err)
|
||||||
|
}
|
||||||
|
created = dueFor(created, rule.ID)
|
||||||
|
if len(created) != 1 {
|
||||||
|
t.Fatalf("after close created %d tickets, want 1", len(created))
|
||||||
|
}
|
||||||
|
if created[0].TicketID == got.TicketID {
|
||||||
|
t.Error("second run returned the same ticket id")
|
||||||
|
}
|
||||||
|
_, total, err = repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list runs: %v", err)
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Errorf("runs total = %d, want 2", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecurringOwnership — чужое правило неотличимо от несуществующего,
|
||||||
|
// админская выборка видит всё, клиент — только своё.
|
||||||
|
func TestRecurringOwnership(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
stranger := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
mk := func(ownerID string) *models.RecurringRule {
|
||||||
|
projectID, objectID, _ := createProjectObject(t, ctx, q, ownerID)
|
||||||
|
rule := &models.RecurringRule{
|
||||||
|
ClientID: ownerID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Category: "climate", Description: "Осмотр вентиляции",
|
||||||
|
Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour),
|
||||||
|
PeriodDays: 30, IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repo.CreateRule(ctx, rule); err != nil {
|
||||||
|
t.Fatalf("create rule: %v", err)
|
||||||
|
}
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
mine := mk(owner.ID)
|
||||||
|
theirs := mk(stranger.ID)
|
||||||
|
|
||||||
|
// Чтение: своё — находит, чужое — нет, админская выборка — находит.
|
||||||
|
if _, err := repo.GetRule(ctx, mine.ID, owner.ID); err != nil {
|
||||||
|
t.Errorf("owner access: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetRule(ctx, mine.ID, stranger.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("stranger access: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetRule(ctx, mine.ID, ""); err != nil {
|
||||||
|
t.Errorf("admin access: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Списки: клиент видит только своё, админ — оба.
|
||||||
|
own, total, err := repo.ListRules(ctx, owner.ID, 0, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list own: %v", err)
|
||||||
|
}
|
||||||
|
if len(own) != 1 || own[0].ID != mine.ID || total != 1 {
|
||||||
|
t.Errorf("owner list = %d rules (total %d), want only mine", len(own), total)
|
||||||
|
}
|
||||||
|
all, total, err := repo.ListRules(ctx, "", 0, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list all: %v", err)
|
||||||
|
}
|
||||||
|
// Админская выборка видит всё, включая правила из живой dev-БД,
|
||||||
|
// поэтому проверяем присутствие своих, а не точное число строк.
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, r := range all {
|
||||||
|
seen[r.ID] = true
|
||||||
|
}
|
||||||
|
if total < 2 || !seen[mine.ID] || !seen[theirs.ID] {
|
||||||
|
t.Errorf("admin list total=%d seen(mine)=%v seen(theirs)=%v, want both present",
|
||||||
|
total, seen[mine.ID], seen[theirs.ID])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правка и удаление чужого — 404, своего — проходят.
|
||||||
|
if err := repo.UpdateRule(ctx, mine.ID, stranger.ID, RulePatch{IsActive: boolPtr(false)}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("stranger update: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.DeleteRule(ctx, theirs.ID, owner.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("stranger delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.UpdateRule(ctx, mine.ID, owner.ID, RulePatch{IsActive: boolPtr(false)}); err != nil {
|
||||||
|
t.Errorf("owner update: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
rule, err := repo.GetRule(ctx, mine.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get after update: %v", err)
|
||||||
|
}
|
||||||
|
if rule.IsActive {
|
||||||
|
t.Error("is_active = true after disabling")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выключенное правило планировщик не трогает.
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET created_at = now() - make_interval(years => 2)
|
||||||
|
WHERE id = $1`, mine.ID); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
created, err := repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run (disabled): %v", err)
|
||||||
|
}
|
||||||
|
if n := len(dueFor(created, mine.ID)); n != 0 {
|
||||||
|
t.Errorf("disabled rule created %d tickets, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecurringProjectCascade — правило живёт в проекте: удаление
|
||||||
|
// проекта (каскад 0013/0014) уносит правила и историю срабатываний,
|
||||||
|
// а заявка остаётся только если её не удалили вместе с проектом.
|
||||||
|
func TestRecurringProjectCascade(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
|
||||||
|
// Снимок до создания: после каскада счётчик должен вернуться к нему —
|
||||||
|
// живая dev-БД свои правила оставляет.
|
||||||
|
_, before, err := repo.ListRules(ctx, "", 0, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list before: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
|
||||||
|
rule := &models.RecurringRule{
|
||||||
|
ClientID: client.ID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Category: "climate", Description: "Осмотр вентиляции",
|
||||||
|
Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour),
|
||||||
|
PeriodDays: 30, IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repo.CreateRule(ctx, rule); err != nil {
|
||||||
|
t.Fatalf("create rule: %v", err)
|
||||||
|
}
|
||||||
|
// Созревшая заявка — чтобы история срабатывания была заполнена.
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET created_at = now() - make_interval(years => 2)
|
||||||
|
WHERE id = $1`, rule.ID); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
created, err := repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
if n := len(dueFor(created, rule.ID)); n != 1 {
|
||||||
|
t.Fatalf("run = %d, %v; want 1, nil", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление проекта — тем же путём, что у админа (каскад 0013).
|
||||||
|
if _, err := NewProjectRepo(q).DeleteProject(ctx, projectID); err != nil {
|
||||||
|
t.Fatalf("delete project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.GetRule(ctx, rule.ID, ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("rule after project delete: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
rules, total, err := repo.ListRules(ctx, "", 0, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(rules) != int(before) || total != before {
|
||||||
|
t.Errorf("rules after cascade = %d (total %d), want back to %d", len(rules), total, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecurringUpdatePatch — частичное обновление правила: применяются
|
||||||
|
// только переданные поля, остальные не трогаются; пустой патч — no-op.
|
||||||
|
func TestRecurringUpdatePatch(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
anchor := time.Now().Add(-40 * 24 * time.Hour).UTC().Truncate(time.Second)
|
||||||
|
rule := &models.RecurringRule{
|
||||||
|
ClientID: client.ID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Category: "climate", Description: "Осмотр вентиляции",
|
||||||
|
Priority: "normal", Floor: "3", AnchorAt: anchor,
|
||||||
|
PeriodDays: 30, IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repo.CreateRule(ctx, rule); err != nil {
|
||||||
|
t.Fatalf("create rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Меняем описание, расписание и приоритет — категория и этаж должны
|
||||||
|
// уцелеть, иначе «частичное» оказалось бы заменой.
|
||||||
|
newAnchor := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second)
|
||||||
|
if err := repo.UpdateRule(ctx, rule.ID, client.ID, RulePatch{
|
||||||
|
Description: strPtr("Осмотр вентиляции и фильтров"),
|
||||||
|
AnchorAt: timePtr(newAnchor),
|
||||||
|
PeriodDays: intPtr(14),
|
||||||
|
Priority: strPtr("high"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("patch: %v", err)
|
||||||
|
}
|
||||||
|
got, err := repo.GetRule(ctx, rule.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Description != "Осмотр вентиляции и фильтров" ||
|
||||||
|
!got.AnchorAt.Equal(newAnchor) || got.PeriodDays != 14 || got.Priority != "high" {
|
||||||
|
t.Errorf("patched fields = %q/%v/%d/%q, want new anchor/period and values",
|
||||||
|
got.Description, got.AnchorAt, got.PeriodDays, got.Priority)
|
||||||
|
}
|
||||||
|
if got.Category != "climate" || got.Floor != "3" || !got.IsActive {
|
||||||
|
t.Errorf("untouched fields = %q/%q/active=%v, want rule copy",
|
||||||
|
got.Category, got.Floor, got.IsActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выключение — только is_active, шаблон не страдает.
|
||||||
|
if err := repo.UpdateRule(ctx, rule.ID, client.ID, RulePatch{IsActive: boolPtr(false)}); err != nil {
|
||||||
|
t.Fatalf("disable: %v", err)
|
||||||
|
}
|
||||||
|
got, err = repo.GetRule(ctx, rule.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.IsActive || !got.AnchorAt.Equal(newAnchor) || got.PeriodDays != 14 {
|
||||||
|
t.Errorf("disable changed more than is_active: active=%v schedule=%v/%d",
|
||||||
|
got.IsActive, got.AnchorAt, got.PeriodDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой этаж — NULL (как при создании пустой строки).
|
||||||
|
if err := repo.UpdateRule(ctx, rule.ID, client.ID, RulePatch{Floor: strPtr("")}); err != nil {
|
||||||
|
t.Fatalf("clear floor: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = repo.GetRule(ctx, rule.ID, ""); err != nil || got.Floor != "" {
|
||||||
|
t.Errorf("floor = %q (err %v), want empty", got.Floor, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecurringRunNow — ручной запуск: обходит ожидание срока, но не
|
||||||
|
// рубежи «выключено» и «есть открытая заявка»; чужое правило —
|
||||||
|
// ErrNotFound; после запуска отсчёт сдвинулся и фоновый тик дубля не делает.
|
||||||
|
func TestRecurringRunNow(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
owner := createProfile(t, ctx, q, "client")
|
||||||
|
stranger := createProfile(t, ctx, q, "client")
|
||||||
|
projectID, objectID, _ := createProjectObject(t, ctx, q, owner.ID)
|
||||||
|
rule := &models.RecurringRule{
|
||||||
|
ClientID: owner.ID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Category: "plumbing", Description: "Промывка системы отопления",
|
||||||
|
Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour),
|
||||||
|
PeriodDays: 30, IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repo.CreateRule(ctx, rule); err != nil {
|
||||||
|
t.Fatalf("create rule: %v", err)
|
||||||
|
}
|
||||||
|
// Свежее правило: планировщик не трогает (срок не наступил)...
|
||||||
|
if created, err := repo.RunDueRules(ctx, time.Now()); err != nil ||
|
||||||
|
len(dueFor(created, rule.ID)) != 0 {
|
||||||
|
t.Fatalf("fresh rule run = %v, %v; want nothing", created, err)
|
||||||
|
}
|
||||||
|
// ...а ручной запуск создаёт заявку сразу.
|
||||||
|
run, err := repo.RunRuleNow(ctx, rule.ID, "", time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run now: %v", err)
|
||||||
|
}
|
||||||
|
if run.TicketID == 0 || run.RuleID != rule.ID {
|
||||||
|
t.Fatalf("run = %+v, want new ticket for rule %d", run, rule.ID)
|
||||||
|
}
|
||||||
|
after, err := repo.GetRule(ctx, rule.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get rule: %v", err)
|
||||||
|
}
|
||||||
|
if after.LastRunAt == nil {
|
||||||
|
t.Fatal("last_run_at is nil after manual run, want set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пока заявка открыта — повторный запуск это конфликт, не дубль.
|
||||||
|
if _, err := repo.RunRuleNow(ctx, rule.ID, "", time.Now()); !errors.Is(err, ErrConflict) ||
|
||||||
|
!errors.Is(err, ErrRuleOpen) {
|
||||||
|
t.Errorf("second run: err = %v, want ErrConflict wrapping ErrRuleOpen", err)
|
||||||
|
}
|
||||||
|
// Фоновый тик сразу после ручного запуска тоже дубль не делает
|
||||||
|
// (заявка открыта), а позже срок от ручного запуска не наступил.
|
||||||
|
if created, err := repo.RunDueRules(ctx, time.Now()); err != nil ||
|
||||||
|
len(dueFor(created, rule.ID)) != 0 {
|
||||||
|
t.Errorf("scheduler right after run now = %v, %v; want nothing", created, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыли — ручной запуск снова проходит.
|
||||||
|
if err := tickets.Close(ctx, run.TicketID, owner.ID, "сделано", []string{"new"}); err != nil {
|
||||||
|
t.Fatalf("close ticket: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.RunRuleNow(ctx, rule.ID, owner.ID, time.Now()); err != nil {
|
||||||
|
t.Errorf("run now after close: err = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выключенное правило — конфликт с причиной; чужое — ErrNotFound.
|
||||||
|
if err := repo.UpdateRule(ctx, rule.ID, "", RulePatch{IsActive: boolPtr(false)}); err != nil {
|
||||||
|
t.Fatalf("disable: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.RunRuleNow(ctx, rule.ID, "", time.Now()); !errors.Is(err, ErrRuleNotActive) {
|
||||||
|
t.Errorf("run disabled: err = %v, want ErrRuleNotActive", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.RunRuleNow(ctx, rule.ID, stranger.ID, time.Now()); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("run stranger: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecurringOrphanRunsCleanup — автоочистка заявки уносит и строку
|
||||||
|
// её истории: у удалённой заявки recurring_runs остаётся сиротой
|
||||||
|
// (ticket_id → SET NULL), DeleteExpired чистит такие строки тем же
|
||||||
|
// прогоном.
|
||||||
|
func TestRecurringOrphanRunsCleanup(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
projectID, objectID, _ := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
rule := &models.RecurringRule{
|
||||||
|
ClientID: client.ID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Category: "elevator", Description: "Плановый осмотр лифта",
|
||||||
|
Priority: "normal", AnchorAt: time.Now().Add(-40 * 24 * time.Hour),
|
||||||
|
PeriodDays: 30, IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repo.CreateRule(ctx, rule); err != nil {
|
||||||
|
t.Fatalf("create rule: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET created_at = now() - make_interval(years => 2)
|
||||||
|
WHERE id = $1`, rule.ID); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
created, err := repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
due := dueFor(created, rule.ID)
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("run created %d tickets, want 1", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
// История есть; закрываем заявку и увозим closed_at за срок retention,
|
||||||
|
// чтобы автоочистка её взяла.
|
||||||
|
if _, _, err := repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit}); err != nil {
|
||||||
|
t.Fatalf("runs before cleanup: %v", err)
|
||||||
|
}
|
||||||
|
if err := tickets.Close(ctx, due[0].TicketID, client.ID, "осмотр выполнен", []string{"new"}); err != nil {
|
||||||
|
t.Fatalf("close ticket: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE tickets SET closed_at = now() - interval '61 days' WHERE id = $1`,
|
||||||
|
due[0].TicketID); err != nil {
|
||||||
|
t.Fatalf("backdate closed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, _, err := tickets.DeleteExpired(ctx, time.Now().Add(-60*24*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("delete expired: %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Contains(ids, due[0].TicketID) {
|
||||||
|
t.Errorf("deleted %v, want our ticket %d among them", ids, due[0].TicketID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка истории ушла вместе с заявкой — сироты не осталось.
|
||||||
|
runs, total, err := repo.ListRuns(ctx, rule.ID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runs after cleanup: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 || len(runs) != 0 {
|
||||||
|
t.Errorf("runs after cleanup = %d (total %d), want 0", len(runs), total)
|
||||||
|
}
|
||||||
|
// И самой заявки нет.
|
||||||
|
if _, err := tickets.GetByID(ctx, due[0].TicketID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("ticket after cleanup: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTicketCreateWithRule — чекбокс «Повторяющаяся заявка» в форме
|
||||||
|
// новой заявки: заявка и правило создаются одной операцией, связь
|
||||||
|
// пишется в recurring_runs (за неё цепляется блок на странице заявки
|
||||||
|
// и история), открытая первая заявка блокирует автозапуск.
|
||||||
|
func TestTicketCreateWithRule(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewRecurringRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
|
||||||
|
tk := &models.Ticket{
|
||||||
|
ClientID: client.ID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Address: address, Floor: "2", Category: "plumbing",
|
||||||
|
Description: "Промывка радиаторов", Priority: "normal",
|
||||||
|
}
|
||||||
|
anchor := time.Now().Add(-14 * 24 * time.Hour).UTC().Truncate(time.Second)
|
||||||
|
ruleID, err := tickets.CreateWithRule(ctx, tk,
|
||||||
|
Schedule{AnchorAt: anchor, PeriodDays: 14}, client.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create with rule: %v", err)
|
||||||
|
}
|
||||||
|
if tk.ID == 0 || ruleID == 0 {
|
||||||
|
t.Fatalf("ids not filled: ticket=%d rule=%d", tk.ID, ruleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка видит своё правило — блок на странице имеет за что
|
||||||
|
// зацепиться.
|
||||||
|
got, err := tickets.GetByID(ctx, tk.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Recurring == nil {
|
||||||
|
t.Fatal("ticket recurring is nil, want rule link")
|
||||||
|
}
|
||||||
|
if got.Recurring.RuleID != ruleID || got.Recurring.PeriodDays != 14 ||
|
||||||
|
!got.Recurring.AnchorAt.Equal(anchor) || !got.Recurring.IsActive {
|
||||||
|
t.Errorf("recurring = %+v, want rule %d, 14 days from %v, active",
|
||||||
|
got.Recurring, ruleID, anchor)
|
||||||
|
}
|
||||||
|
if got.Recurring.NextRunAt == nil {
|
||||||
|
t.Error("next_run_at is nil, want computed at read time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правило — шаблон самой заявки.
|
||||||
|
rule, err := repo.GetRule(ctx, ruleID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get rule: %v", err)
|
||||||
|
}
|
||||||
|
if rule.ClientID != client.ID || rule.ProjectID != projectID ||
|
||||||
|
rule.ObjectID != objectID || rule.Floor != "2" ||
|
||||||
|
rule.Category != "plumbing" || rule.Description != "Промывка радиаторов" {
|
||||||
|
t.Errorf("rule = %+v, want ticket template copy", rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// История содержит «семенную» заявку.
|
||||||
|
runs, total, err := repo.ListRuns(ctx, ruleID, Page{Limit: MaxLimit})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runs: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(runs) != 1 || runs[0].TicketID == nil ||
|
||||||
|
*runs[0].TicketID != tk.ID {
|
||||||
|
t.Errorf("runs = %+v (total %d), want seed ticket %d", runs, total, tk.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Срок наступил, но первая заявка открыта — автозапуск ждёт.
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE recurring_rules SET created_at = now() - make_interval(years => 2)
|
||||||
|
WHERE id = $1`, ruleID); err != nil {
|
||||||
|
t.Fatalf("backdate: %v", err)
|
||||||
|
}
|
||||||
|
created, err := repo.RunDueRules(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run due: %v", err)
|
||||||
|
}
|
||||||
|
if n := len(dueFor(created, ruleID)); n != 0 {
|
||||||
|
t.Errorf("open seed ticket created %d duplicates, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычная заявка без чекбокса — без связи с правилом.
|
||||||
|
plain := &models.Ticket{
|
||||||
|
ClientID: client.ID, ProjectID: projectID, ObjectID: objectID,
|
||||||
|
Address: address, Category: "other", Description: "Прочее",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, plain); err != nil {
|
||||||
|
t.Fatalf("create plain: %v", err)
|
||||||
|
}
|
||||||
|
plainGot, err := tickets.GetByID(ctx, plain.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get plain: %v", err)
|
||||||
|
}
|
||||||
|
if plainGot.Recurring != nil {
|
||||||
|
t.Errorf("plain ticket recurring = %+v, want nil", plainGot.Recurring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueFor — срабатывания конкретного правила. База общая с dev-сервером:
|
||||||
|
// в общем прогоне RunDueRules могут созреть и чужие правила, свои
|
||||||
|
// проверки считаем только по своему rule_id.
|
||||||
|
func dueFor(created []DueRun, ruleID int64) []DueRun {
|
||||||
|
var out []DueRun
|
||||||
|
for _, d := range created {
|
||||||
|
if d.RuleID == ruleID {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolPtr — короткая запись указателя для RulePatch.
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
// strPtr / intPtr / timePtr — то же для строк, чисел и дат в RulePatch.
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
func intPtr(n int) *int { return &n }
|
||||||
|
func timePtr(t time.Time) *time.Time { return &t }
|
||||||
92
internal/repository/refresh_token.go
Normal file
92
internal/repository/refresh_token.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefreshTokenRepo — сессии клиентов (refresh-токены).
|
||||||
|
// В БД лежит только sha256-хеш: утечка дампа не даёт рабочих токенов.
|
||||||
|
type RefreshTokenRepo struct {
|
||||||
|
db Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRefreshTokenRepo создаёт репозиторий refresh-сессий.
|
||||||
|
func NewRefreshTokenRepo(db Querier) *RefreshTokenRepo {
|
||||||
|
return &RefreshTokenRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken — запись о выпущенной сессии.
|
||||||
|
type RefreshToken struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UserAgent string
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create — выпустить сессию. user_agent/ip пишем для пользователя:
|
||||||
|
// где и с чего устройства он вошёл (для будущего списка сессий).
|
||||||
|
func (r *RefreshTokenRepo) Create(ctx context.Context, id, userID, tokenHash string, expiresAt time.Time, userAgent, ip string) error {
|
||||||
|
_, err := r.db.Exec(ctx,
|
||||||
|
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, user_agent, ip)
|
||||||
|
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''))`,
|
||||||
|
id, userID, tokenHash, expiresAt, userAgent, ip,
|
||||||
|
)
|
||||||
|
return classify(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByHash — найти сессию по хешу токена.
|
||||||
|
func (r *RefreshTokenRepo) GetByHash(ctx context.Context, tokenHash string) (*RefreshToken, error) {
|
||||||
|
var t RefreshToken
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT id, user_id, expires_at, revoked_at, created_at,
|
||||||
|
COALESCE(user_agent, ''), COALESCE(ip, '')
|
||||||
|
FROM refresh_tokens WHERE token_hash = $1`, tokenHash,
|
||||||
|
).Scan(&t.ID, &t.UserID, &t.ExpiresAt, &t.RevokedAt, &t.CreatedAt, &t.UserAgent, &t.IP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classify(fmt.Errorf("refresh token: %w", err))
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeByHash — погасить одну сессию (logout). Отзыв идемпотентен:
|
||||||
|
// уже отозванный или несуществующий токен не считается ошибкой.
|
||||||
|
// Возвращает, была ли строка найдена.
|
||||||
|
func (r *RefreshTokenRepo) RevokeByHash(ctx context.Context, tokenHash string) (bool, error) {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE refresh_tokens SET revoked_at = now()
|
||||||
|
WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash)
|
||||||
|
if err != nil {
|
||||||
|
return false, classify(err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllForUser — закрыть все сессии пользователя: нужно при деактивации
|
||||||
|
// аккаунта и при обнаружении повторного использования отозванного токена
|
||||||
|
// (признак, что секретом кто-то ещё пользуется).
|
||||||
|
func (r *RefreshTokenRepo) RevokeAllForUser(ctx context.Context, userID string) (int64, error) {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`UPDATE refresh_tokens SET revoked_at = now()
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, classify(err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExpired — удалить протухшие и отозванные записи.
|
||||||
|
// Взрослые строки в таблице сессий не нужны никому.
|
||||||
|
func (r *RefreshTokenRepo) DeleteExpired(ctx context.Context) (int64, error) {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`DELETE FROM refresh_tokens
|
||||||
|
WHERE expires_at < now() OR revoked_at < now() - interval '7 days'`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, classify(err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
275
internal/repository/telegram.go
Normal file
275
internal/repository/telegram.go
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramRepo — привязка Telegram, токены собственных ботов и выборка
|
||||||
|
// заявок-кандидатов на уведомление (миграции 0011–0012). Держим отдельно
|
||||||
|
// от профилей и заявок: тема одна, а SQL здесь — про «куда слать»,
|
||||||
|
// «чем слать» и «сколько уже слали».
|
||||||
|
type TelegramRepo struct {
|
||||||
|
db Querier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTelegramRepo создаёт репозиторий поверх Querier (пул или транзакция).
|
||||||
|
func NewTelegramRepo(db Querier) *TelegramRepo {
|
||||||
|
return &TelegramRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target — куда слать уведомления ("" — не настроено; NULL в базе
|
||||||
|
// нормализуется в пустую строку). ErrNotFound — профиля нет.
|
||||||
|
func (r *TelegramRepo) Target(ctx context.Context, userID string) (string, error) {
|
||||||
|
var target *string
|
||||||
|
err := r.db.QueryRow(ctx,
|
||||||
|
`SELECT telegram_target FROM profiles WHERE id = $1`, userID).Scan(&target)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("telegram target: %w", err)
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return *target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTarget записывает цель уведомлений; пустая строка очищает
|
||||||
|
// (NULLIF кладёт NULL — «отключено» физически, а не пустым текстом).
|
||||||
|
// ErrNotFound — профиля нет (сессия могла пережить удаление).
|
||||||
|
func (r *TelegramRepo) SetTarget(ctx context.Context, userID, target string) error {
|
||||||
|
tag, err := r.db.Exec(ctx, `
|
||||||
|
UPDATE profiles SET telegram_target = NULLIF($2, '')
|
||||||
|
WHERE id = $1`, userID, target)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set telegram target: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBindCode выпускает одноразовый код привязки: прежние
|
||||||
|
// неиспользованные коды пользователя гаснут — действует ровно один
|
||||||
|
// свежий, как и токены сброса пароля (CreateResetToken).
|
||||||
|
// Возвращает срок жизни (time.Now()+ttl).
|
||||||
|
func (r *TelegramRepo) CreateBindCode(ctx context.Context, userID, codeHash string, ttl time.Duration) (time.Time, error) {
|
||||||
|
if _, err := r.db.Exec(ctx, `
|
||||||
|
DELETE FROM telegram_bind_codes
|
||||||
|
WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("supersede bind codes: %w", err)
|
||||||
|
}
|
||||||
|
expires := time.Now().Add(ttl)
|
||||||
|
if _, err := r.db.Exec(ctx, `
|
||||||
|
INSERT INTO telegram_bind_codes (user_id, code_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3)`, userID, codeHash, expires); err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("create bind code: %w", err)
|
||||||
|
}
|
||||||
|
return expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeBindCode гасит код одним UPDATE — одноразовость и срок живут
|
||||||
|
// в самом WHERE, поэтому две параллельные привязки невозможны.
|
||||||
|
// Возвращает владельца кода; ErrNotFound — неизвестен, уже использован
|
||||||
|
// или истёк (причина наружу не раскрывается).
|
||||||
|
func (r *TelegramRepo) ConsumeBindCode(ctx context.Context, codeHash string) (string, error) {
|
||||||
|
var userID string
|
||||||
|
err := r.db.QueryRow(ctx, `
|
||||||
|
UPDATE telegram_bind_codes
|
||||||
|
SET used_at = now()
|
||||||
|
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
RETURNING user_id`, codeHash).Scan(&userID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("consume bind code: %w", err)
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupBindCodes удаляет протухшие строки (использованные тоже:
|
||||||
|
// история кодов никому не нужна, таблица должна оставаться маленькой).
|
||||||
|
func (r *TelegramRepo) CleanupBindCodes(ctx context.Context, before time.Time) (int64, error) {
|
||||||
|
tag, err := r.db.Exec(ctx,
|
||||||
|
`DELETE FROM telegram_bind_codes WHERE expires_at < $1`, before)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("cleanup bind codes: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotTokenInfo — есть ли у пользователя свой токен бота и как его бот
|
||||||
|
// называется (имя из getMe при сохранении). Токен сюда не входит: он
|
||||||
|
// наружу не отдаётся никому, включая владельца.
|
||||||
|
// ErrNotFound — профиля нет (сессия могла пережить удаление).
|
||||||
|
func (r *TelegramRepo) BotTokenInfo(ctx context.Context, userID string) (has bool, username string, err error) {
|
||||||
|
var enc []byte
|
||||||
|
var name *string
|
||||||
|
err = r.db.QueryRow(ctx, `
|
||||||
|
SELECT telegram_bot_token, telegram_bot_username
|
||||||
|
FROM profiles WHERE id = $1`, userID).Scan(&enc, &name)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, "", fmt.Errorf("telegram bot token info: %w", err)
|
||||||
|
}
|
||||||
|
if name != nil {
|
||||||
|
username = *name
|
||||||
|
}
|
||||||
|
return enc != nil, username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBotToken сохраняет зашифрованный токен и имя бота (оба поля одной
|
||||||
|
// операцией — чтобы статус не показывал имя бота без токена и наоборот).
|
||||||
|
// ErrNotFound — профиля нет.
|
||||||
|
func (r *TelegramRepo) SetBotToken(ctx context.Context, userID string, encToken, username string) error {
|
||||||
|
tag, err := r.db.Exec(ctx, `
|
||||||
|
UPDATE profiles
|
||||||
|
SET telegram_bot_token = $2, telegram_bot_username = NULLIF($3, '')
|
||||||
|
WHERE id = $1`, userID, []byte(encToken), username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set telegram bot token: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearBotToken убирает свой токен: уведомления перестают уходить через
|
||||||
|
// этого бота, его поллер останавливается (см. notify.Registry).
|
||||||
|
// ErrNotFound — профиля нет.
|
||||||
|
func (r *TelegramRepo) ClearBotToken(ctx context.Context, userID string) error {
|
||||||
|
tag, err := r.db.Exec(ctx, `
|
||||||
|
UPDATE profiles
|
||||||
|
SET telegram_bot_token = NULL, telegram_bot_username = NULL
|
||||||
|
WHERE id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clear telegram bot token: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBot — пользователь со своим ботом: токен приходит зашифрованным,
|
||||||
|
// расшифровывает вызывающий (репозиторий не знает про ключ).
|
||||||
|
type UserBot struct {
|
||||||
|
UserID string
|
||||||
|
// TokenEnc — зашифрованный токен (AES-GCM от crypt.Cipher).
|
||||||
|
TokenEnc string
|
||||||
|
// Username — имя бота из getMe, чтобы показывать его до первого
|
||||||
|
// сетевого вызова.
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotTokens — все сохранённые токены: читается один раз при старте,
|
||||||
|
// чтобы поднять клиенты ботов и их поллеры.
|
||||||
|
func (r *TelegramRepo) BotTokens(ctx context.Context) ([]UserBot, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
SELECT id, telegram_bot_token, telegram_bot_username
|
||||||
|
FROM profiles
|
||||||
|
WHERE telegram_bot_token IS NOT NULL
|
||||||
|
ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list telegram bot tokens: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]UserBot, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
var u UserBot
|
||||||
|
var enc []byte
|
||||||
|
var name *string
|
||||||
|
if err := rows.Scan(&u.UserID, &enc, &name); err != nil {
|
||||||
|
return nil, fmt.Errorf("list telegram bot tokens scan: %w", err)
|
||||||
|
}
|
||||||
|
u.TokenEnc = string(enc)
|
||||||
|
if name != nil {
|
||||||
|
u.Username = *name
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("list telegram bot tokens: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleTicket — заявка, которую пора напомнить, и куда напоминание слать.
|
||||||
|
type StaleTicket struct {
|
||||||
|
ID int64
|
||||||
|
Address string
|
||||||
|
Category string
|
||||||
|
Priority string
|
||||||
|
ClientName string
|
||||||
|
// ClientID — владелец заявки: по нему выбирается бот, через который
|
||||||
|
// уйдёт сообщение (свой токен пользователя или глобальный).
|
||||||
|
ClientID string
|
||||||
|
// Target — profiles.telegram_target: числовой chat_id или @группа.
|
||||||
|
Target string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleTickets — кандидаты на уведомление: всё ещё 'new', созданы раньше
|
||||||
|
// cutoff, и последнее уведомление либо не уходило, либо тоже старше cutoff
|
||||||
|
// (повтор — раз в сутки). Без telegram_target и с выключенным аккаунтом —
|
||||||
|
// не попадают: слать некуда. Каким ботом слать — выбирается по ClientID
|
||||||
|
// на стороне нотификатора (личный токен пользователя или глобальный).
|
||||||
|
func (r *TelegramRepo) StaleTickets(ctx context.Context, cutoff time.Time, limit int) ([]StaleTicket, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
SELECT t.id, t.address, t.category, t.priority, p.name,
|
||||||
|
t.client_id, p.telegram_target
|
||||||
|
FROM tickets t
|
||||||
|
JOIN profiles p ON p.id = t.client_id
|
||||||
|
WHERE t.status = 'new'
|
||||||
|
AND t.created_at <= $1
|
||||||
|
AND (t.tg_notified_at IS NULL OR t.tg_notified_at <= $1)
|
||||||
|
AND p.is_active
|
||||||
|
AND p.telegram_target IS NOT NULL
|
||||||
|
ORDER BY t.created_at
|
||||||
|
LIMIT $2`, cutoff, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stale tickets: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]StaleTicket, 0, 16)
|
||||||
|
for rows.Next() {
|
||||||
|
var s StaleTicket
|
||||||
|
if err := rows.Scan(&s.ID, &s.Address, &s.Category, &s.Priority,
|
||||||
|
&s.ClientName, &s.ClientID, &s.Target); err != nil {
|
||||||
|
return nil, fmt.Errorf("stale tickets scan: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("stale tickets: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotified фиксирует отправку: со следующего тика заявка молчит
|
||||||
|
// сутки. Условие status='new' — гонка со взятием заявки в работу:
|
||||||
|
// если её успели назначить, напоминание уже неактуально.
|
||||||
|
func (r *TelegramRepo) MarkNotified(ctx context.Context, ticketID int64) error {
|
||||||
|
tag, err := r.db.Exec(ctx, `
|
||||||
|
UPDATE tickets SET tg_notified_at = now()
|
||||||
|
WHERE id = $1 AND status = 'new'`, ticketID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark notified: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
335
internal/repository/telegram_test.go
Normal file
335
internal/repository/telegram_test.go
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"building-mgmt/internal/models"
|
||||||
|
"building-mgmt/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Тесты темы «куда слать» (миграция 0011): цель в профиле, одноразовые
|
||||||
|
// коды привязки и выборка заявок-кандидатов. Интеграционные: живая БД
|
||||||
|
// через DATABASE_URL, без неё — пропуск.
|
||||||
|
|
||||||
|
// TestTelegramTarget — цель уведомлений: пусто по умолчанию, запись,
|
||||||
|
// очистка, отсутствующий профиль.
|
||||||
|
func TestTelegramTarget(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewTelegramRepo(q)
|
||||||
|
p := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
got, err := repo.Target(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("target: %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("target = %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetTarget(ctx, p.ID, "@buildings_group"); err != nil {
|
||||||
|
t.Fatalf("set target: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = repo.Target(ctx, p.ID); err != nil || got != "@buildings_group" {
|
||||||
|
t.Errorf("target = %q, err = %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка — пустой строкой; читается как «не настроено».
|
||||||
|
if err := repo.SetTarget(ctx, p.ID, ""); err != nil {
|
||||||
|
t.Fatalf("clear target: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = repo.Target(ctx, p.ID); err != nil || got != "" {
|
||||||
|
t.Errorf("target after clear = %q, err = %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сессия могла пережить удаление профиля — это не найдено, не 500.
|
||||||
|
if _, err := repo.Target(ctx, "00000000-0000-0000-0000-000000000000"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("target for missing user: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.SetTarget(ctx, "00000000-0000-0000-0000-000000000000", "@g"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("set for missing user: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTelegramBindCodes — одноразовость, гашение прежних, срок, чистка.
|
||||||
|
func TestTelegramBindCodes(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewTelegramRepo(q)
|
||||||
|
p := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
const ttl = 15 * time.Minute
|
||||||
|
|
||||||
|
// --- выпуск и погашение ---
|
||||||
|
hash := telegram.CodeHash("CODEONE1")
|
||||||
|
expires, err := repo.CreateBindCode(ctx, p.ID, hash, ttl)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create code: %v", err)
|
||||||
|
}
|
||||||
|
if !expires.After(time.Now().Add(ttl - time.Minute)) {
|
||||||
|
t.Errorf("expires = %v, want ~%v from now", expires, ttl)
|
||||||
|
}
|
||||||
|
userID, err := repo.ConsumeBindCode(ctx, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("consume: %v", err)
|
||||||
|
}
|
||||||
|
if userID != p.ID {
|
||||||
|
t.Errorf("user = %s, want %s", userID, p.ID)
|
||||||
|
}
|
||||||
|
// Код одноразовый.
|
||||||
|
if _, err := repo.ConsumeBindCode(ctx, hash); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("reuse: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// Неизвестный код — та же ошибка, без подсказок.
|
||||||
|
if _, err := repo.ConsumeBindCode(ctx, telegram.CodeHash("NOSUCHCD")); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("unknown: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- выпуск нового гасит прежний: живёт ровно один ---
|
||||||
|
first := telegram.CodeHash("CODETWO1")
|
||||||
|
second := telegram.CodeHash("CODETHRE")
|
||||||
|
if _, err := repo.CreateBindCode(ctx, p.ID, first, ttl); err != nil {
|
||||||
|
t.Fatalf("first code: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.CreateBindCode(ctx, p.ID, second, ttl); err != nil {
|
||||||
|
t.Fatalf("second code: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.ConsumeBindCode(ctx, first); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("superseded code: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if userID, err = repo.ConsumeBindCode(ctx, second); err != nil || userID != p.ID {
|
||||||
|
t.Errorf("fresh code: user = %s, err = %v", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- просроченный не работает (реально 15 минут не ждём) ---
|
||||||
|
expired := telegram.CodeHash("CODEFOUR")
|
||||||
|
if _, err := repo.CreateBindCode(ctx, p.ID, expired, ttl); err != nil {
|
||||||
|
t.Fatalf("expired code: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Exec(ctx, `
|
||||||
|
UPDATE telegram_bind_codes
|
||||||
|
SET created_at = now() - interval '2 hour',
|
||||||
|
expires_at = now() - interval '1 minute'
|
||||||
|
WHERE code_hash = $1`, expired); err != nil {
|
||||||
|
t.Fatalf("age code: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.ConsumeBindCode(ctx, expired); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("expired: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- чистка: протухшие строки уходят, свежие остаются ---
|
||||||
|
fresh := telegram.CodeHash("CODEFIVE")
|
||||||
|
if _, err := repo.CreateBindCode(ctx, p.ID, fresh, ttl); err != nil {
|
||||||
|
// Прежний неиспользованный (expired) гасится новым выпуском,
|
||||||
|
// поэтому здесь проверяем именно удаление устаревшего.
|
||||||
|
t.Fatalf("fresh code: %v", err)
|
||||||
|
}
|
||||||
|
// Возвращаем устаревшую строку: удаляли бы её сейчас — нечего было бы чистить.
|
||||||
|
if _, err := q.Exec(ctx, `
|
||||||
|
INSERT INTO telegram_bind_codes (user_id, code_hash, created_at, expires_at)
|
||||||
|
VALUES ($1, $2, now() - interval '2 hour', now() - interval '1 hour')
|
||||||
|
ON CONFLICT (code_hash) DO NOTHING`,
|
||||||
|
p.ID, telegram.CodeHash("CODEOLDX")); err != nil {
|
||||||
|
t.Fatalf("insert stale: %v", err)
|
||||||
|
}
|
||||||
|
n, err := repo.CleanupBindCodes(ctx, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cleanup: %v", err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Errorf("cleanup removed %d, want >= 1", n)
|
||||||
|
}
|
||||||
|
// Свежий код чистке не достаётся.
|
||||||
|
if _, err := repo.ConsumeBindCode(ctx, fresh); err != nil {
|
||||||
|
t.Errorf("fresh code after cleanup: err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTelegramBotToken — собственный токен бота (миграция 0012): запись
|
||||||
|
// зашифрованного значения, статус для API, список для загрузки при
|
||||||
|
// старте, очистка, отсутствующий профиль.
|
||||||
|
func TestTelegramBotToken(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
repo := NewTelegramRepo(q)
|
||||||
|
p := createProfile(t, ctx, q, "client")
|
||||||
|
|
||||||
|
// Токена нет — статус это показывает, списка для старта нет.
|
||||||
|
has, name, err := repo.BotTokenInfo(ctx, p.ID)
|
||||||
|
if err != nil || has || name != "" {
|
||||||
|
t.Fatalf("initial: has=%v name=%q err=%v", has, name, err)
|
||||||
|
}
|
||||||
|
list, err := repo.BotTokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, u := range list {
|
||||||
|
if u.UserID == p.ID {
|
||||||
|
t.Errorf("fresh list contains user %s", p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение: значение не трогаем (оно уже зашифровано снаружи),
|
||||||
|
// имя бота ложится рядом.
|
||||||
|
if err := repo.SetBotToken(ctx, p.ID, "enc-gcm-blob", "my_test_bot"); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
if has, name, err = repo.BotTokenInfo(ctx, p.ID); err != nil || !has || name != "my_test_bot" {
|
||||||
|
t.Errorf("after set: has=%v name=%q err=%v", has, name, err)
|
||||||
|
}
|
||||||
|
list, err = repo.BotTokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, u := range list {
|
||||||
|
if u.UserID == p.ID {
|
||||||
|
found = true
|
||||||
|
if u.TokenEnc != "enc-gcm-blob" || u.Username != "my_test_bot" {
|
||||||
|
t.Errorf("list entry = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("saved token not in BotTokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка: токена больше нет, строка исчезает и из списка.
|
||||||
|
if err := repo.ClearBotToken(ctx, p.ID); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
if has, name, err = repo.BotTokenInfo(ctx, p.ID); err != nil || has || name != "" {
|
||||||
|
t.Errorf("after clear: has=%v name=%q err=%v", has, name, err)
|
||||||
|
}
|
||||||
|
list, err = repo.BotTokens(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
for _, u := range list {
|
||||||
|
if u.UserID == p.ID {
|
||||||
|
t.Errorf("cleared token still listed: %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сессия могла пережить удаление профиля — это не найдено, не 500.
|
||||||
|
const missing = "00000000-0000-0000-0000-000000000000"
|
||||||
|
if _, _, err := repo.BotTokenInfo(ctx, missing); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("info for missing user: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.SetBotToken(ctx, missing, "enc", "b"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("set for missing user: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := repo.ClearBotToken(ctx, missing); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("clear for missing user: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaleTickets — кандидаты на уведомление и дедуп по tg_notified_at.
|
||||||
|
func TestStaleTickets(t *testing.T) {
|
||||||
|
q, ctx := newTestQuerier(t)
|
||||||
|
tg := NewTelegramRepo(q)
|
||||||
|
tickets := NewTicketRepo(q)
|
||||||
|
|
||||||
|
client := createProfile(t, ctx, q, "client")
|
||||||
|
worker := createProfile(t, ctx, q, "worker")
|
||||||
|
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
|
||||||
|
|
||||||
|
tk := &models.Ticket{
|
||||||
|
ClientID: client.ID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Address: address,
|
||||||
|
Floor: "3",
|
||||||
|
Category: "plumbing",
|
||||||
|
Description: "течёт кран",
|
||||||
|
Priority: "high",
|
||||||
|
}
|
||||||
|
if err := tickets.Create(ctx, tk); err != nil {
|
||||||
|
t.Fatalf("create ticket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
// Без цели уведомлений слать некуда.
|
||||||
|
if got, err := tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("no target: got %d, err %v", len(got), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежая заявка — не кандидат, даже с настроенной целью.
|
||||||
|
if err := tg.SetTarget(ctx, client.ID, "@buildings_group"); err != nil {
|
||||||
|
t.Fatalf("set target: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("fresh ticket: got %d, err %v", len(got), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Старше суток — кандидат, все поля на месте.
|
||||||
|
stale := time.Now().Add(-25 * time.Hour)
|
||||||
|
if _, err := q.Exec(ctx,
|
||||||
|
`UPDATE tickets SET created_at = $2 WHERE id = $1`, tk.ID, stale); err != nil {
|
||||||
|
t.Fatalf("age ticket: %v", err)
|
||||||
|
}
|
||||||
|
got, err := tg.StaleTickets(ctx, cutoff, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stale: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("stale: got %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
s := got[0]
|
||||||
|
if s.ID != tk.ID || s.Target != "@buildings_group" ||
|
||||||
|
s.Category != "plumbing" || s.Priority != "high" ||
|
||||||
|
s.ClientName != client.Name || s.Address != address ||
|
||||||
|
s.ClientID != client.ID {
|
||||||
|
t.Errorf("candidate = %+v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уведомление ушло — повтора нет (дедуп по tg_notified_at).
|
||||||
|
if err := tg.MarkNotified(ctx, tk.ID); err != nil {
|
||||||
|
t.Fatalf("mark: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("after mark: got %d, err %v", len(got), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сутки спустя — снова кандидат (имитируем прошедшее время).
|
||||||
|
if _, err := q.Exec(ctx, `
|
||||||
|
UPDATE tickets SET tg_notified_at = now() - interval '25 hours'
|
||||||
|
WHERE id = $1`, tk.ID); err != nil {
|
||||||
|
t.Fatalf("age notified_at: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("next day: got %d, err %v", len(got), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявку взяли — напоминание неактуально: ни кандидатов, ни фиксации.
|
||||||
|
// Меняем как в репозитории: worker_id и status одной операцией плюс
|
||||||
|
// assigned_at (CHECK tickets_assigned_time).
|
||||||
|
if _, err := q.Exec(ctx, `
|
||||||
|
UPDATE tickets
|
||||||
|
SET status = 'assigned', worker_id = $2, assigned_at = now()
|
||||||
|
WHERE id = $1`, tk.ID, worker.ID); err != nil {
|
||||||
|
t.Fatalf("assign: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("assigned: got %d, err %v", len(got), err)
|
||||||
|
}
|
||||||
|
if err := tg.MarkNotified(ctx, tk.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("mark assigned: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выключенный аккаунт — не получает уведомлений.
|
||||||
|
if _, err := q.Exec(ctx, `
|
||||||
|
UPDATE profiles SET is_active = false WHERE id = $1`, client.ID); err != nil {
|
||||||
|
t.Fatalf("disable client: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := q.Exec(ctx, `
|
||||||
|
UPDATE tickets
|
||||||
|
SET status = 'new', worker_id = NULL, assigned_at = NULL,
|
||||||
|
tg_notified_at = NULL
|
||||||
|
WHERE id = $1`, tk.ID); err != nil {
|
||||||
|
t.Fatalf("back to new: %v", err)
|
||||||
|
}
|
||||||
|
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("inactive client: got %d, err %v", len(got), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user