Initial commit: backend structure

This commit is contained in:
edadmin 2026-10-18 22:07:25 +03:00
commit 519903b930
105 changed files with 34325 additions and 0 deletions

16
.dockerignore Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
.env
*.log
/tmp/

39
.golangci.yml Normal file
View 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
View 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
View 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

1158
README.md Normal file

File diff suppressed because it is too large Load Diff

736
cmd/api/main.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

4766
docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

3343
docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

82
go.mod Normal file
View 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
View 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
View 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
View 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, ""}, ".")
}

View 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
View 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
}

View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
}

View 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);

View 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);

View 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;

View 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);

View 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);

View 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);

View 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);

View 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'));

View 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);

View 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;

View 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';

View 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;
-- Отбор пользователей со своим ботом при старте процесса (реестр ботов
-- и их поллеры): колонка трогается редко, индекс не нужен.

View 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;

View 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);

View 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);

View 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
View 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})
}

View 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)
})
}

View 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"
}
}

View 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
}

View 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"})
}
}

View 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))
}
})
}

View 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
View 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
}

View 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)
}
}

View 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})
}

View 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,
})
}

View 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
}

View 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)
}
}
}

View 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
View 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
View 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})
}

View 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)
}
})
}
}

View 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
View 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
}

View 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, "Иван &lt;script&gt;") {
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, "Иван &lt;script&gt;") {
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)
}
}

View 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...)
}
}
}

View 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()
}
}

View 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)
}
}

View 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()
}
}

View 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
View 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
View 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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
).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": "высокий",
}
)

View 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, "&lt;Ленина&gt;") {
t.Errorf("want escaped address:\n%s", text)
}
if !strings.Contains(text, "&amp;") {
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
View 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
View 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)
}

View 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
View 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)
}
}
}
}

View 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...)
}

View 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()
}
}

View 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
View 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)
}

View 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
View 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
}

View 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
}

View 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[:])
}

View 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")
}
}

View 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)
}

View 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)
}
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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 }

View 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
}

View 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
}

View 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