axe/README.md

1171 lines
91 KiB
Markdown
Raw Normal View History

2026-10-18 19:07:25 +00:00
# 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',
'<hash из cmd ниже>', '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/<bot>?start=<code>` (лимит 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": "<JWT на 1 час>", "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/<bot>?start=<code>`. Код живёт
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/<ticketID>/<uuid>.<ext>`). Файл переживает удаление аккаунта
загрузившего (`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`.
- имя файла — `<version>_<name>.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** — Gitea Actions (`.gitea/workflows/ci.yml`, встроенный в Gitea):
`vet` → накат миграций на сервисный PostgreSQL → `go test -race ./...` →
`go build` → `golangci-lint` → сборка docker-образа. Тесты в CI идут
**на живой базе**, а не скипаются: без `DATABASE_URL` интеграционные
тесты пропускаются молча. `-race` на живой базе дороже, зато ловит
гонки между пулом соединений, лимитером и горутинами хендлеров. Деплой
в прод — ручным запуском того же workflow (см. «CI/CD (Gitea Actions)»).
2026-10-18 19:07:25 +00:00
- **Команды** — `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 <token>`).
Роут регистрируется только при `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://<gitea>/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. Образы (CI собирает их сам; вручную — если CI ещё не подключён)
2026-10-18 19:07:25 +00:00
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` обязателен и в ручных запусках, и в CI: имя
2026-10-18 19:07:25 +00:00
проекта задаёт имя сети, разные имена разнесут 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 (Gitea Actions)
2026-10-18 19:07:25 +00:00
CI/CD живёт во встроенном Gitea Actions: пайплайны —
`.gitea/workflows/ci.yml` в обоих репозиториях (бэкенд и веб), сервер —
тот же `axe.engir.by`. GitHub Actions и Drone не используются.
2026-10-18 19:07:25 +00:00
### Установка (однократно, на прод-сервере)
```ini
# app.ini Gitea (Gitea ≥ 1.21), затем перезапуск Gitea:
[actions]
ENABLED = true
2026-10-18 19:07:25 +00:00
```
Раннер исполняет задачи в docker на этом же сервере. Регистрация —
токеном из UI Gitea (Администрирование → Раннеры → Создать
регистрационный токен), каталог `/opt/act-runner`; в
`config.yaml` раннера обязательны две вещи:
```yaml
runner:
# имена меток должны покрывать runs-on в workflow (там ubuntu-latest)
labels:
- "docker:docker://node:20-bookworm-slim"
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
container:
valid_volumes: # сокет docker для сборки/деплоя образов и
- /var/run/docker.sock # каталог стека с постоянным .env
- /opt/building-mgmt
```
2026-10-18 19:07:25 +00:00
### Секреты (Настройки репо → Секреты → Actions)
2026-10-18 19:07:25 +00:00
- бэкенд (`axe`) — секреты не нужны: деплой берёт `compose.prod.yaml`
из дерева того же коммита (`actions/checkout`);
- веб (`axe-web`): `CI_HOST` (`axe.engir.by`), `CI_USER` (`edadmin`),
`CI_TOKEN` (PAT с правом `write:repository`) — web-workflow скачивает
тарбол репо бэкенда, чтобы перезапустить `web` тем же
`compose.prod.yaml` (один стек, один проект). Имена с префиксом
`GITEA_` Gitea запрещает — префикс зарезервирован.
2026-10-18 19:07:25 +00:00
Секреты стека (`/opt/building-mgmt/.env`) в Actions не хранятся: деплой
2026-10-18 19:07:25 +00:00
только читает файл на сервере флагом `--env-file`.
### Что делает каждый workflow
2026-10-18 19:07:25 +00:00
- бэкенд (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 workflow**): бэкенд —
`docker compose -p building-mgmt --env-file /opt/building-mgmt/.env
-f compose.prod.yaml up -d --wait db minio api` (пересоздаёт api новым
образом, данные не трогает); веб — то же с `--no-deps web`. Push в
`main` образы собирает, но на сервер ничего не ставит.
2026-10-18 19:07:25 +00:00
---
## Известные ограничения
- подтверждение 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`.