axe/README.md
edadmin b7c6e3527e
Some checks failed
backend / test (push) Failing after 1m22s
backend / lint (push) Failing after 0s
backend / build-api (push) Failing after 15s
backend / deploy (push) Has been skipped
ci: runs-on ubuntu-latest matches runner labels (v0.6.1 has no GITEA_RUNNER_LABELS)
2026-10-19 00:46:14 +03:00

1171 lines
91 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)»).
- **Команды** — `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 ещё не подключён)
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: имя
проекта задаёт имя сети, разные имена разнесут 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)
CI/CD живёт во встроенном Gitea Actions: пайплайны —
`.gitea/workflows/ci.yml` в обоих репозиториях (бэкенд и веб), сервер —
тот же `axe.engir.by`. GitHub Actions и Drone не используются.
### Установка (однократно, на прод-сервере)
```ini
# app.ini Gitea (Gitea ≥ 1.21), затем перезапуск Gitea:
[actions]
ENABLED = true
```
Раннер исполняет задачи в 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
```
### Секреты (Настройки репо → Секреты → Actions)
- бэкенд (`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 запрещает — префикс зарезервирован.
Секреты стека (`/opt/building-mgmt/.env`) в Actions не хранятся: деплой
только читает файл на сервере флагом `--env-file`.
### Что делает каждый workflow
- бэкенд (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` образы собирает, но на сервер ничего не ставит.
---
## Известные ограничения
- подтверждение 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`.