# building-mgmt-backend Backend для сервиса управления заявками в здании: админ создаёт клиента, клиент заводит **проект**, объекты в нём (здания с адресами) и приглашает мастеров, назначает заявку принятому мастеру, мастер ведёт работу до завершения — каждый переход остаётся в журнале событий заявки. **Стек:** Go · Gin · PostgreSQL (pgx v5) · JWT (golang-jwt) · bcrypt --- ## Быстрый старт 1. Инфраструктура. Один `docker compose up` поднимает базу **и MinIO** — локальный S3 для фотоотчёта (в `.env` подставляется `DATABASE_URL` соответствующего вида): ```bash docker compose up -d --wait # или: make up ``` - **PostgreSQL** — локальная в docker, не зависит от чужих серверов и туннеля. Порт наружу **5433**, чтобы не конфликтовать с пробросом рабочей базы на 5432: `postgres://building_app:building_app@localhost:5433/building_mgmt?sslmode=disable`. Контейнер ограничен по ресурсам (`mem_limit: 512m`, `cpus: 1`): dev-база не должна сожрать машину; `max_connections` дефолтный (100), пул приложения и так маленький. - **MinIO** — S3-совместимое хранилище фотоотчёта: API на **5434**, веб-консоль на **5435** (вход/пароль `minioadmin/minioadmin`), бакет создаётся сам при старте сервера. Образ собирается из официального бинарника релиза `RELEASE.2024-07-26T20-48-21Z` (см. `deploy/minio/Dockerfile`): на Docker Hub `minio/minio` архивирован, анонимный pull недоступен. В проде подойдёт любой S3-совместимый (AWS, R2, Wasabi) — ключи задаются в `.env`. Остановить — `docker compose down` (`make down`), данные остаются в томах. - **внешняя база через туннель** — как раньше: `localhost:5432` (MinIO поднимается тем же compose). 2. Положить рядом `.env` (пример ключей — в `.env.example`): ```dotenv DATABASE_URL=postgres://user:pass@localhost:5432/building_mgmt?sslmode=disable JWT_SECRET=минимум-32-случайных-символа PORT=8080 SWAGGER=true # включить Swagger UI (в проде не ставить) # TRUSTED_PROXIES=172.0.0.0/12 # за reverse proxy — его подсеть # (по умолчанию доверен только loopback) S3_ENDPOINT=http://localhost:5434 # локальный MinIO; у штатного AWS — пусто S3_BUCKET=building-mgmt # обязателен, иначе сервер не стартует # LOG_FORMAT=json|text и LOG_LEVEL=debug|info|warn|error — необязательно, # все ключи с пояснениями перечислены в .env.example ``` 3. Запустить: ```bash go run ./cmd/api # или: make run ``` Миграции применяются **автоматически при старте** — отдельная команда не нужна. Но если нужно накатить схему без запуска сервера (CI, релизный шаг): ```bash go run ./cmd/migrate # или: make migrate ``` Проверка: `GET /ping` — приложение, `GET /db-ping` — база (нужен токен админа), `GET /swagger/index.html` — интерактивная документация API (нужен `SWAGGER=true` в `.env` — включено в примере выше). 4. *(по желанию)* Демо-данные, чтобы не собирать сценарий руками: ```bash go run ./cmd/seed --yes # или: make seed ``` В базе появятся клиент, два мастера, админ, проект «Демо-проект» с двумя объектами и четыре заявки (все ветки статусной машины). Пароль у демо-аккаунтов один и печатается командой. Повторный запуск идемпотентен, `--force` пересоздаёт. **Только локальная/демо-база** — пароли фиксированные. ### Первый админ Самый быстрый способ — `make seed` (флаг `--yes` создаёт `demo-admin@example.com`). Ручной способ описан ниже, он нужен, когда демо-аккаунт не нужен, а админ есть: Аккаунты создаются **только изнутри системы**: админ — админов, клиентов и мастеров (`POST /users`), клиент — исключительно мастеров (роль там разрешена только `worker`), для себя в вебе, раздел «Клиенты». Саморегистрации нет — отдельного эндпоинта и кнопки на экране входа. При этом мастер, созданный клиентом, входит лишь после его подтверждения (раздел «Подтверждение аккаунта (SMTP)»). То есть для самого первого админа нужно завести запись руками, например: ```sql INSERT INTO profiles (id, email, password_hash, name, role, is_active, confirmed_at) VALUES (gen_random_uuid(), 'admin@example.com', '', 'Admin', 'admin', true, now()); ``` Сгенерировать хеш: ```bash go run - <<'EOF' package main import ( "fmt" "golang.org/x/crypto/bcrypt" ) func main() { h, _ := bcrypt.GenerateFromPassword([]byte("ваш-пароль"), 12) fmt.Println(string(h)) } EOF ``` --- ## Роли и эндпоинты | Метод | Путь | Доступ | |---|---|---| | POST | `/auth/login` | открытый (лимит 10/мин на IP); неподтверждённый аккаунт → `403 account not confirmed`, отключённый → `403 account disabled` | | POST | `/auth/refresh` | открытый, без тела: refresh-токен в httpOnly-куке `refresh_token` (лимит 30/мин) | | POST | `/auth/logout` | открытый, без тела: гасит сессию из куки и саму куку | | POST | `/auth/logout-all` | любой авторизованный | | POST | `/auth/forgot-password` | открытый, тело `{email}`; ответ всегда 200 (ящик не «светится»), письмо — только подтверждённым активным; лимит 10/мин на IP и **3 письма/сутки на ящик** → `429` | | POST | `/auth/reset-password` | открытый, тело `{token, password}` (лимит 30/мин) | | GET | `/auth/me` | любой авторизованный | | GET | `/auth/telegram` | любой авторизованный — свой статус уведомлений `{enabled, bound, target, bot_username, has_bot_token, token_configurable}` (токен и другим полям чужим не отдаётся) | | POST | `/auth/telegram/code` | любой авторизованный — одноразовый код привязки + ссылка `t.me/?start=` (лимит 5 кодов за 5 минут на пользователя) | | PUT | `/auth/telegram` | любой авторизованный, тело `{target}` — `@группа`/`chat_id` либо пусто (отключить); неверный формат или чат, которого бот не видит → `400 invalid telegram target` | | DELETE | `/auth/telegram` | любой авторизованный — отключить уведомления | | POST | `/auth/telegram/bot` | любой авторизованный, тело `{token}` — подключить своего бота: формат → `getMe` → шифрование → поллер (лимит 5 попыток за 5 минут); нет `DATA_ENCRYPTION_KEY` → `400 telegram bot token not configured`, не тот токен → `400 bot token rejected` | | DELETE | `/auth/telegram/bot` | любой авторизованный — отключить своего бота: стирает токен и гасит его поллер (повтор безопасен) | | GET | `/projects` | `client` (свои) / `admin` (все, `?client_id=`) | | POST | `/projects` | `client` (себе) / `admin` (для клиента, тело `{name, client_id}`) | | GET | `/projects/:id` | `client` (свой) / `admin` | | PATCH | `/projects/:id` | `client` (свой) / `admin`, тело `{name?, is_active?}` | | DELETE | `/projects/:id` | **только `admin`** — удалить проект вместе с объектами, участиями и всеми заявками (каскад `0013`, события/фото заявок уходят каскадом `0006`/`0008`); объекты фото из хранилища убираются после коммита (сбой — в лог) | | GET | `/projects/:id/objects` | `client` (свой) / `admin` | | POST | `/projects/:id/objects` | `client` (свой) / `admin`, тело `{name, address}` | | PATCH | `/objects/:id` | `client` (свой) / `admin`, тело `{name?, address?, is_active?}` | | GET | `/projects/:id/workers` | `client` (свой) / `admin` — состав со статусами | | POST | `/projects/:id/workers` | `client` (свой) / `admin`, тело `{worker_id}` — **приглашение** | | DELETE | `/projects/:id/workers/:worker_id` | `client` (свой) / `admin` — отзыв, в том числе непринятого приглашения | | GET | `/invitations` | `worker` (только свои приглашения) | | POST | `/projects/:id/invitation/accept` | `worker` (принять своё приглашение) | | POST | `/projects/:id/invitation/decline` | `worker` (отклонить своё приглашение) | | POST | `/tickets` | `client` (тело `{object_id, floor?, category, description, priority?, recurring?}`; `recurring = {anchor_at, period_days}` — той же операцией создаётся связанное правило повторения, в ответе заявки поле `recurring`) | | GET | `/tickets` | любой (список режется по роли) | | GET | `/tickets/:id` | любой (проверка владельца внутри) | | GET | `/tickets/:id/events` | любой (кто видит заявку) — журнал событий | | GET | `/tickets/:id/photos` | любой (кто видит заявку) — фотоотчёт, `url` во временной подписи | | POST | `/tickets/:id/photos` | `worker` (заявка `in_progress` — после «Работа выполнена» отчёт заморожен, активный мастер проекта) / `admin`; multipart-поле `file`, jpeg/png/webp | | DELETE | `/tickets/:id/photos/:photo_id` | `worker` (своё, пока заявка `in_progress`) / `admin` (любое) | | PATCH | `/tickets/:id/assign` | `admin` / `client` (владелец), тело `{worker_id}` | | PATCH | `/tickets/:id/take` | `worker` (свободная `new` из проектов, где он активный участник) — назначает себя | | PATCH | `/tickets/:id/start` | `worker` (только назначенный ему) | | PATCH | `/tickets/:id/done` | `worker` (только назначенный ему) | | PATCH | `/tickets/:id/confirm` | `client` (владелец, только из `done`) | | PATCH | `/tickets/:id/reject` | `client` (владелец, только из `done`, тело `{reason}`) | | PATCH | `/tickets/:id/cancel` | `client` (своя) / `admin` (любая), тело `{reason}` | | GET | `/recurring` | `client` (свои) / `admin` (все), `&project_id=` — правила повторяющихся заявок, обычная страница | | POST | `/recurring` | `client` (объект своего проекта) / `admin` (любой — владелец берётся из проекта), тело `{object_id, floor?, category, description, priority?, anchor_at, period_days}` (`anchor_at` — дата-время первого запуска в RFC3339, `period_days` 1..3650 — «каждые N дней/недель») | | GET | `/recurring/:id` | `client` (своё) / `admin` — чужое неотличимо от несуществующего (`404`) | | PATCH | `/recurring/:id` | `client` (своё) / `admin`, тело — любые из `{floor, category, description, priority, anchor_at, period_days, is_active}` (минимум одно поле, `object_id` не меняется); выключенное правило планировщик пропускает, изменение расписания пересчитывает срок | | DELETE | `/recurring/:id` | `client` (своё) / `admin` — правило удаляется вместе с историей срабатываний (каскад `0014`), созданные заявки не трогаются | | GET | `/recurring/:id/runs` | `client` (своё) / `admin` — история срабатываний: какие заявки создавались по правилу и когда | | POST | `/recurring/:id/run` | `client` (своё) / `admin` — ручной запуск: заявка создаётся сразу, не дожидаясь срока; `409`, если правило/проект/объект выключены или есть незакрытая заявка (дубль не создаётся); ответ `201 {ok, ticket_id}` | | POST | `/users` | `admin` (мастеры, админы и клиенты; созданные админом подтверждаются сразу) / `client` (только мастера; созданные ждут его подтверждения) | | GET | `/users?role=worker` | `admin` (любая роль; `&include_inactive=1` — и отключённые) / `client` (свои мастера: активные участники его проектов плюс созданные им, с `&include_inactive=1` — и отключённые); `&q=` — поиск по имени/email (мин. 2 символа), у клиента всегда только активные | | POST | `/users/:id/confirm` | `admin` (любой неподтверждённый) / `client` (только созданный им мастер); чужой и несуществующий → `404`, повтор → `409 already confirmed`; после успеха пользователю шлётся письмо | | PATCH | `/users/:id` | `admin` (любой) / `client` (свой мастер: активный участник его проекта либо создан им), тело `{is_active}` — включить/выключить аккаунт | | DELETE | `/users/:id` | `admin` — удалить «чистый» аккаунт; `client` — только своих мастеров (участники его проектов либо созданные им; членства в проектах клиента удаляются в той же транзакции); если есть заявки/проекты/чужие участия → `409` (вместо удаления — отключение через PATCH) | | POST | `/users/:id/password-reset` | `admin` — выпустить токен смены пароля | | POST | `/users/:id/password-set` | `admin` — задать пароль напрямую, тело `{password}` (политика та же: 10..72 символа, буквы и цифры); успех гасит все refresh-сессии пользователя и его неиспользованные токены сброса; `404` на несуществующий id | | GET | `/db-ping` | `admin` | Ответы `login` и `refresh` одинаковы: ```json {"token": "", "expires_in": 3600, "user": {"id": "…", "email": "…", "role": "client"}} ``` Создание аккаунта — только `POST /users`: ответ сразу профиль (см. таблицу выше), никакого отдельного статуса ожидания нет. Refresh-токен в JSON не входит: он уходит только в **httpOnly-куку** `refresh_token` (`Path=/auth`, `SameSite=Lax`, `HttpOnly`; `Secure` включается в проде флагом `COOKIE_SECURE=true` — по HTTP браузер Secure-куку отвергает). Фронт в `localStorage` хранит лишь access-токен, профиль и срок — XSS из куки ничего не вытащит, а в cross-site POST кука не уходит вовсе (CSRF закрыт на стороне браузера). **Парольная политика** (`auth.ValidatePassword`, только на установку — вход со старыми паролями работает): 10–72 символа (72 — предел bcrypt), обязательны буквы и цифры. Применяется при создании пользователя и смене пароля; ошибка — `400` с текстом вида `password must be at least 10 characters`. ### Подтверждение аккаунта (SMTP) Неподтверждённый аккаунт — это в первую очередь мастер, созданный клиентом: токенов нет, вход до подтверждения — `403 account not confirmed` (не путать с `account disabled`). Письмо «Аккаунт подтверждён» уходит пользователю **после** подтверждения: multipart `text/plain` + `text/html` — email аккаунта, роль, дата подтверждения и кнопка входа (`${APP_BASE_URL}/#/login`), имя/email в HTML экранируются. Кто подтверждает — правило целиком в SQL (колонки `confirmed_at` / `confirmed_by` / `created_by`, миграция 0010): - **админ** — любой неподтверждённый аккаунт (`POST /users/:id/confirm`); - **клиент** — только созданного им мастера: чужой и несуществующий неразличимы (`404`), повторное подтверждение — `409 already confirmed`; - **создание админом = подтверждение сразу** («создатель и есть подтверждающий»): админские `POST /users` не требуют второго клика; - созданный клиентом мастер ждёт подтверждения этим же клиентом — в вебе у неподтверждённых бейдж «не подтверждён» и кнопка «Подтвердить» (раздел «Мастера»); - существующие на момент обновления аккаунты подтверждены бэкфиллом миграции. Неподтверждённого мастера нельзя пригласить в проект (`POST /projects/:id/workers` → `400 user is not confirmed`) и назначить на заявку — обе проверки в SQL. SMTP настраивается env-переменными: `SMTP_HOST` (пусто — письма не шлются, dev), `SMTP_PORT` (по умолчанию 587), `SMTP_USER` (пусто — без аутентификации), `SMTP_PASSWORD`, `SMTP_FROM` (пусто — берётся `SMTP_USER`), `SMTP_TLS` (`tls` | `starttls` | `none`; пусто — `tls` для порта 465, иначе `starttls`) и `APP_BASE_URL` (ссылка на вход в письме). Некорректные порт/режим — ошибка на старте. Отправка **fail-open**: недоступный SMTP не откатывает подтверждение, в лог уходит `warn`. Локальная замена боевому SMTP — Mailpit: `docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit` и `SMTP_HOST=localhost SMTP_PORT=1025 SMTP_TLS=none`. ### Сброс пароля (по письму) Пользователь жмёт «Забыли пароль?» на экране входа и вводит email — `POST /auth/forgot-password` шлёт письмо со ссылкой (`${APP_BASE_URL}/#/reset-password?token=…`). Ответ **всегда одинаковый** (200 `{"ok": true}`): эндпоинт не перечисляет аккаунты — неизвестный, неподтверждённый и выключенный ящик неотличимы от существующего, но письмо в этих случаях не уходит (получают только **подтверждённые активные** аккаунты). Лимиты: **10 запросов в минуту с IP**; **3 письма в сутки на один ящик** — счётчик тикает при любом запросе (и для несуществующих ящиков тоже), при превышении `429`. Счётчики — в БД (`rate_limits`), переживают рестарт. Свойства токена: **одноразовый**, живёт 24 часа (`repository.ResetTTL`), выпуск нового гасит прежние неиспользованные токены пользователя (включая свежие из писем); в БД хранится только **sha256** — утечка базы не даёт применить выпущенные токены. Пользователь меняет пароль: `POST /auth/reset-password` с телом `{"token": "…", "password": "…"}` — политика та же, что и при создании пользователя. Вместе со сменой пароля закрываются **все refresh-сессии** (прежний access-токен живёт максимум час). Применение лимитировано 30/мин с IP; неизвестный, использованный и истёкший токен дают одно и то же `400 invalid token` — причина не раскрывается. Альтернатива письму — админские способы (оба только для роли `admin`): - `POST /users/:id/password-reset` возвращает `{"ok": true, "token": "…", "expires_at": "…"}`, и админ передаёт ссылку (`${APP_BASE_URL}/#/reset-password?token=…`) пользователю сам любым каналом — пароль выбирает тот; - `POST /users/:id/password-set` с телом `{"password": "…"}` задаёт пароль сразу, без участия пользователя: политика та же (10..72 символа, буквы и цифры). Успех закрывает **все refresh-сессии** пользователя и гасит его неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Слабый пароль и кривой id — `400`, несуществующий пользователь — `404`, не-админ — `403`. Оба способа вынесены в одно окно разделов «Клиенты» и «Мастера» веб-интерфейса. Фильтры `GET /tickets`: - `?status=new` — по статусу, плюс `?category=plumbing` и `?priority=high`; - `?date_from=2026-10-01&date_to=2026-10-05` — диапазон `created_at`; даты трактуются в **UTC**: `date_from` — с полуночи UTC, `date_to` — включая весь день до полуночи UTC следующего дня (сервер парсит `YYYY-MM-DD` через `time.Parse`, у которого часовой пояс всегда UTC) в формате `YYYY-MM-DD`, обе границы включительно; - клиент видит только свои заявки, мастер — назначенные ему плюс свободные (`new`) в проектах, где он активный участник (такие он вправе взять сам через `PATCH /tickets/:id/take`); - админ дополнительно может сузить: `?client_id=…`, `?worker_id=…`; - всем ролям доступны структурные фильтры `?project_id=…` и `?object_id=…` (они накладываются поверх выборки по роли — чужое через них не подсмотреть). Мусор в фильтре — это `400` с именем параметра (`invalid status`, `invalid category`, `invalid date_from`), а не пустой список: «введено не то» и «таких записей нет» — разные вещи. ### Уведомления в Telegram Если заявка пролежала в статусе `new` дольше **порога** (по умолчанию 24 часа), её **клиенту** уходит сообщение в Telegram со ссылкой на заявку (`${APP_BASE_URL}/#/tickets/:id`). Пока заявку не взяли — напоминание повторяется **раз в сутки**, после `PATCH /tickets/:id/assign|take` условие гаснет само (`status = 'new'` в выборке кандидатов). **Чём слать** — два источника бота, и работает это так: - **свой бот пользователя** — токен от @BotFather вводится в разделе «Настройки» (`POST /auth/telegram/bot`): сначала формат, потом `getMe` (неверный токен не сохраняется), затем **шифрование** AES-256-GCM (ключ `DATA_ENCRYPTION_KEY`) и запись в `profiles.telegram_bot_token`. Как только токен сохранён, поднимается поллер этого бота, а нотификатор шлёт уведомления через него. **Токен наружу не отдаётся никогда** — в статусе только `has_bot_token` и имя бота; - **глобальный бот сервера** `TELEGRAM_BOT_TOKEN` — запасной вариант для тех, кто токен не вводил. Ни того, ни другого нет — `GET /auth/telegram` отдаёт `enabled:false`, выдача кода `400 telegram not configured`, а заявки **не помечаются отправленными**: уведомление уйдёт, как только пользователь подключит бота, а не потеряется. Фоновые задачи живут в `cmd/api` (`internal/notify`), все на контексте, как чистка лимитов: - **notifier** — тик каждые `TELEGRAM_TICK_EVERY` (по умолчанию `1m`): кандидаты — `repository.StaleTickets` (частичный индекс `tickets_new_created_idx`); для каждой заявки бот выбирается **по её клиенту** (`notify.Registry`: личный токен, иначе глобальный). Отправка **fail-open**: недоступный Telegram не роняет API, а заявка остаётся кандидатом на следующий тик; «чат недоступен» (`chat not found` / бот заблокирован) повтора не ждёт — сообщение помечается отправленным, чтобы не писать в лог каждую минуту; - **poller** — длинный `getUpdates` для привязки чата: на ошибке экспоненциальный backoff 1–30 с, `offset` сдвигается всегда (иначе одно необработанное сообщение встало бы в очередь навечно). Поллер **на каждый бот свой**: у личного токена — свой экземпляр, у глобального — свой (`notify.Registry` держит дочерние контексты, остановка процесса гасит их разом). Очередь `getUpdates` одна на бота, поэтому инстансов API должно быть один (см. «Известные ограничения»). Привязка — пункт меню **«Настройки»** (виден всем ролям): 0. При желании — **подключить своего бота**: поле токена → `POST /auth/telegram/bot` → `getMe` → шифрование → поллер. Отключить — `DELETE /auth/telegram/bot`. Без `DATA_ENCRYPTION_KEY` поле на сервере не работает (записать токен некуда) — остаётся глобальный бот. 1. «Получить код привязки» → `POST /auth/telegram/code` отдаёт одноразовый код и ссылку `https://t.me/?start=`. Код живёт 15 минут, выпуск нового гасит прежние, в БД лежит только **sha256** (как токены сброса пароля); лимит — 5 выдач за 5 минут на пользователя. Бот для ссылки — свой, а если токена нет — глобальный. 2. Человек отправляет код боту → poller гасит код одним `UPDATE` (одноразовость и срок — в самом `WHERE`), пишет числовой `chat_id` в `profiles.telegram_target` и отвечает «Готово — уведомления будут приходить в этот чат». 3. Либо вручную: `PUT /auth/telegram` с телом `{"target": "@mygroup"}` — группа или канал, куда бот добавлен участником. Формат (`^@[A-Za-z0-9_]{5,32}$` либо `^-?[0-9]{5,20}$`) проверяется до базы, затем бот сверяет чат через `getChat`. **Личный `@ник` здесь не работает намеренно**: Telegram не даёт писать человеку по одному никну, пока он сам боту не напишет, — для этого и нужен код. 4. `DELETE /auth/telegram` (или `PUT` с пустым `target`) — отключить. Поле `telegram_target` отдаётся **только владельцу** (`GET /auth/telegram` и сама цель): в списках `GET /users` его нет. Токен бота не отдаётся **никому** — в статусе только факт его наличия (`has_bot_token`) и имя бота, а в логи он не попадает (там только `getMe`-результаты). Настройка env-переменными: | Переменная | Назначение | |---|---| | `DATA_ENCRYPTION_KEY` | ключ шифрования токенов ботов (AES-256-GCM, 32 байта в base64 или hex: `openssl rand -base64 32`); **пусто — ввод токена выключен**: `POST /auth/telegram/bot` → `400 telegram bot token not configured`, а сохранённые ранее токены не расшифровываются (warn при старте). Неверный формат — фейл-фаст до подключения к БД | | `TELEGRAM_BOT_TOKEN` | токен **глобального** бота (секрет, задаётся env-ом); пусто — не страшно: уведомления пойдут через личные токены пользователей, а если не будет ни того, ни другого, `GET /auth/telegram` отдаёт `enabled:false` и выдача кода — `400 telegram not configured` | | `TELEGRAM_API_URL` | база Bot API; по умолчанию `https://api.telegram.org` | | `TELEGRAM_NOTIFY_AFTER` | порог напоминания, duration; по умолчанию `24h` | | `TELEGRAM_TICK_EVERY` | период тика notifier'а; по умолчанию `1m` | Бот заводится через @BotFather и должен быть добавлен в группу или канал, если цель — общая. Отказ бота в `getChat` и неверный формат дают одно и то же `400 invalid telegram target` — причина уходит в лог, наружу не попадает. ## Пагинация и сортировка Все списки (`/tickets`, `/projects`, `/projects/:id/objects`, `/projects/:id/workers`, `/users`, `/invitations`, `/tickets/:id/events`, `/tickets/:id/photos`) — страницы одинаковой формы: ```json {"items": [ … ], "total": 137, "limit": 20, "offset": 40} ``` - `limit` — `1…100`, по умолчанию `20`; больше потолка → `400 invalid limit`; - `offset` — сдвиг от начала выборки, по умолчанию `0`; сдвиг за концом не ошибка: `items: []`, а `total` остаётся прежним; - `sort` — ключ из белого списка у каждого списка, неизвестный → `400 invalid sort`; - `order` — `asc` | `desc`; направление по умолчанию зависит от поля. | Список | `sort=` | по умолчанию | |---|---|---| | `/tickets` | `created_at`, `priority` (сначала важные), `id` | `created_at` desc | | `/projects` | `created_at`, `name`, `id` | `created_at` desc | | `/projects/:id/objects` | `name`, `created_at`, `id` | `name` asc | | `/projects/:id/workers`, `/users` | `name`, `created_at`, `email`, `id` | `name` asc | | `/invitations` | `invited_at`, `expires_at`, `project_name` | `invited_at` desc | | `/tickets/:id/events` | `id`, `created_at` | `id` asc (хронология) | | `/tickets/:id/photos` | `id`, `created_at` | `id` asc (хронология) | Сортировка всегда дополняется `id`, чтобы одинаковые значения не «перетасовывались» между страницами. Колонка в `ORDER BY` берётся из словаря в коде — пользовательская строка в SQL не попадает никогда. `total` считается **до** обрезки страницы (`COUNT(*) OVER()` одним запросом; для пустого окна — отдельным `COUNT` с тем же `WHERE`), поэтому число страниц стабильно при листании. > Ломающий формат: раньше списки отдавали голый массив. Потребителям > списка нужно читать `items` (Swagger и README обновлены). --- ## Структура: проект → объект → мастер → заявка ``` admin ──создаёт──> client ──создаёт──> project ──┬──> project_objects (адреса) └──> project_workers (мастера, M2M) │ заявка ──object_id──> объект проекта ``` - **Проект** — контур клиента: что и для кого делаем. Уникальность — `(client_id, name)`, у админа при создании обязателен `client_id`. - **Объект** — здание/корпус внутри проекта с адресом. Адрес заявки **копируется из объекта на момент создания** (история показывает адрес, который был тогда; правка объекта старые заявки не меняет). - **Мастера** — M2M `project_workers` со статусами (миграция `0005`): `invited → active | declined`. Мастер может состоять в нескольких проектах; «свои мастера» клиента — это `GET /users?role=worker`, то есть только принявшие приглашение. Состав проекта (`GET /projects/:id/workers`) отдаёт профили вместе со статусом и сроком ответа. Существующего мастера, ещё не состоящего в проектах, клиент находит поиском `GET /users?role=worker&q=<имя или email>` (минимум 2 символа; так клиент узнаёт только того, чей адрес ввёл сам, не видя полного списка) — этим же запросом пользуется поиск в вебе. - **Заявка** всегда привязана к объекту (`object_id` обязателен) и проекту; адрес в теле `POST /tickets` больше не передаётся. Принадлежность проверяется **на уровне БД** (составные FK, миграция `0004`): - `tickets (object_id, project_id) → project_objects (id, project_id)` — объект входит именно в этот проект; - `tickets (project_id, client_id) → projects (id, client_id)` — проект принадлежит клиенту заявки; - членство мастера — проверка внутри самого `UPDATE` назначения (FK отдалял бы уход мастера из проекта навсегда: ссылки остаются от закрытых заявок); - чужой проект/объект неотличим от несуществующего: `404`, а не `403`. Правила жизни структуры: - выключенный проект (`is_active=false`) не принимает новые объекты и заявки — `409 project is not active`; - мастера нельзя убрать из проекта, пока за ним есть **незакрытые** заявки этого проекта — `409 worker has open tickets in this project`; после закрытия отзыв проходит, история заявок при этом сохраняется; - назначить заявку можно только **принявшему приглашение** мастеру её проекта: посторонний — `400 worker is not in the project of this ticket`, приглашённый, но не ответивший — `400 invitation is pending: the worker has not accepted it`, отказавшийся — `400 invitation was declined`. Рубеж стоит в самом `UPDATE` назначения (`status = 'active'`). ### Приглашения мастеров `POST /projects/:id/workers` с 0005 — это **приглашение, а не членство**: ```json {"ok": true, "status": "invited", "expires_at": "2026-10-09T…Z"} ``` - приглашение живёт **7 дней** (`repository.InvitationTTL`); после этого мастеру — `409 invitation expired`, а клиент может отозвать строку обычным `DELETE /projects/:id/workers/:worker_id`; - мастер смотрит свои приглашения в `GET /invitations` (просроченные там не показываются) и решает: `POST /projects/:id/invitation/accept` — `invited → active`, и только тогда проект начинает назначать ему заявки; либо `…/decline` — `invited → declined`; - принудительного членства (`force`) **нет**: единственный путь стать участником — принять приглашение самому; - повторное приглашение после отказа перезапускает ту же строку; дубликаты — `409 worker is already in the project` (уже активен) и `409 invitation is already pending` (ждём ответа); вторичный accept/decline — `409 invitation is not pending`; - `DELETE` отзывает строку любого статуса, непринятое приглашение — тоже. --- ## Статусная машина заявки ``` new ──assign(admin/client) или take(worker)──> assigned ──start(worker)──> in_progress ──done(worker)──> done │ │ │ │ confirm(client) reject(client, причина) │ │ │ │ ▼ ▼ └──cancel(client/admin, причина)──────────┐ closed rejected (доработка) │ confirmed_at │ │ + closed_at start(worker) → in_progress └───────────────────── (второй круг, rework_count+1) └── cancel(…) ──> closed + cancel_reason ``` | Переход | Кто | Условие в SQL | |---|---|---| | `new → assigned` | админ, клиент-владелец или сам мастер (`take`) | `status = 'new'`, мастер принят в проекте заявки (`status = 'active'`) | | `assigned → in_progress` | назначенный мастер | `worker_id = current` | | `in_progress → done` | назначенный мастер | `worker_id = current` | | `done → closed` (принято) | клиент-владелец | `client_id = current`, ставит `confirmed_at` + `closed_at` | | `done → rejected` (доработка) | клиент-владелец | `client_id = current`, причина обязательна, `rework_count+1` | | `rejected → in_progress` | назначенный мастер | второй круг, `started_at` не затирается | | `new/assigned/done/rejected → closed` | клиент (свой) / админ (любой, кроме `closed`) | ставит `cancel_reason` + `closed_by` | Ключевое: **`rejected` — это доработка, а не финал**. Финал у заявки один — `closed`; исход различают `confirmed_at` (работа принята) и `cancel_reason` (отменено/отказ). Клиенту нельзя отменить заявку в `in_progress` — мастер уже выехал; там вправе только админ. Переходы защищены **на уровне БД**, а не только в коде: - `tickets_needs_worker` — мастер обязателен во всех статусах, кроме `new` и закрытых (отменённая заявка может остаться без мастера); - `tickets_assigned_time`, `tickets_started_time`, `tickets_done_time`, `tickets_confirmed_time`, `tickets_closed_time`, `tickets_rejected_time` — достигнутый статус обязан иметь свой таймстамп; - `tickets_rejected_reason` — отклонение только из `done` и только с причиной; - `tickets_closed_reason` — закрыта либо принята клиентом, либо отменена с причиной; - `tickets_status_check`, `tickets_category_check`, `tickets_priority_check` — белые списки значений. **Журнал событий** (`0006`, `GET /tickets/:id/events`): каждый переход оставляет запись — `created`, `assigned`, `started`, `done`, `confirmed`, `rejected`, `canceled` — с автором, парой `from_status → to_status`, причиной (для `rejected`/`canceled`) и временем. Запись создаётся **в той же SQL-операции, что и переход** (CTE `UPDATE … RETURNING → INSERT` в журнал): «заявка перешла, а истории нет» невозможно — либо обе части, либо ни одной. У события `created` нет `from_status`, при удалении аккаунта `actor_id` обнуляется, но сама запись остаётся. Список пагинирован, по умолчанию — хронология (`id` asc), `total` — все события заявки. Пул свободных заявок (`scope=free`) **удалён**: очередь не нужна — назначает админ или клиент-владелец заявки. Мастер видит свободные (`new`) заявки только своих проектов и может взять их сам (`PATCH /tickets/:id/take`). --- ## Фотоотчёт (S3) Мастер показывает ход работ фото: только фото `jpeg`/`png`/`webp` (без видео), формат определяется **по сигнатуре байтов**, а не по заголовку `Content-Type`. Байты живут в S3-совместимом хранилище (локально MinIO в compose, в проде — любой S3), в БД — только метаданные (`ticket_photos`, миграция `0008`). | Метод | Путь | Доступ | |---|---|---| | `POST` | `/tickets/:id/photos` | `worker` / `admin`, multipart-поле `file` → **201** + `url` (временная ссылка) | | `GET` | `/tickets/:id/photos` | все, кто видит заявку; страница как у остальных списков | | `DELETE` | `/tickets/:id/photos/:photo_id` | `worker` — своё, пока заявка `in_progress`; `admin` — любое | Правила живут **в SQL** (та же CTE-схема, что и переходы статусов): - загрузка разрешена только в статусе `in_progress` — и мастеру, и админу: после «Работа выполнена» (`done`) отчёт заморожен, а после отклонения клиентом (`rejected`) откроется снова вместе со стартом доработки; мастер должен быть **активным участником проекта** заявки; - лимиты из окружения: `PHOTO_MAX_BYTES` (по умолчанию 10 МБ на файл) и `PHOTO_MAX_PER_TICKET` (20 файлов на заявку); превышение — `400 photo too large` и `409 too many photos`; - формат вне белого списка — `400 unsupported photo type`; - каждая загрузка и удаление оставляет событие `photo_added`/`photo_removed` в журнале **той же SQL-операцией** (comment — имя файла); у фото-событий статус не меняется: `from_status = to_status`. Порядок в `POST`: валидация → заливка в S3 → `INSERT` строки и события; если вставка не прошла (гонка за лимит) — объект компенсаторно удаляется, «сирот» в хранилище не остаётся. При удалении наоборот: сначала строка и событие, затем объект (сбой чистки уходит в лог, журнал не разъезжается со строкой). Бакет **приватный**: наружу уходят только подписанные ссылки (SigV4, 15 минут), ключ объекта клиенту не виден — его генерирует сервер (`tickets//.`). Файл переживает удаление аккаунта загрузившего (`uploaded_by → SET NULL`). Как и `JWT_SECRET`, конфигурация S3 проверяется на старте: без `S3_BUCKET` (и без ключей при своём `S3_ENDPOINT`) сервер не стартует — ошибка видна сразу, а не на первой загрузке. --- ## Очистка старых заявок Фоновая задача удаляет **закрытые** заявки, чей `closed_at` старше `TICKET_RETENTION_DAYS` (по умолчанию 60 дней; `0` — чистка выключена, кривое значение — сервер не стартует, как у `PHOTO_MAX_*`). Прогон — при старте сервера и дальше раз в сутки. - удаляются только заявки в `closed`: незакрытые (новые, в работе, на доработке) не трогаются **никогда** — иначе терялась бы незакрытая работа; возраст считается от закрытия, а не от создания: заявка, закрытая вчера, но созданная два месяца назад, остаётся; - события журнала (`ticket_events`) и строки фото (`ticket_photos`) уходят каскадом (`ON DELETE CASCADE`) той же операцией; строка истории повторяющейся заявки (`recurring_runs`) удаляется отдельным шагом в конце — у удалённой заявки она осталась бы сиротой (`ticket_id → SET NULL`), жить без заявки ей не на что; - ключи объектов фото снимаются **до** удаления строк, затем файлы удаляются из хранилища best-effort: сбой уходит в лог (`photo object cleanup failed`), строка в БД уже удалена — тот же порядок «сначала строка, потом объект», что в чистке фотоотчёта; - удалённая заявка исчезает из списков, `GET /tickets/:id` отвечает 404; - если что-то удалилось, в лог пишется `tickets cleanup deleted=N photo_objects=M`. --- ## Повторяющиеся заявки Периодические работы («осмотр вентиляции каждые 14 дней») заводятся один раз **правилом** — шаблоном будущей заявки (объект, категория, описание, приоритет) плюс расписанием `anchor_at` (дата-время первого запуска) и `period_days` (период в днях): заявка создаётся **каждые N дней/недель** от точки отсчёта (`recurring_rules`, миграции `0014`/`0016`). Обычный путь — галочка «Повторяющаяся заявка» в `POST /tickets`: заявка, правило и связь создаются одной операцией, в ответе заявки — поле `recurring` (расписание, состояние, ближайший запуск). Управление на странице заявки: `PATCH /recurring/:id` вкл/выкл и история `GET /recurring/:id/runs`. Отдельный CRUD `GET/POST /recurring`, `GET/PATCH/DELETE /recurring/:id` сохранён для API-клиентов (роли — как у проектов): `PATCH` правит шаблон и расписание частично — меняются только присланные поля, объект не переносится (это удаление и создание). `POST /recurring/:id/run` — ручной запуск: заявка создаётся сразу, не дожидаясь срока, но рубежи остаются — выключенное правило/проект/объект или незакрытая прошлая заявка дают `409` (дубль не создаём). После ручного запуска `last_run_at` сдвигается, поэтому следующий срок отсчитывается уже от него. Как считается срок (считается Go-функцией `nextDue`, не SQL): - срок — сетка точек `anchor_at + k·period_days` (k ≥ 0), ближайшая **строго после** отправной точки `COALESCE(last_run_at, created_at)`: первая заявка — первая точка сетки **после создания** правила (`anchor_at` в будущем — это сам `anchor_at`: пользователь выбрал первый запуск), дальше — точка после каждой созданной заявки (и после ручного запуска — тоже); - сетка не «плывёт» после каждого срабатывания: опоздание на тик не накапливается, дата отсчёта не сдвигается; - **пока прошлая заявка по правилу не закрыта, новая не создаётся** (ждём закрытия, без дублей); пропуск не сдвигает `last_run_at` — после закрытия следующий тик создаст заявку сразу; - выключенное правило, проект или объект — пропуск без сдвига срока: когда включат, заявка создастся (возможно, с опозданием); - точность расписания — **до суток**: тик планировщика раз в сутки, выбранное в `anchor_at` время ориентировочное (заявка появится ближайшим тиком после срока). Сам планировщик (`runRecurring` в `cmd/api`) — та же схема, что у чистки заявок: прогон при старте сервера и дальше раз в сутки (срок считается в днях, суточной точности хватает). Каждое правило обрабатывается в **своей транзакции** с `FOR UPDATE` строки правила — параллельный прогон (второй инстанс) не создаст вторую заявку. Заявка, её событие `created` и строка `recurring_runs` со сдвигом `last_run_at` — одной операцией: «заявка есть, а правило думает, что не запускалось», невозможно. Клиенту при авто-создании уходит уведомление в Telegram — как при напоминаниях, через бота (`BotFor(clientID)`: собственный токен пользователя, иначе глобальный) в `telegram_target`; ручной запуск шлёт тем же текстом общим кодом. Отправка fail-open: недоступный Telegram не роняет ни планировщик, ни запрос, ошибка уходит в warn, заявка остаётся созданной. Удаление правила (или каскад проекта/объекта — `0014`) не трогает уже созданные заявки: они живут обычным порядком. --- ## Миграции Лежат в `internal/db/migrations/` и встраиваются в бинарник через `embed`. - имя файла — `_.sql`, например `0001_init.sql`; - применяются по возрастанию версии, каждая в своей транзакции; - применённые версии пишутся в таблицу `schema_migrations` вместе с **checksum файла**. Сейчас в наборе шестнадцать миграций: | Версия | Что делает | |---|---| | `0001_init` | таблицы `profiles` и `tickets`, базовые ограничения и индексы | | `0002_ticket_resolution` | колонки `reject_reason`, `cancel_reason`, `rejected_at`, `closed_by`, `rework_count`; ограничения закрытия цикла | | `0003_hardening` | индекс `profiles_email_lower_key` (email без учёта регистра), таблица `refresh_tokens` | | `0004_projects` | таблицы `projects`, `project_objects`, `project_workers`; у `tickets` — обязательные `project_id`/`object_id` и составные FK (перед этим удаляет тестовые заявки, сделанные до объектов) | | `0005_project_invitations` | у `project_workers` — статусы `invited/active/declined`, `invited_by`, `invited_at`, `responded_at`, `expires_at` и индексы списков; существующие строки становятся `active` | | `0006_ticket_events` | таблица `ticket_events` (журнал переходов заявки) и индекс `(ticket_id, id)` | | `0007_password_reset` | таблица `password_reset_tokens` (sha256 одноразового токена, срок, использование) и индекс по пользователю | | `0008_ticket_photos` | таблица `ticket_photos` (метаданные фотоотчёта; `uploaded_by` → `SET NULL`, CHECK на тип/размер) и расширение `CHECK` журнала на действия `photo_added`/`photo_removed` | | `0009_rate_limits` | таблица `rate_limits` — счётчики брутфорс-лимитов в БД вместо памяти (переживают рестарт и общие для нескольких инстансов) | | `0010_user_confirmation` | колонки `confirmed_at` / `confirmed_by` у `profiles` — подтверждение аккаунта админом или клиентом; правило доступа целиком в SQL | | `0011_telegram_notifications` | `profiles.telegram_target` (куда слать уведомления), `tickets.tg_notified_at` (дедуп «раз в сутки»), таблица `telegram_bind_codes` (sha256 одноразового кода привязки, TTL 15 минут) и частичный индекс `tickets_new_created_idx` | | `0012_user_bot_tokens` | `profiles.telegram_bot_token` (зашифрованный токен собственного бота пользователя, `bytea`) и `telegram_bot_username` (его имя из `getMe`) | | `0013_project_delete_cascade` | два FK в `tickets` (`object_project`, `project_client`) пересоздаются с `ON DELETE CASCADE` — проект удаляется админом вместе с заявками (события/фото и так каскадные с `0006`/`0008`) | | `0014_recurring_rules` | таблицы `recurring_rules` (правило повторяющейся заявки: шаблон + расписание, составные FK как у заявки, каскад с проектом/объектом) и `recurring_runs` (история срабатываний: какая заявка создана по какому правилу; `ticket_id` → `SET NULL`) | | `0015_recurring_annual` | `recurring_rules`: `interval_months` заменяется на `run_month`/`run_day` (повтор ежегодно в эту дату, месяц 1..12, день 1..31; существующие правила переводятся на день/месяц создания) | | `0016_recurring_period` | `recurring_rules`: `run_month`/`run_day` заменяются на `anchor_at` (дата-время первого запуска) и `period_days` (период в днях, 1..3650 — «каждые N дней/недель»); существующие правила получают `anchor_at = COALESCE(last_run_at, created_at)` и `period_days = 365` — ритм «раз в год» сохраняется | Правила: 1. **Применённую миграцию редактировать нельзя** — при несовпадении checksum приложение не стартует. Изменение — это всегда новая миграция со следующим номером (`0008_*.sql`). 2. Все миграции написаны так, чтобы сработать и на пустой базе, и на базе, где таблицы были созданы руками до появления миграций (`IF NOT EXISTS` + приведение ограничений к эталону). --- ## Ошибки API Handlers не отдают тексты ошибок БД наружу — они пишутся в лог, клиенту уходит фиксированное сообщение: | Код | Класс (`internal/repository`) | Ответ | |---|---|---| | 404 | `ErrNotFound` | `{"error":"not found"}` | | 409 | `ErrConflict` (уникальность, конфликт состояния) | `{"error":"conflict"}` | | 422 | `ErrInvalid` (нарушение CHECK/FK) | `{"error":"invalid request"}` | | 500 | инфраструктура (сеть, падение БД) | `{"error":"internal server error"}` | Исключение — сообщения, которые полезны клиенту и не раскрывают внутренности: `email already exists`, `invalid credentials`, `worker not found`, `invalid token`, `invitation is already pending`, `worker is already in the project`, `invitation expired`, `invitation is not pending`, `invitation is pending: the worker has not accepted it`, `invitation was declined`, `photo too large`, `invalid file`, `unsupported photo type`, `too many photos`, `photo cannot be removed`, `user has related records`. --- ## Надёжность и безопасность **Токены и сессии** - access-токен — JWT HS256, живёт **1 час** (`auth.AccessTTL`); refresh-токен — случайный секрет на 30 дней, в БД хранится только его **sha256** (`auth.HashToken`), а клиенту секрет отдаётся **только в httpOnly-куке** `refresh_token` (`Path=/auth`, `SameSite=Lax`, `Secure` — через `COOKIE_SECURE=true`); в JSON его нет и в `localStorage` он не попадает; - `POST /auth/refresh` делает **ротацию**: на входе старый токен (из куки), на выходе новая пара, старая сессия гасится. Повторное предъявление отозванного токена закрывает **все** сессии пользователя — это признак того, что секретом владеет кто-то второй; событие пишется в лог с префиксом `security:`; - `POST /auth/logout` гасит одну сессию, `POST /auth/logout-all` — все; - протухшие и отозванные записи чистятся при старте приложения. **Брутфорс** - `/auth/login` — 10 попыток в минуту с одного IP, `/auth/refresh`, `/auth/logout` и `/auth/reset-password` — 30/мин, `/auth/forgot-password` — 10/мин на IP **плюс 3 письма в сутки на один ящик** (ключ — `lower(email)`, счётчик тикает и для несуществующих ящиков: 429 не раскрывает, что адрес зарегистрирован); - при превышении — `429 {"error":"too many attempts, try again later"}`; - лимитер в PostgreSQL (таблица `rate_limits`, миграция `0009`, ключ — `«лимит»|IP`): счётчик переживает рестарт API и общий для нескольких инстансов; при недоступности БД лимитер **fail-open** (пропускает запрос и пишет `warn` в лог) — вход не должен падать из-за счётчика; - ключ берётся из `c.ClientIP()`, поэтому `gin.SetTrustedProxies` настроен из `TRUSTED_PROXIES` (по умолчанию только loopback, за reverse proxy — его подсеть): без этого gin верил бы любому `X-Forwarded-For` — левый элемент списка подделал бы и ключ лимита, и `client_ip` в логах. **Прокси обязан перезаписывать заголовок** (`$remote_addr`), а не дополнять: иначе левым останется присланный клиентом мусор. **Сеть** - у `http.Server` включены таймауты: `ReadHeaderTimeout` 10с — защита от Slowloris, `ReadTimeout`/`WriteTimeout` 60с — с запасом на загрузку фотоотчёта, `IdleTimeout` 120с — висящие keep-alive соединения; - Swagger UI по умолчанию **выключен** (открытая схема в проде не нужна), включается флагом `SWAGGER=true` — так в `.env` локальной разработки. **CORS** - разрешённые источники фронта задаются `CORS_ORIGINS` через запятую (например `http://localhost:3000,http://127.0.0.1:3000`); - пустое значение — заголовки не отдаются **вовсе**: одностраничнику на том же домене CORS не нужен, а чужому домену их и не должно быть; - разрешённый origin получает свой точный адрес в `Access-Control-Allow-Origin` (не `*`: рядом `Authorization`, звёздочка запрещена спецификацией), плюс `Vary: Origin`,Methods/Headers и `Access-Control-Max-Age: 600`; - preflight `OPTIONS` отвечает `204` до авторизации — браузер спрашивает «можно ли» заранее, запроса с токеном ещё нет. **Учётные записи** - email нормализуется при записи (обрезка + нижний регистр) и ищется без учёта регистра: `Admin@x.ru` и `admin@x.ru` — один пользователь, дубликат любого регистра — `409` (держит индекс `profiles_email_lower_key`); - выключить аккаунт — `PATCH /users/:id {"is_active": false}`: админ — любой, клиент — только свой мастер (активный участник его проектов либо созданный им; граница в SQL: чужой мастер неотличим от несуществующего → `404`). Выключенный не войдёт (login → `403 account disabled`), **все его сессии закрываются сразу**, а не по истечении access-токена; выключить самого себя нельзя. Флаг общий на все проекты мастера — осознанный побочный эффект: он перестаёт работать везде; включить обратно может и админ (`GET /users?include_inactive=1` показывает отключённых — в обоих разделах), и владелец проекта (кнопкой в проекте); - удалить аккаунт — `DELETE /users/:id`: админ удаляет любой «чистый» профиль, его refresh-сессии и токены сброса уходят каскадом. Клиент — только своего мастера (состоит в одном из его проектов — любой статус членства — либо создан им): членства в проектах клиента удаляются в той же транзакции, чужие проекты не трогаются. Если на мастера ссылаются заявки либо чужие участия (FK без каскада), БД не отдаёт строку → `409 user has related records`, и **вся транзакция откатывается** — членства остаются на месте; в этом случае аккаунт отключают, историю не стираем. Чужой мастер и несуществующий id неразличимы (`404`), удалить себя нельзя; - подтвердить аккаунт — `POST /users/:id/confirm`: админ — любой неподтверждённый, клиент — только созданного им мастера (границы в SQL: `404` на чужого, `409 already confirmed` на повтор); после успеха пользователю уходит письмо «аккаунт подтверждён», SMTP недоступен — подтверждение остаётся, в лог пишется `warn`; - `GET /db-ping` закрыт от посторонних: по ответу наружу уходило состояние базы. --- ## Логи и request-id Логирование — структурное, через `log/slog` (`internal/logging`): - `LOG_FORMAT=text` (по умолчанию, читаемо в консоли) или `json` (для агрегатора логов); - `LOG_LEVEL=debug|info|warn|error` — по умолчанию `info`. Каждый запрос проходит цепочку middleware (`cmd/api`, порядок важен): 1. `middleware.RequestID` — берёт входящий `X-Request-Id`, если он осмысленный (до 64 символов из `[A-Za-z0-9._-]`), иначе генерирует свой; кладёт id в контекст и возвращает его тем же заголовком; 2. `middleware.Recovery` — паника не роняет процесс: клиенту `500` с фиксированным текстом, в лог — причина и стек; 3. `middleware.AccessLog` — одна запись на запрос: `method`, `path`, `status`, `duration_ms`, `client_ip`, `user_id` (если есть) и `request_id`. Уровень по статусу: `5xx` → error, `4xx` → warn, остальное → info. Ошибки, которые логируются отдельно (в `respondError`, логинах, деактивации), тоже несут `request_id` — один запрос находится в логах по одному значению. --- ## Инженерная культура - **Линтер** — `golangci-lint` v2, конфиг в `.golangci.yml`: стандартный набор (`errcheck`, `govet`, `staticcheck`, `ineffassign`, `unused`) плюс `gocritic`, `misspell`, `revive`, форматтеры `gofmt`/`goimports`. Исключения узкие и задокументированы (в тестах `t` идёт первым аргументом). ```bash golangci-lint run ./... # или: make lint ``` - **CI** — Drone (`.drone.yml`, сервер подключён к Gitea): `vet` → накат миграций на сервисный PostgreSQL → `go test -race ./...` → `go build` → `golangci-lint` → сборка docker-образа. Тесты в CI идут **на живой базе**, а не скипаются: без `DATABASE_URL` интеграционные тесты пропускаются молча. `-race` на живой базе дороже, зато ловит гонки между пулом соединений, лимитером и горутинами хендлеров. Деплой в прод — ручным запуском того же пайплайна (см. «CI/CD (Drone + Gitea)»). - **Команды** — `Makefile`: `run`, `build`, `test`, `race`, `vet`, `lint`, `fmt`, `swag`, `migrate`, `seed`, `up`, `down`, `tidy`. На Windows без `make` те же пункты — обычные `go`-команды (список выше и есть канон); `up`/`down` — это `docker compose up -d --wait` / `docker compose down`. - **Graceful shutdown** — сервер слушает `SIGINT`/`SIGTERM` и останавливается аккуратно: `http.Server.Shutdown` ждёт текущие запросы (до 15 секунд), затем закрывается пул соединений; процесс завершается с кодом `0`. Точка входа устроена как `main()` → `os.Exit(run())`, чтобы `defer` (в частности `pool.Close()`) гарантированно отработал — `log.Fatal` внутри `run` их бы не выполнил. - **Первый запуск в новой базе**: `go run ./cmd/migrate`, затем `go run ./cmd/api`. --- ## Swagger UI `GET /swagger/index.html` — интерактивная документация по всем эндпоинтам API: схемы тел, коды ответов, кнопка **Authorize** (подставляет `Bearer `). Роут регистрируется только при `SWAGGER=true` (по умолчанию выключен — на проде открытая схема не нужна; в `.env` локальной разработки он включён). Схема генерируется из аннотаций над хендлерами: ```bash go install github.com/swaggo/swag/cmd/swag@latest swag init -g cmd/api/main.go -o docs --parseInternal ``` На выходе — каталог `docs/` (`docs.go`, `swagger.json`, `swagger.yaml`), встроенный в бинарник через импорт `_ "building-mgmt/docs"` в `cmd/api/main.go`. **Поменяли эндпоинт — перегенерируйте** (иначе документация отстаёт от кода). Забыли — свидетельство прогресса, а не дыра: работает ровно то, что зашито в `docs/`. --- ## Тесты ```bash go test ./... # или: make test go test -race ./... # или: make race — как в CI ``` `-race` требует CGO и C-компилятора: в CI (linux-контейнер) они есть, на Windows без MinGW команда отвечает `go: -race requires cgo` — там гонки ищет CI. - `internal/auth` — JWT: подделка `alg`, чужой секрет, просроченный токен, пустой секрет; генерация refresh-секрета; - `internal/ratelimit` — лимитер: исчерпание бюджета, сброс окна, чистка протухших ключей, `429` от middleware; - `internal/middleware` — CORS: разрешённый origin получает точные заголовки и preflight `204` без тела, чужой origin и пустой список — ничего вовсе; - `internal/db` — пул соединений (`applyPoolEnv`): пустые `DB_*` не трогают дефолты pgxpool, кривые значения — фейл-фаст на старте, а не молчаливый дефолт; - `internal/repository` — интеграционные на реальной БД: статусная машина, ограничения схемы (включая констрейнты `0002`/`0003`), фильтры списка, конфликт уникальности, нормализация email, жизненный цикл refresh-сессий (выпуск, отзыв, `logout-all`, чистка протухших), изоляция проектов (`project_test.go`): чужой проект/объект неотличим от несуществующего, дубликаты имён, членство мастера, отзыв мастера при открытых заявках, назначение только «своего» мастера и составной FK на объект чужого проекта; - приглашения (`0005`): invite → accept/decline, отказ, просрочка (принять нельзя, отклонить можно), повторное приглашение по той же строке, точные причины дубликатов, назначение только после `accept`; - журнал событий (`TestTicketEvents`): полный цикл заявки оставляет точную хронологию с авторами и причинами, `from_status` отмены — настоящий статус на момент отмены, пагинация не сбрасывает `total`; - сброс пароля (`TestPasswordReset`, `TestPasswordResetExpired`, `TestSetPasswordDirect`): токен одноразовый, выпуск нового гасит прежний, просроченный не меняет пароль; прямая установка админом меняет хеш, гасит выпущенные токены и отдаёт `ErrNotFound` на неизвестный id; - фотоотчёт (`TestPhotoReportLifecycle`): запрет до старта работ даже для админа, загрузка активным мастером проекта и отказ чужому, лимит файлов (`ErrTooManyPhotos`), события `photo_added`/`photo_removed` с `from_status = to_status`, удаление (своё — можно, повторное — 404, чужая заявка — 404, закрытая заявка мастеру — 409, админу — можно); юнит-тесты хендлера проверяют сигнатуры форматов и вычистку имени файла; - повторяющиеся заявки (`recurring_test.go`): срок (`nextDue`: точка сетки в будущем, граница точки, пропуск многих периодов, недельный и годовой периоды), правило не созрело до срока, по созревании создаётся заявка-копия (адрес, событие `created` от автора правила, `last_run_at` и `recurring_runs` одной транзакцией), незакрытая заявка блокирует следующий запуск **без** сдвига срока, после закрытия цикл продолжается; чужое правило неотличимо от несуществующего (чтение/правка/удаление — `ErrNotFound`), выключенное правило планировщик пропускает, каскад при удалении проекта; частичный `UpdateRule` меняет только переданные поля (ручной запуск (`RunRuleNow`) обходит ожидание срока, но не рубежи: открытая заявка — `ErrConflict`+`ErrRuleOpen`, выключенное правило — `ErrRuleNotActive`, чужое — `ErrNotFound`); заявка с галочкой повтора создаёт правило одной операцией (`CreateWithRule`) и видит его в детали (`GetByID`); автоочистка удаляет строку истории сироты вместе с заявкой (`DeleteExpired`). Интеграционные тесты берут `DATABASE_URL`, выполняют сценарий **в транзакции и откатывают её** — данные в базу не остаются. Без `DATABASE_URL` они пропускаются. В CI (`make test` не нужен — там свои шаги) `DATABASE_URL` задан, база поднимается сервисным контейнером, схема накатывается через `cmd/migrate`, поэтому «зелёный прогон» означает «тесты действительно выполнены». --- ## Деплой в прод Прод — один сервер и один compose-стек (`compose.prod.yaml`): postgres, MinIO, API и веб-nginx в **одной** docker-сети; наружу отдаёт только веб (порт 80). Postgres и MinIO портов не пробрасывают вовсе — видны только контейнерам приложения. БД и S3 живут внутри стека, данные — в томах `pgdata`/`miniodata`, обновления переживают. ### Первый запуск ```bash # 1. Код и env-файл (env — на сервере, в git его нет) git clone http:///building-mgmt-backend.git /opt/building-mgmt-repo mkdir -p /opt/building-mgmt cp /opt/building-mgmt-repo/.env.prod.example /opt/building-mgmt/.env chmod 600 /opt/building-mgmt/.env $EDITOR /opt/building-mgmt/.env # пароли, секреты, домен — см. комментарии # 2. Образы (Drone собирает их сам; вручную — если CI ещё не подключён) docker build -t building-mgmt-api:latest /opt/building-mgmt-repo docker build -t building-mgmt-web:latest <клон репо web> # 3. Стек docker compose -p building-mgmt \ --env-file /opt/building-mgmt/.env \ -f /opt/building-mgmt-repo/compose.prod.yaml up -d --wait ``` `-p building-mgmt` обязателен и в ручных запусках, и в Drone: имя проекта задаёт имя сети, разные имена разнесут web и api по разным сетям, и прокси перестанет находить api. Проверка: `http://<домен>/ping` → `{"message":"pong"}`. `APP_ENV=production` (задан в шаблоне) блокирует `seed` — демо-аккаунты на бою не заводятся. TLS и домен: сертификат вешается на терминал перед веб-nginx (балансировщик / Caddy / Traefik); сам nginx отдаёт HSTS и проставляет `X-Forwarded-Proto`. `TRUSTED_PROXIES=172.16.0.0/12` обязателен: без него api увидит IP nginx-контейнера и лимиты логина всех пользователей сложатся в один общий ключ. ### Обновление и операции ```bash # Обновление: образы новые, данные в томах не трогаются. docker compose -p building-mgmt --env-file /opt/building-mgmt/.env \ -f compose.prod.yaml up -d --wait docker compose -p building-mgmt logs -f api # логи docker compose -p building-mgmt exec api migrate # накат схемы вручную ``` Бэкапы (вне зоны compose, но обязательно): ```bash docker compose -p building-mgmt exec db pg_dump -U building building_mgmt \ > backup-$(date +%F).sql docker run --rm -v building-mgmt_miniodata:/data -v "$PWD":/backup alpine \ tar czf /backup/minio-$(date +%F).tgz -C /data . ``` Ограничение: API — ровно один инстанс (поллер Telegram держит очередь `getUpdates` на процесс; см. «Известные ограничения») — в стеке это соблюдается само. --- ## CI/CD (Drone + Gitea) CI живёт в Drone: сервер подключён к Gitea, пайплайны — `.drone.yml` в обоих репозиториях (бэкенд и веб). GitHub Actions больше нет. ### Установка (однократно, на прод-сервере) ```bash # В Gitea: Настройки → Applications → OAuth2-приложение, # Redirect URI: http://<хост-drone>:8081/authorize mkdir -p /opt/drone && cp deploy/drone/compose.yaml /opt/drone/ # /opt/drone/.env: GITEA_SERVER, DRONE_GITEA_CLIENT_ID, # DRONE_GITEA_CLIENT_SECRET, DRONE_GITEA_ADMIN_USERNAME, # DRONE_RPC_SECRET= # openssl rand -hex 32 docker compose -p drone --env-file /opt/drone/.env \ -f /opt/drone/compose.yaml up -d ``` Drone UI — `http://<хост>:8081` (за TLS-терминалом); оттуда же Gitea шлёт webhook. Runner исполняет шаги пайплайнов в docker на этом же сервере, одна задача за раз (`capacity=1` — деплои не пересекаются). ### Секреты пайплайна (UI Drone, репозиторий веба) - `gitea_host`, `gitea_user`, `gitea_token` — доступ к приватному репо бэкенда: web-пайплайн клонирует его за токен, чтобы перезапустить `web` тем же `compose.prod.yaml` (один стек, один проект). Секреты стека (`/opt/building-mgmt/.env`) в Drone не хранятся: пайплайн только читает файл на сервере флагом `--env-file`. ### Что делает каждый пайплайн - бэкенд (push/PR и ручной запуск): `vet` → `migrate` → `test -race` → `go build` → `golangci-lint` → сборка образа `building-mgmt-api:latest` + тег по SHA; - веб (push/PR и ручной запуск): `node --check` js → сборка образа `building-mgmt-web:latest` + тег по SHA; - **деплой — только ручным запуском** (кнопка **Run Pipeline** в UI, событие `custom`): бэкенд — `docker compose -p building-mgmt --env-file /opt/building-mgmt/.env -f compose.prod.yaml up -d --wait` (пересоздаёт api новым образом, данные не трогает); веб — то же с `--no-deps web`. Push в `main` образы собирает, но на сервер ничего не ставит. --- ## Известные ограничения - подтверждение email — реализовано (см. раздел «Подтверждение аккаунта (SMTP)»): мастер, созданный клиентом, активен только после `POST /users/:id/confirm`, до этого вход закрыт (`403 account not confirmed`); саморегистрации нет, аккаунты заводит только админ (и клиент — мастеров); сброс пароля по письму (`POST /auth/forgot-password`) работает, админу доступны оба своих способа — выпустить токен (`POST /users/:id/password-reset`) либо задать пароль напрямую (`POST /users/:id/password-set`); - несколько инстансов API: лимиты запросов уже считают на общей БД, но их ключ — `client_ip`, поэтому reverse proxy каждого инстанса обязан перезаписывать `X-Forwarded-For` одинаково (см. `TRUSTED_PROXIES`); к ним же — поллеры Telegram (`internal/notify`): очередь `getUpdates` одна на бота, поэтому инстансов API должно быть один, пока у кого-то есть включённый бот (глобальный `TELEGRAM_BOT_TOKEN` или личный токен из «Настроек»), иначе привязка чата достанется случайному; - выключенный пользователь теряет сессии сразу, но **существующий access-токен остаётся валидным до часа** — проверка `is_active` на каждый запрос не делается, чтобы не ходить в БД за каждой операцией. - мастер, заведённый через `POST /users`, но не принявший приглашение ни в один проект, виден только создавшему его клиенту и админу: `GET /users?role=worker` без `q` возвращает мастеров проектов клиента со статусом `active` плюс созданных им самим. Найти для приглашения можно поиском `&q=<имя или email>` (мин. 2 символа) — или сразу пригласить тем же запросом `POST /users`, получив `worker_id` в ответе; - журнал событий ведётся с момента наката `0006`: у более старых заявок `GET /tickets/:id/events` пуст, хотя статусы у них есть; - подписанные ссылки на фото живут 15 минут и по истечении перестают работать: UI перезапрашивает список, получая свежие `url`; у битых ссылок нет фонового переиздания на стороне API; - просроченные приглашения фоновой задачей не чистятся: они просто перестают приниматься и показываться, отозвать строку — ручной `DELETE`.