Files
WhatIDo/API.md
T
dev 3836fc82ef feat(journal): настраиваемые поля карточки отчёта по ученику
Добавлен плавающий виджет «Поля карточки» в журнале: набор видимых
полей карточки отчёта по ученику (фото, фамилия, имя, группа, модуль,
тема, дата, файлы) задаётся пользователем и сохраняется в localStorage.
Имя теперь рендерится двумя спанами, чтобы скрывать частично; добавлен
блок мета-информации с датой и чипами файлов — картинки открываются в
лайтбоксе, играбельное видео воспроизводится в #videoModal через
?play=1, остальное скачивается. Каталог полей вынесен в
REPORT_CARD_FIELDS как единственный источник правды, без дублирования
в HTML.

Также добавлена документация HTTP API (API.md, 45 разделов): адреса и
транспорт, аутентификация сессиями и API-ключами, лимиты частоты,
дата/время/часовой пояс, матрица соответствия и полный индекс
эндпоинтов с ссылками на строки server.js.
2026-10-05 13:15:38 +03:00

279 KiB
Raw Blame History

WhatIDo — HTTP API

Полная документация HTTP-API приложения WhatIDo (учебный центр: журнал посещений, проекты студентов, галерея групп, файлы, публичные витрины).

Документ описывает всё, что реально принимает и отдаёт сервер, и собран напрямую из кода server.js (7728 строк), backup-restore.js, storage.js, redis.js. Ссылки вида server.js:5693 указывают на точные строки, из которых взят факт.

Что покрыто:

Часть Содержание
Разделы 1–9 Общие соглашения: транспорт, аутентификация, API-ключи, rate limits, ошибки, даты и таймзона, загрузка файлов, SSE
Разделы 10–40 176 маршрутов внутреннего API (/api/*) и внешнего API (/api/v1/*) — столько же, сколько объявлено в server.js
Приложение A Индекс всех маршрутов с указанием раздела
Приложение B Известные особенности, «острые углы» и неясности, найденные при разборе кода

Внутри одного приложения работают два независимых механизма аутентификации — их нельзя смешивать:

Механизм Заголовок Где работает Кто может использовать
Сессия X-Auth-Token: <token> весь /api/* любой активный пользователь (роль admin — для админских маршрутов)
API-ключ X-Api-Key: wsk_… или Authorization: Bearer wsk_… только /api/v1/* внешние системы; ключ не даёт доступа к UI

X-Admin-Token не поддерживается — это legacy-заголовок, возвращающий 401. ADMIN_PASSWORD из .env — только для автосоздания первого администратора в пустой БД, это не механизм авторизации API.


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

1. Получить сессионный токен

BASE=http://localhost:3003

TOKEN=$(curl -s -X POST "$BASE/api/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"ВАШ_ПАРОЛЬ"}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')

Ответ: { "token": "<64 hex>", "expires_at": "2026-11-09T…Z" }. Срок сессии — 30 дней.

curl -s "$BASE/api/auth/me" -H "X-Auth-Token: $TOKEN"
# {"id":1,"username":"admin","name":"…","role":"admin","is_active":true,"branch_ids":[]}

2. Работать с журналом

# Последние 20 записей
curl -s "$BASE/api/entries?limit=20&offset=0" -H "X-Auth-Token: $TOKEN"

# Создать запись (multipart: фото + файлы проекта)
curl -s -X POST "$BASE/api/entries" \
  -H "X-Auth-Token: $TOKEN" \
  -F student_name="Иван Иванов" \
  -F group_id=1 \
  -F description="Готовая работа" \
  -F photo=@work.png \
  -F files=@source.psd

# Мягко удалить / восстановить / отменить удаление
curl -s -X DELETE "$BASE/api/entries/42" -H "X-Auth-Token: $TOKEN"
curl -s -X PUT    "$BASE/api/entries/42/restore"  -H "X-Auth-Token: $TOKEN"
curl -s -X PUT    "$BASE/api/entries/42/unschedule" -H "X-Auth-Token: $TOKEN"

3. Выпустить API-ключ для внешней системы

# Сессией администратора
curl -s -X POST "$BASE/api/api-keys" \
  -H "X-Auth-Token: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Интеграция 1С","scopes":["read","write"],"rate_limit_per_min":120,"branch_ids":[1]}'
# {"id":3,"key":"wsk_9f2c…","prefix":"wsk_9f2c1ab34…","scopes":["read","write"],…}

Секрет wsk_… возвращается один раз и больше не восстанавливается — в БД лежит только sha256(ключа).

KEY=wsk_9f2c…
curl -s "$BASE/api/v1/entries?limit=10" -H "X-Api-Key: $KEY"
curl -s "$BASE/api/v1/groups"       -H "Authorization: Bearer $KEY"

4. Подписаться на события

curl -N "$BASE/api/notifications/stream?token=$TOKEN"
curl -N "$BASE/api/events?token=$TOKEN"

Как читать этот документ

  • Доступ: — публичный / requireAuth (любой активный пользователь) / requireAdmin / API-ключ read|write. Глобальный ipGuard дополнительно возвращает 403 {"error":"Доступ заблокирован"} для забаненных IP на любом маршруте.
  • Филиалы: — появляется там, где запрос ограничен user_branches для не-админов. Админ ограничений не имеет.
  • Тело (JSON): / Тело (multipart): / Query: — поля с типами, обязательностью, дефолтами и диапазонами ровно так, как их проверяет код.
  • Ответ: — фактическая форма ответа (конверты списков в проекте разные — см. раздел «Формат ответов, ошибки и пагинация»).
  • Ошибки: — реальные тексты {"error": "…"} из кода, а не абстрактные описания.
  • Примечания: — аудит, инвалидация кэша, SSE-события, работа фоновых воркеров, побочные эффекты.

Обозначения ? в конце пунктов означают «проверено не полностью, требует подтверждения» — такие места собраны в Приложении B.



Оглавление

1. Базовые адреса и транспорт

Порты

Порт Назначение Источник
3003 HTTP (app.listen(PORT, '0.0.0.0')), PORT из env, дефолт 3003 server.js:7628, server.js:7659
3443 HTTPS, HTTPS_PORT из env, дефолт 3443 server.js:7629, server.js:7658

Оба слушают 0.0.0.0. В docker-compose.yml опубликованы оба: "3003:3003", "3443:3443" (docker-compose.yml:64-68).

TLS

Сертификат генерируется на этапе сборки образа, а не в рантайме:

  • Dockerfile:29-31 — openssl req -x509 -nodes -newkey rsa:2048 -days 3650, -subj "/CN=whatido.local", -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"; файлы certs/key.pem, certs/cert.pem.
  • server.js:7647-7648 — пути certs/cert.pem / certs/key.pem.
  • server.js:7656-7662 — если оба файла есть, поднимается https.createServer(...) на 3443 и обычный HTTP на 3003 одновременно (один и тот же app). Если сертификатов нет — только HTTP, в лог HTTP : 3003 (no TLS certs).
  • Самоподписанный сертификат действует 10 лет и покрывает только localhost / 127.0.0.1.

Таймауты сокетов (tuneServer, server.js:7650-7654):

  • requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS
  • headersTimeout = UPLOAD_REQUEST_TIMEOUT_MS + 60000
  • UPLOAD_REQUEST_TIMEOUT_MS = Math.max(300000, env || UPLOAD_TOTAL_LIMIT_MB * 7500) — дефолт 200 МБ → 1 500 000 мс, минимум 300 000 мс (5 мин) (server.js:1172).

Статика и файлы

  • app.use('/vendor', express.static(public/vendor, { maxAge: '30d' })) — server.js:685
  • app.use(express.static(path.join(__dirname, 'public'))) — server.js:686, без явного maxAge
  • Cache-Control middleware (server.js:666-672): для путей, не начинающихся с /uploads и /vendor, ставится no-cache
  • GET /uploads/thumb/:name — миниатюра шириной 480px (THUMB_WIDTH = 480) в WebP, fileLimiter, имя валидируется /^[A-Za-z0-9._-]+$/, иначе 400 (server.js:654-658)
  • GET /uploads/.originals/:name — то же для оригиналов до ИИ-обработки (server.js:660-664)
  • GET /uploads/* — отдача объекта напрямую, Cache-Control: public, max-age=31536000, immutable, без авторизации и без rate limit (server.js:674-684)

Публичные URL

URL Что отдаёт Источник
/s/:token public/share.html (публичная витрина) server.js:3228-3230
/r/:token public/report.html (публичный отчёт) server.js:3232-3234
/api/share/:token JSON-снимок витрины, fileLimiter server.js:3084
/api/share/:shareToken/files/:fileToken файл из витрины, fileLimiter, опциональный пароль server.js:3180

Роуты /s/ и /r/ сами по себе только отдают HTML; данные фронтенд тянет через /api/share/:token.

Внешний доступ (Tailscale)

  • start-tailscale.sh:16 — tailscale funnel --bg --yes http://127.0.0.1:3003. Funnel настроен именно на HTTP 3003, а не на HTTPS 3443 (в AGENTS.md упоминается 3443 — расхождение со скриптом).
  • Адрес внутри tailnet и в интернете через Funnel: https://whatido.<tailnet>.ts.net.
  • Сертификат для .ts.net выпускает сам Tailscale; самоподписанный certs/cert.pem для этого не используется.
  • Альтернатива в .env.example — Cloudflare Tunnel: CLOUDFLARE_TUNNEL_URL=http://app:3003 (тоже 3003).

Глобальные middleware (порядок важен)

  1. app.set('trust proxy', 'loopback') — server.js:538; req.ip берётся из X-Forwarded-For только для loopback. ipOf(req) — server.js:453.
  2. app.use(helmet({ contentSecurityPolicy: {...} })) — server.js:573-588. CSP: default-src 'self', script-src 'self', style-src 'self' 'unsafe-inline', img-src 'self' data: blob:, media-src 'self' blob:, connect-src 'self', object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none'. CORS-middleware в проекте нет.
  3. app.use(express.json({ limit: '1mb' })) — server.js:589. Лимит тела JSON — 1 МБ; multipart разбирает multer.
  4. app.use(ipGuard) — server.js:590, тело server.js:494-504: читает бан из кэша по banKey(ipOf(req)); если banned_until > now() → 403 { error: 'Доступ заблокирован' } на любой маршрут.
  5. res.on('finish') hook — server.js:591-611: только при STORAGE_DRIVER=s3 и statusCode < 400 каждый req.file/req.files из uploads/ persist-ится через storage.persist.


2. Аутентификация (сессии): как это работает

Два независимых механизма: сессии (внутренний API + UI) и API-ключи (только /api/v1/*). Смешивать нельзя.

Сессии

Получение токена — POST /api/auth/login (server.js:1638-1665), под apiLimiter:

  • Тело (JSON): { username, password }. username тримится и приводится к нижнему регистру, длина ≤ 100.
  • Honeypot-поле website (строка): если непустое — сразу 401 + recordFailure(req, 'honeypot', 1, BAN_TTL_MS).
  • Пароль проверяется bcrypt.compare(password, user.password_hash || '').
  • Токен: crypto.randomBytes(32).toString('hex') (64 hex-символа), строка пишется в sessions (user_id, token, expires_at).
  • Ответ 200: { token, expires_at }, где expires_at — ISO 8601 с Z.
  • Ответ 401: { error: 'Неверный логин или пароль' } — один и тот же текст для несуществующего логина, неверного пароля и honeypot.
  • Антибрутфорс: recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS) — 10 неудач → бан IP.
  • Аудит auth.login с { username }.

TTL сессии — SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 = 30 суток (server.js:688). Проверка в SQL: WHERE s.token = $1 AND s.expires_at > now() (server.js:917) — просроченные токены не проходят.

Хранение на фронте — sessionStorage, ключ authToken (не localStorage):

  • public/js/login.js:17 — sessionStorage.setItem('authToken', data.token)
  • public/admin.js:2 — let token = sessionStorage.getItem('authToken')
  • public/admin.js:27 — sessionStorage.removeItem('authToken') при выходе
  • заголовок: { 'X-Auth-Token': token } (public/admin.js:4)

GET /api/auth/me — под requireAuth, возвращает safeUser(req.user) (server.js:1673-1675), ровно 6 полей (server.js:895-904):

{ id, username, name, role, is_active, branch_ids }

branch_ids — INT[] из user_branches через COALESCE(array_agg(ub.branch_id) FILTER (...), '{}') (server.js:913). Пароля и хеша в ответе нет.

Logout — POST /api/auth/logout (server.js:1667-1671), под requireAuth: удаляет строку sessions по req.authToken и ключ session:<token> из кэша. Ответ 200 { ok: true }. Аудита нет.

Кэш сессий — session:<token>, TTL SESSION_CACHE_TTL_MS = 30 * 1000 (server.js:92, server.js:922). Любая мутация users/sessions/user_branches обязана звать invalidateSessions() = cacheDrop('session:') (server.js:122), иначе деактивированный пользователь сохранит доступ до 30 с.

Коды 401 / 403 и их тексты

Ситуация Код Тело
Нет / невалидный / просроченный X-Auth-Token 401 { error: 'Unauthorized' } (server.js:931)
Ошибка БД/кэша внутри requireAuth 500 { error: 'Internal server error' } (server.js:938)
requireAdmin, роль ≠ admin 403 { error: 'Forbidden: требуется роль администратора' } (server.js:944, server.js:948)
ipGuard, IP в бане 403 { error: 'Доступ заблокирован' } (server.js:498)
API-ключ без нужного scope 403 { error: 'API key lacks scope: read' } / { error: 'API key lacks scope: write' } (server.js:1098, server.js:7294)

Middleware

requireAuth (server.js:926-940) — читает только req.headers['x-auth-token']. Ни Authorization, ни X-Admin-Token не поддерживаются. Зовёт loadUserByToken, требует user.is_active, прокидывает req.user и req.authToken.

requireAdmin (server.js:942-951) — самодостаточный: если req.user уже есть, проверяет только роль; иначе сам вызывает requireAuth и потом проверяет роль. Итог — всегда 401 или 403; next() только при role === 'admin'. Типичная связка на CRUD: requireAuth, requireAdmin (второй вызов requireAuth при уже проставленном req.user просто пропускается).

optionalAuth (server.js:958-970) — читает x-auth-token, при валидной активной сессии заполняет req.user, иначе молча идёт дальше. Все исключения проглотаны (catch {}). Применяется на публичных страницах с персонализацией: GET /api/groups (server.js:3237), GET /api/students (server.js:4155). Разница видна в выборке: у анонима фильтр по филиалам не добавляется — req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }.

Филиалы (server.js:953-956, 1116-1122):

  • branchScope(user) → admin: { admin: true, ids: null }; не-admin: { admin: false, ids: user.branch_ids || [] }.
  • branchWhere(user, alias) → admin: пустая строка; не-admin без филиалов: AND 1 = 0 (пустой результат, а не ошибка); иначе AND <alias>.branch_id IN ($1,$2,…).

Бан IP

  • Ключ ban:<ip> в Redis + таблица banned_ips; перечитывается раз в минуту (server.js:7671), при старте — loadBans() (server.js:524-536).
  • recordFailure(req, kind, limit, ms) (server.js:506-520) — инкремент fail:<kind>:<ip> за окно FAIL_WINDOW_MS; при достижении лимита счётчик удаляется и IP банится на ms.
  • Пороги, реально встречающиеся в коде: login-bruteforce — 10, honeypot — 1, apikey-bruteforce — 30, share-password-bruteforce — 10.
  • Управление: GET /api/bans, POST /api/bans, DELETE /api/bans/:ip — все под requireAuth, requireAdmin (server.js:1677-1707). POST принимает { ip, reason, hours }: reason по умолчанию 'manual' (≤100 симв.), hours зажимается в [1, 720], ответ { ok, ip, reason, banned_until }.


3. Внешний API: API-ключи (общие правила)

Монтирование

const apiV1 = express.Router() (server.js:7023), глобальные middleware:

apiV1.use(requireApiKey('read'));   // server.js:7024
apiV1.use(apiKeyLimiter);          // server.js:7025
...
app.use('/api/v1', apiV1);         // server.js:7037

То есть весь /api/v1/* требует scope read и проходит через apiKeyLimiter. Мутации дополнительно навешивают apiWrite('write').

Роут-лист apiV1 (server.js:7039-7589, 26 роутов): GET /me, /branches, /groups, /groups/:id, /students, /students/:id, /modules, /stats, /entries, /entries/:id, /entries/:id/files, /lesson-reports, /lesson-reports/:id; POST/PUT/DELETE /entries, /lesson-reports, /students; POST /ai/wake, /ai/requeue-failed, /photo-jobs/wake, /photo-jobs/requeue-failed, /entries/:id/ai/recheck.

Формат ключа и хранение

  • API_KEY_PREFIX = 'wsk' (server.js:973), секрет — crypto.randomBytes(32).toString('hex') → формат wsk_<64 hex> (server.js:2031-2032).
  • В БД лежит только sha256(raw) в key_hash + первые 12 символов в prefix (raw.slice(0, 12)), server.js:2036. Таблица api_keys (server.js:1586), индексы на key_hash, user_id, revoked_at (server.js:1601-1603).
  • Секрет возвращается один раз: POST /api/api-keys (201) и POST /api/api-keys/:id/rotate (200) — поле key рядом с публичным представлением.
  • Поиск идёт по хешу (WHERE k.key_hash = $1, server.js:1062), не по префиксу.

Заголовки авторизации

apiKeyFromRequest(req) (server.js:997-1006), порядок приоритета:

  1. X-Api-Key: <raw>
  2. Authorization: Bearer <raw> (regex /^Bearer\s+(\S+)$/i)
  3. иначе ''

Работает только на /api/v1/*. На внутреннем API Authorization: Bearer с ключом/паролем не авторизует (контракт зафиксирован в api.smoketest.js).

Scopes

API_KEY_SCOPES = { read: 'Чтение данных', write: 'Изменение данных' } (server.js:974). Каталог отдаётся через GET /api/api-keys/meta → { scopes, default_rpm } (server.js:2006-2008).

normalizeApiScopes(v) (server.js:1008-1012): приводит к строке, трим, нижний регистр, дедуплицирует, отбрасывает значения вне API_KEY_SCOPES; пустой результат → ['read'].

  • Весь /api/v1/* требует read (в apiV1.use).
  • apiWrite(scope) (server.js:7291-7298) — синхронный middleware; без scope отдаёт 403 { error: 'API key lacks scope: write' }.
  • requireApiKey('read') при отсутствии scope отдаёт 403 { error: 'API key lacks scope: read' } (server.js:1097-1099).

Как ключ сужает права

apiKeyUser(row) (server.js:1033-1047) строит req.user:

  • Если у ключа нет branch_ids — возвращается владелец как есть (включая role: 'admin', если владелец админ).
  • Если branch_ids заданы — роль принудительно понижается до 'tutor', а список филиалов = пересечение branch_ids ключа с филиалами владельца (branchScope(owner).admin ? limit : limit.filter(...)).

Вывод: branch_ids ключа сужают и никогда не расширяют права; ключ не может быть шире возможностей выдавшего.

Массовые операции учитывают филиалы отдельно: apiBranchClause(user, expr, params) (server.js:7523-7529) — admin → '', пустой список → ' AND FALSE', иначе AND <expr> = ANY($N::int[]). Пример выражения для записей: (SELECT g.branch_id FROM groups g WHERE g.id = e.group_id) (server.js:7539).

Кэш, троттлинг, лимиты

  • loadApiKey (server.js:1049-1073): ключ apikey:<sha256>, TTL SESSION_CACHE_TTL_MS = 30 с. Строка отбрасывается при revoked_at, !is_active или expires_at <= now(). В кэш кладётся { id, name, scopes: scopes || ['read'], user: apiKeyUser(row), rpm: rate_limit_per_min }.
  • touchApiKey(id, ip) (server.js:1075-1085): маркер apikey:touch:<id> с TTL API_KEY_TOUCH_MS = 5 * 60 * 1000 (5 мин) — last_used_at/last_used_ip пишутся не чаще раза в 5 минут.
  • Инвалидация: invalidateApiKeys() = cacheDrop('apikey:') + cacheDrop('rl:apikey') (server.js:123). Зовётся при создании (server.js:2038), обновлении (server.js:2075), удалении (server.js:2087) и ротации (server.js:2104).
  • Лимит: apiKeyLimiter (server.js:980-991), окно 60 с, limit — функция: Math.min(req.apiKey.rpm, API_KEY_MAX_RPM=10000), иначе API_KEY_DEFAULT_RPM = 120. Store cache.rateLimitStore('apikey', 60 * 1000).

CRUD ключей (внутренний API, под сессией)

Метод и путь Middleware Источник
GET /api/api-keys/meta requireAdmin server.js:2006
GET /api/api-keys requireAuth, requireAdmin server.js:2010
POST /api/api-keys requireAuth, requireAdmin server.js:2022
PUT /api/api-keys/:id requireAuth, requireAdmin server.js:2043
DELETE /api/api-keys/:id requireAuth, requireAdmin server.js:2080
POST /api/api-keys/:id/rotate requireAuth, requireAdmin server.js:2092

GET /api/api-keys возвращает голый массив rows.map(apiKeyPublic) (server.js:2019) — не конверт.

Видимость: apiKeyOwnerScope(req) (server.js:1977-1980) — admin видит все, не-admin только свои ( AND k.user_id = $1). На PUT/DELETE/rotate при role !== 'admin' дополнительно проверяется current.user_id !== req.user.id → 403 { error: 'Forbidden' }.

Тело POST /api/api-keys (server.js:2022-2041):

Поле Валидация
name reqStr(body.name, API_KEY_MAX_NAME=150)
scopes normalizeApiScopes
expires_at apiKeyExpiry: пусто/null → null (без срока); невалидная дата → 400 'Некорректная дата окончания'
branch_ids apiKeyAllowedBranches: пустой массив → []; любой чужой/несуществующий филиал → 400 'Недопустимый список филиалов'
rate_limit_per_min apiKeyRateValue: пусто → null; иначе Number.isInteger(n) && 1 <= n <= 10000, иначе 400 'Некорректный лимит запросов'

Ответ POST — 201 { ...apiKeyPublic(row), key: raw }. Поля apiKeyPublic (server.js:1014-1031): id, name, prefix, scopes, branch_ids, rate_limit_per_min, created_at, last_used_at, last_used_ip, expires_at, revoked_at, user_id, username, user_name.

rotate (server.js:2092-2107) обновляет prefix, key_hash, сбрасывает revoked_at = NULL и last_used_at = NULL; ответ 200 { ...apiKeyPublic(row), key: raw }.

Аудит мутаций через API

apiAudit(req, action, target) (server.js:1111-1114) добавляет в audit_log.target поле via_api_key: <id> (или null). Используется на всех мутациях apiV1; префикс действий — api. (api.student.update, api.ai.wake, …). После мутаций обязательны invalidateEntries() / invalidateLessonReports() / invalidateStudents() / invalidateStats() + broadcastEntryChanged(), иначе фронт не обновится.

Отношение к бэкапу

api_keys не входит в бэкап, и POST /api/restore делает:

await client.query('DELETE FROM sessions');   // server.js:2728
await client.query('DELETE FROM api_keys');    // server.js:2729

После восстановления все сессии и все внешние ключи мертвы — их надо выпустить заново.

GET /api/v1/me

server.js:7039-7045:

{ key: { id, name, scopes }, user: safeUser(req.user), server_time: <ISO с Z> }


4. Ограничения частоты

Все лимитеры построены на cache.rateLimitStore(prefix, windowMs) из redis.js — общем счётчике с Redis (namespace rl:<prefix>). Ни один не использует MemoryStore. У всех: standardHeaders: true, legacyHeaders: false (то есть клиент видит актуальные RateLimit-*), срабатывание = 429.

Limiter Окно Максимум Store Ключ счёта Где используется
apiLimiter 15 мин (15*60*1000) 300 rateLimitStore('api', 900000) по IP (дефолт express-rate-limit) POST /api/auth/login (1638), GET /api/public-settings (2117), GET /api/backup/:token (2610), GET /api/groups (3237), GET /api/groups/active (3259), GET /api/modules (3689), GET /api/students (4155)

5. Формат ответов, ошибки и пагинация

Контракт

  • Успех: полезная нагрузка отдаётся напрямую, без обёртки типа { ok, data }. Часто — массив или объект «как есть»; служебные действия отдают { ok: true, ... }.
  • Ошибка: всегда JSON вида { error: 'сообщение' } с явным status code. Других полей в теле ошибки нет (кроме случаев, когда роут добавляет что-то сам, например GET /api/share/:token/files/:fileToken → 401 { error: 'Требуется пароль' }).
  • Универсального try/catch-оборачивания нет — каждый хендлер оборачивает работу сам. Но в конце файла есть два глобальных fallback-обработчика.

Обработчики в конце server.js

404 (server.js:7611-7616):

isApiRoute(req) = req.path.startsWith('/api/') || '/s/' || '/r/'    // server.js:7594-7596
  • Путь API/публичной страницы → 404 { error: 'Not found' }.
  • Иначе — HTML 404 через renderErrorPage(404, 'Страница не найдена', ...) из public/error.html.

Ошибки (server.js:7618-7626) — error middleware Express (4 аргумента), он есть:

  • API-путь → 500 { error: 'Internal server error' }, стек только в консоль.
  • Не-API → HTML 500; при NODE_ENV === 'production' сообщение заменяется на 'Произошла ошибка на сервере.' и стек скрыт, иначе err.stack попадает в <pre id="errorStack">.

Типичные коды

Код Когда Пример текста
400 валидация входа 'Invalid id' (1793), 'settings required' (2143), 'Некорректный IP' (1687), 'Некорректная дата окончания' (2026), 'Недопустимый список филиалов' (2028), 'Некорректный лимит запросов' (2030), 'Файл слишком большой (макс. N МБ)' (1170)
401 нет валидной сессии / неверный ключ 'Unauthorized' (931), 'Invalid or expired API key' (1095), 'Неверный логин или пароль' (1643), 'Требуется пароль' (3196)
403 сессия есть, прав не хватает / бан / нет scope 'Forbidden: требуется роль администратора' (944), 'Forbidden' (2047), 'Доступ заблокирован' (498), 'API key lacks scope: write' (7294), 'Нет доступа к этой группе' (5391)
404 ресурс не найден 'Not found' (глобальный 7613 и в роутах), 'File missing' (5358), 'Ссылка не найдена' (3184), 'Ключ не найден' (2045), 'Модуль не найден' (7308)
409 нарушение уникальности (e.code === '23505') 'Логин уже занят' (1894, 1958), 'Duplicate name' (3315), 'Duplicate' (3344), 'Филиал с таким названием уже существует' (3488)
410 срок ссылки истёк / файл бэкапа недоступен 'Срок действия ссылки истёк' (3187), 'Файл бэкапа больше недоступен. Сформируйте архив заново.' (2619)
416 неудовлетворённый Range без тела, Accept-Ranges: bytes + Content-Range: bytes */<size> (5215-5218)
429 rate limit / бан-счётчик см. раздел «Ограничения частоты»
500 необработанная ошибка 'Internal server error' (938, 1105, 7621) или err.message в местах с ручным catch (напр. 5794)

Отдельно: для не-admin отсутствие доступа к записи иногда отдаёт 404, а не 403 (entryAccessible, server.js:1141-1154 + 7223-7226) — намеренное сокрытие существования объекта.

Пагинация

Единого стандарта нет — три подхода:

1. /api/v1 — строгий конверт. apiPage(req) (server.js:7027-7031):

limit  = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 500)   // дефолт 50, максимум 500
offset = Math.max(parseInt(req.query.offset, 10) || 0, 0)               // дефолт 0

apiList(rows, total, limit, offset) (server.js:7033-7035) → { items, total, limit, offset }.

Конверты списков — какие формы реально встречаются

Форма Эндпоинты
{ items, total, limit, offset } все списки /api/v1/* (хелпер apiList)
{ items, total } GET /api/notifications (1738, третье поле unread), GET /api/share/:token (2956), список версий отчёта (3962)
{ entries, total } GET /api/entries (5066)
{ entries, total, groups, total_groups } внутренний helper корзины (6990)
{ modules, total } GET /api/modules (3708)
{ photos, total } GET /api/groups/:id/photos (3542), GET /api/students/:id/photos (4360), GET /api/photos (5457)
голый массив GET /api/groups (3246), GET /api/groups/active (3259), GET /api/students (4164), GET /api/students/names (4179), GET /api/api-keys (2019), GET /api/bans (1681), GET /api/entries/:id/files (5107), GET /api/v1/entries/:id/files (7254)
плоский объект GET /api/settings (2110-2114) — { key: value }; GET /api/auth/me — safeUser

Общее правило: только /api/v1/* гарантирует { items, total, limit, offset }. Во внутреннем API формы разные — проверять конкретный роут.

Проверка входных данных

Инлайн-хелперы из backup-restore.js (server.js:15-36): reqStr, optStr, reqInt, optInt, reqTs, optTs, optDate, optUploadPath, sanitizeStudentProfile, isSafeUploadPath, photoRefKey, SAFE_NAME. Все SQL — только через $1, $2, …, интерполяции нет.



6. Даты, время и часовой пояс

Три семейства данных

  1. instant — колонки TIMESTAMPTZ (created_at, updated_at, expires_at, last_used_at, banned_until, ai_checked_at). Отдаются как ISO 8601 с Z (UTC), например 2026-03-14T09:31:00.000Z. Зона отображения применяется на клиенте.
  2. чистая DATE — колонки DATE (lesson_date, taken_at, date_from). Отдаются строкой 'YYYY-MM-DD', без приведения к Date() на клиенте, иначе UTC-полночь сдвинет дату на день назад.
  3. чистая TIME — колонки TIME (lesson_time, time_start/time_end). Отдаются строкой 'HH:MM' или 'HH:MM:SS'; зона не применяется.

Подтверждение со стороны БД: types.setTypeParser(1082, v => v) (server.js:44) — тип date (OID 1082) отдаётся драйвером как строка, без преобразования в JS-объект.

Настройки

Ключ Значение по умолчанию Валидация Где
timezone DEFAULT_TIMEZONE validTimezone() через new Intl.DateTimeFormat('ru-RU', { timeZone: tz }), иначе откат на дефолт server.js:1239-1252; проверка в PUT /api/settings — 2198-2200 (400)
time_format '24h' только '24h' или '12h', иначе 400 { error: 'time_format должен быть 24h или 12h' } server.js:1254-1256; проверка 2201-2202

DEFAULT_TIMEZONE = process.env.TZ, если он проходит validTimezone, иначе жёстко 'Europe/Moscow' (server.js:1239-1242). В Dockerfile:2 задано ENV TZ=Europe/Moscow — это только фолбэк; фактическая зона берётся из настройки в БД.

Серверные хелперы: appTimezone() (1249-1252), appHour12() (1254-1256).

Границы дней в SQL

Фильтры по датам в query-параметрах

Параметр Смысл Где применяется
date_from включительно, начало дня в зоне настройки GET /api/entries (5008), /api/photos (5381), /api/files (5244), /api/share/:token (3117), /api/v1/entries (7198)
date_to не включительно: реализован как < tzDayEnd, т.е. < (дата + 1 день) 00:00 те же

Следствие: клиент, которому нужен включительный верх, должен передать date_to на единицу больше.

Прочие фильтры внутреннего API: group_id, module_id, student_name, search, deleted=1, unread=1. В /api/v1: group_id, module_id, student_name, search (ILIKE по student_name и description), date_from, date_to (server.js:7190-7198).

GET /api/public-settings

server.js:2117-2139, под apiLimiter, без авторизации, ответ кэшируется на PUBLIC_TTL_MS = 60 * 1000.

Белый список ключей (server.js:2119):

system_name, system_logo, footer_left, footer_right,
share_show_student_message, share_show_entry_date, share_show_student_names,
share_show_group_photos, cookie_notice_text, spam_interval_min,
photo_capture_resolution, photo_capture_quality, photo_enhance_engine,
camera_enabled, photo_ai_face_mode, photo_ai_face_model, photo_ai_device_pref,
timezone, time_format

Дефолты (server.js:2120): system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_device_pref: 'auto', timezone: DEFAULT_TIMEZONE, time_format: '24h'.

Дополнительно вычисляются/добавляются:

Поле Как получено Источник
photo_capture_width / photo_capture_height разбор photo_capture_resolution regex /^(\d{2,5})x(\d{2,5})$/, при неудаче '640' / '480' 2123-2130
photo_capture_quality (нормализованная) parseFloat, допускается только [0.5, 1], иначе '0.92' 2131-2132
photo_ai_enabled 'true' если PHOTO_AI_URL задан, иначе 'false' 2135
upload_file_limit_mb String(UPLOAD_FILE_LIMIT_MB) 2136
upload_total_limit_mb String(UPLOAD_TOTAL_LIMIT_MB) 2137

Значения лимитов отдаются строками. Фронт обязан читать их отсюда, а не хардкодить.


TIMESTAMPTZ-колонки фильтруются только через tzDayStart / tzDayEnd / tzWall + bindTz (server.js:1258-1266):

tzDayStart(idx) → ( $N::date::timestamp AT TIME ZONE $TZ$ )
tzDayEnd(idx)   → ( $N::date::timestamp + interval '1 day') AT TIME ZONE $TZ$ )
tzWall()        → ( now() AT TIME ZONE $TZ$ )

bindTz(sql, params, tz) подставляет зону только если в SQL есть литерал $TZ$ — иначе Postgres ответит bind message supplies 1 parameters, but prepared statement requires 0. Это критично: при отсутствии фильтра по датам $TZ$ в SQL нет, и зона в params не добавляется.

DATE-колонки (lr.lesson_date) сравниваются напрямую, зона не нужна.


7. Загрузка файлов (multipart)

Транспорт — multipart/form-data. Все загрузки проходят через multer, который всегда пишет на диск в uploads/ с именем <timestamp>-<random6><ext> (server.js:1174-1199, 1202-1223). Путь, сохраняемый в БД, — всегда /uploads/<name>; ключ объекта в S3 — тот же <name> (плюс .originals/<name> для оригиналов фото).

Лимиты

UPLOAD_FILE_LIMIT_MB  = Math.max(1, env.UPLOAD_FILE_LIMIT_MB || 50)                        // server.js:1166
UPLOAD_TOTAL_LIMIT_MB = Math.max(UPLOAD_FILE_LIMIT_MB, env.UPLOAD_TOTAL_LIMIT_MB || 200)    // server.js:1167
MAX_FILE_UPLOAD_BYTES  = UPLOAD_FILE_LIMIT_MB  * 1024 * 1024
MAX_TOTAL_UPLOAD_BYTES = UPLOAD_TOTAL_LIMIT_MB * 1024 * 1024

Тексты ошибок формируются динамически (server.js:1170-1171):

  • Файл слишком большой (макс. ${UPLOAD_FILE_LIMIT_MB} МБ) — при err.code === 'LIMIT_FILE_SIZE'
  • Суммарный размер файлов слишком велик (макс. ${UPLOAD_TOTAL_LIMIT_MB} МБ) — при превышении суммы

Значения отдаются в GET /api/public-settings как upload_file_limit_mb и upload_total_limit_mb (строки, server.js:2136-2137). Лимит бэкапа отдельно: BACKUP_UPLOAD_LIMIT_MB, дефолт 500 (server.js:2457, .env.example:5).

Три конфигурации multer

Конфиг Назначение Фильтр расширений
upload (server.js:1174) фото поле photo — только изображения (ALLOWED_IMAGE_EXT: jpg, jpeg, png, gif, webp, bmp, avif, ico, heic, heif, jfif); остальное — проверка BLOCKED_EXT; ошибки 'Only images', 'Not allowed extension'
adminUpload (server.js:1202) файлы проекта ADMIN_ALLOWED_EXT: pdf, doc, docx, txt, md, html, htm, zip, rar, 7z + изображения. BLOCKED_EXT блокируется, если расширения нет в списке
uploadBackup (server.js:2460) восстановление только \.(?:tar\.gz|tgz|gz)$; пишет во временный каталог os.tmpdir()/wido-up-XXXX, не в uploads/

BLOCKED_EXT (server.js:1164) — regexp, запрещающий html, htm, js, mjs, cjs, svg, xml, json, map, wasm, php*, phtml, asp*, jsp, sh, bat, cmd, cgi, exe, dll, com, msi, scr, hta, vbs, py, r, rb, htaccess.

Эндпоинты и имена полей form-data

Эндпоинт Multer Имя поля maxCount Ответ
POST /api/entries upload.fields photo + files по 10 на каждое 201 { ...row, files: <count>, photos: <count> } (server.js:5799)
PUT /api/entries/:id upload.array photo 10 обновлённая запись
POST /api/entries/:id/files adminUpload.array files 10 201 { ok: true, count: <count> } (server.js:5161)
POST /api/groups/:id/photos upload.single photo 1 { photos: rows, photo_path } (server.js:3542, 3545)
POST /api/modules/:id/photo upload.single photo 1 — (server.js:3771)
POST /api/students/:id/photos upload.single photo 1 { photos: rows, photo_path } (server.js:4360, 4343)
POST /api/settings/logo upload.single logo 1 пишет настройку system_logo = /uploads/<name> (server.js:2233-2260)
POST /api/restore uploadBackup.single backup 1 результат restore с version
POST /api/entries/:id/photo/enhance-ai upload.single photo 1 (server.js:6032)

Обработка ошибок multer везде одинаковая: LIMIT_FILE_SIZE → 400 с FILE_TOO_LARGE_ERROR, 'Only images' → 400 с текстом про изображения, 'Not allowed extension' → 400 'Недопустимый тип файла', иначе → 400 'Недопустимый файл'. Примеры блоков: server.js:5694-5701 (записи), 5114-5119 (файлы), 2236-2240 (логотип).

Что возвращается и как доставать файл

Клиенту не возвращается путь в S3/bucket. Возвращаются:

  • Токен файла — project_files.token, 32 hex (crypto.randomBytes(16).toString('hex'), server.js:5789). Ссылка на скачивание: GET /api/files/:token.
  • Путь вида /uploads/<timestamp>-<rand6>.<ext> — в entries.photo_path, entry_photos.photo_path, project_files.path, groups.cover_path, students.photo_path, modules.photo_path, настройке system_logo. Открывается напрямую: GET /uploads/<name>.
  • Списки: GET /api/entries/:id/files → голый массив { id, token, name } (server.js:5106); внутри записи — поле files (server.js:5043).

Отдача файлов, Range и ?play=1

GET /api/files/:token (server.js:5352-5372), под fileLimiter:

  1. Поиск project_files по token → нет: 404 { error: 'Not found' }.
  2. storage.keyFromPath(r.path) не дал ключа → 404 { error: 'File missing' }.
  3. Картинка (isImageName: jpg, jpeg, jfif, png, gif, webp, bmp, avif, ico) → Cache-Control: public, max-age=31536000, immutable; при ?thumb — миниатюра 480px WebP.
  4. Видео в браузере (BROWSER_VIDEO_EXT = mp4, m4v, webm, ogv) и задан ?play → sendPlayableFile().
  5. Иначе — storage.streamTo(res, key, { download: true, name: r.name }), то есть Content-Disposition: attachment.

sendPlayableFile (server.js:5210-5235) — единственное место с поддержкой Range:

  • Cache-Control: private, max-age=3600 (не immutable — файл может быть заменён).
  • parseByteRange(header, size) (server.js:5188-5208) разбирает Range: bytes=start-end, bytes=start-, bytes=-suffix.
  • Недопустимый диапазон → 416 с Accept-Ranges: bytes и Content-Range: bytes */<size>, без тела.
  • Без Range → 200 с Accept-Ranges: bytes, Content-Type из mimeFor(name), Content-Length, поток через storage.getStream.
  • С Range → storage.streamRangeTo(res, key, start, end, { contentType, cacheControl }), что даёт 206 + Content-Range.

Важно: ?play обрабатывается как любая непустая строка (if (isPlayableVideoName(r.name) && req.query.play)) — конкретно значение 1 не проверяется. Без ?play видео уходит как attachment, чтобы старые ссылки не поменяли поведение. Видео вне BROWSER_VIDEO_EXT (OTHER_VIDEO_EXT: mov, mkv, avi, mpeg, mpg, 3gp, ts) ?play не активирует — только скачивание.

Диапазоны в других местах: GET /api/share/:shareToken/files/:fileToken (server.js:3180-3224) поддерживает только ?thumb и скачивание — Range/?play там не реализованы. GET /uploads/* — тоже без Range.

Жизненный цикл в S3

res.on('finish') hook (server.js:591-611) при STORAGE_DRIVER=s3 и успешном ответе персистит каждый загруженный файл через storage.persist, проверяя, что абсолютный путь начинается с UPLOADS_DIR + path.sep. Кэш .thumbs / .cache чистится раз в час (server.js:7695-7697), .originals — нет.


HEIC автоматически конвертируется в JPEG через heic-convert (convertPhoto; вызывается в /api/settings/logo и при загрузке фото).



Исключения: ветки без филиалов → apiList([], 0, 50, 0) (7052); «безпагинационные» выборки → apiList(rows, rows.length, rows.length, 0) (7065, 7254).


8. Потоки событий (SSE)

Оба потока — text/event-stream, без rate limit и без requireAuth-middleware: авторизация делается вручную внутри хендлера, потому что EventSource в браузере не умеет задавать заголовки.

GET /api/events

server.js:198-218. Общий поток изменений для UI.

Авторизация: const token = req.headers['x-auth-token'] || req.query.token; (server.js:200). Работает и заголовок, и query-параметр ?token=. Фронт использует второй вариант: new EventSource(\${API}/api/events?token=...`), токен из sessionStorage.getItem('authToken') (public/js/journal.js:422, public/js/lessons.js:166`).

  • Нет валидной активной сессии → 401, тело пустое (.end()).
  • Ошибка БД/кэша → 500, тело пустое.

Заголовки ответа (server.js:206-211):

Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no

Сразу после открытия пишется :ok\n\n (SSE-комментарий). Далее каждые 25000 мс — :ping\n\n (heartbeat/keep-alive, server.js:214-216). При ошибке записи или по req.on('close') клиент удаляется из sseClients, интервал очищается.

Формат кадра: writeFrame(event, data) (server.js:143-148) — event: <name>\ndata: <JSON>\n\n.

Имена событий (диспетчер dispatchEvent, server.js:150-175):

event: data: (JSON)
entries_changed { ts: <Date.now()> } — значение по умолчанию для любого типа, кроме двух ниже
ai_status { id, ai_status, ai_error, description, description_ai, description_original, ts } (server.js:163-171)
lesson_report_status { id, ai_status, ai_error, text, text_ai, ts } (server.js:152-159)

Важная деталь: broadcastLessonChanged() публикует в канал тип 'lessons_changed' (server.js:182-185), но ветки под него в dispatchEvent нет, поэтому клиент получает событие entries_changed — SSE-клиент не может отличить изменение записей от изменения отчётов.

Транспорт: cache.publish(EVENTS_CHANNEL, ...), канал whatido:events (server.js:125), подписка cache.on(EVENTS_CHANNEL, ...) (server.js:192-196). Работает и через Redis, и через in-memory fallback. Есть отдельный низкоуровневый путь через Postgres LISTEN entries_changed (server.js:54-84).

GET /api/notifications/stream

server.js:1749-1774. Поток уведомлений.

Авторизация: та же схема — req.headers['x-auth-token'] || req.query.token (server.js:1752), затем loadUserByToken.

  • Нет валидной сессии → 401 пустым телом; ошибка БД → 500 пустым телом.
  • Фронт: new EventSource(\${API}/api/notifications/stream?token=...`), токен из sessionStorage.getItem('authToken') (public/admin.js:347, 349`).

Заголовки — те же четыре, включая X-Accel-Buffering: no (server.js:1758-1763). Сразу :ok\n\n, heartbeat :ping\n\n раз в 25000 мс (server.js:1770-1772), очистка по req.on('close').

События:

event: Когда data:
ready сразу после подключения JSON результата notificationsCounts(user) — минимум { total, unread } (server.js:1766-1769)
notification при публикации новой записи вся строка БД notifications как JSON (server.js:278-294)

Формат кадра — writeNotifyFrame (server.js:278-280), тот же event: / data: JSON.

Поля строки уведомления (по выборке в GET /api/notifications, server.js:1729): id, type, level, title, body, link, target, admin_only, branch_id, created_at, read.

Фильтрация по видимости (notificationVisible, server.js:270-276): админ видит всё; остальные — только admin_only = false и (branch_id IS NULL или филиал из user_branches клиента). Невидимые кадры не отправляются вовсе.

Транспорт: канал whatido:notifications (NOTIFY_CHANNEL, server.js:224); запись в БД → cache.publish → рассылка подключённым клиентам с фильтрацией. При недоступном Redis работает in-memory pub/sub.

Прочие замечания по SSE

  • Keep-alive/heartbeat — SSE-комментарии (:ok, :ping), не события; клиентский EventSource их не показывает.
  • retry: (интервал переподключения) сервером не задаётся — используется браузерное значение по умолчанию.
  • Бан IP (ipGuard) применяется к обоим потокам как глобальный middleware — 403 до входа в хендлер.

2. Внутренний API — «мягкая» пагинация без дефолта (напр. GET /api/entries, server.js:5034-5037):

lim = parseInt(limit, 10);  if (lim > 0) { LIMIT $n }
off = parseInt(offset, 10); if (off > 0) { OFFSET $n }

9. Аутентификация: эндпоинты

POST /api/auth/login

  • Доступ: публичный (rate limit apiLimiter: 300 / 15 мин, cache.rateLimitStore('api')).
  • Тело (JSON): username (string, обязат., ≤100 символов, trim() + toLowerCase()), password (string, обязат., приводится к строке), website (honeypot — не должен содержать непустую строку).
  • Query: нет.
  • Ответ 200: { token, expires_at } — token = crypto.randomBytes(32).toString('hex') (сессионный токен, идёт в заголовок X-Auth-Token), expires_at = ISO now + SESSION_TTL_MS (SESSION_TTL_MS = 30 дней).
  • Ошибки: 401 {error:'Неверный логин или пароль'} — на honeypot, пустой/длинный логин, несуществующего или неактивного пользователя и неверный пароль; 429 «Слишком много запросов. Попробуйте позже.»; 403 (бан IP).
  • Примечания: пароль сверяется bcrypt.compare. При неудаче — recordFailure(req,'login-bruteforce',10,BAN_TTL_MS): 10 неудач за окно FAIL_WINDOW_MS (15 мин) дают бан IP на BAN_TTL_MS (24 ч); honeypot даёт recordFailure(req,'honeypot',1,BAN_TTL_MS) (бан с первого срабатывания). Сессия пишется в таблицу sessions; аудит logAudit({ip, user:{id}}, 'auth.login', {username}).

POST /api/auth/logout

  • Доступ: requireAuth. Тело/Query: не используются.
  • Ответ 200: { ok: true }.
  • Ошибки: 401 Unauthorized; 403 (бан).
  • Примечания: DELETE FROM sessions WHERE token=$1 + сброс кэша session:<token>. Аудита нет.

GET /api/auth/me

  • Доступ: requireAuth. Тело/Query: нет.
  • Ответ 200: safeUser(req.user) = { id, username, name, role, is_active, branch_ids } (хэш пароля не отдаётся, branch_ids — из user_branches).
  • Ошибки: 401 Unauthorized.
  • Примечания: пользователь кэшируется в Redis на SESSION_CACHE_TTL_MS (30 с); после правки роли/активности кэш сбрасывается через invalidateSessions().

10. Баны IP

GET /api/bans

  • Доступ: requireAuth, requireAdmin. Тело/Query: нет.
  • Ответ 200: голый массив { ip, reason, banned_until, created_at } — только активные баны (banned_until > now()), сортировка banned_until DESC.
  • Ошибки: 401 / 403.
  • Примечания: reason хранится кодом (honeypot, login-bruteforce, share-password-bruteforce, apikey-bruteforce, manual); человекочитаемые подписи — BAN_REASON_LABELS.

POST /api/bans

  • Доступ: requireAuth, requireAdmin.
  • Тело (JSON): ip (string, обязат., ≤64, только символы [0-9a-fA-F:.]), reason (string, опц., обрезка до 100, дефолт 'manual'), hours (parseInt, clamp в 1…720, дефолт 24).
  • Query: нет.
  • Ответ 200: { ok: true, ip, reason, banned_until } (banned_until = ISO now + hours*3600000).
  • Ошибки: 400 {error:'Некорректный IP'}.
  • Примечания: banIpAddr пишет ban:<ip> в кэш (TTL = срок бана) и делает upsert в banned_ips, аудит ip.ban {ip, reason}, уведомление типа ip.ban (adminOnly: true).

DELETE /api/bans/:ip

  • Доступ: requireAuth, requireAdmin. Параметр: ip — та же валидация, что в POST. Тело/Query: нет.
  • Ответ 200: { ok: true }.
  • Ошибки: 400 {error:'Некорректный IP'}.
  • Примечания: DELETE FROM banned_ips WHERE ip=$1, unbanIpAddr (удаляет ban:<ip> и все fail:*:<ip>), аудит ip.unban {ip}.

11. Уведомления

Общий лимитер notificationLimiter: 600 / 15 мин, cache.rateLimitStore('notify'), сообщение «Слишком много запросов. Попробуйте позже.». Видимость (notificationsScope): админ видит всё; не-admin — только admin_only = false и (branch_id IS NULL или филиал из user_branches).

GET /api/notifications

  • Доступ: requireAuth + notificationLimiter.
  • Query: limit (1…100, дефолт 30), offset (≥0, дефолт 0), unread ('1' — только непрочитанные).
  • Тело: нет.
  • Ответ 200: { items, total, unread }. items — строки { id, type, level, title, body, link, target, admin_only, branch_id, created_at, read } (read считается LEFT JOIN'ом на notification_reads текущего пользователя), сортировка n.id DESC; total и unread — из notificationsCounts по всей видимой выборке, а не по странице.
  • Ошибки: 401 / 429.

GET /api/notifications/meta

  • Доступ: requireAdmin. Тело/Query: нет.
  • Ответ 200: { enabled, retention_days, types }, где enabled = notify_enabled !== 'false'; retention_days = notify_retention_days (дефолт NOTIFY_RETENTION_DEFAULT_DAYS = 30); types = notifyCatalog() — массив { type, key, label, hint, icon, level, admin_only, default_enabled } из NOTIFY_TYPES (без скрытых).
  • Ошибки: 401 / 403.

GET /api/notifications/stream (SSE)

  • Доступ: своя авторизация внутри хендлера (не requireAuth); токен берётся из заголовка X-Auth-Token или из query ?token= (нужно для EventSource).
  • Тело: нет. Query: token (опц., альтернатива заголовку).
  • Ответ 200: Content-Type: text/event-stream, заголовки Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no.
  • События: ready — сразу после подключения, data = JSON { total, unread } (из notificationsCounts); notification — при публикации в Redis-канал уведомлений, data = JSON строки уведомления (публикуется вся строка БД). Служебные кадры (не события): :ok при открытии и :ping раз в 25 с. Формат кадра: event: <имя>\ndata: <json>\n\n.
  • Ошибки: 401 пустым ответом без тела (нет валидной сессии); 500 при ошибке БД.
  • Примечания: клиент кладётся в notifyClients, рассылка фильтруется по notificationVisible(user, payload) (админ — всё, иначе !admin_only и филиал из branch_ids), удаляется из сета на req.on('close'). Rate limit на этот маршрут не навешен.

POST /api/notifications/read-all

  • Доступ: requireAuth + notificationLimiter. Тело/Query: не используются.
  • Ответ 200: { ok: true, marked, unread } — marked = число вставленных строк в notification_reads (только ещё не прочитанные и только видимые), unread — остаток после операции.
  • Ошибки: 401 / 429.
  • Примечания: INSERT ... SELECT ... ON CONFLICT DO NOTHING. Аудита нет.

POST /api/notifications/:id/read

  • Доступ: requireAuth + notificationLimiter. Параметр: id (целое ≥1). Тело/Query: нет.
  • Ответ 200: { ok: true }.
  • Ошибки: 400 {error:'Invalid id'}; 404 {error:'Not found'} (нет уведомления в видимой области).
  • Примечания: сначала SELECT с проверкой видимости, затем INSERT INTO notification_reads ... ON CONFLICT DO NOTHING.

POST /api/notifications/test

  • Доступ: requireAdmin + notificationLimiter. Тело/Query: не используются.
  • Ответ 200: { ok: true, id, delivered } — id (или null) и delivered = прошло ли событие через pushNotification (тип мог быть выключен настройкой).
  • Ошибки: 401 / 403 / 429.
  • Примечания: создаёт событие system.test: title: 'Тестовое уведомление', body: 'Отправлено из настроек пользователем <username>', link: 'notifications.html', adminOnly: true. Аудита нет.

DELETE /api/notifications/:id

  • Доступ: requireAdmin. Параметр: id (целое ≥1). Тело/Query: нет.
  • Ответ 200: { ok: true }.
  • Ошибки: 400 {error:'Invalid id'}; 404 {error:'Not found'}.
  • Примечания: DELETE FROM notifications WHERE id=$1 (прочтения удаляются каскадом), аудит notifications.delete {id}.

DELETE /api/notifications

  • Доступ: requireAdmin. Тело/Query: нет.
  • Ответ 200: { ok: true, deleted } (число удалённых строк).
  • Ошибки: 401 / 403.
  • Примечания: полная очистка таблицы notifications; аудит notifications.clear {deleted}.

12. Пользователи

GET /api/users

  • Доступ: requireAuth, requireAdmin. Тело/Query: нет.
  • Ответ 200: голый массив { id, username, name, role, is_active, created_at, branch_ids }, только role = 'tutor' (админы не возвращаются), branch_ids — array_agg по user_branches (пустой массив, если филиалов нет), сортировка по id.
  • Ошибки: 401 / 403.

GET /api/users/tutors

  • Доступ: requireAuth (любой активный пользователь, включая не-admin). Тело/Query: нет.
  • Ответ 200: голый массив { id, name, username } — только role='tutor' AND is_active=true, сортировка по COALESCE(NULLIF(name,''), username).
  • Ошибки: 401.
  • Примечания: филиалы не фильтруются — список всех тьюторов.

GET /api/users/branches

  • Доступ: requireAuth. Тело/Query: нет.
  • Ответ 200: голый массив строк SELECT * FROM branches (все колонки), сортировка по id; не-admin получает только свои филиалы (WHERE id = ANY($1::int[])), при пустом списке — [].
  • Ошибки: 401.
  • Примечания: фильтрация через branchScope(req.user).

POST /api/users

  • Доступ: requireAuth, requireAdmin.
  • Тело (JSON): username (string, обязат., ≤100, trim() + toLowerCase()), password (string, обязат., ≥6), name (string, опц., ≤150, иначе null), role (строго 'admin' → admin, иначе tutor), is_active (дефолт true; выключает только строгое false), branch_ids (массив чисел, дедуплицируется через Number()/filter(Boolean); при role='admin' принудительно []).
  • Ответ 201: safeUser(...) = { id, username, name, role, is_active, branch_ids }.
  • Ошибки: 400 {error:'Пароль должен быть не короче 6 символов'}; 409 {error:'Логин уже занят'} (код 23505).
  • Примечания: bcrypt.hash(password, 10); вставка пользователя и строк user_branches в одной транзакции; аудит user.create {id, username, role}.

PUT /api/users/:id

  • Доступ: requireAuth, requireAdmin.
  • Тело (JSON): все поля опциональны (PATCH-семантика): name (≤150), role ('admin' → admin, иначе tutor), is_active (!== false), branch_ids (массив — только при передаче массива, иначе филиалы не трогаются), password (≥6, иначе 400).
  • Ответ 200: safeUser свежепрочитанного пользователя (тот же SELECT, что в GET /api/users).
  • Ошибки: 404 {error:'Пользователь не найден'}; 400 {error:'Нельзя деактивировать самого себя'}; 400 {error:'Нельзя снять роль администратора с самого себя'}; 400 {error:'Пароль должен быть не короче 6 символов'}; 409 {error:'Логин уже занят'}.
  • Примечания: всё в транзакции; при is_active=false удаляются все сессии пользователя; после коммита — invalidateSessions() + invalidateApiKeys(); аудит user.update {id, role, is_active}. Логин не меняется (username в теле не принимается).

DELETE /api/users/:id

  • Доступ: requireAuth, requireAdmin. Тело/Query: нет.
  • Ответ 200: { ok: true }.
  • Ошибки: 400 {error:'Нельзя удалить самого себя'}.
  • Примечания: DELETE FROM users WHERE id=$1, invalidateSessions() + invalidateApiKeys(), аудит user.delete {id}. 404 не возвращается — удаление несуществующего id тоже даёт {ok:true}.

13. API-ключи (внутренний UI)

Форма ответа apiKeyPublic: { id, name, prefix, scopes, branch_ids, rate_limit_per_min, created_at, last_used_at, last_used_ip, expires_at, revoked_at, user_id, username?, user_name? }. Секрет в БД не хранится (только sha256 в key_hash + prefix из первых 12 символов) и возвращается один раз при создании и ротации.

GET /api/api-keys/meta

  • Доступ: requireAdmin. Тело/Query: нет.
  • Ответ 200: { scopes: { read: 'Чтение данных', write: 'Изменение данных' }, default_rpm: 120 }.
  • Ошибки: 401 / 403.

GET /api/api-keys

  • Доступ: requireAuth, requireAdmin. Тело/Query: нет.
  • Ответ 200: голый массив apiKeyPublic(...) + username/user_name владельца (JOIN по users), сортировка k.id DESC.
  • Ошибки: 401 / 403.
  • Примечания: не-admin видит только свои ключи (apiKeyOwnerScope → AND k.user_id = $1).

POST /api/api-keys

  • Доступ: requireAuth, requireAdmin.
  • Тело (JSON): name (string, обязат., ≤ API_KEY_MAX_NAME = 150), scopes (массив или строка; фильтруются по API_KEY_SCOPES, дефолт ['read']), branch_ids (массив; не-admin может указать только свои филиалы; чужой/несуществующий → ошибка), expires_at (строка-дата; null/'' → бессрочно; невалидная дата → 400), rate_limit_per_min (целое 1…API_KEY_MAX_RPM = 10000; null/'' → null, затем дефолт 120).
  • Ответ 201: apiKeyPublic(...) + key — полный секрет формата wsk_<64 hex> (в БД: prefix = первые 12 символов, key_hash = sha256).
  • Ошибки: 400 {error:'Некорректная дата окончания'}; 400 {error:'Недопустимый список филиалов'}; 400 {error:'Некорректный лимит запросов'}.
  • Примечания: ключ создаётся на req.user.id; invalidateApiKeys(); аудит api_key.create {id, name, scopes, branch_ids, expires_at}.

PUT /api/api-keys/:id

  • Доступ: requireAuth, requireAdmin; не-admin — только свои ключи, иначе 403.
  • Тело (JSON): любое подмножество: name (≤150), scopes, branch_ids (снова проверяется принадлежность филиалу), expires_at (в т.ч. null → бессрочно), rate_limit_per_min (1…10000; null → сброс к дефолту).
  • Ответ 200: apiKeyPublic(...).
  • Ошибки: 404 {error:'Ключ не найден'}; 403 {error:'Forbidden'}; 400 — те же тексты про дату / список филиалов / лимит запросов.
  • Примечания: секрет и префикс не меняются (для этого есть rotate); invalidateApiKeys(); аудит api_key.update {id, name, scopes, branch_ids, expires_at}.

DELETE /api/api-keys/:id

  • Доступ: requireAuth, requireAdmin; не-admin — только свои ключи. Тело/Query: нет.
  • Ответ 200: { ok: true }.
  • Ошибки: 404 {error:'Ключ не найден'}; 403 {error:'Forbidden'}.
  • Примечания: строка удаляется физически (не revoked_at); invalidateApiKeys(); аудит api_key.delete {id, name}.

POST /api/api-keys/:id/rotate

  • Доступ: requireAuth, requireAdmin; не-admin — только свои ключи. Тело/Query: нет.
  • Ответ 200: apiKeyPublic(...) + key (новый секрет wsk_<64 hex>).
  • Ошибки: 404 {error:'Ключ не найден'}; 403 {error:'Forbidden'}.
  • Примечания: UPDATE перезаписывает prefix/key_hash и сбрасывает revoked_at и last_used_at — то есть ротация «оживляет» отозванный ключ; invalidateApiKeys(); аудит api_key.rotate {id, name}.

14. Настройки

GET /api/settings

  • Доступ: requireAdmin. Тело/Query: нет.
  • Ответ 200: плоский объект { key: value } — все строки таблицы settings, сортировка по ключу.
  • Ошибки: 401 / 403.

GET /api/public-settings

  • Доступ: публичный (apiLimiter), без авторизации. Тело/Query: нет.
  • Ответ 200: объект (все значения — строки) по ключам: system_name, system_logo, footer_left, footer_right, share_show_student_message, share_show_entry_date, share_show_student_names, share_show_group_photos, cookie_notice_text, spam_interval_min, photo_capture_resolution, photo_capture_quality, photo_enhance_engine, camera_enabled, photo_ai_face_mode, photo_ai_face_model, photo_ai_device_pref, timezone, time_format. Дефолты из кода: system_name='WhatIDo', system_logo='', spam_interval_min='30', photo_capture_resolution='640x480', photo_capture_quality='0.92', photo_enhance_engine='auto', camera_enabled='true', photo_ai_face_mode=PHOTO_AI_DEFAULT_FACE_MODE (env PHOTO_AI_FACE_MODE, дефолт off), photo_ai_face_model=PHOTO_AI_FACE_MODEL (env, дефолт gfpgan), photo_ai_device_pref='auto', timezone=DEFAULT_TIMEZONE, time_format='24h'. Дополнительно: photo_capture_width / photo_capture_height (парсятся из WxH, иначе 640 / 480), photo_ai_enabled ('true'|'false' по наличию PHOTO_AI_URL), upload_file_limit_mb и upload_total_limit_mb (из env).
  • Ошибки: 429.
  • Примечания: тело оборачивается в cacheWrap('public-settings', PUBLIC_TTL_MS = 60 с), но photo_ai_enabled и лимиты загрузки дописываются каждый раз уже вне кэша. photo_capture_quality нормализуется: не число или вне 0.5…1 → '0.92'.

PUT /api/settings

  • Доступ: requireAdmin.
  • Тело (JSON): { settings: { key: value, ... } } — объект обязателен. Все значения сохраняются строками (String(value ?? '')), upsert по ключу.
  • Валидация по ключам: spam_interval_min — целое 1…10080; trash_purge_days — 1…3650; photo_capture_resolution — /^\d{2,5}x\d{2,5}$/; photo_capture_quality — число 0.5…1; photo_enhance_engine ∈ auto|server|client; photo_ai_face_mode ∈ off|face|all; photo_ai_face_model ∈ gfpgan|codeformer; photo_ai_device_pref ∈ auto|cuda|cpu; system_name ≤60 символов после trim(); system_logo — '' или isSafeUploadPath(); notify_retention_days — целое 1…365; lesson_ai_enabled — строго 'true'|'false'; lesson_ai_prompt ≤8000 символов; timezone — validTimezone(); time_format ∈ 24h|12h; любой другой notify_* (кроме notify_retention_days) — только 'true'|'false'. Остальные ключи пишутся без проверок.
  • Ответ 200: полный актуальный объект { key: value } (те же строки, что и в GET /api/settings).
  • Ошибки: 400 {error:'settings required'} и 400 с конкретным текстом на каждый случай: «spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)», «trash_purge_days должен быть целым числом от 1 до 3650», «photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480», «photo_capture_quality должен быть числом от 0.5 до 1», «photo_enhance_engine должен быть auto, server или client», «photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref должен быть одним из: …», «system_name не может быть длиннее 60 символов», «system_logo — некорректный путь», «notify_retention_days должен быть целым числом от 1 до 365», «lesson_ai_enabled должен быть true или false», «lesson_ai_prompt длиннее 8000 символов», «timezone должен быть корректным часовым поясом IANA, например Europe/Moscow», «time_format должен быть 24h или 12h», «<key> должен быть true или false».
  • Примечания: вся пачка пишется в одной транзакции (ON CONFLICT DO UPDATE), при ошибке — ROLLBACK; аудит settings.update {settings} (со всеми значениями); invalidateSettings() сбрасывает кэш, включая public-settings.
  • Доступ: requireAdmin.
  • Тело: multipart/form-data, поле файла — logo (upload.single('logo')); лимит размера — UPLOAD_FILE_LIMIT_MB; принимаются только изображения.
  • Ответ 200: { system_logo: '/uploads/<file>' }.
  • Ошибки: 400 «Файл слишком большой (макс. N МБ)» (FILE_TOO_LARGE_ERROR, код multer LIMIT_FILE_SIZE); 400 «Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)» (ошибка multer Only images); 400 «Недопустимый тип файла» (Not allowed extension); 400 «Недопустимый файл» (прочие ошибки multer); 400 «Файл обязателен» (нет req.file); 400 «Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)» — повторная проверка ALLOWED_IMAGE_EXT по path.extname(originalname), с удалением файла.
  • Примечания: HEIC конвертируется через convertPhoto; старый логотип (isSafeUploadPath(old) && old !== finalPath) удаляется через safeUnlink; settings.system_logo обновляется, аудит settings.logo.upload {path}, invalidateSettings(). При исключении после multer файл снимается removeUpload.
  • Доступ: requireAdmin. Тело/Query: нет.
  • Ответ 200: { ok: true, system_logo: '' }.
  • Ошибки: 401 / 403.
  • Примечания: значение system_logo обнуляется, старый файл удаляется через safeUnlink (только при isSafeUploadPath(old)); аудит settings.logo.remove {}; invalidateSettings().

15. Аудит

GET /api/audit

  • Доступ: requireAdmin.
  • Query: limit (дефолт 100, верхняя граница 1000 — Math.min(parseInt(...) || 100, 1000)), offset (≥0, дефолт 0), action (непустая строка — точное совпадение по a.action).
  • Тело: нет.
  • Ответ 200: голый массив { id, action, target, ip, created_at, user_id, user_name }, сортировка a.id DESC; user_name из LEFT JOIN users (может быть null).
  • Ошибки: 401 / 403.
  • Примечания: target прогоняется через stripDiffs — диффы вырезаются, чтобы список не отдавал килобайты текста на строку.

GET /api/audit/:id

  • Доступ: requireAdmin. Параметр: id (целое ≥1). Тело/Query: нет.
  • Ответ 200: одна строка { id, action, target, ip, created_at, user_id, user_name } — с полным target, включая diff.
  • Ошибки: 400 {error:'Invalid id'}; 404 {error:'Запись не найдена'}.
  • Примечания: дифф здесь намеренно возвращается (контракт зафиксирован в api.smoketest.js).

16. Бэкап и восстановление

POST /api/backup

  • Доступ: requireAdmin. Тело/Query: нет.
  • Ответ 200: { url: '/api/backup/<token>', filename, size, expires_at, counts, format_version } — url ведёт на тикет, counts = количества строк по таблицам BACKUP_TABLES + files, format_version = BACKUP_FORMAT_VERSION.
  • Ошибки: 500 {error: err.message} (ошибка сборки архива); 401 / 403.
  • Примечания: архив tar.gz (data.json + uploads/) кладётся в BACKUP_DIR, тикет (token = 24 байта hex) хранится в in-memory Map на BACKUP_TTL_MS = 30 мин; одновременно живых тикетов не больше BACKUP_TICKETS_MAX = 3 (pruneBackupTickets; файлы добиваются sweepBackupStorage с запасом 5 мин, всё чистится раз в минуту). После рестарта приложения ссылка даёт 404 — это ожидаемо. Аудит backup.download {size, counts}, уведомление backup.create (adminOnly: true). В бэкап не входят sessions и api_keys; входят audit_log, notifications, notification_reads, banned_ips.

GET /api/backup/:token

  • Доступ: публичный (apiLimiter), авторизации по токену нет — знание тикета и есть доступ. Тело/Query: нет.
  • Ответ 200: бинарный архив — res.download(...) с Cache-Control: no-store, имя файла из тикета.
  • Ошибки: 404 «Ссылка на бэкап устарела. Сформируйте архив заново.» (тикета нет или истёк — истёкший тикет удаляется); 410 «Файл бэкапа больше недоступен. Сформируйте архив заново.» (файла на диске нет); 500 «Не удалось отправить бэкап»; 429.
  • Примечания: тикет не «сжигается» чтением — скачивание можно повторять, в том числе после обрыва связи и F5 (и HEAD, т.к. Express 4 отдаёт HEAD через GET-хендлер).

GET /api/backup

  • Доступ: requireAdmin (прямое скачивание без тикета). Тело/Query: нет.
  • Ответ 200: бинарный архив tar.gz (Cache-Control: no-store).
  • Ошибки: 500 {error: err.message}; 401 / 403.
  • Примечания: тот же buildBackupArchive + аудит backup.download {size, counts} + уведомление backup.create; файл удаляется sweepBackupStorage после истечения TTL.

POST /api/restore

  • Доступ: requireAdmin.
  • Тело: multipart/form-data, поле файла — backup (uploadBackup.single('backup')); лимит BACKUP_UPLOAD_LIMIT_MB (env, дефолт 500); fileFilter пропускает только .tar.gz|.tgz|.gz.
  • Ответ 200: { ok: true, format_version, restored }, где restored — счётчики из restoredCounts(ndata).
  • Ошибки: 400 {error:'backup file required'}; 400 {error:'Неверный файл бэкапа'} (мусор / не распаковывается); 400 «Это архив скрипта scripts/backup.sh (db.sql.gz + _uploads) — восстанавливайте его через scripts/restore.sh. Для веб-восстановления скачайте архив в Настройках админки.»; 400 {error:'Неверный формат бэкапа'} (isSupportedBackupVersion); 400 «Неверный формат бэкапа: <message> (ошибка normalizeRestoreData); 500 «Ошибка восстановления: <message>; 401 / 403. Ошибки multer (превышение размера, недопустимое расширение) в этом хендлере не перехватываются и уходят в общий error-handler → 500 {error:'Internal server error'} для /api/*.
  • Примечания: поддерживается legacy-формат — если «tar.gz» на деле gzip-JSON, берётся data.photos[] (base64) и файлы кладутся поштучно через storage.put. Восстановление в одной транзакции: полный DELETE в FK-безопасном порядке (notification_reads → notifications → banned_ips → project_files → lesson_report_versions → lesson_reports → entries → modules → students → share_links → groups → user_branches → sessions → api_keys → audit_log → users → branches), затем INSERT данных и setval по BACKUP_SEQUENCE_TABLES; записи lesson_reports, lesson_report_versions и photo_jobs пропускаются, если нет родительской строки. Вне транзакции: storage.uploadTree(staging/uploads), sweepOrphanedUploads(), loadBans(), ensureFirstAdmin(), аудит backup.restore {format_version}, уведомление backup.restore (adminOnly: true), invalidateAll(). После restore все сессии и все API-ключи мертвы (DELETE FROM sessions, DELETE FROM api_keys) — ключи надо выпускать заново.

17. Публичные ссылки (share)

  • Доступ: requireAuth (сессия в X-Auth-Token); филиалы: не-admin видят только ссылки, чей group_id входит в user_branches; при пустом списке филиалов — пустой результат (WHERE l.group_id IS NULL AND 1 = 0)
  • Query: limit (optInt, 1..200), offset (optInt, ≥0, дефолт 0). Если limit не задан — отдаётся голый массив без пагинации
  • Ответ: при limit — { items, total }, иначе — массив share_links * + group_name. ORDER BY l.created_at DESC, даты нормализованы normDates (date_from/date_to → YYYY-MM-DD)
  • Ошибки: нет явных; optInt бросает исключение вне диапазона — оно не перехватывается в async-хендлере (?)
  • Примечания: без кэша и без аудита (чтение)

POST /api/links

  • Доступ: requireAuth; филиалы: не-admin не может указать чужую группу → 403 «Нет доступа к этой группе»
  • Тело (JSON): name (string, обязат., не пустой после trim), group_id (int, опц.), student_name, date_from, date_to, message (≤2000 симв.), link_url (≤500, только http:/https:), access_password (опц., bcrypt, 10 раундов), expires_at (опц., дефолт +7 дней), флаги show_student_names, show_student_message, show_entry_date, show_group_photos (три состояния: не передан → NULL → fallback на настройку при показе)
  • Ответ 201: строка share_links * (token = crypto.randomBytes(20).toString('hex'), 40 hex-символов)
  • Ошибки: 400 «Название обязательно», 400 «Сообщение слишком длинное (макс. 2000 символов)», 400 «Ссылка слишком длинная (макс. 500 символов)», 400 «Некорректная ссылка», 400 «Ссылка должна начинаться с http:// или https://», 400 «Неверный формат даты истечения», 403 «Нет доступа к этой группе»
  • Примечания: аудит link.create ({id, name}), invalidateShare() (сброс кэша share:payload:)

PUT /api/links/:id

  • Доступ: requireAuth; филиалы: не-admin проверяются и по текущей ссылке (group_id), и по новому group_id в теле
  • Тело (JSON): те же поля, что и в POST; access_password: ''/null снимает пароль, expires_at: null снимает срок. Если поле не передано — access_password/expires_at не меняются, остальные поля перезаписываются всегда
  • Ответ 200: строка share_links * (нормализованные даты)
  • Ошибки: 400 «Название обязательно» / «Сообщение слишком длинное…» / «Ссылка слишком длинная…» / «Некорректная ссылка» / «Ссылка должна начинаться с http:// или https://» / «Неверный формат даты истечения», 403 «Нет доступа к этой ссылке» / «Нет доступа к этой группе», 404 «Не найдено»
  • Примечания: аудит link.update ({id, name}), invalidateShare()

DELETE /api/links/:id

  • Доступ: requireAuth; филиалы: не-admin — только если у ссылки нет group_id или группа принадлежит его филиалам
  • Ответ 200: { ok: true }
  • Ошибки: 403 «Нет доступа к этой ссылке», 404 «Не найдено» (для не-admin при отсутствии записи)
  • Примечания: жёсткий DELETE FROM share_links (мягкого удаления нет); аудит link.delete, invalidateShare()

GET /api/share/:token

  • Доступ: публичный, fileLimiter (300 / 15 мин, cache.rateLimitStore('file', …)); пароль — заголовок X-Share-Password или query ?password=
  • Ответ 200: payload из cacheWrap('share:payload:<token>', SHARE_TTL_MS = 60 c) — { name, group_name, student_name, group_id, date_from, date_to, message, link_url, created_at, expires_at, show_student_names, show_student_message, show_entry_date, show_group_photos, entries[], photos[] }. В entries вложен files[] (token, name). photos — максимум 12 фото группы, порядок sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
  • Ошибки: 404 «Ссылка не найдена», 410 «Срок действия ссылки истёк», 401 «Требуется пароль» + passwordRequired: true, 401 «Неверный пароль» (+ recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS))
  • Примечания: выборка только e.deleted_at IS NULL; границы дат — через tzDayStart/tzDayEnd + bindTz(…, appTimezone()). Если show_student_names выключен, имена заменяются на «Ученик N». Флаги в ссылке NULL → дефолт из настроек: share_show_student_names='true', share_show_student_message='false', share_show_entry_date='false', share_show_group_photos='true'

GET /api/share/:shareToken/files/:fileToken

  • Доступ: публичный, fileLimiter; та же проверка пароля, что и в GET /api/share/:token
  • Ответ 200: поток файла. Картинки (isImageName: jpg/jpeg/jfif/png/gif/webp/bmp/avif/ico) — inline с Cache-Control: public, max-age=31536000, immutable, при ?thumb — миниатюра sendImageThumb. Остальное — attachment с оригинальным именем
  • Ошибки: 404 «Ссылка не найдена», 410 «Срок действия ссылки истёк», 401 «Требуется пароль» / «Неверный пароль», 404 Not found (токен файла не подходит под фильтры ссылки), 404 File missing (нет ключа в storage / поток не отдался)
  • Примечания: файл должен принадлежать записи, попадающей под фильтры ссылки (группа/студент/даты) и не удалённой; путь берётся через storage.keyFromPath + storage.streamTo

GET /s/:token

  • Доступ: публичный HTML, без аутентификации и без rate-limiter
  • Ответ 200: public/share.html (res.sendFile); :token на сервере не используется — страница сама читает его из URL
  • Ошибки: нет (только 404/500 самого sendFile)

GET /r/:token

  • Доступ: публичный HTML, без аутентификации и без rate-limiter
  • Ответ 200: public/report.html
  • Ошибки: нет (только 404/500 самого sendFile)

18. Группы

GET /api/groups

  • Доступ: apiLimiter (300 / 15 мин) + optionalAuth — работает и без токена; филиалы: при наличии пользователя выборка ограничивается branchWhere(req.user, 'g'), без токена фильтра нет
  • Ответ 200: массив групп (ORDER BY g.id), только deleted_at IS NULL. Поле cover_path = явная обложка либо первое фото группы по sort_order ASC, taken_at DESC NULLS LAST, created_at DESC; добавляется branch_name
  • Ошибки: нет явных
  • Примечания: кэш cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS = 60 c); scopeKey = all для админа, иначе отсортированные id филиалов или none; без токена — anon

GET /api/groups/active

  • Доступ: apiLimiter, аутентификации нет ((_, res)), филиалы: ограничения НЕТ — отдаёт все подходящие группы
  • Ответ 200: массив групп, у которых заданы day_of_week, time_start, time_end, день недели совпадает с текущим в зоне appTimezone() и текущее время попадает в интервал
  • Ошибки: нет явных
  • Примечания: кэш groups:active, TTL 60 c; сравнение времени — now() AT TIME ZONE $1, поэтому учитывает настройку timezone

POST /api/groups

  • Доступ: requireAuth; филиалы: не-admin не может задать branch_id → 403 «Назначение филиала — только для администратора», иначе филиал принудительно NULL
  • Тело (JSON): name (string, обязат.), branch_id (int, только для админа), tutor_id (int, опц. — должен существовать пользователь с role = 'tutor')
  • Ответ 201: строка groups *
  • Ошибки: 400 «Name required», 400 «Пользователь не найден или не является тутором», 403 «Назначение филиала — только для администратора», 409 «Duplicate» (уникальное имя)
  • Примечания: аудит group.create ({id, name, branch_id}), invalidateGroups() + invalidateStats()

PUT /api/groups/:id

  • Доступ: requireAuth; филиалы: groupBelongsToBranches(req.user, :id) — 403 «Нет доступа к этой группе»
  • Тело (JSON): name (опц., COALESCE($1, name)), day_of_week (опц.), time_start, time_end, branch_id (только админ, иначе 403), tutor_id (передача поля — явное намерение сменить тьютора, null/'' снимает; проверяется role = 'tutor')
  • Ответ 200: строка groups *
  • Ошибки: 403 «Нет доступа к этой группе» / «Назначение филиала — только для администратора», 400 «Пользователь не найден или не является тутором», 409 «Duplicate name»
  • Примечания: аудит group.update ({id, ...req.body} — тело аудируется целиком), invalidateGroups() + invalidateStats()

DELETE /api/groups/:id

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403 «Нет доступа к этой группе»
  • Ответ 200: { ok: true }
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Группа не найдена или уже в корзине»
  • Примечания: мягкое удаление — UPDATE groups SET deleted_at = now() WHERE deleted_at IS NULL. Аудит group.soft-delete, invalidateGroups() + invalidateStats(). Физического удаления здесь нет (см. DELETE /api/groups/:id/permanent)

PUT /api/groups/:id/restore

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403
  • Ответ 200: { ok: true }
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Группа не найдена или не в корзине»
  • Примечания: снимает deleted_at и purge_at (WHERE deleted_at IS NOT NULL). Аудит group.restore, invalidateGroups() + invalidateStats()

DELETE /api/groups/:id/permanent

  • Доступ: requireAuth (не requireAdmin); филиалы: groupBelongsToBranches → 403
  • Тело: пустое
  • Ответ 200: { ok: true, purge_at } — фактическая метка времени из БД
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Группа не найдена, не в корзине или уже помечена на удаление»
  • Примечания: физического удаления не делает — только ставит purge_at = now() + trash_purge_days days, где trash_purge_days — настройка (дефолт 30, валидна 1..3650). Условие deleted_at IS NOT NULL AND purge_at IS NULL. Реальное удаление выполняет фоновая задача корзины через hardDeleteGroup (server.js:3349, вызов на 1277). Аудит group.schedule-delete ({id, days}), invalidateGroups() + invalidateStats()

PUT /api/groups/:id/unschedule

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403
  • Ответ 200: { ok: true }
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Группа не найдена или не помечена на удаление»
  • Примечания: снимает отметку purge_at = NULL WHERE purge_at IS NOT NULL, сама группа остаётся в корзине (deleted_at не трогается). Аудит group.unschedule, invalidateGroups() + invalidateStats()

19. Филиалы

GET /api/branches

  • Доступ: requireAuth; филиалы: не-admin — только свои user_branches, при пустом списке WHERE 1 = 0 (пустой массив)
  • Ответ 200: массив branches * + groups_count (count(g.id)::int, LEFT JOIN groups), ORDER BY b.id
  • Ошибки: нет явных
  • Примечания: кэша нет; аудита нет (чтение)

POST /api/branches

  • Доступ: requireAdmin
  • Тело (JSON): name (string, обязат.), address, phone (опц., пустая строка → NULL)
  • Ответ 201: строка branches *
  • Ошибки: 400 «Название обязательно», 409 «Филиал с таким названием уже существует»
  • Примечания: аудит branch.create ({id, name}), invalidateGroups()

PUT /api/branches/:id

  • Доступ: requireAdmin
  • Тело (JSON): name (обязат.), address, phone — все три поля перезаписываются, отсутствующие станут NULL
  • Ответ 200: строка branches *
  • Ошибки: 400 «Название обязательно», 404 «Не найдено», 409 «Филиал с таким названием уже существует»
  • Примечания: аудит branch.update ({id, ...req.body}), invalidateGroups()

DELETE /api/branches/:id

  • Доступ: requireAdmin
  • Ответ 200: { ok: true }
  • Ошибки: 400 «Нельзя удалить филиал: есть привязанные группы» (проверяется наличие групп с branch_id = $1); при несуществующем :id rowCount не проверяется и ответ всё равно 200
  • Примечания: жёсткий DELETE FROM branches; аудит branch.delete, invalidateGroups()

20. Фотохроника группы

GET /api/groups/:id/photos

  • Доступ: requireAuth; филиалы: groupBelongsToBranches(req.user, :id) → 403 «Нет доступа к этой группе»
  • Query: limit, offset — обычный parseInt(…, 10), применяются только если > 0; неверные значения молча игнорируются (не optInt)
  • Ответ 200: { photos, total }; порядок sort_order ASC, taken_at DESC NULLS LAST, created_at DESC
  • Ошибки: 403 «Нет доступа к этой группе»
  • Примечания: кэша и аудита нет; группа может быть в корзине — deleted_at не проверяется

POST /api/groups/:id/photos

  • Доступ: requireAuth + upload.single('photo'); филиалы: проверка groupBelongsToBranches выполняется после multer; при отказе загруженный файл удаляется (removeUpload)
  • Тело: multipart/form-data, поле файла photo (только изображения: jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif), текстовые caption, taken_at (DATE)
  • Ответ 201: строка group_photos *
  • Ошибки: 400 «Файл обязателен», 400 «Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)», 400 «Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)», 400 Файл слишком большой (макс. N МБ) (UPLOAD_FILE_LIMIT_MB, дефолт 50), 400 «Недопустимый файл», 403 «Нет доступа к этой группе», 500 при сбое БД/конвертации
  • Примечания: HEIC автоматически конвертируется (convertPhoto); sort_order = MIN(sort_order) - 1 по группе, то есть новое фото становится первым. Аудит group.photo.create, invalidateShare() + invalidateGroups() + invalidateStats(). При ошибке файл подчищается safeUnlink

PUT /api/groups/:id/photos/reorder

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403
  • Тело (JSON): order (массив целых id фото). Порядок не обязан быть полным: не переданные фото дописываются в конец с текущим порядком. Дубликаты запрещены
  • Ответ 200: { ok: true }
  • Ошибки: 403 «Нет доступа к этой группе», 400 «Некорректный порядок фото», 400 «Порядок фото содержит дубликаты», 404 «Фото не найдено» (id не принадлежит группе)
  • Примечания: транзакция (BEGIN/COMMIT/ROLLBACK), sort_order переписывается как 1..N. Аудит group.photo.reorder, invalidateShare() + invalidateGroups() (invalidateStats не вызывается)

PUT /api/groups/:id/photos/:photoId

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403
  • Тело (JSON): caption (string, пустая строка → NULL), taken_at (DATE, пустое → NULL). Оба поля перезаписываются всегда; sort_order здесь не меняется — только через reorder
  • Ответ 200: строка group_photos *
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Не найдено»
  • Примечания: аудит group.photo.update, только invalidateShare() (группы и статистика не сбрасываются)

DELETE /api/groups/:id/photos/:photoId

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403
  • Ответ 200: { ok: true }
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Не найдено»
  • Примечания: файл удаляется safeUnlink(photo_path); если он был обложкой — groups.cover_path сбрасывается в NULL. Аудит group.photo.delete, invalidateShare() + invalidateGroups() + invalidateStats()

PUT /api/groups/:id/photos/:photoId/cover

  • Доступ: requireAuth; филиалы: groupBelongsToBranches → 403
  • Тело: пустое — только :photoId
  • Ответ 200: полная строка groups * (после обновления cover_path)
  • Ошибки: 403 «Нет доступа к этой группе», 404 «Не найдено» (фото не найдено в этой группе)
  • Примечания: единственный способ задать обложку — PUT .../cover; POST и PUT .../:photoId её не трогают. Аудит group.photo.set_cover, invalidateShare() + invalidateGroups() (invalidateStats не вызывается). Снять обложку отдельным эндпоинтом нельзя — только удалением фото

21. Модули

GET /api/modules

  • Доступ: apiLimiter (300 / 15 мин), без аутентификации и без ограничения по филиалам
  • Query: search (ILIKE %…% по m.name), active ('1' или 'true' → только is_active = true), limit, offset (обычный parseInt, применяется при > 0)
  • Ответ 200: { modules, total }; каждый модуль с entries_count (count(e.id)::int, LEFT JOIN entries), порядок m.is_active DESC, m.id
  • Ошибки: нет явных
  • Примечания: кэша и аудита нет; total считается по тем же фильтрам

POST /api/modules

  • Доступ: requireAdmin
  • Тело (JSON): name (string, обязат.), lessons_count (parseLessonsCount: целое 0..10000, дефолт 0, пустая строка → 0)
  • Ответ 201: строка modules * (is_active = true)
  • Ошибки: 400 «Название обязательно», 400 «Количество занятий — целое число от 0 до 10000», 409 «Модуль с таким названием уже существует»
  • Примечания: аудит module.create ({id, name, lessons_count}); кэш не инвалидируется (?)

PUT /api/modules/:id

  • Доступ: requireAdmin
  • Тело (JSON): name (обязат.), lessons_count (те же правила 0..10000) — оба поля перезаписываются
  • Ответ 200: строка modules *
  • Ошибки: 400 «Название обязательно», 400 «Количество занятий — целое число от 0 до 10000», 404 «Не найдено», 409 «Модуль с таким названием уже существует»
  • Примечания: аудит module.update ({id, name, lessons_count}); кэш не инвалидируется (?)

DELETE /api/modules/:id

  • Доступ: requireAdmin
  • Ответ 200: { ok: true }
  • Ошибки: 404 «Не найдено»
  • Примечания: строка не удаляется — UPDATE modules SET is_active = false (мягкое скрытие). Восстановление — PUT /api/modules/:id/restore. Аудит module.delete

PUT /api/modules/:id/restore

  • Доступ: requireAdmin
  • Ответ 200: строка modules *
  • Ошибки: 404 «Не найдено»
  • Примечания: UPDATE modules SET is_active = true (идемпотентно — повторный вызов на активном модуле тоже 200). Аудит module.restore

POST /api/modules/:id/photo

  • Доступ: requireAdmin + upload.single('photo')
  • Тело: multipart/form-data, поле photo (только изображения), заменяет прежнюю картинку модуля
  • Ответ 200: строка modules * (с новым photo_path)
  • Ошибки: 400 «Файл обязателен», 400 «Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)», 400 «Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)», 400 Файл слишком большой (макс. N МБ), 400 «Недопустимый файл», 404 «Не найдено» (модуля нет — файл удаляется), 500 при сбое
  • Примечания: HEIC конвертируется (convertPhoto); старый photo_path удаляется через safeUnlink после успешного UPDATE. Аудит module.photo.create ({id, photo_path})

DELETE /api/modules/:id/photo

  • Доступ: requireAdmin
  • Ответ 200: { ok: true }
  • Ошибки: 404 «Не найдено» (модуля нет)
  • Примечания: файл удаляется safeUnlink, photo_path → NULL. Аудит module.photo.delete ({id, photo_path})

22. Отчёты о занятиях

GET /api/lesson-reports

  • Доступ: requireAuth
  • Query: date_from ('YYYY-MM-DD'), date_to ('YYYY-MM-DD'), group_id (int), search (строка, ILIKE %search% по lr.text), limit, offset. Все опциональны, валидации нет — значения уходят параметризованными в SQL. limit/offset — parseInt, добавляются только если > 0; невалидное значение молча игнорируется.
  • Фильтры: lr.group_id = $n, lr.lesson_date >= $n::date, lr.lesson_date <= $n::date, lr.text ILIKE $n. Филиалы: не-admin → g.branch_id IN (...); при пустом user_branches → 1 = 0 (пустой список). FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id LEFT JOIN users u ON u.id = lr.author_id.
  • Ответ 200: { items, total } — total из count(*)::int; строки: id, group_id, lesson_date, lesson_time, topic, text, author_id, text_original, text_ai, ai_status, ai_checked_at, ai_error, created_at, updated_at, group_name, author_name, author_username. Порядок: lesson_date DESC, lesson_time DESC NULLS LAST, id DESC.
  • Ошибки: нет, кроме 401 от requireAuth.
  • Примечания: результат оборачивается в cacheWrap с ключом lessons:list:<scopeKey(user)>:<date_from>:<date_to>:<group_id>:<search>:<limit>:<offset>, TTL 30 с.

GET /api/lesson-reports/:id

  • Доступ: requireAuth
  • Тело: нет. Query: нет.
  • Ответ 200: одна строка lr.* (id, group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) + group_name, author_name, author_username. Голый объект, не конверт.
  • Ошибки: 404 «Не найдено» (некорректный id или нет записи); 403 «Нет доступа к этому отчёту» (не-admin, филиал группы вне user_branches).
  • Примечания: не кэшируется.

POST /api/lesson-reports

  • Доступ: requireAuth
  • Тело (JSON): group_id (обязат., через lessonReportGroup), lesson_date (обязат., 'YYYY-MM-DD'), lesson_time ('HH:MM'(:SS), опц.), topic (строка, trim, ≤300; пустая → NULL), text (обязат., trim, непустой, ≤5000), ai_check (строго === true; строка "true" не срабатывает).
  • Query: нет
  • Ответ 201: строка INSERT ... RETURNING * — id, group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at (без group_name/author_name).
  • Ошибки: 400 «Группа не выбрана» / «Некорректная дата занятия» / «Некорректное время занятия» / «Тема занятия длиннее 300 символов» / «Введите текст отчёта» / «Текст отчёта длиннее 5000 символов»; 404 «Группа не найдена»; 403 «Нет доступа к этой группе»; 409 «За эту группу и дату отчёт уже есть — откройте его для редактирования» + { id } (уникальный индекс idx_lesson_reports_group_date (group_id, lesson_date)).
  • Примечания: при ai_check === true и настройке lesson_ai_enabled !== 'false' пишет text_original = text, ai_status = 'pending', ai_checked_at = now(); иначе text_original = NULL, ai_status = 'none', ai_checked_at = NULL. Роут не ждёт модель — только wakeLessonAiWorker(). Первая версия сохраняется с source = 'manual'. Аудит lesson_report.create (id, group_id, lesson_date, ai_status). Далее invalidateLessonReports() (сбрасывает lessons:, stats:, dashboard:) и pushNotification({ type: 'lesson.report', title: 'Отчёт о занятии: <group>', body: <text до 300 символов>, link: 'lessons.html', target: { lesson_report_id, group_id, lesson_date }, branchId: group.branch_id }).

PUT /api/lesson-reports/:id

  • Доступ: requireAuth
  • Тело (JSON): все поля применяются только при наличии ключа в теле:
    • lesson_date — если undefined/null/'', остаётся прежняя; иначе parseLessonReportDate.
    • lesson_time — если undefined, остаётся прежнее; если передано, валидируется (null/'' → занулить).
    • text — если undefined, текст не меняется (COALESCE($4, text)); если передан — trim, должен быть непустым, ≤5000.
    • topic — если undefined, тема не меняется; иначе trim, ≤300, пустая → NULL.
    • ai_check — строго true; учитывается только вместе с непустым text и при lesson_ai_enabled !== 'false'.
  • Query: нет
  • Ответ 200: строка UPDATE ... RETURNING * (состав полей как у 201).
  • Ошибки: 404 «Не найдено»; 403 «Нет доступа к этому отчёту»; 400 «Некорректная дата занятия» / «Некорректное время занятия» / «Введите текст отчёта» / «Текст отчёта длиннее 5000 символов» / «Тема занятия длиннее 300 символов»; 409 «За эту группу и дату уже есть другой отчёт» (проверка только если дата реально изменилась, с id <> $3).
  • Примечания: при ai_check === true обнуляет text_ai, ставит ai_status = 'pending', ai_checked_at = now(), ai_error = NULL и перезаписывает text_original текущим текстом. Версия с source = 'manual' сохраняется только если text передан. Аудит lesson_report.update (id, lesson_date, ai_status). Далее invalidateLessonReports() + wakeLessonAiWorker(). Уведомление не создаётся.

GET /api/lesson-reports/:id/versions

  • Доступ: requireAuth
  • Тело/Query: нет
  • Ответ 200: { items, current } — items: { id, lesson_report_id, text, source, created_at, author_name, author_username } (source = manual | ai | restore), ORDER BY v.id DESC LIMIT 50 (LESSON_AI_VERSION_LIMIT); current = текущий report.text.
  • Ошибки: 404 «Не найдено»; 403 «Нет доступа к этому отчёту».
  • Примечания: автор версии — LEFT JOIN users, при удалённом пользователе author_name/author_username = null. Список не кэшируется, аудита нет.

POST /api/lesson-reports/:id/versions/:versionId/restore

  • Доступ: requireAuth
  • Тело (JSON): нет (пустое тело игнорируется). Query: нет
  • Ответ 200: строка UPDATE ... RETURNING * после SET text = <version.text>, text_ai = NULL, ai_status = 'reverted', ai_error = NULL, ai_checked_at = now(), updated_at = now().
  • Ошибки: 404 «Не найдено»; 403 «Нет доступа к этому отчёту»; 400 «Некорректный идентификатор версии» (Number.isInteger(vid) && vid >= 1); 404 «Версия не найдена» (версия не принадлежит этому отчёту).
  • Примечания: восстановленный текст сам сохраняется в историю с source = 'restore'. Аудит lesson_report.version.restore (payload: id, source: 'ai_revert', changed, fields: ['text'], changes: [{ field: 'description', label: 'Текст отчёта', stats, diff: segments, truncated }] из textDiff). Далее invalidateLessonReports().

POST /api/lesson-reports/:id/ai/revert

  • Доступ: requireAuth
  • Тело (JSON): нет. Query: нет
  • Ответ 200: строка RETURNING * после отката к тексту тьютора: text = text_original, text_ai = NULL, ai_status = 'reverted', ai_error = NULL, ai_checked_at = now(), updated_at = now().
  • Ошибки: 404 «Не найдено»; 403 «Нет доступа к этому отчёту»; 400 «Оригинал текста недоступен» (text_original IS NULL, т.е. отчёт создавался без ai_check).
  • Примечания: версия сохраняется с source = 'restore'. Аудит lesson_report.ai.revert (тот же diff-payload, что и при restore). Далее invalidateLessonReports().

DELETE /api/lesson-reports/:id

  • Доступ: requireAdmin (только role = admin; филиалы не проверяются)
  • Тело/Query: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 «Не найдено»; 403 (роль не admin).
  • Примечания: DELETE FROM lesson_reports каскадно удаляет lesson_report_versions (ON DELETE CASCADE). Аудит lesson_report.delete (id, group_id, lesson_date). Далее invalidateLessonReports().


23. Студенты

GET /api/students

  • Доступ: apiLimiter, optionalAuth (публичный: 200 и без токена)
  • Query: нет
  • Ответ 200: голый массив строк SELECT s.*, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id ORDER BY s.name — поля students: id, name, group_id, created_at, photo_path, profile + group_name (NULL, если group_id IS NULL).
  • Ошибки: 429 от apiLimiter.
  • Примечания: кэш students:list:<scopeKey(user)>, TTL PUBLIC_TTL_MS = 60 с. Филиалы: при авторизации — branchWhere(req.user, 'g'); для анонимов фильтр не добавляется. В ответе наружу уходят вся profile (JSONB) и photo_path.

GET /api/students/names

  • Доступ: requireAuth
  • Query: нет
  • Ответ 200: голый массив строк — SELECT DISTINCT e.student_name AS name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.student_name IS NOT NULL AND e.student_name <> '' <branchWhere> ORDER BY name. Имена берутся из журнала записей, а не из таблицы students.
  • Ошибки: нет.
  • Примечания: без кэша и без аудита. Мягко удалённые записи не исключаются — в фильтре нет e.deleted_at IS NULL.

POST /api/students

  • Доступ: requireAuth
  • Тело (JSON): name (строка, обязат., trim(); проверки длины нет), group_id (опц.; Number(group_id), при falsy → null).
  • Query: нет
  • Ответ 201: строка INSERT INTO students (name, group_id) VALUES ($1,$2) RETURNING * — id, name, group_id, created_at, photo_path, profile.
  • Ошибки: 400 «Name required»; 403 «Нет доступа к этой группе» (не-admin, group_id вне user_branches); 409 «Duplicate» при нарушении students.name UNIQUE.
  • Примечания: аудит student.create (id, name). Далее invalidateStudents() (префикс students:) + invalidateStats() (stats:, dashboard:, system-info).

PUT /api/students/:id

  • Доступ: requireAuth
  • Тело (JSON): name (строка, обязат., trim()), group_id — undefined/null/'' → null (снятие с группы), иначе Number(group_id).
  • Query: нет
  • Ответ 200: строка UPDATE students SET name = $1, group_id = $2 WHERE id = $3 RETURNING *.
  • Ошибки: 400 «Name required»; 404 «Not found» (для не-admin — если ученика нет; для admin проверки нет, но UPDATE вернёт 0 строк → 404 «Not found»); 403 «Нет доступа к этому ученику» (текущая группа вне филиала) / «Нет доступа к этой группе» (новая группа вне филиала); 409 «Duplicate».
  • Примечания: аудит student.update (id, name) — без group_id, смена группы в аудит не попадает. Далее invalidateStudents().

POST /api/students/batch-group

  • Доступ: requireAuth
  • Тело (JSON): group_id (обяз., truthy; reqInt не используется), student_ids (массив, обязат., непустой).
  • Query: нет
  • Ответ 200: { ok: true, updated } — updated = число реально обновлённых строк (из RETURNING id).
  • Ошибки: 400 «group_id and student_ids required» (нет group_id, student_ids не массив или пуст); 400 «No valid students» (после map(Number).filter(Boolean) не осталось валидных id); 403 «Нет доступа к этой группе».
  • Примечания: id дедуплицируются через new Set. UPDATE students SET group_id = $1 WHERE id IN (...) не ограничен филиалами учеников — проверяется только доступ к целевой группе. Аудит student.batch-group (group_id, count). Далее invalidateStudents().

DELETE /api/students/:id

  • Доступ: requireAuth
  • Тело/Query: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 «Not found» (не-admin, если ученика нет); 403 «Нет доступа к этому ученику» (не-admin, текущая группа вне филиала). Для admin запрос выполняется безусловно — 404 не возвращается даже при 0 удалённых строк.
  • Примечания: перед удалением читаются photo_path из student_photos, для каждого вызывается safeUnlink; строки student_photos удаляются каскадом (ON DELETE CASCADE). Фото из entries.photo_path / entry_photos ученика не трогаются — они привязаны к записям. Аудит student.delete (id). Далее invalidateStudents() + invalidateStats().

GET /api/students/:id/profile

  • Доступ: requireAuth
  • Тело/Query: нет
  • Ответ 200: { id, name, group_id, photo_path, profile } — profile приводится к null, если в БД NULL. Голый объект, не конверт.
  • Ошибки: 400 «Неверный id ученика» (reqInt); 404 «Ученик не найден»; 403 «Нет доступа к этому ученику».
  • Примечания: studentProfileAccess (server.js:4280) для не-admin проверяет филиал только при непустом group_id — ученик без группы доступен всем активным пользователям.

PUT /api/students/:id/profile

  • Доступ: requireAuth
  • Тело (JSON): profile (объект; sanitizeStudentProfile, белый список полей), photo_path (строка /uploads/...; optUploadPath(photo_path, 255); обрабатывается только если ключ присутствует; null → сброс в NULL).
  • Валидация profile (белый список, лишние ключи отбрасываются): role ≤200, status ≤60, status_note ≤120, city ≤120, mentor ≤150, joined ≤120, bio ≤2000, quote ≤300; списки: tags ≤20 по 40, achievements ≤40 по 200, contacts ≤20 ({icon ≤32 по /^[a-z0-9-]+$/ иначе 'link', label ≤120 обязат., href ≤500 по whitelist-регулярке}), skills ≤80 ({group ≤80 или 'Навыки', name ≤120 обязат., level ≤40, value = optInt 0..100}), experience ≤30 ({title ≤160 обязат., company ≤160, period ≤80, date ≤40, badge ≤40, description ≤800, tags ≤10 по 40}), education ≤60 ({module ≤200 обязат., progress = optInt 0..100, grade ≤80, teacher ≤150}), stats ≤12 ({icon, value ≤20 обязат., suffix ≤20, label ≤80 обязат., hint ≤120, delta ≤60}). Если все значения пустые → возвращается null.
  • Ответ 200: { id, name, group_id, photo_path, profile } (RETURNING).
  • Ошибки: 400 «Неверный id ученика»; 400 «Неверные данные профиля: <сообщение>» (например «Ожидался объект профиля», «Слишком длинное значение», «Ожидался список»); 404 «Ученик не найден»; 403 «Нет доступа к этому ученику».
  • Примечания: profile = $1 входит в SET всегда — если ключа profile в теле нет, профиль будет записан как NULL (частичное обновление профиля не поддержано). Аудит student.profile.update (id, name, blocks = список непустых ключей профиля). Далее invalidateStudents().


24. Фото студентов

Все эндпоинты используют studentProfileAccess, поэтому общие коды: 400 «Неверный id ученика» / 404 «Ученик не найден» / 403 «Нет доступа к этому ученику». Таблица student_photos: id, student_id (ON DELETE CASCADE), photo_path, created_at.

GET /api/students/:id/photos

  • Доступ: requireAuth
  • Тело/Query: нет
  • Ответ 200: { photos, photo_path } — photos: SELECT id, photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC; photo_path — текущее «главное» фото из students.photo_path (может быть null).
  • Ошибки: общие для хелпера доступа; 404 «Фото не найден» не применяется.
  • Примечания: без кэша и без аудита.

POST /api/students/:id/photos

  • Доступ: requireAuth
  • Тело: multipart/form-data, поле photo — upload.single('photo') (только изображения, лимит UPLOAD_FILE_LIMIT_MB).
  • Query: нет
  • Ответ 201: { id, photo_path, created_at } — INSERT INTO student_photos (student_id, photo_path) ... RETURNING.
  • Ошибки: 400 «Файл обязателен»; 400 «Файл слишком большой (макс. N МБ)» (LIMIT_FILE_SIZE); 400 «Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)»; 400 «Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)»; 400 «Недопустимый файл»; общие коды доступа; 500 { error: <err.message> }.
  • Примечания: convertPhoto (HEIC → JPEG). Если students.photo_path пуст, первое фото автоматически становится главным. При ошибке БД/конвертации файл удаляется через safeUnlink('uploads/<file>') и отдаётся 500. Аудит student.photo.create (id, photo_path). Далее invalidateStudents() + invalidateShare() (сброс share:payload:). Глобальный хук res.on('finish') персистит файл в S3 при STORAGE_DRIVER=s3.

DELETE /api/students/:id/photos/:pid

  • Доступ: requireAuth. Тело/Query: нет
  • Ответ 200: { ok: true }
  • Ошибки: 400 «Неверный id» (любой из двух параметров не проходит reqInt); общие коды доступа; 404 «Фото не найден» (фото не принадлежит ученику); 500 { error }.
  • Примечания: если удаляемое фото было главным, students.photo_path переставляется на следующее оставшееся (ORDER BY id DESC LIMIT 1), иначе в NULL. Файл удаляется через safeUnlink после DELETE. Аудит student.photo.delete (id, photo_path). Далее invalidateStudents() + invalidateShare().

PUT /api/students/:id/photos/:pid/main

  • Доступ: requireAuth
  • Тело (JSON): нет (тело игнорируется). Query: нет
  • Ответ 200: { ok: true, photo_path } — photo_path взят из student_photos и продублирован в students.photo_path.
  • Ошибки: 400 «Неверный id»; общие коды доступа; 404 «Фото не найден».
  • Примечания: файлы не перемещаются — меняется только ссылка. Аудит student.photo.main (id, photo_path). Далее invalidateStudents() + invalidateShare().


25. Экспорт

GET /api/export/student

  • Доступ: requireAuth
  • Query:
    • name — обязат., reqStr(query.name, 150) (строка, trim, непустая, ≤150)
    • include_entries, include_photos, include_files, include_captions, include_reports, show_dates — проверка !== '0', т.е. включено по умолчанию при любом другом значении
    • date_from, date_to — optDate, строго 'YYYY-MM-DD'
  • Ответ 200: бинарный ZIP. Content-Type: application/zip; Content-Disposition: attachment; filename="student_<safeName>_<YYYY-MM-DD>.zip"; filename*=UTF-8''student_<name>_<YYYY-MM-DD>.zip, где safeName = name.replace(/[^a-zA-Z0-9._-]+/g,'_').slice(0,80) || 'student', дата — new Date().toISOString().slice(0,10).
  • Состав архива: index.html (renderStudentReport из student-report.js), data.json, каталоги photos/ (фото entry_photos + главные entries.photo_path + student_photos + аватар) и files/ (project_files с санитизацией имени и разрешением коллизий имя(2).ext), logo.<ext> (из настройки system_logo, если isSafeUploadPath).
  • data.json: exported_at (ISO с Z), student {id, name, created_at, group_name, branch_name}, profile, period, totals {entries, modules, photos, files, groupPhotos, studentPhotos, lessonReports}, options, entries[] {id, group, module, created_at, ai_status, description}, photos[] {file, caption, created_at, entry_id}, student_photos[] {file, main, created_at}, files[] {file, name, size, created_at, entry_id}, lesson_reports[] {id, group_id, group_name, lesson_date, lesson_time, topic, text}.
  • Ошибки: 400 «Укажите имя резидента» (пустое/не строка/>150); 400 «Выберите, что включать в отчёт» (include_entries=0 + include_photos=0 + include_files=0); 400 «Неверный период»; 400 «Дата «С» позже даты «По»»; 404 «Нет данных за выбранный период» / «У резидента нет данных для отчёта» (нет ни записей, ни фото, ни файлов); 500 { error: err.message }.
  • Фильтры: e.student_name = $1 AND e.deleted_at IS NULL; границы периода по e.created_at через tzDayStart/tzDayEnd + bindTz(..., appTimezone()) (TIMESTAMPTZ, зона из настройки timezone); филиалы — branchWhere(req.user, 'g') (не-admin). Отчёты о занятиях фильтруются по lr.lesson_date >= date_from / <= date_to (сравнение чистых DATE, зона не применяется) и по филиалам; лимит LESSON_REPORTS_LIMIT = 60, при превышении в data.json ставится lessonReportsTruncated: true.
  • Примечания: 7 параллельных запросов (Promise.all) + отдельные выборки student_photos и lesson_reports. Файлы читаются через storage.getBuffer, пути проверяются isSafeUploadPath, дубликаты отсекаются по имени файла. Аудит export.student (student, entries, photos, files, lesson_reports, opts, date_from, date_to). Инвалидация кэша не вызывается (чтение). Период в period рендерится fmtLongDate по зоне приложения.

GET /api/groups/:id/export/files

  • Доступ: requireAuth
  • Параметр пути: id — parseInt(...,10), требуется целое >= 1.
  • Query: include_files (!== '0', default true), include_photos (!== '0', default true), photos_mode ('all' → все entry_photos, иначе latest), include_main (!== '0', учитывается только при include_photos), include_originals (=== '1', только при include_photos), date_from, date_to — optDate 'YYYY-MM-DD'.
  • Ответ 200: бинарный ZIP, Content-Type: application/zip; Content-Disposition: attachment; filename="group_<safeGroup>_<YYYY-MM-DD>.zip"; filename*=UTF-8''group_<name>_<YYYY-MM-DD>.zip, safeGroup = groupName.replace(/[^a-zA-Z0-9._-]+/g,'_').slice(0,60) || 'group'.
  • Структура архива: data.json в корне + отдельная папка на каждого студента (имя берётся из entries.student_name, санитизация [\\/:*?"<>|] → _, дедуп Имя (2), срез 80 символов, fallback Без имени):
    • работы project_files → <Студент>/<файл> (коллизии → имя(2).ext)
    • главное фото записи → <Студент>/Фото записи/photo_<YYYY-MM-DD>.jpg
    • entry_photos (при photos_mode=all) → <Студент>/Фото записи/<исходное имя файла>
    • оригиналы до ИИ-обработки (при include_originals=1) → <Студент>/Фото записи/Оригиналы/original_<YYYY-MM-DD>.jpg
  • data.json: exported_at (ISO с Z), group {id, name}, period {date_from, date_to}, options {files, photos, main_photo, photos_mode, originals}, totals {students, files, works, photos, originals}, students[] (имена из students WHERE group_id = $1), files[] {folder, file, original, size, created_at}.
  • Ошибки: 400 «Некорректная группа»; 403 «Нет доступа к этой группе» (groupBelongsToBranches; для admin всегда true); 400 «Выберите хотя бы одну категорию: работы или фото записи»; 400 «Неверный период»; 400 «Дата «С» позже даты «По»»; 404 «Группа не найдена» (groups.deleted_at IS NULL); 500 { error: err.message }.
  • Фильтры: e.group_id = $1 AND e.deleted_at IS NULL + период по e.created_at через tzDayStart/tzDayEnd/bindTz; project_files дополнительно pf.detached_at IS NULL. Содержимое читается через photoRefKey + storage.getBuffer, дубликаты отсекаются по пути.
  • Примечания: аудит export.group_files (group_id, group_name, students, files, works, photos, originals, photos_mode, date_from, date_to). Инвалидация кэша не вызывается. Эндпоинт не обёрнут в fileLimiter — ограничение только requireAuth, при этом архив собирается целиком в памяти.


26. Записи журнала (чтение)

GET /api/entries

  • Доступ: requireAuth (сессия в X-Auth-Token); филиалы: не-admin ограничены по user_branches — фильтр g.branch_id IN (…), при пустом списке 1 = 0 (пустой ответ)
  • Query: group_id (int), module_id (int), date_from ('YYYY-MM-DD'), date_to ('YYYY-MM-DD'), student_name (точное равенство), search (ILIKE %…% по student_name и description), deleted ('1' — только корзина, иначе/по умолчанию — живые записи), limit, offset
  • Тело: нет
  • Ответ 200: { entries, total } — entries: поля entries e.* + group_name, module_name, вложенные files[] (id, entry_id, token, name) и photos[] (id, entry_id, photo_path, caption, sort_order); total — count(*) по тем же условиям
  • Ошибки: 401 Unauthorized; 403 Нет доступа к этой группе (не-admin, явный чужой group_id)
  • Примечания: даты фильтруются по e.created_at через tzDayStart/tzDayEnd + bindTz ($TZ$), зона из appTimezone(); limit/offset — parseInt, без дефолта и максимума: без limit отдаётся вся выборка. Валидации формата дат и типов нет — параметры параметризованы, но не провалидированы. Сортировка e.created_at DESC. Кэша нет, logAudit не нужен (read-only)

GET /api/entries/:id

  • Доступ: requireAuth; филиалы: для не-admin — entryAccessible(user, id): 404 Not found, если записи нет, 403 Нет доступа к этой записи, если группа вне филиалов; для admin проверок нет
  • Query: нет (:id — сырой, без валидации)
  • Тело: нет
  • Ответ 200: один объект записи (entries e.* + group_name, module_name) с добавленными files[] (project_files: id, entry_id, token, name) и photos[] (entry_photos: id, entry_id, photo_path, caption, sort_order)
  • Ошибки: 401; 403 (не-admin); 404 Not found (нет записи либо для не-admin запись вне его филиалов)
  • Примечания: фильтра deleted_at нет — мягко удалённая запись отдаётся так же, как живая. JOIN groups (не LEFT) — запись без группы не найдётся. Не кэшируется

GET /api/entries/:id/files

  • Доступ: requireAuth; филиалы: не-admin — groupBelongsToBranches(user, group_id); admin без проверок
  • Query: нет
  • Тело: нет
  • Ответ 200: голый массив project_files: { id, token, name } (без path, сортировка ORDER BY id)
  • Ошибки: 401; 404 Запись не найдена (только для не-admin — проверка идёт по entries JOIN groups); 403 Нет доступа к этой записи (не-admin)
  • Примечания: у admin запрос к записям вообще не выполняется, поэтому несуществующий :id вернёт 200 [], а не 404. Возвращает файлы прикреплённых записей

27. Файлы

POST /api/entries/:id/files

  • Доступ: requireAuth; филиалы: не-admin — groupBelongsToBranches(user, group_id) по группе записи; admin без проверок
  • Query: нет
  • Тело: multipart/form-data, поле files — максимум 10 файлов, adminUpload (расширенный белый список)
  • Ответ 201: { ok: true, count } — только количество; id/токены не возвращаются
  • Ошибки: 401; 400 Файлы не выбраны, FILE_TOO_LARGE_ERROR (лимит на файл), Недопустимый тип файла / Недопустимый файл (фильтр расширений), TOTAL_TOO_LARGE_ERROR (сумма > MAX_TOTAL_UPLOAD_BYTES); 404 Запись не найдена; 403 Нет доступа к этой записи; 500 { error: e.message }
  • Примечания: при любой ошибке загруженные файлы снимаются через removeUpload. Вставка project_files (entry_id, token, path, name) в транзакции, token = crypto.randomBytes(16).toString('hex'), path = /uploads/<multer filename>. После COMMIT — invalidateShare() + invalidateEntries(). logAudit не вызывается — см. «Неясности». При STORAGE_DRIVER=s3 фактическое сохранение делает глобальный хук res.on('finish') через storage.persist

GET /api/files

  • Доступ: requireAuth; филиалы: не-admin — g.branch_id IN (…), при пустом списке 1 = 0; admin без ограничений
  • Query: search (ILIKE по pf.name), student_name (равенство), group_id, date_from, date_to (по e.created_at, tzDayStart/tzDayEnd + bindTz), limit, offset
  • Тело: нет
  • Ответ 200: { files, total } — files: { id, token, name, student_name, group_name, created_at, size }, где size из storage.sizeOf(path) (может быть null); total — count(*) по тем же условиям
  • Ошибки: 401; 403 Нет доступа к этой группе (не-admin, чужой group_id)
  • Примечания: условие e.deleted_at IS NULL жёсткое — параметра deleted нет, корзина не видна. limit без дефолта и максимума. Сортировка pf.id DESC. size считается по одному запросу на файл (N+1 к storage), кэша нет. path наружу не отдаётся

GET /api/files/detached

  • Доступ: requireAdmin (только админ, филиалы не применяются)
  • Query: search (ILIKE по name), limit, offset
  • Тело: нет
  • Ответ 200: { files, total } — files: { id, token, name, created_at, size }; total — count(*)
  • Ошибки: 401; 403 (не-admin)
  • Примечания: жёсткое условие entry_id IS NULL (отсоединённые файлы), фильтров по датам и группе нет вообще. size — снова N+1 к storage. limit без дефолта/максимума, сортировка created_at DESC. Кэша нет

POST /api/files/:id/detach

  • Доступ: requireAuth; филиалы: не-admin — файл должен быть прикреплён к записи своей филиальной группы
  • Query: нет
  • Тело: нет (мутация без тела)
  • Ответ 200: { ok: true } — сама строка из RETURNING * не отдаётся
  • Ошибки: 401; 404 Не найдено; 403 Нет доступа к этому файлу (не-admin: файл уже detached или группа вне филиалов)
  • Примечания: UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *; файлы у клиента остаются валидными по token. После мутации — invalidateShare() + invalidateEntries(). logAudit не вызывается

DELETE /api/files/:id

  • Доступ: requireAuth; филиалы: не-admin — та же проверка, что и в detach (файл прикреплён + группа в филиалах)
  • Query: нет
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 401; 404 Не найдено (нет строки в project_files); 403 Нет доступа к этому файлу (не-admin)
  • Примечания: safeUnlink(path) перед DELETE FROM project_files — файл удаляется и локально, и из бакета, safeUnlink идемпотентен. Не транзакция: при сбое удаления остаётся «висячий» файл (подберёт sweepOrphanedUploads). После мутации — invalidateShare() + invalidateEntries(). logAudit не вызывается

GET /api/files/:token

  • Доступ: публичный — только fileLimiter (rate limit), requireAuth нет: доступ даёт сам 32-hex токен из ссылки
  • Query: thumb (для картинок — миниатюра через sendImageThumb), play (для браузерного видео — inline-воспроизведение)
  • Тело: нет; заголовок Range разбирается только в ветке браузерного видео с ?play (sendPlayableFile → parseByteRange). Для картинок, для видео без ?play и для всех прочих файлов Range игнорируется — файл отдаётся целиком (200)
  • Ответ 200: бинарный поток из storage; картинки — inline (Content-Disposition не задан), Cache-Control: public, max-age=31536000, immutable; видео с ?play — Accept-Ranges: bytes, Cache-Control: private, max-age=3600, при Range — 206 с Content-Range; все прочие файлы — attachment (download: true, name)
  • Ошибки: 404 Not found (нет строки по токену), 404 File missing (storage.keyFromPath пуст или поток не отдал), 416 (диапазон не удовлетворим, Content-Range: bytes */<size>)
  • Примечания: isPlayableVideoName = mp4|m4v|webm|ogv → ?play=1 даёт inline 206; mov|mkv|avi|mpeg|mpg|3gp|ts и всё прочее — только скачивание независимо от play. Порядок веток: картинка → thumb/стрим; браузерное видео + play → sendPlayableFile; иначе attachment. Ключ берётся через storage.keyFromPath(path), прямых fs.* нет

28. Фотографии

GET /api/photos

  • Доступ: requireAuth; филиалы: не-admin — t.branch_id IN (…) по объединённой выборке, при пустом списке 1 = 0
  • Query: search (ILIKE по t.title), student_name (равенство), group_id, date_from, date_to (по t.created_at, tzDayStart/tzDayEnd + bindTz), limit, offset
  • Тело: нет
  • Ответ 200: { photos, total } — photos: { source_type, source_label, source_id, path, title, student_name, group_id, group_name, created_at }
  • Ошибки: 401; 403 Нет доступа к этой группе (не-admin, чужой group_id)
  • Примечания: UNION ALL из 5 источников — entry («Главное фото записи»), entry_photo («Фото записи», кроме дубликата главного), group_photo («Фотохроника группы»), student_photo («Фото ученика»), module_photo («Тема модуля»). Для не-admin module_photo недоступен всегда — там branch_id = NULL::int против IN (…); фото учеников без группы (LEFT JOIN groups) тоже отсекаются. Сортировка t.created_at DESC, limit без дефолта/максимума, кэша нет

29. Статистика

GET /api/stats

  • Доступ: requireAuth; филиалы: да — все счётчики для не-admin ограничены user_branches (пустой список → 1 = 0)
  • Query: нет (сводка фиксированная)
  • Тело: нет
  • Ответ 200: { entries, trash, trash_pending, groups, students, today } — все целые: живые записи; корзина (записи deleted_at IS NOT NULL AND purge_at IS NULL + видимые удалённые группы); корзина к удалению (purge_at IS NOT NULL, записи + группы); активные группы; count(DISTINCT student_name); записи за сегодня по tzWall()
  • Ошибки: 401 (handler без try/catch — исключение уходит в обработчик Express)
  • Примечания: кэш cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS = 15 c), ключ включает роль и отсортированный список филиалов (all / none / 1-2-3). Сегодняшние записи считаются по ${tzWall()}::date с ручной подстановкой .replace(/\$TZ\$/g, '$' + p.length) и tz последним параметром — не bindTz, но корректно. Кэш сбрасывается только по TTL

30. Системная информация

GET /api/system-info

  • Доступ: requireAdmin; филиалы не применяются (глобальная сводка по всей БД и хранилищу)
  • Query: нет
  • Тело: нет
  • Ответ 200: два уровня — кэшированный payload плюс некэшированный stack
  • Верхний уровень: database (size, size_bytes, tables[] = { name, size, size_bytes } по pg_stat_user_tables, отсортировано по размеру); photos (group_photos {count,size,size_bytes}, entry_photos {count,size,size_bytes}, total_count, total_size, total_size_bytes); files (project_files { count }); uploads (count, size, size_bytes из storage.usage()); storage (driver s3/local, bucket, prefix, count, size, size_bytes); disk (total/used/free + *_bytes, used_pct; fs.statfsSync, фолбэк df -B1 ., иначе null); cache (см. stack.cache)
  • stack.app: name, version (из package.json), commit, commit_date (из public/version.json), node, pid, uptime_s, rss, heap_used
  • stack.deps: версии STACK_DEPS = express, pg, redis, sharp, multer, tar, helmet, bcrypt, @aws-sdk/client-s3 (кэшируются в процессе; отсутствующий пакет → null)
  • stack.runtime: container (Docker/Kubernetes/null), os (PRETTY_NAME из /etc/os-release), os_version, kernel, arch, cpus, cpu_model, cpu_load, mem_total, mem_free, mem_total_bytes, mem_free_bytes, uptime_s
  • stack.database: engine: 'PostgreSQL', version (SHOW server_version), host (только hostname[:port] из DATABASE_URL), pool_total, pool_idle, pool_waiting
  • stack.cache: engine: 'Redis', driver (redis/memory), version, ready, enabled, host (hostname[:port] из REDIS_URL), keys, used_memory, uptime_s, hits, misses, fallback_ops, errors; при недоступном Redis driver='memory', version/keys/used_memory/uptime_s = null, счётчики — из памяти
  • stack.storage: engine (S3/Файловая система), driver (s3/local), endpoint (hostname[:port] из S3_ENDPOINT), bucket, region (только s3), dir (UPLOADS_DIR для local), local_fallback, keep_local
  • stack.photo_ai: engine: 'Real-ESRGAN + GFPGAN', host (hostname[:port] из PHOTO_AI_URL), configured, reachable, latency_ms, error, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models[], face_models[], loaded[]
  • Ошибки: 401; 403 (не-admin); 500 { error: e.message } — единственный эндпоинт диапазона с try/catch
  • Примечания: cacheWrap('system-info', SYSTEM_TTL_MS = 30 c) покрывает только payload; stack собирается каждый запрос через getStackInfo(payload.cache). photoAiHealth(2000) вызывается вне кэша — недоступный photo-AI добавляет до 2 с задержки. Секреты в payload не попадают: хосты берутся только через hostOf(URL), логины/пароли из DATABASE_URL, REDIS_URL, S3_ENDPOINT не выводятся. Размеры фото считаются поштучным storage.sizeOf по всем group_photos и entries.photo_path; entry_photos, student_photos, modules.photo_path не учитываются

31. Дашборд

GET /api/dashboard

  • Доступ: requireAuth; филиалы: да — все выборки для не-admin ограничены user_branches (AND g.branch_id IN (…), при пустом списке AND 1 = 0)
  • Query: нет (лимиты жёстко заданы в коде)
  • Тело: нет
  • Ответ 200: stats {entries, trash, groups, students, today}; activity[] — {d: 'YYYY-MM-DD', n} за 14 дней (to_char(created_at AT TIME ZONE $TZ$,…)); active_groups[] — {id, name, day_label, time_start, time_end} (группы, чей day_of_week/time_start/time_end совпадают с текущим tzWall()); recent_entries[] — 7 последних {id, student_name, photo_path, created_at, group_name}; top_students[] — 5 {student_name, n}; photos[] — 8 {id, photo_path, caption, taken_at, group_name}; latest_photo_taken; recent_lessons[] — 5 {id, group_id, lesson_date, lesson_time, text, group_name}; disk (тот же getDiskInfo())
  • Ошибки: 401 (handler без try/catch)
  • Примечания: кэш cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS = 15 c); ключ учитывает роль и филиалы. Восемь запросов параллельно через Promise.all. Зона подставляется вручную: .replace(/\$TZ\$/g, '$' + params.length + 1) с tz последним параметром (не bindTz) — для выборок без фильтра по филиалам плейсхолдер корректно получает $1. Лимиты LIMIT 7/5/8/1/5 зашиты, пагинации нет. Мягко удалённые записи и группы отфильтрованы

32. Записи журнала (создание и правка)

POST /api/entries

  • Доступ: публичный маршрут — только entryLimiter (10 запросов / 15 мин, cache.rateLimitStore('entry')), requireAuth не вызывается; apiLimiter не применяется
  • Филиалы: нет; филиал берётся из groups.branch_id указанной группы (связь филиал↔группа)
  • Тело: multipart/form-data через upload.fields([{name:'photo',maxCount:10},{name:'files',maxCount:10}]) — два разных поля; текстовые student_name, group_id, description, module_id (опц.), website — honeypot
  • Ответ 201: объект записи entries + files: <кол-во> и photos: <кол-во>
  • Ошибки: 429 лимита/антиспама Уже ответили: подождите N минут; 400 Spam detected (honeypot + recordFailure('honeypot',1,BAN_TTL_MS)), All fields required, Фото обязательно, Группа не найдена, Модуль не найден, FILE_TOO_LARGE_ERROR, TOTAL_TOO_LARGE_ERROR, Только images, Not allowed extension; 500 с текстом исключения
  • Примечания: convertPhoto для каждого фото (HEIC→JPEG); при любой ошибке файлы снимаются removeUpload; антиспам — SELECT count(*) … created_at >= now() - (spam_interval_min||' minutes') по student_name; транзакция BEGIN/COMMIT/ROLLBACK: students (upsert по имени) → entries (description_original = description) → entry_photos (sort_order = индекс) → project_files (токен randomBytes(16).hex); после коммита entryAutoChecker.notify(), invalidateEntries(), invalidateStats(), broadcastEntryChanged()

PUT /api/entries/:id

  • Доступ: requireAuth + upload.array('photo', 10) — одно поле photo
  • Филиалы: для role !== 'admin' — entryAccessible(req.user, id): !found → 404 Not found, !allowed → 403 Нет доступа к этой записи; admin пропускается без проверки
  • Тело: текстовые student_name, group_id, description, module_id (наличие ключа module_id определяется через hasOwnProperty), файлы photo (до 10)
  • Ответ 200: полная строка entries (актуальный photo_path)
  • Ошибки: 404 Not found; 400 Модуль не найден (не-целое или нет строки в modules); ошибки multer стандартной обработки (здесь LIMIT_FILE_SIZE не перехватывается)
  • Примечания: COALESCE по всем текстовым полям, module_id обновляется только если ключ передан; новые фото добавляются в конец (sort_order = existing + i); если у записи не было photo_path — первое новое фото становится главным; аудит entry.update с diff-полями (buildEntryUpdateTarget, photos_added), затем invalidateEntries() + invalidateStats(); broadcastEntryChanged() не вызывается

DELETE /api/entries/:id

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403 как выше)
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Not found / 403 Нет доступа к этой записи — только для не-admin
  • Примечания: мягкое удаление — UPDATE entries SET deleted_at = now() WHERE id = $1; rowCount не проверяется, поэтому для admin несуществующий id тоже даёт { ok: true }; аудит entry.soft-delete, invalidateEntries(), invalidateStats(); файлы не удаляются

PUT /api/entries/:id/restore

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin; при отказе пишется console.warn('[RESTORE DENIED] …') с id пользователя, филиалами и group_id записи
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Запись не найдена или уже восстановлена (при rowCount === 0); 403/404 — по филиалам
  • Примечания: UPDATE entries SET deleted_at = NULL, purge_at = NULL — снимает и мягкое удаление, и отметку об удалении; успех логируется в консоль ([RESTORE] … rowCount); аудит entry.restore, invalidateEntries(), invalidateStats()

DELETE /api/entries/:id/permanent

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin + console.warn('[PERM DELETE DENIED] …')
  • Тело: нет; срок — из настройки trash_purge_days (trashPurgeDays(), дефолт 30, допустимо 1..3650)
  • Ответ 200: { ok: true }
  • Ошибки: 404 Запись не найдена, не в корзине или уже помечена на удаление; 403/404 — по филиалам
  • Примечания: удаления строки нет — ставится purge_at = now() + (days||' days')::interval при deleted_at IS NOT NULL AND purge_at IS NULL; физический purge делает фоновый процесс; аудит entry.schedule-delete с days, invalidateEntries(), invalidateStats()

PUT /api/entries/:id/unschedule

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Запись не найдена или не помечена на удаление (при rowCount === 0)
  • Примечания: UPDATE entries SET purge_at = NULL WHERE id = $1 AND purge_at IS NOT NULL — отмена отложенного удаления; deleted_at не трогается (запись остаётся в корзине); аудит entry.unschedule, invalidateEntries(), invalidateStats()

POST /api/entries/:id/ai/recheck

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Запись не найдена (по RETURNING id); 403/404 — по филиалам
  • Примечания: UPDATE entries SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL; entryAutoChecker.notify() — только пинок, задачу воркер берёт из БД через FOR UPDATE SKIP LOCKED, обработка не гарантирована; аудит entry.ai.recheck, invalidateEntries(), invalidateStats()

POST /api/entries/:id/ai/revert

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 400 Оригинал текста недоступен (когда description_original IS NULL); 403/404 — по филиалам
  • Примечания: description = description_original, description_ai = NULL, ai_status='reverted', ai_error=NULL, ai_checked_at=now(); аудит entry.ai.revert с source:'ai_revert' и пословным textDiff (segments/stats/truncated); invalidateEntries(), invalidateStats()

33. Фото записи

GET /api/entries/:id/photos

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: массив строк entry_photos — { id, photo_path, caption, sort_order, created_at }, ORDER BY sort_order, id
  • Ошибки: 404 Not found, 403 Нет доступа к этой записи — только для не-admin
  • Примечания: у admin проверки существования записи нет — для несуществующего :id вернётся пустой массив; записи в корзине (deleted_at) не фильтруются

DELETE /api/entries/:id/photos/:photoId

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Фото не найдено (нет строки с id в этой записи); 404/403 — по филиалам
  • Примечания: DELETE FROM entry_photos … + safeUnlink(photo_path); sort_order у оставшихся уменьшается на 1 (sort_order > $2); если удалённое фото было главным (entries.photo_path), главным становится первое оставшееся или NULL; аудит entry.photo.delete, invalidateEntries()

PUT /api/entries/:id/photos/:photoId

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: { caption?, sort_order? } (JSON; оба через COALESCE, null/undefined = не менять)
  • Ответ 200: полная строка entry_photos (RETURNING *)
  • Ошибки: 404 Фото не найдено; 404/403 — по филиалам
  • Примечания: значения не валидируются и не приводятся к типу (sort_order уходит как есть); аудит entry.photo.update, invalidateEntries()

PUT /api/entries/:id/photos/:photoId/main

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true, photo_path: '/uploads/…' }
  • Ошибки: 404 Фото не найдено; 404/403 — по филиалам
  • Примечания: только UPDATE entries SET photo_path = … — sort_order в entry_photos не меняется, т.е. фото становится обложкой записи, но не первым в галерее; аудит entry.photo.set_main, invalidateEntries()

PUT /api/entries/:id/photo/enhance

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: два варианта по content-type: multipart/form-data — одно поле photo (результат обработки на клиенте, engine:'client'); application/json — серверная обработка через sharp
  • Ответ 200: { ok: true, photo_path: '/uploads/<hex>.jpg', engine: 'client' | 'sharp' }
  • Ошибки: 503 sharp недоступен на сервере; 404 Запись не найдена; 400 У записи нет фото, Файл фото не найден (объект storage отсутствует), Нет файла; 404/403 — по филиалам; 500 Ошибка замены фото
  • Примечания: JSON-параметры клампятся clampEnhanceParam: brightness 10..300 (100), contrast 10..300 (100), saturate 0..300 (100), sharp 0..100 (0), denoise 0..100 (0; >70 → median(5), иначе median(3)); результат — jpeg({quality:92, mozjpeg:true}) + storage.persist; далее swapEntryPhotoFiles: старый файл копируется в .originals/<hex>.<ext>, обновляются entries.photo_path/photo_original_path и строки entry_photos, thumbUnlinkFor, вставляется photo_jobs со status='done', аудит entry.photo.enhance, invalidateEntries()

POST /api/entries/:id/photo/restore-original

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true, photo_path: '/uploads/<hex><ext>' }
  • Ошибки: 404 Запись не найдена; 400 Оригинал не сохранён (photo_original_path IS NULL), Некорректный путь оригинала (имя не [A-Za-z0-9._-]+), Файл оригинала не найден; 404/403 — по филиалам; 500 Ошибка восстановления оригинала
  • мечания: copyObject(.originals/<name> → <hex><ext>) + storage.del оригинала, photo_original_path = NULL; текущее фото предварительно копируется в .originals/; entry_photos переписываются по старому пути, thumbUnlinkFor; вставляется photo_jobs (action='restore', status='done', finished_at=now()); аудит entry.photo.restore_original, invalidateEntries()

34. ИИ-улучшение фото

POST /api/entries/:id/photo/enhance-ai

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: пустое тело (или любые поля) → action:'ai', params:null. Иначе parsePhotoAiRequest: model (из PHOTO_AI_MODELS = x2plus|general-x4v3|animevideo-v3, дефолт x2plus), face (из PHOTO_AI_FACE_MODES = off|face|all, дефолт off), face_model (из PHOTO_AI_FACE_MODELS, дефолт env PHOTO_AI_FACE_MODEL=gfpgan), strength (0..1, дефолт 0.7, только для codeformer)
  • Ответ 200: { jobId: <int>, action: 'ai'|'ai_face', params } — заметьте, ключ jobId в camelCase, а в статус-эндпоинте ниже — job_id
  • Ошибки: 503 ИИ-обработка фото не настроена (пустой PHOTO_AI_URL); 400 с текстом parsed.error — про model/face/face_model из списков, strength должен быть числом от 0 до 1, «strength применяется только к codeformer…»; 404 Запись не найдена; 400 У записи нет фото; 404/403 — по филиалам; 500 Ошибка постановки задания в очередь
  • Примечания: создаёт photo_jobs (entry_id, action, status='pending', params=JSON); photoWorker.notify() — только пинок, обработка не гарантирована (задача берётся воркером из БД через FOR UPDATE SKIP LOCKED); аудит entry.photo.enhance-ai.queue (entry_id, job_id, action, params)

GET /api/entries/:id/photo/enhance-ai/:jobId

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { status, applied, job_id }; при status='error' добавляется error, при status='done' — photo_path (= after_path)
  • Ошибки: 404 Задание не найдено (не-целый jobId или нет строки с этой entry_id); 404/403 — по филиалам; 500 Ошибка получения статуса задания
  • Примечания: опрос статуса (long-poll нет, SSE нет); выбираются только id, status, error, after_path, applied

GET /api/entries/:id/photo/jobs

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: массив до 50 последних заданий записи, ORDER BY id DESC: id, action, status, applied, params, before_path, after_path, error, created_at, finished_at
  • Ошибки: 404/403 — по филиалам
  • Примечания: список не ограничен по status — попадают и клиентские enhance, и rollback/restore, и ИИ-задания; пагинации нет

POST /api/entries/:id/photo/jobs/:jobId/apply

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true, photo_path: '/uploads/…' } (при повторном вызове, когда entries.photo_path уже равен after_path, — тот же ответ, но только с UPDATE photo_jobs SET applied = true)
  • Ошибки: 404 Задание не найдено; 400 Результат ещё не готов (status !== 'done'), Результат уже применён (applied = true — проверяется до сравнения путей), Некорректный путь результата (!isSafeUploadPath), Файл результата не найден (storage.exists = false); 404 Запись не найдена; 404/403 — по филиалам; 500 Ошибка применения фотографии
  • Примечания: всё в одной транзакции: SELECT … FOR UPDATE по photo_jobs, затем copyObject текущего фото в .originals/, UPDATE entries SET photo_path, photo_original_path, UPDATE entry_photos … WHERE photo_path = <old>, thumbUnlinkFor, UPDATE photo_jobs SET applied=true, before_path=COALESCE(before_path,$1); при любом ответе с ошибкой — ROLLBACK; аудит entry.photo.apply (entry_id, job_id, before_path, after_path), invalidateEntries()

POST /api/entries/:id/photo/jobs/:jobId/reject

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Задание не найдено (в т.ч. не-целый jobId); 400 Результат уже применён; 404/403 — по филиалам; 500 Ошибка отклонения результата
  • Примечания: если status='done' и after_path проходит isSafeUploadPath — файл результата удаляется safeUnlink; UPDATE photo_jobs SET status='rejected', error='Отклонено пользователем', finished_at=now() WHERE … AND applied=false; аудит entry.photo.reject; кэш не сбрасывается (фото записи не менялось)

POST /api/entries/:id/photo/jobs/:jobId/rollback

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: нет
  • Ответ 200: { ok: true, photo_path: '/uploads/<hex><ext>' }
  • Ошибки: 404 Задание не найдено (нужен status='done'); 400 Нет сохранённой версии для отката (before_path не из .originals/), Некорректный путь, Файл версии не найден (нет объекта storage или copyObject вернул false); 404 Запись не найдена; 400 У записи нет фото; 404/403 — по филиалам; 500 Ошибка отката фотографии
  • Примечания: восстанавливается версия из photo_jobs.before_path (префикс /uploads/.originals/, имя по [A-Za-z0-9._-]+); текущее фото копируется в .originals/ как новая точка отката, thumbUnlinkFor; вставляется новое задание photo_jobs (action='rollback', status='done', finished_at=now()) — откат можно откатить; аудит entry.photo.rollback (job_id, restored_path), invalidateEntries()

DELETE /api/entries/:id/photo/enhance-ai/preview

  • Доступ: requireAuth; филиалы: entryAccessible() для не-admin (404/403)
  • Тело: JSON { path: '/uploads/…' } — путь файла-превью (тело обязательно даже для DELETE)
  • Ответ 200: { ok: true }
  • Ошибки: 400 Некорректный путь (isSafeUploadPath = false); 404 Результат не найден; 404/403 — по филиалам; 500 Ошибка удаления результата
  • Примечания: ищется последнее задание с after_path = path AND status='done' AND applied=false; файл удаляется safeUnlink, задание переводится в status='rejected', error='Отклонено пользователем'; аудит entry.photo.reject (тот же, что у /reject)

35. ИИ-профили и очередь

POST /api/ai/correct

  • Доступ: requireAuth; филиалы: не проверяются — доступен любому авторизованному пользователю, включая тьютора без доступа к конкретным филиалам
  • Тело: { text: string } (JSON), лимит 5000 символов; значение читается reqStr(req.body?.text, 5000) — хелпер бросает исключение при не-строке, пустой строке или длине > 5000
  • Ответ 200: { suggestion: '<исправленный текст>' }
  • Ошибки: 502 Сервис ИИ недоступен: <message> при ошибке aiCorrectText; 400 Текст не указан — недостижимая ветка (см. «?»)
  • Примечания: вызов синхронный (блокирует HTTP-запрос до ответа модели); аудита, invalidate* и SSE нет — это пробный вызов без побочных эффектов

GET /api/ai/profiles

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: нет
  • Ответ 200: { active, native: { id:'native', name:'Нативная (llama.cpp в Docker)', base_url: AI_URL, model: AI_MODEL }, profiles: [...] }, где active — настройка ai_active_profile (дефолт native)
  • Ошибки: 401/403 — от requireAdmin
  • Примечания: профили лежат в settings.ai_profiles как JSON-массив (getAiProfiles()); api_key профиля возвращается в ответе (см. «?»)

POST /api/ai/profiles

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: { name, base_url, model, api_key, max_tokens }; validateProfileBody: имя 1..100 симв., base_url по /^https?:\/\//i до 300 симв. (срезаются хвостовые /), model 1..150 симв., api_key до 300 симв., max_tokens — целое 16..32768 или null
  • Ответ 201: созданный профиль { id, name, base_url, model, api_key, max_tokens }, id = randomBytes(8).hex
  • Ошибки: 400 по каждому правилу validateProfileBody («Укажите название профиля (до 100 символов)», «Base URL должен быть корректным http(s)://… (до 300 символов)», «Укажите название модели (до 150 символов)», «API-ключ слишком длинный», «max_tokens должен быть целым числом от 16 до 32768»); 400 Слишком много профилей (макс. 20)
  • Примечания: весь массив перезаписывается в settings.ai_profiles через INSERT … ON CONFLICT (key) DO UPDATE; аудит ai.profile.create (id, name, model), invalidateSettings(), entryAutoChecker.notify()

PUT /api/ai/profiles/:id

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: те же поля, что и при создании, валидация validateProfileBody — замена целиком, частичного обновления нет
  • Ответ 200: обновлённый профиль (с исходным id из URL)
  • Ошибки: 404 Профиль не найден; 400 — ошибки валидации тела; 401/403 — от requireAdmin
  • Примечания: тот же upsert в settings.ai_profiles; аудит ai.profile.update, invalidateSettings(), entryAutoChecker.notify()

DELETE /api/ai/profiles/:id

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 404 Профиль не найден; 401/403 — от requireAdmin
  • Примечания: профиль вырезается из массива, массив перезаписывается; если удаляемый профиль был активным, ai_active_profile сбрасывается в native; аудит ai.profile.delete (id, name), invalidateSettings(), entryAutoChecker.notify()

POST /api/ai/profiles/activate

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: { id: 'native' | <id профиля> }
  • Ответ 200: { ok: true, active: '<id>' }
  • Ошибки: 400 Профиль не найден (для любого id, кроме native, которого нет в списке); 401/403 — от requireAdmin
  • Примечания: ai_active_profile через upsert; аудит ai.profile.activate, invalidateSettings(), entryAutoChecker.notify() — переключение влияет на следующий запрос воркера

POST /api/ai/profiles/test

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: { name, base_url, model, api_key, max_tokens } — валидируется целиком, сохранять профиль не нужно
  • Ответ 200 (всегда): { ok: true, latency_ms, sample: '<до 120 симв.>' } либо { ok: false, latency_ms, error } (HTTP <code>: <body до 200 симв.>, Таймаут 15 с или текст сетевой ошибки)
  • Ошибки: 400 — ошибки validateProfileBody; ответы 4xx/5xx внешнего сервиса не пробрасываются, а возвращаются как ok:false в 200
  • Примечания: прямой fetch(${normalizeOpenAiBase(base_url)}/chat/completions), max_tokens: 8, temperature: 0, prompt «Ответь одним словом: ок», AbortController с таймаутом 15 с; Bearer <api_key> добавляется только при непустом ключе

GET /api/ai/queue

  • Доступ: requireAdmin; филиалы: не применимо (сводка по всем записям с deleted_at IS NULL)
  • Тело: нет
  • Ответ 200: { enabled, counts: { pending, processing, done, skipped, error, reverted }, worker, model }; enabled — ai_autocheck_enabled !== 'false'; worker = entryAutoChecker.getStats() (или null); model — «Имя (модель)» активного профиля или AI_MODEL
  • Ошибки: 401/403 — от requireAdmin
  • Примечания: GROUP BY ai_status — статусы вне списка попадут в объект как дополнительные ключи; списков задач здесь нет (для них GET /api/ai/status)

GET /api/photo-ai/health

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: нет
  • Ответ 200: результат photoAiHealth(5000): { ...payload сервиса, configured, reachable, latency_ms, error }; при пустом PHOTO_AI_URL — configured:false, error:'PHOTO_AI_URL не настроен'
  • Ошибки: HTTP-кодов нет — недоступность отдаётся полями reachable:false и error (HTTP <code> / timeout / текст ошибки)
  • Примечания: GET ${PHOTO_AI_URL}/health с таймаутом 5 с; JSON сервиса разбирается мягко (при не-JSON data = null)

GET /api/ai/status

  • Доступ: requireAdmin; филиалы: не применимо — выборки по всем неудалённым записям
  • Тело: нет
  • Ответ 200: { enabled, model, ai_url, service, worker, counts, pending, recent, errors }; pending — до 20 записей со ai_status IN ('pending','processing') (id, student_name, created_at, group_name); recent — до 30 последних проверенных (ai_checked_at, ai_status, ai_error, group_name, description_original, description_ai, changed); errors — до 20 со ai_status='error'; worker = entryAutoChecker.getInfo()
  • Ошибки: 401/403 — от requireAdmin; недоступность модели — в service.reachable:false, HTTP-код не меняется
  • Примечания: service — aiHealthCheck(): для активного профиля GET <base>/models, иначе GET ${AI_URL}/health, таймаут 5 с; counts по тем же шести статусам

POST /api/ai/wake

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 401/403 — от requireAdmin
  • Примечания: только entryAutoChecker.notify() + аудит ai.wake; не гарантирует немедленную обработку — воркер берёт pending из БД через FOR UPDATE SKIP LOCKED и просыпается по своему backoff-циклу

POST /api/ai/enabled

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: { enabled: <truthy> } — приводится через !!req.body?.enabled, хелпера reqBool в проекте нет (строка "false" даст true)
  • Ответ 200: { ok: true, enabled: <boolean> }
  • Ошибки: 401/403 — от requireAdmin; 400 отсутствует — любое тело без enabled корректно
  • Примечания: настройка ai_autocheck_enabled ('true'/'false') через upsert; при enabled вызывается entryAutoChecker.notify(); аудит ai.enabled; invalidateSettings() не вызывается (см. «?»)

POST /api/ai/requeue-failed

  • Доступ: requireAdmin; филиалы: не применимо — UPDATE идёт по всей таблице (внутренний API, admin-only; на apiV1 такой же UPDATE фильтруется apiBranchClause)
  • Тело: нет
  • Ответ 200: { ok: true, count: <rowCount> }
  • Ошибки: 401/403 — от requireAdmin
  • Примечания: UPDATE entries SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL WHERE ai_status='error' AND deleted_at IS NULL; entryAutoChecker.notify(); аудит ai.requeue-failed с count; invalidateEntries(); invalidateStats() не вызывается

36. Фото-джобы: статус и управление воркером

GET /api/photo-jobs/status

  • Доступ: requireAdmin; филиалы: не применимо — сводка по всем photo_jobs (без deleted_at IS NULL, в отличие от /api/ai/status)
  • Тело/Query: recent_limit (целое 1..200, дефолт 15), recent_offset (целое ≥ 0, дефолт 0) — читаются optInt, который бросает исключение на не-целом значении (см. «?»)
  • Ответ 200: { enabled, ai_configured, ai_url, service, worker, counts: { pending, processing, done, error }, pending, recent, recent_total, errors }; pending — до 20 заданий в работе (id, entry_id, action, created_at, student_name, group_name); recent — страница завершённых с полями params, before_path, after_path, error, finished_at; recent_total — счётчик завершённых; errors — до 20 с status='error'; worker = photoWorker.getInfo()
  • Ошибки: 401/403 — от requireAdmin; недоступность photo-сервиса — в service (configured, reachable, latency_ms, error), HTTP-код прежний
  • Примечания: enabled — photo_worker_enabled !== 'false'; ai_configured = !!PHOTO_AI_URL, ai_url = сам URL; пагинация только для recent (у pending/errors жёсткий LIMIT 20)

POST /api/photo-jobs/wake

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: нет
  • Ответ 200: { ok: true }
  • Ошибки: 401/403 — от requireAdmin
  • Примечания: только photoWorker.notify() + аудит photo-jobs.wake; не гарантирует немедленную обработку (задачи берутся из БД через FOR UPDATE SKIP LOCKED)

POST /api/photo-jobs/enabled

  • Доступ: requireAdmin; филиалы: не применимо
  • Тело: { enabled: <truthy> } — !!req.body?.enabled, строка "false" даст true
  • Ответ 200: { ok: true, enabled: <boolean> }
  • Ошибки: 401/403 — от requireAdmin; 400 отсутствует
  • Примечания: настройка photo_worker_enabled ('true'/'false') через upsert; при включении photoWorker.notify(); аудит photo-jobs.enabled; invalidateSettings() не вызывается (см. «?»)

POST /api/photo-jobs/requeue-failed

  • Доступ: requireAdmin; филиалы: не применимо — UPDATE по всей photo_jobs (в apiV1 этот же путь фильтруется apiBranchClause по филиалам через photo_jobs → entries → groups)
  • Тело: нет
  • Ответ 200: { ok: true, count: <rowCount> }
  • Ошибки: 401/403 — от requireAdmin
  • Примечания: UPDATE photo_jobs SET status='pending', error=NULL, finished_at=NULL WHERE status='error'; photoWorker.notify(); аудит photo-jobs.requeue-failed с count; invalidateEntries(); invalidateStats() не вызывается

37. Корзина

GET /api/trash

  • Доступ: requireAuth; филиалы: branchScope(req.user) — для не-admin к обоим спискам добавляется AND g.branch_id IN (…), при пустом списке — AND 1 = 0 (пусто); admin видит всё
  • Тело/Query: limit, offset — parseInt без валидации; при NaN/≤0 LIMIT/OFFSET просто не добавляются
  • Ответ 200: { entries, total, groups, total_groups }: записи с deleted_at IS NOT NULL AND purge_at IS NULL (ORDER BY deleted_at DESC) с добавленным полем files = массив { id, entry_id, token, name } из project_files; группы с теми же условиями (ORDER BY deleted_at DESC, LEFT JOIN branches → branch_name)
  • Ошибки: 401 — без сессии; иных кодов нет (пустой результат — валидный 200)
  • Примечания: данные собирает общий хелпер trashData(req, 'visible', limit, offset); группы не фильтруются по deleted_at самой записи — только по своей корзине; файлы записей на диске/S3 не удаляются

GET /api/trash/pending

  • Доступ: requireAuth; филиалы: та же логика branchScope, что и в GET /api/trash
  • Тело/Query: нет — пагинация не применяется (trashData(req, 'pending') вызывается без limit/offset)
  • Ответ 200: { entries, total, groups, total_groups, purge_days } — записи и группы с purge_at IS NOT NULL (ORDER BY purge_at DESC), с заполненным files; purge_days из trashPurgeDays() (настройка trash_purge_days, дефолт 30, диапазон 1..3650)
  • Ошибки: 401 — без сессии
  • Примечания: записи, уже помеченные на удаление, остаются в корзине (deleted_at IS NOT NULL AND purge_at IS NULL их больше не показывают); entries и groups возвращаются целиком

DELETE /api/trash

  • Доступ: requireAuth + requireAdmin (двойной middleware — admin-only); филиалы: не применимо
  • Тело: нет; срок — trashPurgeDays() (trash_purge_days, дефолт 30)
  • Ответ 200: { ok: true, entries: <rowCount>, groups: <rowCount>, days }
  • Ошибки: 401/403 — от requireAuth/requireAdmin
  • Примечания: ставит purge_at = now() + (days||' days')::interval всем, у кого deleted_at IS NOT NULL AND purge_at IS NULL, отдельно для entries и groups — строки не удаляются, физический purge делает фоновый процесс; invalidateEntries(), invalidateGroups(), invalidateStats(); аудит trash.clear с entries, groups, days

38. Внешний API api-v1 — обзор

Внешний API смонтирован на отдельный роутер apiV1 и подключён только здесь — app.use('/api/v1', apiV1) (server.js:7037). Сессионная аутентификация (X-Auth-Token / requireAuth) к /api/v1/* отношения не имеет.

Всего 26 эндпоинтов: 13 читающих и 13 мутаций/управления воркерами.

Аутентификация и scopes

  • apiV1.use(requireApiKey('read')) (server.js:7024) — навешено на весь роутер, поэтому весь /api/v1 требует scope read. Ни один эндпоинт не доступен без read.
  • apiV1.use(apiKeyLimiter) (server.js:7025) — сразу после аутентификации.
  • Ключ передаётся в заголовке X-Api-Key либо Authorization: Bearer <key> (apiKeyFromRequest, server.js:997). Больше никаких способов аутентификации нет.
  • Мутации дополнительно проходят apiWrite('write') (server.js:7291). Без scope write — 403 {"error":"API key lacks scope: write"}. Ключ с одним read читает всё, но на любую запись получает 403.

Ключ не шире выдавшего

apiKeyUser(row) (server.js:1033) строит «пользователя» для запроса:

  • базовые поля берутся у владельца ключа (id, username, name, role, is_active, branch_ids = owner_branch_ids);
  • если branch_ids ключа пустой — возвращается владелец как есть (admin остаётся admin, ограничений по филиалу нет);
  • если branch_ids ключа непустой — список филиалов пересекается с филиалами владельца (limit ∩ ownerScope.ids, для админ-владельца пересечение со всеми его филиалами), а роль принудительно понижается до tutor, даже если владелец — admin.

Отсюда: requireApiKey кладёт в req.user (server.js:1101) этого пониженного пользователя, и все хелперы филиалов (branchScope, branchWhere, groupBelongsToBranches, entryAccessible) считают его не-админом. Ключ с branch_ids не может читать чужие филиалы и не может получить права админа.

Ограничение по филиалам

  • Чтение списков: branchWhere(user, alias) (server.js:1116) возвращает {where, params} — пустую строку для админа, AND 1 = 0 для не-админа без филиалов, иначе AND alias.branch_id IN ($1,...).
  • Точечные объекты: groupBelongsToBranches (server.js:1130), entryAccessible (server.js:1141, при отказе пишет [ACCESS DENIED] в консоль), lessonReportGroup / lessonReportById (server.js:3866, 3880).
  • Массовые UPDATE: apiBranchClause(user, expr, params) (server.js:7523) — для админа '', для не-админа без филиалов AND FALSE, иначе AND <expr> = ANY($N::int[]). Используется только в двух эндпоинтах (/ai/requeue-failed, /photo-jobs/requeue-failed).
  • Исключения: /modules не фильтруется по филиалам вовсе (модули глобальные), а /students/:id пропускает проверку, если у ученика group_id IS NULL.

Конверт списков

apiList(rows, total, limit, offset) (server.js:7033) → { items, total, limit, offset }. Пагинация — apiPage(req) (server.js:7027): limit по умолчанию 50, зажат в диапазон 1..500; offset по умолчанию 0, отрицательные значения поднимаются до 0.

Конверт используют: /groups, /students, /modules, /entries, /entries/:id/files, /lesson-reports, /branches. Без конверта (плоский ответ) отдают: /me, /stats, /groups/:id, /students/:id, /entries/:id, /lesson-reports/:id и все мутации.

Оговорка о значениях в конверте: в /branches и /entries/:id/files поля limit и offset кладутся как rows.length и 0, а не как запрошенные значения apiPage (server.js:7065, server.js:7253). Это не влияет на пагинацию по факту (для /branches пагинации нет вовсе), но означает, что limit/offset в ответе этих двух маршрутов нельзя использовать для построения курсора — ориентируйтесь на total.

Rate limit

apiKeyLimiter (server.js:980): окно 60 с, store: cache.rateLimitStore('apikey', 60 * 1000), standardHeaders: true, legacyHeaders: false. Лимит — функция: req.apiKey.rpm (поле rate_limit_per_min ключа), если это целое > 0 — Math.min(rpm, API_KEY_MAX_RPM=10000), иначе API_KEY_DEFAULT_RPM = 120. Ключ счёта: k<id> для аутентифицированных, ip<ipKeyGenerator(ipOf(req))> — для неавторизованных. Ответ при превышении: 429 {"error":"Превышен лимит запросов для API-ключа"}.

Аудит

apiAudit(req, action, target) (server.js:1111) = logAudit(req, action, { ...target, via_api_key: req.apiKey.id }). Все 13 мутаций аудируются, действия с префиксом api.. В списке аудита по действию видно, каким ключом сделано изменение.

Прочее

  • 404: собственного обработчика у apiV1 нет; запрос проваливается до общего app.use (server.js:7611), который для путей /api/ отдаёт 404 {"error":"Not found"}.
  • Ошибки: async-хендлеры ничем не обёрнуты (Express 4, async-обёртки в проекте нет), поэтому брошенное исключение внутри хендлера не попадает в error-middleware (server.js:7618) — запрос остаётся без ответа, ошибка уходит в unhandledRejection. Это касается, в частности, валидаторов reqStr/optInt (см. ниже) и невалидных date_from/date_to/:id.
  • api_keys не входит в бэкап, POST /api/restore делает DELETE FROM api_keys — после восстановления все внешние ключи мертвы.

Валидаторы (импортируются из backup-restore.js)

  • reqStr(v, max) (backup-restore.js:52) — **бросает** Error, если vне строка, строка пустая послеtrim()или длиннееmax`. Возвращает обрезанную строку. Значит ошибка валидации = 500/зависание, а не 400.
  • optInt(v, lo, hi) (backup-restore.js:45) — null для null/undefined/'', иначе целое в границах, иначе бросает.
  • LESSON_REPORT_TEXT_MAX = 5000, LESSON_REPORT_TOPIC_MAX = 300 (backup-restore.js:2-3).
  • types.setTypeParser(1082, v => v) (server.js:44) — DATE (lesson_date) приходит из pg строкой, не объектом Date.

Чего во внешнем API НЕТ (проверено по коду)

Отсутствуют целиком:

Ресурс Внутренние роуты Эквивалент в /api/v1
Файлы и загрузки POST /api/files, POST /api/entries/:id/files, GET/DELETE /api/files/:id, POST /api/files/:id/detach, GET /api/files, GET /api/files/detached нет
Фото и галереи */photos*, /photo/jobs*, /photo/restore-original, /photo/enhance*, entry_photos, student_photos, group_photos нет
Пользователи GET/POST/PUT/DELETE /api/users*, /api/users/branches, /api/users/tutors нет
API-ключи /api/api-keys* (CRUD, rotate, meta) нет
Настройки GET/PUT /api/settings, /api/settings/logo, /api/public-settings, /api/system-info, /api/ai/enabled, /api/photo-ai/health нет
Бэкап/восстановление POST /api/backup, GET /api/backup/:token, POST /api/restore нет
Корзина GET/DELETE /api/trash, /api/trash/pending, /api/entries/:id/restore, /permanent, unschedule нет (кроме мягкого DELETE /entries/:id)
Share-ссылки /api/links*, /api/share/* нет
ИИ-профили /api/ai/profiles*, /api/ai/correct, /api/ai/status, /api/ai/queue нет
Уведомления /api/notifications*, /api/events, SSE /api/notifications/stream нет
Student-report (портфолио) /api/export/student, renderStudentReport, zip нет
Группы: запись POST/PUT/DELETE /api/groups*, restore, unschedule, /export/files, active нет (только чтение)
Модули: запись POST/PUT/DELETE /api/modules*, /restore, /photo нет (только чтение)
Студенты: удаление DELETE /api/students/:id, POST /api/students/batch-group нет (только создание/правка)
Отчёты: версии/ИИ /lesson-reports/:id/versions, /versions/:versionId/restore, /ai/revert, POST /api/entries/:id/ai/revert нет
Статусы и дашборд GET /api/ai/status, /api/photo-jobs/status, /api/dashboard, /api/events, /api/audit нет (кроме /stats)
Баны /api/bans* нет

39. Эндпоинты

GET /api/v1/me

  • Доступ: API-ключ, scope read
  • Query: нет
  • Тело: нет
  • Ответ 200: { key: { id, name, scopes }, user: { id, username, name, role, is_active, branch_ids }, server_time: "<ISO>" } — user проходит через safeUser() (server.js:895), пароля/хеша там нет
  • Ошибки: 401 (нет ключа / отозван / истёк / владелец неактивен), 429, 500
  • Примечания: единственный способ проверить ключ и увидеть свои фактические права. role здесь уже понижен до tutor, если у ключа задан branch_ids.

GET /api/v1/branches

  • Доступ: API-ключ, scope read
  • Query: нет — пагинации здесь нет вообще, apiPage не вызывается
  • Тело: нет
  • Ответ 200: конверт apiList; items: id, name, address, phone, created_at, groups_count (int). total = items.length, limit = total, offset = 0
  • Ошибки: 401, 429, 500
  • Примечания: для не-админа — только филиалы из branchScope(req.user); если список пуст, сразу возвращается apiList([], 0, 50, 0) (жёстко зашитый limit = 50, не фактический размер). groups_count считается по LEFT JOIN groups g ON g.branch_id = b.id без фильтра deleted_at, то есть включает удалённые группы. Аудита нет.

GET /api/v1/groups

  • Доступ: API-ключ, scope read
  • Query: limit (int, дефолт 50, диапазон 1..500), offset (int, дефолт 0), deleted ('1' → только мягко удалённые deleted_at IS NOT NULL; любое другое значение/отсутствие → только активные deleted_at IS NULL)
  • Тело: нет
  • Ответ 200: { items, total, limit, offset }; items: id, name, branch_id, branch_name, day_of_week, time_start, time_end, cover_path, tutor_id, created_at, deleted_at; ORDER BY g.id
  • Ошибки: 401, 429, 500
  • Примечания: филиалы — branchWhere(req.user, 'g'). total считается отдельным count(*) с тем же WHERE. Аудита нет.

GET /api/v1/groups/:id

  • Доступ: API-ключ, scope read
  • Query: нет
  • Тело: нет
  • Ответ 200: плоский объект (без конверта) — те же поля, что в списке: id, name, branch_id, branch_name, day_of_week, time_start, time_end, cover_path, tutor_id, created_at, deleted_at
  • Ошибки: 403 {"error":"Нет доступа к этой группе"} (не-админ и группа вне его филиалов), 404 {"error":"Not found"}, 401, 429, 500
  • Примечания: проверка доступа идёт до выборки (groupBelongsToBranches), поэтому для чужой группы приходит 403, а не 404. В выдаче есть deleted_at, т.е. мягко удалённая группа читается так же. :id не валидируется как число → нечисловой :id даст ошибку Postgres (зависший запрос). Аудита нет.

GET /api/v1/students

  • Доступ: API-ключ, scope read
  • Query: limit (дефолт 50, 1..500), offset (дефолт 0), search (строка, %search% → s.name ILIKE)
  • Тело: нет
  • Ответ 200: { items, total, limit, offset }; items: id, name, group_id, group_name, photo_path, created_at; ORDER BY s.name
  • Ошибки: 401, 429, 500
  • Примечания: фильтр по филиалам сделан через LEFT JOIN groups g ON g.id = s.group_id + branchWhere(req.user, 'g') — студент без группы (group_id IS NULL) не привязан к филиалу и виден всем не-админам. Аудита нет.

GET /api/v1/students/:id

  • Доступ: API-ключ, scope read
  • Query: нет
  • Тело: нет
  • Ответ 200: плоский объект: id, name, group_id, group_name, photo_path, profile, created_at
  • Ошибки: 403 {"error":"Нет доступа к этому ученику"}, 404 {"error":"Not found"}, 401, 429, 500
  • Примечания: 404 проверяется первым, затем 403 — но только если group_id непустой. Поле profile доступно целиком (сантайз при записи, не при чтении). Аудита нет.

GET /api/v1/modules

  • Доступ: API-ключ, scope read
  • Query: limit (дефолт 50, 1..500), offset (дефолт 0), search (строка, m.name ILIKE), active ('1' или 'true' → m.is_active = true)
  • Тело: нет
  • Ответ 200: { items, total, limit, offset }; items: id, name, lessons_count, is_active, created_at, entries_count (int); ORDER BY m.is_active DESC, m.id
  • Ошибки: 401, 429, 500
  • Примечания: филиалы не применяются — каталог модулей глобальный и отдаётся целиком любому валидному ключу. entries_count считается по LEFT JOIN entries без фильтра deleted_at, то есть включает удалённые записи. Аудита нет.

GET /api/v1/stats

  • Доступ: API-ключ, scope read
  • Query: нет
  • Тело: нет
  • Ответ 200: плоский объект { entries, groups, students, today } — все int. entries = записи (deleted_at IS NULL), groups = активные группы, students = COUNT(DISTINCT e.student_name) из entries, а не из таблицы students, today = записи с e.created_at >= (now() AT TIME ZONE <tz>)::date
  • Ошибки: 401, 429, 500
  • Примечания: не-админу всё считается в пределах его филиалов; если филиалов нет — сразу { entries: 0, groups: 0, students: 0, today: 0 }. Зона берётся из настройки timezone (appTimezone()), четыре запроса идут параллельно через Promise.all. Конверта списка нет — это сводка, а не список. Аудита нет.

GET /api/v1/entries

  • Доступ: API-ключ, scope read
  • Query: limit (дефолт 50, 1..500), offset (дефолт 0), group_id (int, точное равенство), module_id (int), student_name (строка, точное равенство, не ILIKE), search (%s% → student_name ILIKE … OR description ILIKE …), date_from ('YYYY-MM-DD', включительно), date_to ('YYYY-MM-DD', включительно — граница считается как < date_to + 1 day)
  • Тело: нет
  • Ответ 200: { items, total, limit, offset }; items: id, student_name, group_id, group_name, module_id, module_name, description, photo_path, created_at; ORDER BY e.created_at DESC
  • Ошибки: 401, 429, 500
  • Примечания: в WHERE всегда жёстко e.deleted_at IS NULL — удалённые записи через список не видны. Фильтры по датам применяются к created_at через tzDayStart/tzDayEnd + bindTz (зона настроек), т.е. границы считаются в часовом поясе сервера-настройки. Филиалы — branchScope + g.branch_id IN (...); не-админ без филиалов получает условие 1 = 0. date_from/date_to не валидируются как даты — нестрока приведёт к ошибке Postgres и зависшему запросу. Аудита нет.

GET /api/v1/entries/:id

  • Доступ: API-ключ, scope read
  • Query: нет
  • Тело: нет
  • Ответ 200: плоский объект: id, student_name, group_id, group_name, module_id, module_name, description, description_original, photo_path, ai_status, created_at, deleted_at + поле files: массив { id, token, name } из project_files (ORDER BY id)
  • Ошибки: 404 {"error":"Not found"} (не найдена — только для не-админа; для админа 404 из пустой выборки), 403 {"error":"Нет доступа к этой записи"}, 401, 429, 500
  • Примечания: доступ проверяется через entryAccessible только для role !== 'admin'. Фильтра deleted_at IS NULL нет — мягко удалённая запись читается, и её deleted_at возвращается. Поле files.token открыто отдаётся, но скачать файл по нему внешним ключом нельзя: GET /api/files/:token обслуживается сессионным requireAuth, а не requireApiKey. :id не валидируется → нечисловой :id даст зависший запрос. Аудита нет.

GET /api/v1/entries/:id/files

  • Доступ: API-ключ, scope read
  • Query: нет — пагинации и фильтров нет, отдаётся вся выборка
  • Тело: нет
  • Ответ 200: конверт apiList, где total = limit = rows.length, offset = 0; items: id, token, name, created_at (project_files, ORDER BY id)
  • Ошибки: 404, 403, 401, 429, 500
  • Примечания: та же проверка entryAccessible. limit в конверте нереальный (равен длине массива), клиентский limit/offset игнорируются — при записи с большим числом файлов ответ будет большим. Скачивание файла по token во внешнем API недоступно. Аудита нет.

GET /api/v1/lesson-reports

  • Доступ: API-ключ, scope read
  • Query: limit (дефолт 50, 1..500), offset (дефолт 0), group_id (int), date_from / date_to ('YYYY-MM-DD', сравнение с lr.lesson_date::date, обе границы включительные), search (строка, %s% → lr.text ILIKE)
  • Тело: нет
  • Ответ 200: { items, total, limit, offset }; items: id, group_id, lesson_date, lesson_time, topic, text, ai_status, author_id, created_at, updated_at, group_name; ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC
  • Ошибки: 401, 429, 500
  • Примечания: даты фильтруются по колонке lesson_date (DATE), а не по created_at, поэтому зона не применяется и сравнение идёт напрямую. Филиалы — branchScope + g.branch_id IN (...) через JOIN groups. В ответе нет text_ai, text_original, ai_error, branch_id и истории версий — они доступны только в /lesson-reports/:id. Аудита нет.

GET /api/v1/lesson-reports/:id

  • Доступ: API-ключ, scope read
  • Query: нет
  • Тело: нет
  • Ответ 200: плоский объект — lr.* целиком (включая text, text_original, text_ai, ai_status, ai_checked_at, ai_error, lesson_date, lesson_time, topic, author_id, branch_id, created_at, updated_at) + group_name, author_name, author_username
  • Ошибки: 404 {"error":"Не найдено"} (в т.ч. при нечисловом :id), 403 {"error":"Нет доступа к этому отчёту"}, 401, 429, 500
  • Примечания: проверка через lessonReportById — здесь :id валидируется как целое >= 1, в отличие от /groups/:id и /entries/:id. История версий (/versions) и откат ИИ (/ai/revert) во внешнем API отсутствуют. Аудита нет.

POST /api/v1/entries

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело: student_name (строка, обязательна, reqStr(…, 150)), description (строка, обязательна, reqStr(…, 20000)), group_id (int, обязателен — проверяется lessonReportGroup), module_id (опц.; null/'' → NULL, иначе optInt(…, 1) + проверка существования в modules)
  • Ответ 201: полная строка entries (RETURNING *)
  • Ошибки: 400 (Группа не выбрана / Модуль не найден), 403 (Нет доступа к этой группе), 404 (Группа не найдена), 401, 429, 500
  • Примечания: транзакция BEGIN/COMMIT (при ошибке — ROLLBACK, клиент освобождается в finally). Внутри транзакции сначала INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING — студент создаётся без группы, если такого имени ещё нет. В entries пишутся student_name, group_id, module_id, description, причём description_original = description. Аудит api.entry.create → { id, group_id, student_name } + via_api_key. После мутации: invalidateEntries() + invalidateStats() + broadcastEntryChanged(). Ошибки reqStr бросаются наружу (не 400).

PUT /api/v1/entries/:id

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело (все поля опциональны, семантика «не передал — оставь как есть»): student_name (reqStr(…, 150)), description (reqStr(…, 20000)), group_id (через lessonReportGroup), module_id (специальный флаг hasModule: null/'' → установить NULL, иначе optInt(…, 1) + проверка существования)
  • Ответ 200: полная строка entries (RETURNING *)
  • Ошибки: 404, 403 {"error":"Нет доступа к этой записи"}, 400 (Модуль не найден / ошибки группы), 401, 429, 500
  • Примечания: реализовано через COALESCE($n, колонка), поэтому null-значение поля не очищает колонку (кроме module_id, у которого отдельный булев флаг). Переданный description одновременно записывается в description_original — это сбрасывает «оригинал тьютора» и отправляет запись на повторную ИИ-проверку неявно (сам ai_status тут не трогается). Перед UPDATE делается SELECT before для аудита. Аудит api.entry.update → { id, before } + via_api_key. После: invalidateEntries() + invalidateStats() + broadcastEntryChanged(). Проверка entryAccessible — для не-админа; удалённую запись тоже можно обновить (нет фильтра deleted_at).

DELETE /api/v1/entries/:id

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело: нет
  • Ответ 200: {"ok": true}
  • Ошибки: 404, 403, 401, 429, 500
  • Примечания: мягкое удаление — UPDATE entries SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL. Физического удаления во внешнем API нет (корзина недоступна). Ответ {ok:true} возвращается даже если rowCount === 0 (повторное удаление или несуществующий :id для админа) — код ответа это не отражает. Файлы записей не удаляются; их снесёт purgeScheduledDeletions по purge_at. Аудит api.entry.delete → { id } + via_api_key. После: invalidateEntries() + invalidateStats() + broadcastEntryChanged().

POST /api/v1/lesson-reports

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело: group_id (int, обязателен), lesson_date (строка 'YYYY-MM-DD', обязательна, parseLessonReportDate), lesson_time (опц.; 'HH:MM' или 'HH:MM:SS', нормализуется к 'HH:MM:SS'; null/'' → NULL; мусор → 400), topic (строка, опц., trim(), ≤ 300; пустая → NULL), text (строка, обязательна, trim(), непустая, ≤ 5000), ai_check (строго boolean true)
  • Ответ 201: полная строка lesson_reports (RETURNING *)
  • Ошибки: 400 (Группа не выбрана / Некорректная дата занятия / Некорректное время занятия / Введите текст отчёта / превышение лимитов символов / ошибки группы), 403, 404 (Группа не найдена), 409 {"error":"За эту группу и дату отчёт уже есть — откройте его для редактирования", id: <id>} (уникальность по group_id + lesson_date, в ответе возвращается id существующего), 401, 429, 500
  • Примечания: aiWanted = req.body.ai_check === true && (await getSetting('lesson_ai_enabled','true')) !== 'false'. Если да — text_original = text, text_ai = NULL, ai_status = 'pending', ai_checked_at = now(); иначе text_original = NULL, ai_status = 'none', ai_checked_at = NULL. Строка "true" вместо boolean не срабатывает. Заполняются author_id = req.user.id и branch_id группы. Первая версия текста сохраняется через saveLessonReportVersion(id, text, 'manual', req.user.id). Аудит api.lesson_report.create → { id, group_id, lesson_date } + via_api_key. После: invalidateLessonReports() и wakeLessonAiWorker() — broadcastEntryChanged() здесь нет. Ответ возвращается сразу, не дожидаясь модели.

PUT /api/v1/lesson-reports/:id

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело (все поля опц., кроме правил ниже): lesson_date (если передан — валидный 'YYYY-MM-DD', иначе остаётся текущий), lesson_time (если передан — 'HH:MM[:SS]'), text (если передан — обязателен: непустая строка ≤ 5000; если передан "" → 400), topic (если передан — строка ≤ 300; пустая → NULL), ai_check (строго boolean true)
  • Ответ 200: полная строка lesson_reports (RETURNING *)
  • Ошибки: 404, 403 (Нет доступа к этому отчёту), 400 (некорректные дата/время/пустой текст/превышение лимитов), 409 {"error":"За эту группу и дату уже есть другой отчёт"} (только если дата реально изменилась на занятую), 401, 429, 500
  • Примечания: доступ — через lessonReportById (:id валидируется). Логика ИИ: aiWanted требует и непустой body, и ai_check === true, и включённую настройку. Тогда text_original = text, text_ai = NULL, ai_status = 'pending', ai_checked_at = now(), ai_error = NULL. topic пишется только если поле присутствует в теле (отдельный булев флаг), text — через COALESCE. При изменении текста сохраняется версия saveLessonReportVersion(..., 'manual', ...). Всегда обновляется updated_at. Аудит api.lesson_report.update → { id, lesson_date } + via_api_key. После: invalidateLessonReports() и wakeLessonAiWorker() при aiWanted; broadcastEntryChanged() не вызывается.

DELETE /api/v1/lesson-reports/:id

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело: нет
  • Ответ 200: {"ok": true}
  • Ошибки: 404, 403, 401, 429, 500
  • Примечания: жёсткое удаление — DELETE FROM lesson_reports WHERE id = $1 (в отличие от DELETE /entries/:id, который мягкий). История версий удаляется каскадом; восстановить отчёт через внешний API нельзя, в корзине его тоже нет. Аудит api.lesson_report.delete → { id } + via_api_key. После: invalidateLessonReports().

POST /api/v1/students

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело: name (строка, обязательна, reqStr(…, 150)), group_id (опц.; не передан / null / '' → NULL, иначе проверяется lessonReportGroup)
  • Ответ 201: полная строка students (RETURNING *)
  • Ошибки: 400 (Группа не выбрана), 403, 404 (Группа не найдена), 401, 429, 500
  • Примечания: простой INSERT, без транзакции и без ON CONFLICT — в отличие от POST /entries, дубли имён тут не допускаются на уровне запроса. Аудит api.student.create → { id, name } + via_api_key. После: invalidateStudents() + invalidateStats(). broadcastEntryChanged() не вызывается.

PUT /api/v1/students/:id

  • Доступ: API-ключ, scopes read + write
  • Query: нет
  • Тело: name (строка, обязательна, reqStr(…, 150)), group_id (опц.; не передан / null / '' → NULL, иначе проверяется lessonReportGroup)
  • Ответ 200: полная строка students (RETURNING *)
  • Ошибки: 404 {"error":"Not found"}, 403 {"error":"Нет доступа к этому ученику"}, 400, 401, 429, 500
  • Примечания: важная особенность — UPDATE без COALESCE: SET name = $1, group_id = $2. Если group_id не передан, связь с группой молча сбрасывается в NULL, и студент перестаёт быть виден в фильтрах по филиалам. name передать обязательно — хотя бы текущее значение. Проверка доступа — по текущей группе (cur.rows[0].group_id), студент без группы доступен любому не-админу. Аудит api.student.update → { id, name } + via_api_key. После: invalidateStudents(). Удаления студента во внешнем API нет.

POST /api/v1/ai/wake

  • Доступ: API-ключ, scopes read + write
  • Query: нет · Тело: нет
  • Ответ 200: {"ok": true}
  • Ошибки: 403 (нет write), 401, 429, 500
  • Примечания: только entryAutoChecker.notify(). Это пинок, а не команда «обработать сейчас»: воркер берёт pending из БД через FOR UPDATE SKIP LOCKED и просыпается по своему backoff-циклу. Если воркер не инициализирован (entryAutoChecker null) — тихо {ok:true} без эффекта. Инвалидация кэша и broadcast не делаются. Аудит api.ai.wake → {} + via_api_key.

POST /api/v1/ai/requeue-failed

  • Доступ: API-ключ, scopes read + write
  • Query: нет · Тело: нет
  • Ответ 200: {"ok": true, count: <rowCount>}
  • Ошибки: 403, 401, 429, 500
  • Примечания: UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE e.ai_status = 'error' AND e.deleted_at IS NULL + apiBranchClause(req.user, '(SELECT g.branch_id FROM groups g WHERE g.id = e.group_id)', params) — ключ не может затронуть чужие филиалы; для не-админа без филиалов условие AND FALSE и count = 0. Затем entryAutoChecker.notify(). Аудит api.ai.requeue-failed → { count } + via_api_key. После: invalidateEntries() + invalidateStats(). broadcastEntryChanged() не вызывается.

POST /api/v1/photo-jobs/wake

  • Доступ: API-ключ, scopes read + write
  • Query: нет · Тело: нет
  • Ответ 200: {"ok": true}
  • Ошибки: 403, 401, 429, 500
  • Примечания: только photoWorker.notify() — тот же пинок, без гарантии немедленной обработки; при photoWorker == null тихо {ok:true}. Инвалидация и broadcast не делаются. Аудит api.photo-jobs.wake → {} + via_api_key.

POST /api/v1/photo-jobs/requeue-failed

  • Доступ: API-ключ, scopes read + write
  • Query: нет · Тело: нет
  • Ответ 200: {"ok": true, count: <rowCount>}
  • Ошибки: 403, 401, 429, 500
  • Примечания: UPDATE photo_jobs p SET status = 'pending', error = NULL, finished_at = NULL WHERE p.status = 'error' + apiBranchClause(req.user, '(SELECT g.branch_id FROM groups g JOIN entries e ON e.id = p.entry_id WHERE g.id = e.group_id)', params). Отличие от /ai/requeue-failed: нет фильтра deleted_at IS NULL, поэтому в пересчёт попадают джобы мягко удалённых записей. Затем photoWorker.notify(). Аудит api.photo-jobs.requeue-failed → { count } + via_api_key. После: invalidateEntries().

POST /api/v1/entries/:id/ai/recheck

  • Доступ: API-ключ, scopes read + write
  • Query: нет · Тело: нет
  • Ответ 200: {"ok": true}
  • Ошибки: 404 {"error":"Запись не найдена"} (если rowCount === 0), 403 {"error":"Нет доступа к этой записи"} (для не-админа), 401, 429, 500
  • Примечания: UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1 — точечный сброс одной записи (в отличие от массового /ai/requeue-failed, который берёт только status = 'error'). Фильтра deleted_at IS NULL нет. Затем entryAutoChecker.notify(); обработка всё равно асинхронная. Аудит api.entry.ai.recheck → { id } + via_api_key. После: invalidateEntries() + invalidateStats() + broadcastEntryChanged().

40. Матрица соответствия

«Внутренний API» — сессионный (X-Auth-Token), «Внешний» — /api/v1 по API-ключу.

Есть полный аналог

Внутренний Внешний Совпадение контракта
GET /api/groups GET /v1/groups фильтры другие: только limit/offset/deleted=1; конверт {items,total,limit,offset} вместо {groups,total}
GET /api/groups/:id GET /v1/groups/:id набор колонок тот же
GET /api/students GET /v1/students внешний фильтр — только search (ILIKE по имени)
GET /api/students/:id GET /v1/students/:id внешний дополнительно отдаёт profile
GET /api/modules GET /v1/modules внешний: search, active; конверт вместо {modules,total}
GET /api/entries GET /v1/entries набор фильтров совпадает (group_id, module_id, student_name, search, date_from, date_to); внешний всегда deleted_at IS NULL
GET /api/entries/:id GET /v1/entries/:id внешний шире: добавляет description_original, ai_status, deleted_at и вложенный files
GET /api/entries/:id/files GET /v1/entries/:id/files внешний отдаёт только метаданные (id, token, name, created_at), без скачивания
GET /api/lesson-reports GET /v1/lesson-reports фильтры те же, сортировка та же
GET /api/lesson-reports/:id GET /v1/lesson-reports/:id внешний дополнительно author_name / author_username
POST /api/entries POST /v1/entries те же обязательные поля; внешний дополнительно автосоздаёт студента
PUT /api/entries/:id PUT /v1/entries/:id внешний: тот же COALESCE, но нет полей ИИ/фото
DELETE /api/entries/:id DELETE /v1/entries/:id оба мягкие (deleted_at)
POST /api/lesson-reports POST /v1/lesson-reports та же валидация, тот же ai_check === true, тот же конфликт 409
PUT /api/lesson-reports/:id PUT /v1/lesson-reports/:id та же семантика ai_check, тот же конфликт 409
DELETE /api/lesson-reports/:id DELETE /v1/lesson-reports/:id оба жёсткие
POST /api/students POST /v1/students контракт совпадает
PUT /api/students/:id PUT /v1/students/:id контракт совпадает, включая сброс group_id в NULL
POST /api/ai/wake POST /v1/ai/wake только пинок воркера
POST /api/ai/requeue-failed POST /v1/ai/requeue-failed внешний дополнительно ограничен филиалами ключа
POST /api/photo-jobs/wake POST /v1/photo-jobs/wake только пинок воркера
POST /api/photo-jobs/requeue-failed POST /v1/photo-jobs/requeue-failed внешний ограничен филиалами ключа и не фильтрует deleted_at
POST /api/entries/:id/ai/recheck POST /v1/entries/:id/ai/recheck идентично

Частичный аналог / урезан

Внутренний Внешний Что потеряно
GET /api/stats GET /v1/stats наружу отдаются только 4 счётчика; students считается по entries.student_name, а не по таблице students
GET /api/dashboard — нет (сводок нет вообще)
GET /api/branches GET /v1/branches без пагинации; groups_count включает удалённые группы
GET /api/ai/status, GET /api/photo-jobs/status — нет способа проверить состояние очередей
POST /api/entries/:id/ai/revert — нет отката ИИ-правки
POST /api/lesson-reports/:id/versions/:versionId/restore — истории версий во внешнем API нет вообще
GET /api/lesson-reports/:id/versions — нет

Нет эквивалента

  • Файлы: POST /api/files, POST /api/entries/:id/files (загрузка), GET/DELETE /api/files/:id, POST /api/files/:id/detach, GET /api/files, GET /api/files/detached, GET /api/files/:token, GET /api/groups/:id/export/files. Внешний API отдаёт только метаданные файлов — загрузить или скачать файл ключом нельзя.
  • Фото и галереи: все */photos*, /photo/jobs*, /photo/enhance*, /photo/restore-original, выбора главного фото, обложки и reorder.
  • Пользователи, роли, филиалы: /api/users*, /api/users/branches, /api/users/tutors.
  • API-ключи: /api/api-keys, /:id, /:id/rotate, /meta — внешний ключ не может управлять ключами.
  • Настройки и диагностика: /api/settings, /api/settings/logo, /api/public-settings, /api/system-info, /api/ai/enabled, /api/photo-ai/health, /api/events.
  • Бэкап/восстановление: /api/backup, /api/backup/:token, /api/restore.
  • Корзина: /api/trash, /api/trash/pending, POST /api/entries/:id/restore, /permanent, /unschedule.
  • Share-ссылки: /api/links*, /api/share/*.
  • ИИ-профили: /api/ai/profiles*, /api/ai/correct, /api/ai/queue, /api/ai/status.
  • Уведомления: /api/notifications* (включая SSE /api/notifications/stream).
  • Student-report (портфолио/экспорт): /api/export/student, zip-выгрузка.
  • Аудит: /api/audit, /api/audit/:id — писать через apiAudit можно, читать нет.
  • Баны IP: /api/bans*.
  • Запись групп и модулей: POST/PUT/DELETE /api/groups* (включая restore, unschedule, active), POST/PUT/DELETE /api/modules* (включая restore, photo) — во внешнем API только чтение.
  • Удаление студентов и массовые операции: DELETE /api/students/:id, POST /api/students/batch-group, */profile.
  • Wake воркера отчётов о занятии — эндпоинта нет ни во внутреннем API, ни в /api/v1; будится неявно через wakeLessonAiWorker() из POST/PUT /lesson-reports.

Приложение A. Индекс всех эндпоинтов

Документировано 173 уникальных маршрута. В server.js объявлено 176 роутов — остальные три присутствуют в тексте как перекрёстные ссылки (например, GET /api/public-settings описан и в общих соглашениях, и в своём разделе).

Маршрут Раздел
GET /api/v1/me 3.
GET /api/public-settings 6.
GET /api/events 8.
GET /api/notifications/stream 8.
POST /api/auth/login 9.
POST /api/auth/logout 9.
GET /api/auth/me 9.
GET /api/bans 10.
POST /api/bans 10.
DELETE /api/bans/:ip 10.
GET /api/notifications 11.
GET /api/notifications/meta 11.
POST /api/notifications/read-all 11.
POST /api/notifications/:id/read 11.
POST /api/notifications/test 11.
DELETE /api/notifications/:id 11.
DELETE /api/notifications 11.
GET /api/users 12.
GET /api/users/tutors 12.
GET /api/users/branches 12.
POST /api/users 12.
PUT /api/users/:id 12.
DELETE /api/users/:id 12.
GET /api/api-keys/meta 13.
GET /api/api-keys 13.
POST /api/api-keys 13.
PUT /api/api-keys/:id 13.
DELETE /api/api-keys/:id 13.
POST /api/api-keys/:id/rotate 13.
GET /api/settings 14.
PUT /api/settings 14.
POST /api/settings/logo 14.
DELETE /api/settings/logo 14.
GET /api/audit 15.
GET /api/audit/:id 15.
POST /api/backup 16.
GET /api/backup/:token 16.
GET /api/backup 16.
POST /api/restore 16.
GET /api/links 17.
POST /api/links 17.
PUT /api/links/:id 17.
DELETE /api/links/:id 17.
GET /api/share/:token 17.
GET /api/share/:shareToken/files/:fileToken 17.
GET /s/:token 17.
GET /r/:token 17.
GET /api/groups 18.
GET /api/groups/active 18.
POST /api/groups 18.
PUT /api/groups/:id 18.
DELETE /api/groups/:id 18.
PUT /api/groups/:id/restore 18.
DELETE /api/groups/:id/permanent 18.
PUT /api/groups/:id/unschedule 18.
GET /api/branches 19.
POST /api/branches 19.
PUT /api/branches/:id 19.
DELETE /api/branches/:id 19.
GET /api/groups/:id/photos 20.
POST /api/groups/:id/photos 20.
PUT /api/groups/:id/photos/reorder 20.
PUT /api/groups/:id/photos/:photoId 20.
DELETE /api/groups/:id/photos/:photoId 20.
PUT /api/groups/:id/photos/:photoId/cover 20.
GET /api/modules 21.
POST /api/modules 21.
PUT /api/modules/:id 21.
DELETE /api/modules/:id 21.
PUT /api/modules/:id/restore 21.
POST /api/modules/:id/photo 21.
DELETE /api/modules/:id/photo 21.
GET /api/lesson-reports 22.
GET /api/lesson-reports/:id 22.
POST /api/lesson-reports 22.
PUT /api/lesson-reports/:id 22.
GET /api/lesson-reports/:id/versions 22.
POST /api/lesson-reports/:id/versions/:versionId/restore 22.
POST /api/lesson-reports/:id/ai/revert 22.
DELETE /api/lesson-reports/:id 22.
GET /api/students 23.
GET /api/students/names 23.
POST /api/students 23.
PUT /api/students/:id 23.
POST /api/students/batch-group 23.
DELETE /api/students/:id 23.
GET /api/students/:id/profile 23.
PUT /api/students/:id/profile 23.
GET /api/students/:id/photos 24.
POST /api/students/:id/photos 24.
DELETE /api/students/:id/photos/:pid 24.
PUT /api/students/:id/photos/:pid/main 24.
GET /api/export/student 25.
GET /api/groups/:id/export/files 25.
GET /api/entries 26.
GET /api/entries/:id 26.
GET /api/entries/:id/files 26.
POST /api/entries/:id/files 27.
GET /api/files 27.
GET /api/files/detached 27.
POST /api/files/:id/detach 27.
DELETE /api/files/:id 27.
GET /api/files/:token 27.
GET /api/photos 28.
GET /api/stats 29.
GET /api/system-info 30.
GET /api/dashboard 31.
POST /api/entries 32.
PUT /api/entries/:id 32.
DELETE /api/entries/:id 32.
PUT /api/entries/:id/restore 32.
DELETE /api/entries/:id/permanent 32.
PUT /api/entries/:id/unschedule 32.
POST /api/entries/:id/ai/recheck 32.
POST /api/entries/:id/ai/revert 32.
GET /api/entries/:id/photos 33.
DELETE /api/entries/:id/photos/:photoId 33.
PUT /api/entries/:id/photos/:photoId 33.
PUT /api/entries/:id/photos/:photoId/main 33.
PUT /api/entries/:id/photo/enhance 33.
POST /api/entries/:id/photo/restore-original 33.
POST /api/entries/:id/photo/enhance-ai 34.
GET /api/entries/:id/photo/enhance-ai/:jobId 34.
GET /api/entries/:id/photo/jobs 34.
POST /api/entries/:id/photo/jobs/:jobId/apply 34.
POST /api/entries/:id/photo/jobs/:jobId/reject 34.
POST /api/entries/:id/photo/jobs/:jobId/rollback 34.
DELETE /api/entries/:id/photo/enhance-ai/preview 34.
POST /api/ai/correct 35.
GET /api/ai/profiles 35.
POST /api/ai/profiles 35.
PUT /api/ai/profiles/:id 35.
DELETE /api/ai/profiles/:id 35.
POST /api/ai/profiles/activate 35.
POST /api/ai/profiles/test 35.
GET /api/ai/queue 35.
GET /api/photo-ai/health 35.
GET /api/ai/status 35.
POST /api/ai/wake 35.
POST /api/ai/enabled 35.
POST /api/ai/requeue-failed 35.
GET /api/photo-jobs/status 36.
POST /api/photo-jobs/wake 36.
POST /api/photo-jobs/enabled 36.
POST /api/photo-jobs/requeue-failed 36.
GET /api/trash 37.
GET /api/trash/pending 37.
DELETE /api/trash 37.
GET /api/v1/branches 39.
GET /api/v1/groups 39.
GET /api/v1/groups/:id 39.
GET /api/v1/students 39.
GET /api/v1/students/:id 39.
GET /api/v1/modules 39.
GET /api/v1/stats 39.
GET /api/v1/entries 39.
GET /api/v1/entries/:id 39.
GET /api/v1/entries/:id/files 39.
GET /api/v1/lesson-reports 39.
GET /api/v1/lesson-reports/:id 39.
POST /api/v1/entries 39.
PUT /api/v1/entries/:id 39.
DELETE /api/v1/entries/:id 39.
POST /api/v1/lesson-reports 39.
PUT /api/v1/lesson-reports/:id 39.
DELETE /api/v1/lesson-reports/:id 39.
POST /api/v1/students 39.
PUT /api/v1/students/:id 39.
POST /api/v1/ai/wake 39.
POST /api/v1/ai/requeue-failed 39.
POST /api/v1/photo-jobs/wake 39.
POST /api/v1/photo-jobs/requeue-failed 39.
POST /api/v1/entries/:id/ai/recheck 39.

Приложение B. Неясности, «острые углы» и особенности, найденные при разборе кода

Ниже — места, которые требуют внимания при работе через API: подтверждённые расхождения в поведении, потенциальные баги и пункты, которые не удалось полностью проверить по коду. Пометка “?” означает «нужно подтвердить рантаймом».

Открытые вопросы

Ниже — то, что не удалось подтвердить кодом или что в коде противоречиво. Не додумывать при использовании.

  1. 409 Conflict используется только для уникальности. Все найденные res.status(409) — это e.code === '23505' (violation unique constraint) в хендлерах пользователей/групп/филиалов. Других конфликтных ситуаций (например, «занято» вне unique-индекса) в коде нет.
  2. API_KEY_DEFAULT_RPM не читается из env. В server.js:976 это жёсткая константа 120. В .env.example переменной API_KEY_DEFAULT_RPM нет. Значение из prompt'а («дефолт из env API_KEY_DEFAULT_RPM») кодом не подтверждается — потребитель env не может его переопределить.
  3. ADMIN_PASSWORD в .env.example есть, но как auth-механизм не используется — только автосоздание первого админа в пустой БД (server.js:567-571). В API он не принимается ни в каком виде.
  4. Глобальный error middleware есть, хотя в правилах проекта написано «no global error handler». Фактически в конце server.js два глобальных обработчика: 404 (7611) и error-500 (7618). Тело ответа на 500 всегда { error: 'Internal server error' } — реальные тексты ошибок из хендлеров теряются.
  5. Расхождение по Tailscale: start-tailscale.sh:16 указывает funnel на http://127.0.0.1:3003, а AGENTS.md и docker-compose.yml:130 говорят про HTTPS 3443. Какая схема актуальна — по скрипту 3003.
  6. ?play не проверяет значение — принимается любая непустая строка, включая ?play=0. Формально контракт «?play=1» в коде не выражен.
  7. X-Admin-Token не поддерживается — проверено: 0 вхождений x-admin-token в server.js (упоминание есть только в устаревшем SECURITY_AUDIT_RU.md).
  8. Хранение токена на фронте — sessionStorage, а не localStorage (в prompt'е указано localStorage). Ключ authToken; очищается при logout. Следствие: после закрытия вкладки сессия на клиенте теряется, хотя серверная сессия живёт 30 суток.
  9. Пагинация внутреннего API не имеет дефолтного limit — при отсутствии параметра отдаётся вся выборка. Для больших таблиц (/api/entries, /api/photos, /api/files) это потенциально тяжёлые ответы; верхнего предела нет.
  10. Форма date_to не включительная — реализована как < (дата + 1 день). Клиент обязан сам прибавлять единицу, иначе потеряет последний день. Это неочевидно и не отражено в схеме ответа.
  11. getStackInfo (server.js:815) — вне кэша ответа и используется только в GET /api/system-info (server.js:5594). Как независимый публичный контракт не документируется; состав блоков app/deps/runtime/database/cache/storage следует из AGENTS.md, точные поля требуют сверки с самим хелпером.
  12. Тексты SSE-события lessons_changed не существует — внутренний тип есть, наружу уходит entries_changed. Клиент, различающий типы, работать не будет.
  13. Состав SSE ready-события точно — { total, unread } (взято из GET /api/notifications, где используется counts.total/counts.unread, server.js:1738); полный набор полей notificationsCounts не проверялся.
  14. Полей ответа POST /api/entries в части файлов — { ...rows[0], files: <count>, photos: <count> } (server.js:5799), т.е. числа, а не массивы объектов. Токены файлов в этом ответе нет — за ними нужно идти через GET /api/entries/:id/files. У POST /api/entries/:id/files ответ { ok, count } — тоже без токенов.
  15. res.on('finish') persist-hook не срабатывает при statusCode >= 400 — загруженные файлы остаются в uploads/ (чистка — sweepOrphanedUploads). Это ожидаемо, но стоит учитывать при отладке «файл не появился».
  16. Загрузка файлов на /api/v1/* не поддержана — на apiV1 нет ни одного multipart-роута, только JSON. Файлы создаются только через внутренний API.
  17. notice про авторизацию optionalAuth на /api/students и /api/groups: при анонимном запросе кэш ключа scopeKey(req.user) даёт 'anon', то есть персонализированный и общий ответы лежат в разных ключах кэша — проблемы инвалидации не видно, но проверить не удалось. Если limit не передан — выборка не ограничена по объёму. Отрицательные и нечисловые значения игнорируются.

3. Частные случаи:

  • GET /api/notifications (1720-1721): limit дефолт 30, максимум 100; offset от 0; дополнительно unread=1.
  • GET /api/audit (2282): Math.min(parseInt(req.query.limit, 10) || 100, 1000) — дефолт 100, максимум 1000.
  • GET /api/share/:token (2940): optInt(req.query.limit, 1, 200) — дефолт 1, максимум 200.
  • GET /api/photos, GET /api/files — мягкий шаблон без дефолта (5438).

| entryLimiter | 15 мин | 10 | rateLimitStore('entry', 900000) | по IP | POST /api/entries (5693) — публичная форма отправки | | fileLimiter | 15 мин | 300 | rateLimitStore('file', 900000) | по IP | GET /uploads/thumb/:name (654), GET /uploads/.originals/:name (660), GET /api/share/:token (3084), GET /api/share/:shareToken/files/:fileToken (3180), GET /api/files/:token (5352) | | notificationLimiter | 15 мин | 600 | rateLimitStore('notify', 900000) | по IP | GET /api/notifications (1719), POST /api/notifications/read-all (1776), POST /api/notifications/:id/read (1791), POST /api/notifications/test (1807) | | apiKeyLimiter | 60 с | динамический: rate_limit_per_min ключа, кап API_KEY_MAX_RPM=10000, дефолт API_KEY_DEFAULT_RPM=120 | rateLimitStore('apikey', 60000) | 'k' + req.apiKey.id для аутентифицированных, 'ip' + ipKeyGenerator(ipOf(req)) иначе | весь /api/v1/* через apiV1.use(apiKeyLimiter) (7025) |

Тексты message (тело ответа 429):

  • apiLimiter / notificationLimiter / fileLimiter: { error: 'Слишком много запросов. Попробуйте позже.' } (546, 1716, 564)
  • entryLimiter: { error: 'Слишком много запросов. Подождите немного.' } (555)
  • apiKeyLimiter: { error: 'Превышен лимит запросов для API-ключа' } (990)

Дополнительно (не rate limit, а бан IP через ipGuard, см. раздел «Бан IP»): пороги login-bruteforce 10, honeypot 1, apikey-bruteforce 30, share-password-bruteforce 10.

Роутов под apiLimiter/fileLimiter нет для GET /uploads/* — прямая отдача объектов (server.js:674-684) не ограничена.


  • keyGenerator: 'k' + req.apiKey.id для аутентифицированных, 'ip' + ipKeyGenerator(ipOf(req)) для неавторизованных.
  • Ответ при превышении: 429 { error: 'Превышен лимит запросов для API-ключа' }. standardHeaders: true, legacyHeaders: false.
  • Подбор ключа: recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS) (server.js:1094).

«?» / неясности

  • reqStr/optStr (импорт из backup-restore.js) бросают Error('Invalid string' | 'Invalid string length'), а не возвращают 400. В async-хендлере Express 4 такой throw не пробрасывается в error-handler (next(err) не вызывается) → по коду запрос, вероятно, остаётся без ответа, срабатывает только process.on('unhandledRejection') (server.js:7631). Фактический статус/поведение не подтверждены кодом; затронуты POST /api/users, PUT /api/users/:id, POST|PUT /api/api-keys.
  • GET /api/audit: limit клипуется только сверху (Math.min(..., 1000)), нижняя граница не проверяется — поведение при limit <= 0 кодом не задано.
  • DELETE /api/users/:id не возвращает 404 для несуществующего id (похоже на недосмотр, но так и есть).
  • GET /api/notifications/stream — единственный маршрут /api/notifications без rate limit.
  • Точные ключи объектов counts (POST /api/backup) и restored (POST /api/restore) задаются в backup-restore.js (BACKUP_TABLES, restoredCounts) — в этом диапазоне server.js не перечислены.
  • POST /api/settings/logo: два текста ошибки об изображениях расходятся (со jfif и без) — какая копия актуальна, по коду не определено.
  • GET /api/backup (строка 2624) не был в списке задания, но попал в диапазон и описан.

Неясности и замечания к коду

  • optInt в GET /api/links (server.js:2940–2941) бросит исключение вне диапазона 1..200. Роут — async без try/catch; Express 4 не передаёт rejected-promise в error-middleware (server.js:7618), а обработчик unhandledRejection только пишет в лог. Ожидаемое поведение — 500 «Internal server error», фактическое — вероятный зависший запрос. Проверить не удалось, отмечено «?».
  • GET /api/groups/active не фильтрует по филиалам и не требует авторизации — группы любых филиалов видны анонимно. Это соответствует коду, но расходится с политикой branchScope.
  • GET /api/groups без токена тоже отдаёт все группы без ограничения по филиалам (req.user отсутствует → branchWhere не вызывается) — кэш-ключ при этом groups:list:anon.
  • Модули (POST/PUT /api/modules, фото модуля) не вызывают invalidateGroups()/invalidateEntries(), хотя GET /api/modules не кэшируется — вероятно безопасно, но единообразие с группами нарушено (помечено «?»).
  • DELETE /api/branches/:id при несуществующем :id отвечает 200 { ok: true } (rowCount не проверяется), тогда как PUT в той же группе отдаёт 404 «Не найдено».
  • PUT /api/groups/:id пишет в аудит всё тело запроса (...req.body) — payload не ограничен; в фото-эндпоинтах аудит ограничен id.
  • logAudit / cache.publish: в этом диапазоне мутации вызывают только invalidate*() (сброс кэша) — прямых вызовов cache.publish для share/groups/branches/photos/modules нет, SSE-события (broadcastEntryChanged и др.) здесь не вызываются.
  • Кэш GET /api/groups ключуется по scopeKey(req.user); при смене филиалов пользователя старый ключ не сбрасывается и живёт до истечения TTL (60 c) — инвалидации кэша групп при мутации user_branches здесь нет.
  • GET /s/:token и GET /r/:token — единственные маршруты диапазона без rate-limiter'а и без какой-либо валидации токена.
  • GET /api/groups/:id/photos не проверяет, что группа не в корзине (deleted_at), и не ограничивает limit (в отличие от optInt в /api/links) — неверный limit молча игнорируется.

«?» — неясности и замечания по коду

  1. GET /api/export/student: выборки «шапки» (строка ученика из students, student_photos, group_photos) не ограничены филиалами — branchWhere применяется только к записям, фото записей, файлам и отчётам. Ученик из чужого филиала может попасть в имя, группу, филиал и фотографии отчёта.
  2. GET /api/export/student: group_photos берутся по group_id ученика без фильтра периода и филиала — в архив попадут все фото хроники группы.
  3. sanitizeStudentProfile (backup-restore.js:205-270) не сохраняет profile.photo_path, хотя экспорт читает student.profile && student.profile.photo_path (server.js:4644, 4656). Эта ветка всегда даёт null и откатывается на students.photo_path — либо подразумевается недокументированное поле профиля.
  4. DELETE /api/students/:id для admin: проверки существования нет — { ok: true } и аудит student.delete возвращаются даже при 0 удалённых строк.
  5. POST /api/students / PUT /api/students/:id: длина имени не проверяется (только name?.trim()), ограничение даёт БД (VARCHAR(150) + UNIQUE); group_id приводится Number() без проверки целого — нечисловое значение уйдёт в FK.
  6. POST /api/students/batch-group: group_id не проходит reqInt; student_ids фильтруется как Number(x) && truthy — 0 отбрасывается, дробные значения остаются. UPDATE не ограничен филиалами переносимых учеников (проверяется только целевая группа).
  7. GET /api/lesson-reports: limit/offset разбираются parseInt без валидации — невалидное значение молча отключает LIMIT/OFFSET, 400 не возвращается.
  8. GET /api/lesson-reports отдаёт наружу text_original, text_ai, ai_error и полный text — ограничения на объём ответа нет.
  9. POST/PUT /api/lesson-reports при ai_check === true только вызывают wakeLessonAiWorker(): на момент ответа состояние отчёта ai_status = 'pending', роут модель не ждёт.
  10. POST /api/lesson-reports/:id/versions/:versionId/restore: в аудите пишется source: 'ai_revert' (server.js:4109) — по смыслу для восстановления версии ожидалось бы 'restore'; вероятно, копипаст из соседнего роута.
  11. PUT /api/students/:id/profile: profile перезаписывается всегда (в NULL, если ключ не передан), а photo_path — только при наличии ключа. Частичное обновление профиля не поддержано.
  12. GET /api/students/names: не исключает мягко удалённые записи (e.deleted_at IS NULL в фильтре нет) — в выдачу попадут имена из удалённых записей.
  13. PUT /api/students/:id: аудит student.update не содержит group_id, поэтому перевод ученика в другую группу в журнале аудита не виден.
  14. GET /api/export/student и GET /api/groups/:id/export/files собирают весь архив в памяти (zip.toBuffer()) и отдают без стриминга; лимита на суммарный размер нет, fileLimiter не применяется.

Неясности и замечания по коду

  • Нет аудита мутаций. Ни POST /api/entries/:id/files, ни POST /api/files/:id/detach, ни DELETE /api/files/:id не вызывают logAudit — все три меняют файлы/БД и только инвалидируют кэш (invalidateShare(), invalidateEntries()). Это расходится с правилом проекта «мутации аудируются logAudit(...)». ? — возможно, аудит ведётся на стороне вызывающего фронтенда; в строках 5000–5700 вызовов logAudit нет.
  • Валидация параметров отсутствует почти везде: limit/offset без дефолта и максимума (0, abc, отрицательные и огромные значения просто отбрасываются или уходят в SQL как есть), date_from/date_to не проверяются на формат, group_id/module_id/id/token не приводятся к int. SQL параметризован корректно — риска инъекции нет, но есть риск дорогого запроса без LIMIT (GET /api/entries, GET /api/photos, GET /api/files/detached).
  • indexOf при построении плейсхолдеров в GET /api/stats (s.ids.map(id => '$' + s.ids.indexOf(id) + 1)): при дубликатах в user_branches повторяющиеся филиалы дадут один номер плейсхолдера, а params сохранит исходную длину → сдвиг параметров и ошибка Postgres. В GET /api/dashboard и в списках используется .map((_, i) => '$' + (i + 1)) — там корректно. ? — уникальность user_branches.branch_id вероятно гарантирована схемой, но код на это явно не опирается.
  • Разное отношение к 404 в GET /api/entries/:id/files: у не-admin проверка существования записи есть, у admin — нет, и при загрузке в несуществующую запись POST /api/entries/:id/files ответит 201 { ok: true, count: N } и вставит строки в project_files со ссылкой на несуществующий entry_id. ? — защищено ли это внешним ключом в db/init.sql.
  • N+1 к хранилищу: GET /api/files и GET /api/files/detached вызывают storage.sizeOf для каждой строки (для detached — по всем отсоединённым файлам, т.к. limit опционален); GET /api/system-info считает размер каждого group_photos.photo_path и каждого entries.photo_path. Батчинга и кэша нет.
  • Удаление файла не в транзакции: safeUnlink(path) идёт до DELETE FROM project_files; при сбое удаления остаётся «висячий» файл (его подберёт sweepOrphanedUploads). Порядок выбран, чтобы не оставить строку БД без файла.
  • GET /api/files/:token публичный: доступ к файлу даёт знание 32-hex токена, защита — только rate limit fileLimiter (300/15 мин). Фильтра по филиалам нет по построению — это осознанная ссылка для шаринга.
  • Кэш stats/dashboard живёт до 15 секунд и не инвалидируется мутациями (invalidateStats() в этом диапазоне не вызывается) — после правки записи цифры обновятся не сразу.
  • ? play-режим: условие req.query.play проверяется на истинность (любое непустое значение, включая play=0), документирован только ?play=1.
  • GET /api/photos и module_photo: фото тем модулей недоступны не-admin всегда (branch_id = NULL::int в подзапросе против IN (…)), и то же касается фото учеников без группы (LEFT JOIN groups). Похоже на осознанное ограничение, но в UI выглядит как «часть фото пропала».

Сводка неясностей («?»)

  1. POST /api/ai/correct — необработанный throw из reqStr (вероятный баг). reqStr(req.body?.text, 5000) вызывается до try, а хелпер из backup-restore.js бросает Error('Invalid string'). В проекте нет глобального error-handler, а Express 4 не ловит rejected-promise из async-хендлера → при отсутствующем/нестроковом/пустом text запрос, вероятно, зависнет без ответа. Отсюда же — 400 Текст не указан недостижим: reqStr бросает на пустой строке раньше, чем сработает проверка. Требует подтверждения рантаймом.
  2. GET /api/photo-jobs/status — та же проблема с optInt. optInt(req.query.recent_limit, 1, 200) и optInt(req.query.recent_offset, 0, …) бросают исключение вне try при нецелом значении (?recent_limit=abc). Значения по умолчанию при этом заменены ?? 15 / ?? 0, но до них выполнение не дойдёт.
  3. invalidateSettings() отсутствует в POST /api/ai/enabled и POST /api/photo-jobs/enabled, хотя в PUT/DELETE /api/ai/profiles* он есть. Нужно ли сбрасывать кэш setting: после смены ai_autocheck_enabled / photo_worker_enabled — по коду не видно (значения читаются напрямую из БД через getSetting).
  4. GET /api/ai/profiles возвращает api_key профилей открытым текстом (профили целиком лежат в settings.ai_profiles и приходят в ответе). Это by-design (нужно для формы редактирования) или утечка — из кода не следует.
  5. PUT /api/entries/:id не вызывает broadcastEntryChanged() и не проверяет существование group_id (только module_id) — в отличие от публичного POST /api/entries. Осознанно или упущение — не подтверждено.
  6. DELETE /api/entries/:id не проверяет rowCount → для admin удаление несуществующей записи вернёт { ok: true }. Аналогично PUT /api/entries/:id/ai/recheck (там 404 есть через RETURNING id).
  7. Разные имена ключа job id: POST …/enhance-ai возвращает jobId, а GET …/enhance-ai/:jobId — job_id. Фронтенд обязан знать оба; в коде константы нет.
  8. GET /api/entries/:id/photos для admin не проверяет существование записи (только филиальную доступность для не-admin) — несуществующий :id даст 200 [], а не 404.
  9. DELETE /api/entries/:id/photo/enhance-ai/preview и /reject пишут один и тот же аудит entry.photo.reject и не вызывают invalidateEntries() — сознательно (фото не применялось) или нет.
  10. PUT /api/entries/:id/photos/:photoId/main не меняет sort_order — обложка записи и первый фото в галерее могут различаться; фронтенд так и запрашивает.
  11. POST /api/entries/:id/photo/jobs/:jobId/apply: ветка «уже применён» (400 Результат уже применён) проверяется до сравнения entries.photo_path === job.after_path, хотя следующая строка явно рассчитана на идемпотентный возврат 200 — порядок проверок выглядит противоречивым.
  12. POST /api/entries/:id/photo/enhance-ai при пустом теле: hasParams считается по всем ключам body; при отсутствии body создаётся задание с action='ai' и params = null (без модели/лица) — воркер подставит дефолты. Подтверждено кодом, но не документацией.