axe/README.md
edadmin 4f254d9faa
Some checks are pending
backend / test (push) Waiting to run
backend / lint (push) Waiting to run
backend / build-api (push) Waiting to run
backend / deploy (push) Blocked by required conditions
ci: Gitea Actions instead of Drone (workflow, README, secrets)
2026-10-18 23:22:02 +03:00

91 KiB
Raw Permalink Blame History

building-mgmt-backend

Backend для сервиса управления заявками в здании: админ создаёт клиента, клиент заводит проект, объекты в нём (здания с адресами) и приглашает мастеров, назначает заявку принятому мастеру, мастер ведёт работу до завершения — каждый переход остаётся в журнале событий заявки.

Стек: Go · Gin · PostgreSQL (pgx v5) · JWT (golang-jwt) · bcrypt


Быстрый старт

  1. Инфраструктура. Один docker compose up поднимает базу и MinIO — локальный S3 для фотоотчёта (в .env подставляется DATABASE_URL соответствующего вида):

    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):

    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. Запустить:

    go run ./cmd/api        # или: make run
    

Миграции применяются автоматически при старте — отдельная команда не нужна. Но если нужно накатить схему без запуска сервера (CI, релизный шаг):

go run ./cmd/migrate      # или: make migrate

Проверка: GET /ping — приложение, GET /db-ping — база (нужен токен админа), GET /swagger/index.html — интерактивная документация API (нужен SWAGGER=true в .env — включено в примере выше).

  1. (по желанию) Демо-данные, чтобы не собирать сценарий руками:

    go run ./cmd/seed --yes   # или: make seed
    

    В базе появятся клиент, два мастера, админ, проект «Демо-проект» с двумя объектами и четыре заявки (все ветки статусной машины). Пароль у демо-аккаунтов один и печатается командой. Повторный запуск идемпотентен, --force пересоздаёт. Только локальная/демо-база — пароли фиксированные.

Первый админ

Самый быстрый способ — make seed (флаг --yes создаёт demo-admin@example.com). Ручной способ описан ниже, он нужен, когда демо-аккаунт не нужен, а админ есть:

Аккаунты создаются только изнутри системы: админ — админов, клиентов и мастеров (POST /users), клиент — исключительно мастеров (роль там разрешена только worker), для себя в вебе, раздел «Клиенты». Саморегистрации нет — отдельного эндпоинта и кнопки на экране входа. При этом мастер, созданный клиентом, входит лишь после его подтверждения (раздел «Подтверждение аккаунта (SMTP)»). То есть для самого первого админа нужно завести запись руками, например:

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

Сгенерировать хеш:

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 одинаковы:

{"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 должно быть один (см. «Известные ограничения»).

Привязка — пункт меню «Настройки» (виден всем ролям):

  1. При желании — подключить своего бота: поле токена → POST /auth/telegram/bot → getMe → шифрование → поллер. Отключить — DELETE /auth/telegram/bot. Без DATA_ENCRYPTION_KEY поле на сервере не работает (записать токен некуда) — остаётся глобальный бот.
  2. «Получить код привязки» → POST /auth/telegram/code отдаёт одноразовый код и ссылку https://t.me/<bot>?start=<code>. Код живёт 15 минут, выпуск нового гасит прежние, в БД лежит только sha256 (как токены сброса пароля); лимит — 5 выдач за 5 минут на пользователя. Бот для ссылки — свой, а если токена нет — глобальный.
  3. Человек отправляет код боту → poller гасит код одним UPDATE (одноразовость и срок — в самом WHERE), пишет числовой chat_id в profiles.telegram_target и отвечает «Готово — уведомления будут приходить в этот чат».
  4. Либо вручную: PUT /auth/telegram с телом {"target": "@mygroup"} — группа или канал, куда бот добавлен участником. Формат (^@[A-Za-z0-9_]{5,32}$ либо ^-?[0-9]{5,20}$) проверяется до базы, затем бот сверяет чат через getChat. Личный @ник здесь не работает намеренно: Telegram не даёт писать человеку по одному никну, пока он сам боту не напишет, — для этого и нужен код.
  5. 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) — страницы одинаковой формы:

{"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 — это приглашение, а не членство:

{"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 идёт первым аргументом).

    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 локальной разработки он включён).

Схема генерируется из аннотаций над хендлерами:

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/.


Тесты

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, обновления переживают.

Первый запуск

# 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-контейнера и лимиты логина всех пользователей сложатся в один общий ключ.

Обновление и операции

# Обновление: образы новые, данные в томах не трогаются.
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, но обязательно):

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 не используются.

Установка (однократно, на прод-сервере)

# app.ini Gitea (Gitea ≥ 1.21), затем перезапуск Gitea:
[actions]
ENABLED = true

Раннер исполняет задачи в docker на этом же сервере. Регистрация — токеном из UI Gitea (Администрирование → Раннеры → Создать регистрационный токен, метка docker), каталог /opt/act-runner; в config.yaml раннера обязательны две вещи:

runner:
  labels: [docker]        # совпадает с runs-on: docker в workflow
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.