# 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: ` | весь `/api/*` | любой активный пользователь (роль `admin` — для админских маршрутов) | | API-ключ | `X-Api-Key: wsk_…` или `Authorization: Bearer wsk_…` | **только** `/api/v1/*` | внешние системы; ключ не даёт доступа к UI | > `X-Admin-Token` **не поддерживается** — это legacy-заголовок, возвращающий `401`. > `ADMIN_PASSWORD` из `.env` — только для автосоздания первого администратора в пустой БД, это **не** механизм авторизации API. --- ## Быстрый старт ### 1. Получить сессионный токен ```bash 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 дней**. ```bash curl -s "$BASE/api/auth/me" -H "X-Auth-Token: $TOKEN" # {"id":1,"username":"admin","name":"…","role":"admin","is_active":true,"branch_ids":[]} ``` ### 2. Работать с журналом ```bash # Последние 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-ключ для внешней системы ```bash # Сессией администратора 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(ключа)`. ```bash 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. Подписаться на события ```bash 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. Базовые адреса и транспорт](#1-базовые-адреса-и-транспорт) - [2. Аутентификация (сессии): как это работает](#2-аутентификация-сессии-как-это-работает) - [3. Внешний API: API-ключи (общие правила)](#3-внешний-api-api-ключи-общие-правила) - [4. Ограничения частоты](#4-ограничения-частоты) - [5. Формат ответов, ошибки и пагинация](#5-формат-ответов-ошибки-и-пагинация) - [6. Даты, время и часовой пояс](#6-даты-время-и-часовой-пояс) - [7. Загрузка файлов (multipart)](#7-загрузка-файлов-multipart) - [8. Потоки событий (SSE)](#8-потоки-событий-sse) - [9. Аутентификация: эндпоинты](#9-аутентификация-эндпоинты) - [10. Баны IP](#10-баны-ip) - [11. Уведомления](#11-уведомления) - [12. Пользователи](#12-пользователи) - [13. API-ключи (внутренний UI)](#13-api-ключи-внутренний-ui) - [14. Настройки](#14-настройки) - [15. Аудит](#15-аудит) - [16. Бэкап и восстановление](#16-бэкап-и-восстановление) - [17. Публичные ссылки (share)](#17-публичные-ссылки-share) - [18. Группы](#18-группы) - [19. Филиалы](#19-филиалы) - [20. Фотохроника группы](#20-фотохроника-группы) - [21. Модули](#21-модули) - [22. Отчёты о занятиях](#22-отчёты-о-занятиях) - [23. Студенты](#23-студенты) - [24. Фото студентов](#24-фото-студентов) - [25. Экспорт](#25-экспорт) - [26. Записи журнала (чтение)](#26-записи-журнала-чтение) - [27. Файлы](#27-файлы) - [28. Фотографии](#28-фотографии) - [29. Статистика](#29-статистика) - [30. Системная информация](#30-системная-информация) - [31. Дашборд](#31-дашборд) - [32. Записи журнала (создание и правка)](#32-записи-журнала-создание-и-правка) - [33. Фото записи](#33-фото-записи) - [34. ИИ-улучшение фото](#34-ии-улучшение-фото) - [35. ИИ-профили и очередь](#35-ии-профили-и-очередь) - [36. Фото-джобы: статус и управление воркером](#36-фото-джобы-статус-и-управление-воркером) - [37. Корзина](#37-корзина) - [38. Внешний API api-v1 — обзор](#38-внешний-api-api-v1--обзор) - [39. Эндпоинты](#39-эндпоинты) - [40. Матрица соответствия](#40-матрица-соответствия) ## 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..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:` из кэша. Ответ `200 { ok: true }`. Аудита нет. **Кэш сессий** — `session:`, 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 .branch_id IN ($1,$2,…)`. ### Бан IP - Ключ `ban:` в Redis + таблица `banned_ips`; перечитывается раз в минуту (`server.js:7671`), при старте — `loadBans()` (`server.js:524-536`). - `recordFailure(req, kind, limit, ms)` (`server.js:506-520`) — инкремент `fail::` за окно `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: ` 2. `Authorization: Bearer ` (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 = 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:`, 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:` с 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: ` (или `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: } ``` --- --- ## 4. Ограничения частоты Все лимитеры построены на `cache.rateLimitStore(prefix, windowMs)` из `redis.js` — общем счётчике с Redis (namespace `rl:`). Ни один не использует `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` попадает в `
`.

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

| Код | Когда | Пример текста |
|---|---|---|
| 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 */` (`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/` с именем `-` (`server.js:1174-1199`, `1202-1223`). Путь, сохраняемый в БД, — всегда `/uploads/`; ключ объекта в S3 — тот же `` (плюс `.originals/` для оригиналов фото).

### Лимиты

```js
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: , photos:  }` (`server.js:5799`) |
| `PUT /api/entries/:id` | `upload.array` | `photo` | 10 | обновлённая запись |
| `POST /api/entries/:id/files` | `adminUpload.array` | `files` | 10 | **201** `{ ok: true, 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/` (`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/-.` — в `entries.photo_path`, `entry_photos.photo_path`, `project_files.path`, `groups.cover_path`, `students.photo_path`, `modules.photo_path`, настройке `system_logo`. Открывается напрямую: `GET /uploads/`.
- Списки: `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 */`, без тела.
- Без 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: \ndata: \n\n`.

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

| `event:` | `data:` (JSON) |
|---|---|
| `entries_changed` | `{ ts:  }` — значение по умолчанию для любого типа, кроме двух ниже |
| `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:`. Аудита нет.

### `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:` в кэш (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:` и все `fail:*:`), аудит `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: \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: 'Отправлено из настроек пользователем '`, `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», «`` должен быть true или false».
- **Примечания:** вся пачка пишется в одной транзакции (`ON CONFLICT DO UPDATE`), при ошибке — `ROLLBACK`; аудит `settings.update {settings}` (со всеми значениями); `invalidateSettings()` сбрасывает кэш, включая `public-settings`.

### `POST /api/settings/logo`

- **Доступ:** `requireAdmin`.
- **Тело:** `multipart/form-data`, поле файла — **`logo`** (`upload.single('logo')`); лимит размера — `UPLOAD_FILE_LIMIT_MB`; принимаются только изображения.
- **Ответ 200:** `{ system_logo: '/uploads/' }`.
- **Ошибки:** 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`.

### `DELETE /api/settings/logo`

- **Доступ:** `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/', 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 «Неверный формат бэкапа: `` (ошибка `normalizeRestoreData`); 500 «Ошибка восстановления: ``; 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)

### `GET /api/links`

- **Доступ:** `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:', 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:::::::`, 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: 'Отчёт о занятии: ', body: , 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 = , 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:`, 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 <> ''  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:  }`.
- **Примечания:** `convertPhoto` (HEIC → JPEG). Если `students.photo_path` пуст, первое фото автоматически становится главным. При ошибке БД/конвертации файл удаляется через `safeUnlink('uploads/')` и отдаётся 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__.zip"; filename*=UTF-8''student__.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.` (из настройки `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__.zip"; filename*=UTF-8''group__.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_.jpg`
  - `entry_photos` (при `photos_mode=all`) → `<Студент>/Фото записи/<исходное имя файла>`
  - оригиналы до ИИ-обработки (при `include_originals=1`) → `<Студент>/Фото записи/Оригиналы/original_.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/`. После 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 */`)
- **Примечания:** `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/.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/.`, обновляются `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/' }`
- **Ошибки:** 404 `Запись не найдена`; 400 `Оригинал не сохранён` (`photo_original_path IS NULL`), `Некорректный путь оригинала` (имя не `[A-Za-z0-9._-]+`), `Файл оригинала не найден`; 404/403 — по филиалам; 500 `Ошибка восстановления оригинала`
- **мечания:** `copyObject(.originals/ → )` + `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: , 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 = `, `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/' }`
- **Ошибки:** 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 `Сервис ИИ недоступен: ` при ошибке `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' |  }`
- **Ответ 200:** `{ ok: true, active: '' }`
- **Ошибки:** 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 : `, `Таймаут 15 с` или текст сетевой ошибки)
- **Ошибки:** 400 — ошибки `validateProfileBody`; ответы 4xx/5xx внешнего сервиса **не пробрасываются**, а возвращаются как `ok:false` в 200
- **Примечания:** прямой `fetch(`${normalizeOpenAiBase(base_url)}/chat/completions`)`, `max_tokens: 8`, `temperature: 0`, prompt «Ответь одним словом: ок», `AbortController` с таймаутом 15 с; `Bearer ` добавляется только при непустом ключе

### `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 ` / `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 /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:  }` — приводится через `!!req.body?.enabled`, хелпера `reqBool` в проекте нет (строка `"false"` даст `true`)
- **Ответ 200:** `{ ok: true, enabled:  }`
- **Ошибки:** 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:  }`
- **Ошибки:** 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:  }` — `!!req.body?.enabled`, строка `"false"` даст `true`
- **Ответ 200:** `{ ok: true, enabled:  }`
- **Ошибки:** 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:  }`
- **Ошибки:** 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: , groups: , 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 ` (`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  = 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` для аутентифицированных, `ip` — для неавторизованных. Ответ при превышении: `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: "" }` — `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 )::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: }` (уникальность по `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: }`
- **Ошибки:** 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: }`
- **Ошибки:** 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: , photos:  }` (`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` (без модели/лица) — воркер подставит дефолты. Подтверждено кодом, но не документацией.