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

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

2722 lines
279 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WhatIDo — HTTP API
Полная документация HTTP-API приложения WhatIDo (учебный центр: журнал посещений, проекты студентов, галерея групп, файлы, публичные витрины).
Документ описывает **всё, что реально принимает и отдаёт сервер**, и собран напрямую из кода `server.js` (7728 строк), `backup-restore.js`, `storage.js`, `redis.js`. Ссылки вида `server.js:5693` указывают на точные строки, из которых взят факт.
**Что покрыто:**
| Часть | Содержание |
|---|---|
| Разделы 1–9 | Общие соглашения: транспорт, аутентификация, API-ключи, rate limits, ошибки, даты и таймзона, загрузка файлов, SSE |
| Разделы 10–40 | **176 маршрутов** внутреннего API (`/api/*`) и внешнего API (`/api/v1/*`) — столько же, сколько объявлено в `server.js` |
| Приложение A | Индекс всех маршрутов с указанием раздела |
| Приложение B | Известные особенности, «острые углы» и неясности, найденные при разборе кода |
Внутри одного приложения работают **два независимых механизма аутентификации** — их нельзя смешивать:
| Механизм | Заголовок | Где работает | Кто может использовать |
|---|---|---|---|
| Сессия | `X-Auth-Token: <token>` | весь `/api/*` | любой активный пользователь (роль `admin` — для админских маршрутов) |
| API-ключ | `X-Api-Key: wsk_…` или `Authorization: Bearer wsk_…` | **только** `/api/v1/*` | внешние системы; ключ не даёт доступа к UI |
> `X-Admin-Token` **не поддерживается** — это legacy-заголовок, возвращающий `401`.
> `ADMIN_PASSWORD` из `.env` — только для автосоздания первого администратора в пустой БД, это **не** механизм авторизации API.
---
## Быстрый старт
### 1. Получить сессионный токен
```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.<tailnet>.ts.net`.
- Сертификат для `.ts.net` выпускает сам Tailscale; самоподписанный `certs/cert.pem` для этого не используется.
- Альтернатива в `.env.example` — Cloudflare Tunnel: `CLOUDFLARE_TUNNEL_URL=http://app:3003` (тоже 3003).
### Глобальные middleware (порядок важен)
1. `app.set('trust proxy', 'loopback')` — `server.js:538`; `req.ip` берётся из `X-Forwarded-For` только для loopback. `ipOf(req)` — `server.js:453`.
2. `app.use(helmet({ contentSecurityPolicy: {...} }))` — `server.js:573-588`. CSP: `default-src 'self'`, `script-src 'self'`, `style-src 'self' 'unsafe-inline'`, `img-src 'self' data: blob:`, `media-src 'self' blob:`, `connect-src 'self'`, `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'`. CORS-middleware в проекте **нет**.
3. `app.use(express.json({ limit: '1mb' }))` — `server.js:589`. Лимит тела JSON — **1 МБ**; multipart разбирает multer.
4. `app.use(ipGuard)` — `server.js:590`, тело `server.js:494-504`: читает бан из кэша по `banKey(ipOf(req))`; если `banned_until > now()` → **403 `{ error: 'Доступ заблокирован' }`** на любой маршрут.
5. `res.on('finish')` hook — `server.js:591-611`: только при `STORAGE_DRIVER=s3` и `statusCode < 400` каждый `req.file`/`req.files` из `uploads/` persist-ится через `storage.persist`.
---
---
## 2. Аутентификация (сессии): как это работает
Два **независимых** механизма: сессии (внутренний API + UI) и API-ключи (только `/api/v1/*`). Смешивать нельзя.
### Сессии
**Получение токена** — `POST /api/auth/login` (`server.js:1638-1665`), под `apiLimiter`:
- Тело (JSON): `{ username, password }`. `username` тримится и приводится к нижнему регистру, длина ≤ 100.
- Honeypot-поле `website` (строка): если непустое — сразу 401 + `recordFailure(req, 'honeypot', 1, BAN_TTL_MS)`.
- Пароль проверяется `bcrypt.compare(password, user.password_hash || '')`.
- Токен: `crypto.randomBytes(32).toString('hex')` (64 hex-символа), строка пишется в `sessions (user_id, token, expires_at)`.
- Ответ `200`: `{ token, expires_at }`, где `expires_at` — ISO 8601 с `Z`.
- Ответ `401`: `{ error: 'Неверный логин или пароль' }` — один и тот же текст для несуществующего логина, неверного пароля и honeypot.
- Антибрутфорс: `recordFailure(req, 'login-bruteforce', 10, BAN_TTL_MS)` — 10 неудач → бан IP.
- Аудит `auth.login` с `{ username }`.
**TTL сессии** — `SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000` = **30 суток** (`server.js:688`). Проверка в SQL: `WHERE s.token = $1 AND s.expires_at > now()` (`server.js:917`) — просроченные токены не проходят.
**Хранение на фронте** — **`sessionStorage`, ключ `authToken`** (не localStorage):
- `public/js/login.js:17` — `sessionStorage.setItem('authToken', data.token)`
- `public/admin.js:2` — `let token = sessionStorage.getItem('authToken')`
- `public/admin.js:27` — `sessionStorage.removeItem('authToken')` при выходе
- заголовок: `{ 'X-Auth-Token': token }` (`public/admin.js:4`)
**`GET /api/auth/me`** — под `requireAuth`, возвращает `safeUser(req.user)` (`server.js:1673-1675`), ровно 6 полей (`server.js:895-904`):
```
{ id, username, name, role, is_active, branch_ids }
```
`branch_ids` — `INT[]` из `user_branches` через `COALESCE(array_agg(ub.branch_id) FILTER (...), '{}')` (`server.js:913`). Пароля и хеша в ответе нет.
**Logout** — `POST /api/auth/logout` (`server.js:1667-1671`), под `requireAuth`: удаляет строку `sessions` по `req.authToken` и ключ `session:<token>` из кэша. Ответ `200 { ok: true }`. Аудита нет.
**Кэш сессий** — `session:<token>`, TTL `SESSION_CACHE_TTL_MS = 30 * 1000` (`server.js:92`, `server.js:922`). Любая мутация `users`/`sessions`/`user_branches` обязана звать `invalidateSessions()` = `cacheDrop('session:')` (`server.js:122`), иначе деактивированный пользователь сохранит доступ до 30 с.
### Коды 401 / 403 и их тексты
| Ситуация | Код | Тело |
|-----------|-----|------|
| Нет / невалидный / просроченный `X-Auth-Token` | **401** | `{ error: 'Unauthorized' }` (`server.js:931`) |
| Ошибка БД/кэша внутри `requireAuth` | **500** | `{ error: 'Internal server error' }` (`server.js:938`) |
| `requireAdmin`, роль ≠ `admin` | **403** | `{ error: 'Forbidden: требуется роль администратора' }` (`server.js:944`, `server.js:948`) |
| `ipGuard`, IP в бане | **403** | `{ error: 'Доступ заблокирован' }` (`server.js:498`) |
| API-ключ без нужного scope | **403** | `{ error: 'API key lacks scope: read' }` / `{ error: 'API key lacks scope: write' }` (`server.js:1098`, `server.js:7294`) |
### Middleware
**`requireAuth`** (`server.js:926-940`) — читает **только** `req.headers['x-auth-token']`. Ни `Authorization`, ни `X-Admin-Token` не поддерживаются. Зовёт `loadUserByToken`, требует `user.is_active`, прокидывает `req.user` и `req.authToken`.
**`requireAdmin`** (`server.js:942-951`) — самодостаточный: если `req.user` уже есть, проверяет только роль; иначе сам вызывает `requireAuth` и потом проверяет роль. Итог — всегда 401 или 403; `next()` только при `role === 'admin'`. Типичная связка на CRUD: `requireAuth, requireAdmin` (второй вызов `requireAuth` при уже проставленном `req.user` просто пропускается).
**`optionalAuth`** (`server.js:958-970`) — читает `x-auth-token`, при валидной активной сессии заполняет `req.user`, иначе **молча идёт дальше**. Все исключения проглотаны (`catch {}`). Применяется на публичных страницах с персонализацией: `GET /api/groups` (`server.js:3237`), `GET /api/students` (`server.js:4155`). Разница видна в выборке: у анонима фильтр по филиалам не добавляется — `req.user ? branchWhere(req.user, 'g') : { where: '', params: [] }`.
**Филиалы** (`server.js:953-956`, `1116-1122`):
- `branchScope(user)` → admin: `{ admin: true, ids: null }`; не-admin: `{ admin: false, ids: user.branch_ids || [] }`.
- `branchWhere(user, alias)` → admin: пустая строка; не-admin без филиалов: ` AND 1 = 0` (пустой результат, а не ошибка); иначе ` AND <alias>.branch_id IN ($1,$2,…)`.
### Бан IP
- Ключ `ban:<ip>` в Redis + таблица `banned_ips`; перечитывается раз в минуту (`server.js:7671`), при старте — `loadBans()` (`server.js:524-536`).
- `recordFailure(req, kind, limit, ms)` (`server.js:506-520`) — инкремент `fail:<kind>:<ip>` за окно `FAIL_WINDOW_MS`; при достижении лимита счётчик удаляется и IP банится на `ms`.
- Пороги, реально встречающиеся в коде: `login-bruteforce` — 10, `honeypot` — 1, `apikey-bruteforce` — 30, `share-password-bruteforce` — 10.
- Управление: `GET /api/bans`, `POST /api/bans`, `DELETE /api/bans/:ip` — все под `requireAuth, requireAdmin` (`server.js:1677-1707`). `POST` принимает `{ ip, reason, hours }`: `reason` по умолчанию `'manual'` (≤100 симв.), `hours` зажимается в `[1, 720]`, ответ `{ ok, ip, reason, banned_until }`.
---
---
## 3. Внешний API: API-ключи (общие правила)
### Монтирование
`const apiV1 = express.Router()` (`server.js:7023`), глобальные middleware:
```
apiV1.use(requireApiKey('read')); // server.js:7024
apiV1.use(apiKeyLimiter); // server.js:7025
...
app.use('/api/v1', apiV1); // server.js:7037
```
То есть **весь** `/api/v1/*` требует scope `read` и проходит через `apiKeyLimiter`. Мутации дополнительно навешивают `apiWrite('write')`.
Роут-лист `apiV1` (`server.js:7039-7589`, 26 роутов): GET `/me`, `/branches`, `/groups`, `/groups/:id`, `/students`, `/students/:id`, `/modules`, `/stats`, `/entries`, `/entries/:id`, `/entries/:id/files`, `/lesson-reports`, `/lesson-reports/:id`; POST/PUT/DELETE `/entries`, `/lesson-reports`, `/students`; POST `/ai/wake`, `/ai/requeue-failed`, `/photo-jobs/wake`, `/photo-jobs/requeue-failed`, `/entries/:id/ai/recheck`.
### Формат ключа и хранение
- `API_KEY_PREFIX = 'wsk'` (`server.js:973`), секрет — `crypto.randomBytes(32).toString('hex')` → формат **`wsk_<64 hex>`** (`server.js:2031-2032`).
- В БД лежит **только** `sha256(raw)` в `key_hash` + первые 12 символов в `prefix` (`raw.slice(0, 12)`), `server.js:2036`. Таблица `api_keys` (`server.js:1586`), индексы на `key_hash`, `user_id`, `revoked_at` (`server.js:1601-1603`).
- Секрет возвращается **один раз**: `POST /api/api-keys` (201) и `POST /api/api-keys/:id/rotate` (200) — поле `key` рядом с публичным представлением.
- Поиск идёт по хешу (`WHERE k.key_hash = $1`, `server.js:1062`), не по префиксу.
### Заголовки авторизации
`apiKeyFromRequest(req)` (`server.js:997-1006`), порядок приоритета:
1. `X-Api-Key: <raw>`
2. `Authorization: Bearer <raw>` (regex `/^Bearer\s+(\S+)$/i`)
3. иначе `''`
Работает **только** на `/api/v1/*`. На внутреннем API `Authorization: Bearer` с ключом/паролем не авторизует (контракт зафиксирован в `api.smoketest.js`).
### Scopes
`API_KEY_SCOPES = { read: 'Чтение данных', write: 'Изменение данных' }` (`server.js:974`). Каталог отдаётся через `GET /api/api-keys/meta` → `{ scopes, default_rpm }` (`server.js:2006-2008`).
`normalizeApiScopes(v)` (`server.js:1008-1012`): приводит к строке, трим, нижний регистр, дедуплицирует, отбрасывает значения вне `API_KEY_SCOPES`; пустой результат → `['read']`.
- Весь `/api/v1/*` требует `read` (в `apiV1.use`).
- `apiWrite(scope)` (`server.js:7291-7298`) — синхронный middleware; без scope отдаёт **403** `{ error: 'API key lacks scope: write' }`.
- `requireApiKey('read')` при отсутствии scope отдаёт **403** `{ error: 'API key lacks scope: read' }` (`server.js:1097-1099`).
### Как ключ сужает права
`apiKeyUser(row)` (`server.js:1033-1047`) строит `req.user`:
- Если у ключа **нет** `branch_ids` — возвращается владелец как есть (включая `role: 'admin'`, если владелец админ).
- Если `branch_ids` заданы — роль **принудительно понижается до `'tutor'`**, а список филиалов = пересечение `branch_ids` ключа с филиалами владельца (`branchScope(owner).admin ? limit : limit.filter(...)`).
Вывод: `branch_ids` ключа сужают и никогда не расширяют права; ключ не может быть шире возможностей выдавшего.
Массовые операции учитывают филиалы отдельно: `apiBranchClause(user, expr, params)` (`server.js:7523-7529`) — admin → `''`, пустой список → `' AND FALSE'`, иначе ` AND <expr> = ANY($N::int[])`. Пример выражения для записей: `(SELECT g.branch_id FROM groups g WHERE g.id = e.group_id)` (`server.js:7539`).
### Кэш, троттлинг, лимиты
- `loadApiKey` (`server.js:1049-1073`): ключ `apikey:<sha256>`, TTL `SESSION_CACHE_TTL_MS` = **30 с**. Строка отбрасывается при `revoked_at`, `!is_active` или `expires_at <= now()`. В кэш кладётся `{ id, name, scopes: scopes || ['read'], user: apiKeyUser(row), rpm: rate_limit_per_min }`.
- `touchApiKey(id, ip)` (`server.js:1075-1085`): маркер `apikey:touch:<id>` с TTL `API_KEY_TOUCH_MS = 5 * 60 * 1000` (5 мин) — `last_used_at`/`last_used_ip` пишутся не чаще раза в 5 минут.
- Инвалидация: `invalidateApiKeys()` = `cacheDrop('apikey:')` + `cacheDrop('rl:apikey')` (`server.js:123`). Зовётся при создании (`server.js:2038`), обновлении (`server.js:2075`), удалении (`server.js:2087`) и ротации (`server.js:2104`).
- Лимит: `apiKeyLimiter` (`server.js:980-991`), окно 60 с, `limit` — функция: `Math.min(req.apiKey.rpm, API_KEY_MAX_RPM=10000)`, иначе `API_KEY_DEFAULT_RPM = 120`. Store `cache.rateLimitStore('apikey', 60 * 1000)`.
### CRUD ключей (внутренний API, под сессией)
| Метод и путь | Middleware | Источник |
|---|---|---|
| `GET /api/api-keys/meta` | `requireAdmin` | `server.js:2006` |
| `GET /api/api-keys` | `requireAuth, requireAdmin` | `server.js:2010` |
| `POST /api/api-keys` | `requireAuth, requireAdmin` | `server.js:2022` |
| `PUT /api/api-keys/:id` | `requireAuth, requireAdmin` | `server.js:2043` |
| `DELETE /api/api-keys/:id` | `requireAuth, requireAdmin` | `server.js:2080` |
| `POST /api/api-keys/:id/rotate` | `requireAuth, requireAdmin` | `server.js:2092` |
`GET /api/api-keys` возвращает **голый массив** `rows.map(apiKeyPublic)` (`server.js:2019`) — не конверт.
Видимость: `apiKeyOwnerScope(req)` (`server.js:1977-1980`) — admin видит все, не-admin только свои (` AND k.user_id = $1`). На `PUT`/`DELETE`/`rotate` при `role !== 'admin'` дополнительно проверяется `current.user_id !== req.user.id` → **403** `{ error: 'Forbidden' }`.
Тело `POST /api/api-keys` (`server.js:2022-2041`):
| Поле | Валидация |
|---|---|
| `name` | `reqStr(body.name, API_KEY_MAX_NAME=150)` |
| `scopes` | `normalizeApiScopes` |
| `expires_at` | `apiKeyExpiry`: пусто/`null` → `null` (без срока); невалидная дата → **400** `'Некорректная дата окончания'` |
| `branch_ids` | `apiKeyAllowedBranches`: пустой массив → `[]`; любой чужой/несуществующий филиал → **400** `'Недопустимый список филиалов'` |
| `rate_limit_per_min` | `apiKeyRateValue`: пусто → `null`; иначе `Number.isInteger(n) && 1 <= n <= 10000`, иначе **400** `'Некорректный лимит запросов'` |
Ответ `POST` — **201** `{ ...apiKeyPublic(row), key: raw }`. Поля `apiKeyPublic` (`server.js:1014-1031`): `id, name, prefix, scopes, branch_ids, rate_limit_per_min, created_at, last_used_at, last_used_ip, expires_at, revoked_at, user_id, username, user_name`.
`rotate` (`server.js:2092-2107`) обновляет `prefix`, `key_hash`, сбрасывает `revoked_at = NULL` и `last_used_at = NULL`; ответ `200 { ...apiKeyPublic(row), key: raw }`.
### Аудит мутаций через API
`apiAudit(req, action, target)` (`server.js:1111-1114`) добавляет в `audit_log.target` поле `via_api_key: <id>` (или `null`). Используется на всех мутациях `apiV1`; префикс действий — `api.` (`api.student.update`, `api.ai.wake`, …). После мутаций обязательны `invalidateEntries()` / `invalidateLessonReports()` / `invalidateStudents()` / `invalidateStats()` + `broadcastEntryChanged()`, иначе фронт не обновится.
### Отношение к бэкапу
`api_keys` **не входит** в бэкап, и `POST /api/restore` делает:
```
await client.query('DELETE FROM sessions'); // server.js:2728
await client.query('DELETE FROM api_keys'); // server.js:2729
```
После восстановления все сессии и все внешние ключи мертвы — их надо выпустить заново.
### `GET /api/v1/me`
`server.js:7039-7045`:
```
{ key: { id, name, scopes }, user: safeUser(req.user), server_time: <ISO с Z> }
```
---
---
## 4. Ограничения частоты
Все лимитеры построены на `cache.rateLimitStore(prefix, windowMs)` из `redis.js` — общем счётчике с Redis (namespace `rl:<prefix>`). Ни один не использует `MemoryStore`. У всех: `standardHeaders: true`, `legacyHeaders: false` (то есть клиент видит актуальные `RateLimit-*`), срабатывание = **429**.
| Limiter | Окно | Максимум | Store | Ключ счёта | Где используется |
|---|---|---|---|---|---|
| `apiLimiter` | 15 мин (`15*60*1000`) | **300** | `rateLimitStore('api', 900000)` | по IP (дефолт express-rate-limit) | `POST /api/auth/login` (`1638`), `GET /api/public-settings` (`2117`), `GET /api/backup/:token` (`2610`), `GET /api/groups` (`3237`), `GET /api/groups/active` (`3259`), `GET /api/modules` (`3689`), `GET /api/students` (`4155`) |
---
## 5. Формат ответов, ошибки и пагинация
### Контракт
- Успех: полезная нагрузка отдаётся **напрямую**, без обёртки типа `{ ok, data }`. Часто — массив или объект «как есть»; служебные действия отдают `{ ok: true, ... }`.
- Ошибка: **всегда** JSON вида `{ error: 'сообщение' }` с явным status code. Других полей в теле ошибки нет (кроме случаев, когда роут добавляет что-то сам, например `GET /api/share/:token/files/:fileToken` → 401 `{ error: 'Требуется пароль' }`).
- Универсального try/catch-оборачивания нет — каждый хендлер оборачивает работу сам. Но в конце файла есть **два глобальных fallback-обработчика**.
### Обработчики в конце `server.js`
**404** (`server.js:7611-7616`):
```
isApiRoute(req) = req.path.startsWith('/api/') || '/s/' || '/r/' // server.js:7594-7596
```
- Путь API/публичной страницы → `404 { error: 'Not found' }`.
- Иначе — HTML 404 через `renderErrorPage(404, 'Страница не найдена', ...)` из `public/error.html`.
**Ошибки** (`server.js:7618-7626`) — error middleware Express (4 аргумента), он есть:
- API-путь → `500 { error: 'Internal server error' }`, стек только в консоль.
- Не-API → HTML 500; при `NODE_ENV === 'production'` сообщение заменяется на `'Произошла ошибка на сервере.'` и стек скрыт, иначе `err.stack` попадает в `<pre id="errorStack">`.
### Типичные коды
| Код | Когда | Пример текста |
|---|---|---|
| 400 | валидация входа | `'Invalid id'` (`1793`), `'settings required'` (`2143`), `'Некорректный IP'` (`1687`), `'Некорректная дата окончания'` (`2026`), `'Недопустимый список филиалов'` (`2028`), `'Некорректный лимит запросов'` (`2030`), `'Файл слишком большой (макс. N МБ)'` (`1170`) |
| 401 | нет валидной сессии / неверный ключ | `'Unauthorized'` (`931`), `'Invalid or expired API key'` (`1095`), `'Неверный логин или пароль'` (`1643`), `'Требуется пароль'` (`3196`) |
| 403 | сессия есть, прав не хватает / бан / нет scope | `'Forbidden: требуется роль администратора'` (`944`), `'Forbidden'` (`2047`), `'Доступ заблокирован'` (`498`), `'API key lacks scope: write'` (`7294`), `'Нет доступа к этой группе'` (`5391`) |
| 404 | ресурс не найден | `'Not found'` (глобальный `7613` и в роутах), `'File missing'` (`5358`), `'Ссылка не найдена'` (`3184`), `'Ключ не найден'` (`2045`), `'Модуль не найден'` (`7308`) |
| 409 | нарушение уникальности (`e.code === '23505'`) | `'Логин уже занят'` (`1894`, `1958`), `'Duplicate name'` (`3315`), `'Duplicate'` (`3344`), `'Филиал с таким названием уже существует'` (`3488`) |
| 410 | срок ссылки истёк / файл бэкапа недоступен | `'Срок действия ссылки истёк'` (`3187`), `'Файл бэкапа больше недоступен. Сформируйте архив заново.'` (`2619`) |
| 416 | неудовлетворённый `Range` | без тела, `Accept-Ranges: bytes` + `Content-Range: bytes */<size>` (`5215-5218`) |
| 429 | rate limit / бан-счётчик | см. раздел «Ограничения частоты» |
| 500 | необработанная ошибка | `'Internal server error'` (`938`, `1105`, `7621`) или `err.message` в местах с ручным catch (напр. `5794`) |
Отдельно: для не-admin отсутствие доступа к записи иногда отдаёт **404**, а не 403 (`entryAccessible`, `server.js:1141-1154` + `7223-7226`) — намеренное сокрытие существования объекта.
### Пагинация
Единого стандарта нет — три подхода:
**1. `/api/v1` — строгий конверт.** `apiPage(req)` (`server.js:7027-7031`):
```
limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 500) // дефолт 50, максимум 500
offset = Math.max(parseInt(req.query.offset, 10) || 0, 0) // дефолт 0
```
`apiList(rows, total, limit, offset)` (`server.js:7033-7035`) → `{ items, total, limit, offset }`.
### Конверты списков — какие формы реально встречаются
| Форма | Эндпоинты |
|---|---|
| `{ items, total, limit, offset }` | все списки `/api/v1/*` (хелпер `apiList`) |
| `{ items, total }` | `GET /api/notifications` (`1738`, третье поле `unread`), `GET /api/share/:token` (`2956`), список версий отчёта (`3962`) |
| `{ entries, total }` | `GET /api/entries` (`5066`) |
| `{ entries, total, groups, total_groups }` | внутренний helper корзины (`6990`) |
| `{ modules, total }` | `GET /api/modules` (`3708`) |
| `{ photos, total }` | `GET /api/groups/:id/photos` (`3542`), `GET /api/students/:id/photos` (`4360`), `GET /api/photos` (`5457`) |
| голый массив | `GET /api/groups` (`3246`), `GET /api/groups/active` (`3259`), `GET /api/students` (`4164`), `GET /api/students/names` (`4179`), `GET /api/api-keys` (`2019`), `GET /api/bans` (`1681`), `GET /api/entries/:id/files` (`5107`), `GET /api/v1/entries/:id/files` (`7254`) |
| плоский объект | `GET /api/settings` (`2110-2114`) — `{ key: value }`; `GET /api/auth/me` — `safeUser` |
Общее правило: **только `/api/v1/*` гарантирует `{ items, total, limit, offset }`**. Во внутреннем API формы разные — проверять конкретный роут.
### Проверка входных данных
Инлайн-хелперы из `backup-restore.js` (`server.js:15-36`): `reqStr`, `optStr`, `reqInt`, `optInt`, `reqTs`, `optTs`, `optDate`, `optUploadPath`, `sanitizeStudentProfile`, `isSafeUploadPath`, `photoRefKey`, `SAFE_NAME`. Все SQL — только через `$1, $2, …`, интерполяции нет.
---
---
## 6. Даты, время и часовой пояс
### Три семейства данных
1. **instant** — колонки `TIMESTAMPTZ` (`created_at`, `updated_at`, `expires_at`, `last_used_at`, `banned_until`, `ai_checked_at`). Отдаются как ISO 8601 **с `Z`** (UTC), например `2026-03-14T09:31:00.000Z`. Зона отображения применяется **на клиенте**.
2. **чистая DATE** — колонки `DATE` (`lesson_date`, `taken_at`, `date_from`). Отдаются строкой `'YYYY-MM-DD'`, **без приведения к `Date()`** на клиенте, иначе UTC-полночь сдвинет дату на день назад.
3. **чистая TIME** — колонки `TIME` (`lesson_time`, `time_start`/`time_end`). Отдаются строкой `'HH:MM'` или `'HH:MM:SS'`; зона не применяется.
Подтверждение со стороны БД: `types.setTypeParser(1082, v => v)` (`server.js:44`) — тип `date` (OID 1082) отдаётся драйвером **как строка**, без преобразования в JS-объект.
### Настройки
| Ключ | Значение по умолчанию | Валидация | Где |
|---|---|---|---|
| `timezone` | `DEFAULT_TIMEZONE` | `validTimezone()` через `new Intl.DateTimeFormat('ru-RU', { timeZone: tz })`, иначе откат на дефолт | `server.js:1239-1252`; проверка в `PUT /api/settings` — `2198-2200` (400) |
| `time_format` | `'24h'` | только `'24h'` или `'12h'`, иначе **400** `{ error: 'time_format должен быть 24h или 12h' }` | `server.js:1254-1256`; проверка `2201-2202` |
`DEFAULT_TIMEZONE` = `process.env.TZ`, если он проходит `validTimezone`, иначе жёстко `'Europe/Moscow'` (`server.js:1239-1242`). В `Dockerfile:2` задано `ENV TZ=Europe/Moscow` — это только фолбэк; фактическая зона берётся из настройки в БД.
Серверные хелперы: `appTimezone()` (`1249-1252`), `appHour12()` (`1254-1256`).
### Границы дней в SQL
### Фильтры по датам в query-параметрах
| Параметр | Смысл | Где применяется |
|---|---|---|
| `date_from` | включительно, начало дня в зоне настройки | `GET /api/entries` (`5008`), `/api/photos` (`5381`), `/api/files` (`5244`), `/api/share/:token` (`3117`), `/api/v1/entries` (`7198`) |
| `date_to` | **не** включительно: реализован как `< tzDayEnd`, т.е. `< (дата + 1 день) 00:00` | те же |
Следствие: клиент, которому нужен включительный верх, должен передать `date_to` на единицу больше.
Прочие фильтры внутреннего API: `group_id`, `module_id`, `student_name`, `search`, `deleted=1`, `unread=1`. В `/api/v1`: `group_id`, `module_id`, `student_name`, `search` (ILIKE по `student_name` и `description`), `date_from`, `date_to` (`server.js:7190-7198`).
### `GET /api/public-settings`
`server.js:2117-2139`, под `apiLimiter`, без авторизации, ответ кэшируется на `PUBLIC_TTL_MS = 60 * 1000`.
Белый список ключей (`server.js:2119`):
```
system_name, system_logo, footer_left, footer_right,
share_show_student_message, share_show_entry_date, share_show_student_names,
share_show_group_photos, cookie_notice_text, spam_interval_min,
photo_capture_resolution, photo_capture_quality, photo_enhance_engine,
camera_enabled, photo_ai_face_mode, photo_ai_face_model, photo_ai_device_pref,
timezone, time_format
```
Дефолты (`server.js:2120`): `system_name: 'WhatIDo'`, `system_logo: ''`, `spam_interval_min: '30'`, `photo_capture_resolution: '640x480'`, `photo_capture_quality: '0.92'`, `photo_enhance_engine: 'auto'`, `camera_enabled: 'true'`, `photo_ai_device_pref: 'auto'`, `timezone: DEFAULT_TIMEZONE`, `time_format: '24h'`.
Дополнительно вычисляются/добавляются:
| Поле | Как получено | Источник |
|---|---|---|
| `photo_capture_width` / `photo_capture_height` | разбор `photo_capture_resolution` regex `/^(\d{2,5})x(\d{2,5})$/`, при неудаче `'640'` / `'480'` | `2123-2130` |
| `photo_capture_quality` (нормализованная) | `parseFloat`, допускается только `[0.5, 1]`, иначе `'0.92'` | `2131-2132` |
| `photo_ai_enabled` | `'true'` если `PHOTO_AI_URL` задан, иначе `'false'` | `2135` |
| `upload_file_limit_mb` | `String(UPLOAD_FILE_LIMIT_MB)` | `2136` |
| `upload_total_limit_mb` | `String(UPLOAD_TOTAL_LIMIT_MB)` | `2137` |
Значения лимитов отдаются **строками**. Фронт обязан читать их отсюда, а не хардкодить.
---
`TIMESTAMPTZ`-колонки фильтруются **только** через `tzDayStart` / `tzDayEnd` / `tzWall` + `bindTz` (`server.js:1258-1266`):
```
tzDayStart(idx) → ( $N::date::timestamp AT TIME ZONE $TZ$ )
tzDayEnd(idx) → ( $N::date::timestamp + interval '1 day') AT TIME ZONE $TZ$ )
tzWall() → ( now() AT TIME ZONE $TZ$ )
```
`bindTz(sql, params, tz)` подставляет зону **только если в SQL есть литерал `$TZ$`** — иначе Postgres ответит `bind message supplies 1 parameters, but prepared statement requires 0`. Это критично: при отсутствии фильтра по датам `$TZ$` в SQL нет, и зона в `params` не добавляется.
`DATE`-колонки (`lr.lesson_date`) сравниваются напрямую, зона не нужна.
---
## 7. Загрузка файлов (multipart)
Транспорт — `multipart/form-data`. Все загрузки проходят через multer, который **всегда** пишет на диск в `uploads/` с именем `<timestamp>-<random6><ext>` (`server.js:1174-1199`, `1202-1223`). Путь, сохраняемый в БД, — всегда `/uploads/<name>`; ключ объекта в S3 — тот же `<name>` (плюс `.originals/<name>` для оригиналов фото).
### Лимиты
```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: <count>, photos: <count> }` (`server.js:5799`) |
| `PUT /api/entries/:id` | `upload.array` | `photo` | 10 | обновлённая запись |
| `POST /api/entries/:id/files` | `adminUpload.array` | `files` | 10 | **201** `{ ok: true, count: <count> }` (`server.js:5161`) |
| `POST /api/groups/:id/photos` | `upload.single` | `photo` | 1 | `{ photos: rows, photo_path }` (`server.js:3542`, `3545`) |
| `POST /api/modules/:id/photo` | `upload.single` | `photo` | 1 | — (`server.js:3771`) |
| `POST /api/students/:id/photos` | `upload.single` | `photo` | 1 | `{ photos: rows, photo_path }` (`server.js:4360`, `4343`) |
| `POST /api/settings/logo` | `upload.single` | `logo` | 1 | пишет настройку `system_logo` = `/uploads/<name>` (`server.js:2233-2260`) |
| `POST /api/restore` | `uploadBackup.single` | `backup` | 1 | результат restore с `version` |
| `POST /api/entries/:id/photo/enhance-ai` | `upload.single` | `photo` | 1 | (`server.js:6032`) |
Обработка ошибок multer везде одинаковая: `LIMIT_FILE_SIZE` → 400 с `FILE_TOO_LARGE_ERROR`, `'Only images'` → 400 с текстом про изображения, `'Not allowed extension'` → 400 `'Недопустимый тип файла'`, иначе → 400 `'Недопустимый файл'`. Примеры блоков: `server.js:5694-5701` (записи), `5114-5119` (файлы), `2236-2240` (логотип).
### Что возвращается и как доставать файл
Клиенту **не возвращается** путь в S3/bucket. Возвращаются:
- **Токен файла** — `project_files.token`, 32 hex (`crypto.randomBytes(16).toString('hex')`, `server.js:5789`). Ссылка на скачивание: `GET /api/files/:token`.
- **Путь** вида `/uploads/<timestamp>-<rand6>.<ext>` — в `entries.photo_path`, `entry_photos.photo_path`, `project_files.path`, `groups.cover_path`, `students.photo_path`, `modules.photo_path`, настройке `system_logo`. Открывается напрямую: `GET /uploads/<name>`.
- Списки: `GET /api/entries/:id/files` → голый массив `{ id, token, name }` (`server.js:5106`); внутри записи — поле `files` (`server.js:5043`).
### Отдача файлов, Range и `?play=1`
**`GET /api/files/:token`** (`server.js:5352-5372`), под `fileLimiter`:
1. Поиск `project_files` по `token` → нет: **404** `{ error: 'Not found' }`.
2. `storage.keyFromPath(r.path)` не дал ключа → **404** `{ error: 'File missing' }`.
3. Картинка (`isImageName`: jpg, jpeg, jfif, png, gif, webp, bmp, avif, ico) → `Cache-Control: public, max-age=31536000, immutable`; при `?thumb` — миниатюра 480px WebP.
4. Видео в браузере (`BROWSER_VIDEO_EXT` = mp4, m4v, webm, ogv) **и** задан `?play` → `sendPlayableFile()`.
5. Иначе — `storage.streamTo(res, key, { download: true, name: r.name })`, то есть `Content-Disposition: attachment`.
**`sendPlayableFile`** (`server.js:5210-5235`) — единственное место с поддержкой Range:
- `Cache-Control: private, max-age=3600` (не immutable — файл может быть заменён).
- `parseByteRange(header, size)` (`server.js:5188-5208`) разбирает `Range: bytes=start-end`, `bytes=start-`, `bytes=-suffix`.
- Недопустимый диапазон → **416** с `Accept-Ranges: bytes` и `Content-Range: bytes */<size>`, без тела.
- Без Range → 200 с `Accept-Ranges: bytes`, `Content-Type` из `mimeFor(name)`, `Content-Length`, поток через `storage.getStream`.
- С Range → `storage.streamRangeTo(res, key, start, end, { contentType, cacheControl })`, что даёт **206** + `Content-Range`.
**Важно:** `?play` обрабатывается как **любая** непустая строка (`if (isPlayableVideoName(r.name) && req.query.play)`) — конкретно значение `1` не проверяется. Без `?play` видео уходит как `attachment`, чтобы старые ссылки не поменяли поведение. Видео вне `BROWSER_VIDEO_EXT` (`OTHER_VIDEO_EXT`: mov, mkv, avi, mpeg, mpg, 3gp, ts) `?play` не активирует — только скачивание.
**Диапазоны в других местах:** `GET /api/share/:shareToken/files/:fileToken` (`server.js:3180-3224`) поддерживает только `?thumb` и скачивание — **Range/`?play` там не реализованы**. `GET /uploads/*` — тоже без Range.
### Жизненный цикл в S3
`res.on('finish')` hook (`server.js:591-611`) при `STORAGE_DRIVER=s3` и успешном ответе персистит каждый загруженный файл через `storage.persist`, проверяя, что абсолютный путь начинается с `UPLOADS_DIR + path.sep`. Кэш `.thumbs` / `.cache` чистится раз в час (`server.js:7695-7697`), `.originals` — нет.
---
HEIC автоматически конвертируется в JPEG через `heic-convert` (`convertPhoto`; вызывается в `/api/settings/logo` и при загрузке фото).
---
---
Исключения: ветки без филиалов → `apiList([], 0, 50, 0)` (`7052`); «безпагинационные» выборки → `apiList(rows, rows.length, rows.length, 0)` (`7065`, `7254`).
---
## 8. Потоки событий (SSE)
Оба потока — `text/event-stream`, **без** rate limit и без `requireAuth`-middleware: авторизация делается вручную внутри хендлера, потому что `EventSource` в браузере не умеет задавать заголовки.
### `GET /api/events`
`server.js:198-218`. Общий поток изменений для UI.
**Авторизация:** `const token = req.headers['x-auth-token'] || req.query.token;` (`server.js:200`). Работает и заголовок, и query-параметр `?token=`. Фронт использует второй вариант: `new EventSource(\`${API}/api/events?token=...\`)`, токен из `sessionStorage.getItem('authToken')` (`public/js/journal.js:422`, `public/js/lessons.js:166`).
- Нет валидной активной сессии → **401**, тело пустое (`.end()`).
- Ошибка БД/кэша → **500**, тело пустое.
**Заголовки ответа** (`server.js:206-211`):
```
Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
```
Сразу после открытия пишется `:ok\n\n` (SSE-комментарий). Далее каждые **25000 мс** — `:ping\n\n` (heartbeat/keep-alive, `server.js:214-216`). При ошибке записи или по `req.on('close')` клиент удаляется из `sseClients`, интервал очищается.
**Формат кадра:** `writeFrame(event, data)` (`server.js:143-148`) — `event: <name>\ndata: <JSON>\n\n`.
**Имена событий** (диспетчер `dispatchEvent`, `server.js:150-175`):
| `event:` | `data:` (JSON) |
|---|---|
| `entries_changed` | `{ ts: <Date.now()> }` — значение по умолчанию для любого типа, кроме двух ниже |
| `ai_status` | `{ id, ai_status, ai_error, description, description_ai, description_original, ts }` (`server.js:163-171`) |
| `lesson_report_status` | `{ id, ai_status, ai_error, text, text_ai, ts }` (`server.js:152-159`) |
Важная деталь: `broadcastLessonChanged()` публикует в канал тип `'lessons_changed'` (`server.js:182-185`), но ветки под него в `dispatchEvent` нет, поэтому клиент получает **событие `entries_changed`** — SSE-клиент не может отличить изменение записей от изменения отчётов.
Транспорт: `cache.publish(EVENTS_CHANNEL, ...)`, канал `whatido:events` (`server.js:125`), подписка `cache.on(EVENTS_CHANNEL, ...)` (`server.js:192-196`). Работает и через Redis, и через in-memory fallback. Есть отдельный низкоуровневый путь через Postgres `LISTEN entries_changed` (`server.js:54-84`).
### `GET /api/notifications/stream`
`server.js:1749-1774`. Поток уведомлений.
**Авторизация:** та же схема — `req.headers['x-auth-token'] || req.query.token` (`server.js:1752`), затем `loadUserByToken`.
- Нет валидной сессии → **401** пустым телом; ошибка БД → **500** пустым телом.
- Фронт: `new EventSource(\`${API}/api/notifications/stream?token=...\`)`, токен из `sessionStorage.getItem('authToken')` (`public/admin.js:347`, `349`).
**Заголовки** — те же четыре, включая `X-Accel-Buffering: no` (`server.js:1758-1763`). Сразу `:ok\n\n`, heartbeat `:ping\n\n` раз в **25000 мс** (`server.js:1770-1772`), очистка по `req.on('close')`.
**События:**
| `event:` | Когда | `data:` |
|---|---|---|
| `ready` | сразу после подключения | JSON результата `notificationsCounts(user)` — минимум `{ total, unread }` (`server.js:1766-1769`) |
| `notification` | при публикации новой записи | **вся строка БД `notifications`** как JSON (`server.js:278-294`) |
Формат кадра — `writeNotifyFrame` (`server.js:278-280`), тот же `event:` / `data:` JSON.
Поля строки уведомления (по выборке в `GET /api/notifications`, `server.js:1729`): `id, type, level, title, body, link, target, admin_only, branch_id, created_at, read`.
**Фильтрация по видимости** (`notificationVisible`, `server.js:270-276`): админ видит всё; остальные — только `admin_only = false` **и** (`branch_id IS NULL` или филиал из `user_branches` клиента). Невидимые кадры не отправляются вовсе.
Транспорт: канал `whatido:notifications` (`NOTIFY_CHANNEL`, `server.js:224`); запись в БД → `cache.publish` → рассылка подключённым клиентам с фильтрацией. При недоступном Redis работает in-memory pub/sub.
### Прочие замечания по SSE
- Keep-alive/heartbeat — SSE-комментарии (`:ok`, `:ping`), не события; клиентский `EventSource` их не показывает.
- `retry:` (интервал переподключения) сервером **не задаётся** — используется браузерное значение по умолчанию.
- Бан IP (`ipGuard`) применяется к обоим потокам как глобальный middleware — 403 до входа в хендлер.
---
**2. Внутренний API — «мягкая» пагинация без дефолта** (напр. `GET /api/entries`, `server.js:5034-5037`):
```
lim = parseInt(limit, 10); if (lim > 0) { LIMIT $n }
off = parseInt(offset, 10); if (off > 0) { OFFSET $n }
```
---
## 9. Аутентификация: эндпоинты
### `POST /api/auth/login`
- **Доступ:** публичный (rate limit `apiLimiter`: 300 / 15 мин, `cache.rateLimitStore('api')`).
- **Тело (JSON):** `username` (string, обязат., ≤100 символов, `trim()` + `toLowerCase()`), `password` (string, обязат., приводится к строке), `website` (honeypot — не должен содержать непустую строку).
- **Query:** нет.
- **Ответ 200:** `{ token, expires_at }` — `token` = `crypto.randomBytes(32).toString('hex')` (сессионный токен, идёт в заголовок `X-Auth-Token`), `expires_at` = ISO `now + SESSION_TTL_MS` (`SESSION_TTL_MS = 30 дней`).
- **Ошибки:** 401 `{error:'Неверный логин или пароль'}` — на honeypot, пустой/длинный логин, несуществующего или неактивного пользователя и неверный пароль; 429 «Слишком много запросов. Попробуйте позже.»; 403 (бан IP).
- **Примечания:** пароль сверяется `bcrypt.compare`. При неудаче — `recordFailure(req,'login-bruteforce',10,BAN_TTL_MS)`: 10 неудач за окно `FAIL_WINDOW_MS` (15 мин) дают бан IP на `BAN_TTL_MS` (24 ч); honeypot даёт `recordFailure(req,'honeypot',1,BAN_TTL_MS)` (бан с первого срабатывания). Сессия пишется в таблицу `sessions`; аудит `logAudit({ip, user:{id}}, 'auth.login', {username})`.
### `POST /api/auth/logout`
- **Доступ:** `requireAuth`. **Тело/Query:** не используются.
- **Ответ 200:** `{ ok: true }`.
- **Ошибки:** 401 `Unauthorized`; 403 (бан).
- **Примечания:** `DELETE FROM sessions WHERE token=$1` + сброс кэша `session:<token>`. Аудита нет.
### `GET /api/auth/me`
- **Доступ:** `requireAuth`. **Тело/Query:** нет.
- **Ответ 200:** `safeUser(req.user)` = `{ id, username, name, role, is_active, branch_ids }` (хэш пароля не отдаётся, `branch_ids` — из `user_branches`).
- **Ошибки:** 401 `Unauthorized`.
- **Примечания:** пользователь кэшируется в Redis на `SESSION_CACHE_TTL_MS` (30 с); после правки роли/активности кэш сбрасывается через `invalidateSessions()`.
---
## 10. Баны IP
### `GET /api/bans`
- **Доступ:** `requireAuth, requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** голый массив `{ ip, reason, banned_until, created_at }` — только активные баны (`banned_until > now()`), сортировка `banned_until DESC`.
- **Ошибки:** 401 / 403.
- **Примечания:** `reason` хранится кодом (`honeypot`, `login-bruteforce`, `share-password-bruteforce`, `apikey-bruteforce`, `manual`); человекочитаемые подписи — `BAN_REASON_LABELS`.
### `POST /api/bans`
- **Доступ:** `requireAuth, requireAdmin`.
- **Тело (JSON):** `ip` (string, обязат., ≤64, только символы `[0-9a-fA-F:.]`), `reason` (string, опц., обрезка до 100, дефолт `'manual'`), `hours` (`parseInt`, clamp в 1…720, дефолт 24).
- **Query:** нет.
- **Ответ 200:** `{ ok: true, ip, reason, banned_until }` (`banned_until` = ISO `now + hours*3600000`).
- **Ошибки:** 400 `{error:'Некорректный IP'}`.
- **Примечания:** `banIpAddr` пишет `ban:<ip>` в кэш (TTL = срок бана) и делает upsert в `banned_ips`, аудит `ip.ban {ip, reason}`, уведомление типа `ip.ban` (`adminOnly: true`).
### `DELETE /api/bans/:ip`
- **Доступ:** `requireAuth, requireAdmin`. **Параметр:** `ip` — та же валидация, что в POST. **Тело/Query:** нет.
- **Ответ 200:** `{ ok: true }`.
- **Ошибки:** 400 `{error:'Некорректный IP'}`.
- **Примечания:** `DELETE FROM banned_ips WHERE ip=$1`, `unbanIpAddr` (удаляет `ban:<ip>` и все `fail:*:<ip>`), аудит `ip.unban {ip}`.
---
## 11. Уведомления
Общий лимитер `notificationLimiter`: 600 / 15 мин, `cache.rateLimitStore('notify')`, сообщение «Слишком много запросов. Попробуйте позже.». Видимость (`notificationsScope`): админ видит всё; не-admin — только `admin_only = false` и (`branch_id IS NULL` или филиал из `user_branches`).
### `GET /api/notifications`
- **Доступ:** `requireAuth` + `notificationLimiter`.
- **Query:** `limit` (1…100, дефолт 30), `offset` (≥0, дефолт 0), `unread` (`'1'` — только непрочитанные).
- **Тело:** нет.
- **Ответ 200:** `{ items, total, unread }`. `items` — строки `{ id, type, level, title, body, link, target, admin_only, branch_id, created_at, read }` (`read` считается LEFT JOIN'ом на `notification_reads` текущего пользователя), сортировка `n.id DESC`; `total` и `unread` — из `notificationsCounts` по всей видимой выборке, а не по странице.
- **Ошибки:** 401 / 429.
### `GET /api/notifications/meta`
- **Доступ:** `requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** `{ enabled, retention_days, types }`, где `enabled` = `notify_enabled !== 'false'`; `retention_days` = `notify_retention_days` (дефолт `NOTIFY_RETENTION_DEFAULT_DAYS = 30`); `types` = `notifyCatalog()` — массив `{ type, key, label, hint, icon, level, admin_only, default_enabled }` из `NOTIFY_TYPES` (без скрытых).
- **Ошибки:** 401 / 403.
### `GET /api/notifications/stream` (SSE)
- **Доступ:** своя авторизация внутри хендлера (не `requireAuth`); токен берётся из заголовка `X-Auth-Token` **или** из query `?token=` (нужно для `EventSource`).
- **Тело:** нет. **Query:** `token` (опц., альтернатива заголовку).
- **Ответ 200:** `Content-Type: text/event-stream`, заголовки `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`, `X-Accel-Buffering: no`.
- **События:** `ready` — сразу после подключения, `data` = JSON `{ total, unread }` (из `notificationsCounts`); `notification` — при публикации в Redis-канал уведомлений, `data` = JSON строки уведомления (публикуется вся строка БД). Служебные кадры (не события): `:ok` при открытии и `:ping` раз в 25 с. Формат кадра: `event: <имя>\ndata: <json>\n\n`.
- **Ошибки:** 401 пустым ответом без тела (нет валидной сессии); 500 при ошибке БД.
- **Примечания:** клиент кладётся в `notifyClients`, рассылка фильтруется по `notificationVisible(user, payload)` (админ — всё, иначе `!admin_only` и филиал из `branch_ids`), удаляется из сета на `req.on('close')`. Rate limit на этот маршрут не навешен.
### `POST /api/notifications/read-all`
- **Доступ:** `requireAuth` + `notificationLimiter`. **Тело/Query:** не используются.
- **Ответ 200:** `{ ok: true, marked, unread }` — `marked` = число вставленных строк в `notification_reads` (только ещё не прочитанные и только видимые), `unread` — остаток после операции.
- **Ошибки:** 401 / 429.
- **Примечания:** `INSERT ... SELECT ... ON CONFLICT DO NOTHING`. Аудита нет.
### `POST /api/notifications/:id/read`
- **Доступ:** `requireAuth` + `notificationLimiter`. **Параметр:** `id` (целое ≥1). **Тело/Query:** нет.
- **Ответ 200:** `{ ok: true }`.
- **Ошибки:** 400 `{error:'Invalid id'}`; 404 `{error:'Not found'}` (нет уведомления в видимой области).
- **Примечания:** сначала SELECT с проверкой видимости, затем `INSERT INTO notification_reads ... ON CONFLICT DO NOTHING`.
### `POST /api/notifications/test`
- **Доступ:** `requireAdmin` + `notificationLimiter`. **Тело/Query:** не используются.
- **Ответ 200:** `{ ok: true, id, delivered }` — `id` (или `null`) и `delivered` = прошло ли событие через `pushNotification` (тип мог быть выключен настройкой).
- **Ошибки:** 401 / 403 / 429.
- **Примечания:** создаёт событие `system.test`: `title: 'Тестовое уведомление'`, `body: 'Отправлено из настроек пользователем <username>'`, `link: 'notifications.html'`, `adminOnly: true`. Аудита нет.
### `DELETE /api/notifications/:id`
- **Доступ:** `requireAdmin`. **Параметр:** `id` (целое ≥1). **Тело/Query:** нет.
- **Ответ 200:** `{ ok: true }`.
- **Ошибки:** 400 `{error:'Invalid id'}`; 404 `{error:'Not found'}`.
- **Примечания:** `DELETE FROM notifications WHERE id=$1` (прочтения удаляются каскадом), аудит `notifications.delete {id}`.
### `DELETE /api/notifications`
- **Доступ:** `requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** `{ ok: true, deleted }` (число удалённых строк).
- **Ошибки:** 401 / 403.
- **Примечания:** полная очистка таблицы `notifications`; аудит `notifications.clear {deleted}`.
---
## 12. Пользователи
### `GET /api/users`
- **Доступ:** `requireAuth, requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** голый массив `{ id, username, name, role, is_active, created_at, branch_ids }`, **только `role = 'tutor'`** (админы не возвращаются), `branch_ids` — `array_agg` по `user_branches` (пустой массив, если филиалов нет), сортировка по `id`.
- **Ошибки:** 401 / 403.
### `GET /api/users/tutors`
- **Доступ:** `requireAuth` (любой активный пользователь, включая не-admin). **Тело/Query:** нет.
- **Ответ 200:** голый массив `{ id, name, username }` — только `role='tutor' AND is_active=true`, сортировка по `COALESCE(NULLIF(name,''), username)`.
- **Ошибки:** 401.
- **Примечания:** филиалы не фильтруются — список всех тьюторов.
### `GET /api/users/branches`
- **Доступ:** `requireAuth`. **Тело/Query:** нет.
- **Ответ 200:** голый массив строк `SELECT * FROM branches` (все колонки), сортировка по `id`; не-admin получает только свои филиалы (`WHERE id = ANY($1::int[])`), при пустом списке — `[]`.
- **Ошибки:** 401.
- **Примечания:** фильтрация через `branchScope(req.user)`.
### `POST /api/users`
- **Доступ:** `requireAuth, requireAdmin`.
- **Тело (JSON):** `username` (string, обязат., ≤100, `trim()` + `toLowerCase()`), `password` (string, обязат., ≥6), `name` (string, опц., ≤150, иначе `null`), `role` (строго `'admin'` → admin, иначе `tutor`), `is_active` (дефолт `true`; выключает только строгое `false`), `branch_ids` (массив чисел, дедуплицируется через `Number()`/`filter(Boolean)`; при `role='admin'` принудительно `[]`).
- **Ответ 201:** `safeUser(...)` = `{ id, username, name, role, is_active, branch_ids }`.
- **Ошибки:** 400 `{error:'Пароль должен быть не короче 6 символов'}`; 409 `{error:'Логин уже занят'}` (код `23505`).
- **Примечания:** `bcrypt.hash(password, 10)`; вставка пользователя и строк `user_branches` в одной транзакции; аудит `user.create {id, username, role}`.
### `PUT /api/users/:id`
- **Доступ:** `requireAuth, requireAdmin`.
- **Тело (JSON):** все поля опциональны (PATCH-семантика): `name` (≤150), `role` (`'admin'` → admin, иначе `tutor`), `is_active` (`!== false`), `branch_ids` (массив — только при передаче массива, иначе филиалы не трогаются), `password` (≥6, иначе 400).
- **Ответ 200:** `safeUser` свежепрочитанного пользователя (тот же SELECT, что в `GET /api/users`).
- **Ошибки:** 404 `{error:'Пользователь не найден'}`; 400 `{error:'Нельзя деактивировать самого себя'}`; 400 `{error:'Нельзя снять роль администратора с самого себя'}`; 400 `{error:'Пароль должен быть не короче 6 символов'}`; 409 `{error:'Логин уже занят'}`.
- **Примечания:** всё в транзакции; при `is_active=false` удаляются все сессии пользователя; после коммита — `invalidateSessions()` + `invalidateApiKeys()`; аудит `user.update {id, role, is_active}`. Логин не меняется (username в теле не принимается).
### `DELETE /api/users/:id`
- **Доступ:** `requireAuth, requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** `{ ok: true }`.
- **Ошибки:** 400 `{error:'Нельзя удалить самого себя'}`.
- **Примечания:** `DELETE FROM users WHERE id=$1`, `invalidateSessions()` + `invalidateApiKeys()`, аудит `user.delete {id}`. 404 не возвращается — удаление несуществующего id тоже даёт `{ok:true}`.
---
## 13. API-ключи (внутренний UI)
Форма ответа `apiKeyPublic`: `{ id, name, prefix, scopes, branch_ids, rate_limit_per_min, created_at, last_used_at, last_used_ip, expires_at, revoked_at, user_id, username?, user_name? }`. Секрет в БД не хранится (только `sha256` в `key_hash` + `prefix` из первых 12 символов) и возвращается один раз при создании и ротации.
### `GET /api/api-keys/meta`
- **Доступ:** `requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** `{ scopes: { read: 'Чтение данных', write: 'Изменение данных' }, default_rpm: 120 }`.
- **Ошибки:** 401 / 403.
### `GET /api/api-keys`
- **Доступ:** `requireAuth, requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** голый массив `apiKeyPublic(...)` + `username`/`user_name` владельца (JOIN по `users`), сортировка `k.id DESC`.
- **Ошибки:** 401 / 403.
- **Примечания:** не-admin видит только свои ключи (`apiKeyOwnerScope` → `AND k.user_id = $1`).
### `POST /api/api-keys`
- **Доступ:** `requireAuth, requireAdmin`.
- **Тело (JSON):** `name` (string, обязат., ≤ `API_KEY_MAX_NAME` = 150), `scopes` (массив или строка; фильтруются по `API_KEY_SCOPES`, дефолт `['read']`), `branch_ids` (массив; не-admin может указать только свои филиалы; чужой/несуществующий → ошибка), `expires_at` (строка-дата; `null`/`''` → бессрочно; невалидная дата → 400), `rate_limit_per_min` (целое 1…`API_KEY_MAX_RPM` = 10000; `null`/`''` → `null`, затем дефолт 120).
- **Ответ 201:** `apiKeyPublic(...)` + `key` — полный секрет формата `wsk_<64 hex>` (в БД: `prefix` = первые 12 символов, `key_hash` = sha256).
- **Ошибки:** 400 `{error:'Некорректная дата окончания'}`; 400 `{error:'Недопустимый список филиалов'}`; 400 `{error:'Некорректный лимит запросов'}`.
- **Примечания:** ключ создаётся на `req.user.id`; `invalidateApiKeys()`; аудит `api_key.create {id, name, scopes, branch_ids, expires_at}`.
### `PUT /api/api-keys/:id`
- **Доступ:** `requireAuth, requireAdmin`; не-admin — только свои ключи, иначе 403.
- **Тело (JSON):** любое подмножество: `name` (≤150), `scopes`, `branch_ids` (снова проверяется принадлежность филиалу), `expires_at` (в т.ч. `null` → бессрочно), `rate_limit_per_min` (1…10000; `null` → сброс к дефолту).
- **Ответ 200:** `apiKeyPublic(...)`.
- **Ошибки:** 404 `{error:'Ключ не найден'}`; 403 `{error:'Forbidden'}`; 400 — те же тексты про дату / список филиалов / лимит запросов.
- **Примечания:** секрет и префикс не меняются (для этого есть `rotate`); `invalidateApiKeys()`; аудит `api_key.update {id, name, scopes, branch_ids, expires_at}`.
### `DELETE /api/api-keys/:id`
- **Доступ:** `requireAuth, requireAdmin`; не-admin — только свои ключи. **Тело/Query:** нет.
- **Ответ 200:** `{ ok: true }`.
- **Ошибки:** 404 `{error:'Ключ не найден'}`; 403 `{error:'Forbidden'}`.
- **Примечания:** строка удаляется физически (не `revoked_at`); `invalidateApiKeys()`; аудит `api_key.delete {id, name}`.
### `POST /api/api-keys/:id/rotate`
- **Доступ:** `requireAuth, requireAdmin`; не-admin — только свои ключи. **Тело/Query:** нет.
- **Ответ 200:** `apiKeyPublic(...)` + `key` (новый секрет `wsk_<64 hex>`).
- **Ошибки:** 404 `{error:'Ключ не найден'}`; 403 `{error:'Forbidden'}`.
- **Примечания:** `UPDATE` перезаписывает `prefix`/`key_hash` и сбрасывает `revoked_at` и `last_used_at` — то есть ротация «оживляет» отозванный ключ; `invalidateApiKeys()`; аудит `api_key.rotate {id, name}`.
---
## 14. Настройки
### `GET /api/settings`
- **Доступ:** `requireAdmin`. **Тело/Query:** нет.
- **Ответ 200:** плоский объект `{ key: value }` — все строки таблицы `settings`, сортировка по ключу.
- **Ошибки:** 401 / 403.
### `GET /api/public-settings`
- **Доступ:** публичный (`apiLimiter`), без авторизации. **Тело/Query:** нет.
- **Ответ 200:** объект (все значения — строки) по ключам: `system_name`, `system_logo`, `footer_left`, `footer_right`, `share_show_student_message`, `share_show_entry_date`, `share_show_student_names`, `share_show_group_photos`, `cookie_notice_text`, `spam_interval_min`, `photo_capture_resolution`, `photo_capture_quality`, `photo_enhance_engine`, `camera_enabled`, `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref`, `timezone`, `time_format`. Дефолты из кода: `system_name='WhatIDo'`, `system_logo=''`, `spam_interval_min='30'`, `photo_capture_resolution='640x480'`, `photo_capture_quality='0.92'`, `photo_enhance_engine='auto'`, `camera_enabled='true'`, `photo_ai_face_mode=PHOTO_AI_DEFAULT_FACE_MODE` (env `PHOTO_AI_FACE_MODE`, дефолт `off`), `photo_ai_face_model=PHOTO_AI_FACE_MODEL` (env, дефолт `gfpgan`), `photo_ai_device_pref='auto'`, `timezone=DEFAULT_TIMEZONE`, `time_format='24h'`. Дополнительно: `photo_capture_width` / `photo_capture_height` (парсятся из `WxH`, иначе `640` / `480`), `photo_ai_enabled` (`'true'|'false'` по наличию `PHOTO_AI_URL`), `upload_file_limit_mb` и `upload_total_limit_mb` (из env).
- **Ошибки:** 429.
- **Примечания:** тело оборачивается в `cacheWrap('public-settings', PUBLIC_TTL_MS = 60 с)`, но `photo_ai_enabled` и лимиты загрузки дописываются каждый раз уже вне кэша. `photo_capture_quality` нормализуется: не число или вне 0.5…1 → `'0.92'`.
### `PUT /api/settings`
- **Доступ:** `requireAdmin`.
- **Тело (JSON):** `{ settings: { key: value, ... } }` — объект обязателен. Все значения сохраняются строками (`String(value ?? '')`), upsert по ключу.
- **Валидация по ключам:** `spam_interval_min` — целое 1…10080; `trash_purge_days` — 1…3650; `photo_capture_resolution` — `/^\d{2,5}x\d{2,5}$/`; `photo_capture_quality` — число 0.5…1; `photo_enhance_engine` ∈ `auto|server|client`; `photo_ai_face_mode` ∈ `off|face|all`; `photo_ai_face_model` ∈ `gfpgan|codeformer`; `photo_ai_device_pref` ∈ `auto|cuda|cpu`; `system_name` ≤60 символов после `trim()`; `system_logo` — `''` или `isSafeUploadPath()`; `notify_retention_days` — целое 1…365; `lesson_ai_enabled` — строго `'true'|'false'`; `lesson_ai_prompt` ≤8000 символов; `timezone` — `validTimezone()`; `time_format` ∈ `24h|12h`; любой другой `notify_*` (кроме `notify_retention_days`) — только `'true'|'false'`. Остальные ключи пишутся без проверок.
- **Ответ 200:** полный актуальный объект `{ key: value }` (те же строки, что и в `GET /api/settings`).
- **Ошибки:** 400 `{error:'settings required'}` и 400 с конкретным текстом на каждый случай: «spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)», «trash_purge_days должен быть целым числом от 1 до 3650», «photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480», «photo_capture_quality должен быть числом от 0.5 до 1», «photo_enhance_engine должен быть auto, server или client», «photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref должен быть одним из: …», «system_name не может быть длиннее 60 символов», «system_logo — некорректный путь», «notify_retention_days должен быть целым числом от 1 до 365», «lesson_ai_enabled должен быть true или false», «lesson_ai_prompt длиннее 8000 символов», «timezone должен быть корректным часовым поясом IANA, например Europe/Moscow», «time_format должен быть 24h или 12h», «`<key>` должен быть true или false».
- **Примечания:** вся пачка пишется в одной транзакции (`ON CONFLICT DO UPDATE`), при ошибке — `ROLLBACK`; аудит `settings.update {settings}` (со всеми значениями); `invalidateSettings()` сбрасывает кэш, включая `public-settings`.
### `POST /api/settings/logo`
- **Доступ:** `requireAdmin`.
- **Тело:** `multipart/form-data`, поле файла — **`logo`** (`upload.single('logo')`); лимит размера — `UPLOAD_FILE_LIMIT_MB`; принимаются только изображения.
- **Ответ 200:** `{ system_logo: '/uploads/<file>' }`.
- **Ошибки:** 400 «Файл слишком большой (макс. N МБ)» (`FILE_TOO_LARGE_ERROR`, код multer `LIMIT_FILE_SIZE`); 400 «Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)» (ошибка multer `Only images`); 400 «Недопустимый тип файла» (`Not allowed extension`); 400 «Недопустимый файл» (прочие ошибки multer); 400 «Файл обязателен» (нет `req.file`); 400 «Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)» — повторная проверка `ALLOWED_IMAGE_EXT` по `path.extname(originalname)`, с удалением файла.
- **Примечания:** HEIC конвертируется через `convertPhoto`; старый логотип (`isSafeUploadPath(old) && old !== finalPath`) удаляется через `safeUnlink`; `settings.system_logo` обновляется, аудит `settings.logo.upload {path}`, `invalidateSettings()`. При исключении после multer файл снимается `removeUpload`.
### `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/<token>', filename, size, expires_at, counts, format_version }` — `url` ведёт на тикет, `counts` = количества строк по таблицам `BACKUP_TABLES` + `files`, `format_version` = `BACKUP_FORMAT_VERSION`.
- **Ошибки:** 500 `{error: err.message}` (ошибка сборки архива); 401 / 403.
- **Примечания:** архив `tar.gz` (`data.json` + `uploads/`) кладётся в `BACKUP_DIR`, тикет (`token` = 24 байта hex) хранится в **in-memory `Map`** на `BACKUP_TTL_MS` = 30 мин; одновременно живых тикетов не больше `BACKUP_TICKETS_MAX` = 3 (`pruneBackupTickets`; файлы добиваются `sweepBackupStorage` с запасом 5 мин, всё чистится раз в минуту). После рестарта приложения ссылка даёт 404 — это ожидаемо. Аудит `backup.download {size, counts}`, уведомление `backup.create` (`adminOnly: true`). В бэкап **не входят** `sessions` и `api_keys`; входят `audit_log`, `notifications`, `notification_reads`, `banned_ips`.
### `GET /api/backup/:token`
- **Доступ:** публичный (`apiLimiter`), авторизации по токену нет — знание тикета и есть доступ. **Тело/Query:** нет.
- **Ответ 200:** бинарный архив — `res.download(...)` с `Cache-Control: no-store`, имя файла из тикета.
- **Ошибки:** 404 «Ссылка на бэкап устарела. Сформируйте архив заново.» (тикета нет или истёк — истёкший тикет удаляется); 410 «Файл бэкапа больше недоступен. Сформируйте архив заново.» (файла на диске нет); 500 «Не удалось отправить бэкап»; 429.
- **Примечания:** тикет не «сжигается» чтением — скачивание можно повторять, в том числе после обрыва связи и F5 (и HEAD, т.к. Express 4 отдаёт HEAD через GET-хендлер).
### `GET /api/backup`
- **Доступ:** `requireAdmin` (прямое скачивание без тикета). **Тело/Query:** нет.
- **Ответ 200:** бинарный архив `tar.gz` (`Cache-Control: no-store`).
- **Ошибки:** 500 `{error: err.message}`; 401 / 403.
- **Примечания:** тот же `buildBackupArchive` + аудит `backup.download {size, counts}` + уведомление `backup.create`; файл удаляется `sweepBackupStorage` после истечения TTL.
### `POST /api/restore`
- **Доступ:** `requireAdmin`.
- **Тело:** `multipart/form-data`, поле файла — **`backup`** (`uploadBackup.single('backup')`); лимит `BACKUP_UPLOAD_LIMIT_MB` (env, дефолт 500); `fileFilter` пропускает только `.tar.gz|.tgz|.gz`.
- **Ответ 200:** `{ ok: true, format_version, restored }`, где `restored` — счётчики из `restoredCounts(ndata)`.
- **Ошибки:** 400 `{error:'backup file required'}`; 400 `{error:'Неверный файл бэкапа'}` (мусор / не распаковывается); 400 «Это архив скрипта scripts/backup.sh (db.sql.gz + _uploads) — восстанавливайте его через scripts/restore.sh. Для веб-восстановления скачайте архив в Настройках админки.»; 400 `{error:'Неверный формат бэкапа'}` (`isSupportedBackupVersion`); 400 «Неверный формат бэкапа: `<message>` (ошибка `normalizeRestoreData`); 500 «Ошибка восстановления: `<message>`; 401 / 403. Ошибки multer (превышение размера, недопустимое расширение) в этом хендлере не перехватываются и уходят в общий error-handler → 500 `{error:'Internal server error'}` для `/api/*`.
- **Примечания:** поддерживается legacy-формат — если «tar.gz» на деле gzip-JSON, берётся `data.photos[]` (base64) и файлы кладутся поштучно через `storage.put`. Восстановление в одной транзакции: полный `DELETE` в FK-безопасном порядке (`notification_reads → notifications → banned_ips → project_files → lesson_report_versions → lesson_reports → entries → modules → students → share_links → groups → user_branches → sessions → api_keys → audit_log → users → branches`), затем `INSERT` данных и `setval` по `BACKUP_SEQUENCE_TABLES`; записи `lesson_reports`, `lesson_report_versions` и `photo_jobs` пропускаются, если нет родительской строки. Вне транзакции: `storage.uploadTree(staging/uploads)`, `sweepOrphanedUploads()`, `loadBans()`, `ensureFirstAdmin()`, аудит `backup.restore {format_version}`, уведомление `backup.restore` (`adminOnly: true`), `invalidateAll()`. **После restore все сессии и все API-ключи мертвы** (`DELETE FROM sessions`, `DELETE FROM api_keys`) — ключи надо выпускать заново.
---
## 17. Публичные ссылки (share)
### `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:<token>', SHARE_TTL_MS = 60 c)` — `{ name, group_name, student_name, group_id, date_from, date_to, message, link_url, created_at, expires_at, show_student_names, show_student_message, show_entry_date, show_group_photos, entries[], photos[] }`. В `entries` вложен `files[]` (`token`, `name`). `photos` — максимум **12** фото группы, порядок `sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`
- **Ошибки:** 404 «Ссылка не найдена», 410 «Срок действия ссылки истёк», 401 «Требуется пароль» + `passwordRequired: true`, 401 «Неверный пароль» (+ `recordFailure(req, 'share-password-bruteforce', 10, BAN_TTL_MS)`)
- **Примечания:** выборка только `e.deleted_at IS NULL`; границы дат — через `tzDayStart`/`tzDayEnd` + `bindTz(…, appTimezone())`. Если `show_student_names` выключен, имена заменяются на «Ученик N». Флаги в ссылке `NULL` → дефолт из настроек: `share_show_student_names='true'`, `share_show_student_message='false'`, `share_show_entry_date='false'`, `share_show_group_photos='true'`
### `GET /api/share/:shareToken/files/:fileToken`
- **Доступ:** публичный, `fileLimiter`; та же проверка пароля, что и в `GET /api/share/:token`
- **Ответ 200:** поток файла. Картинки (`isImageName`: jpg/jpeg/jfif/png/gif/webp/bmp/avif/ico) — inline с `Cache-Control: public, max-age=31536000, immutable`, при `?thumb` — миниатюра `sendImageThumb`. Остальное — `attachment` с оригинальным именем
- **Ошибки:** 404 «Ссылка не найдена», 410 «Срок действия ссылки истёк», 401 «Требуется пароль» / «Неверный пароль», 404 `Not found` (токен файла не подходит под фильтры ссылки), 404 `File missing` (нет ключа в storage / поток не отдался)
- **Примечания:** файл должен принадлежать записи, попадающей под фильтры ссылки (группа/студент/даты) и не удалённой; путь берётся через `storage.keyFromPath` + `storage.streamTo`
### `GET /s/:token`
- **Доступ:** публичный HTML, без аутентификации и без rate-limiter
- **Ответ 200:** `public/share.html` (`res.sendFile`); `:token` на сервере не используется — страница сама читает его из URL
- **Ошибки:** нет (только 404/500 самого `sendFile`)
### `GET /r/:token`
- **Доступ:** публичный HTML, без аутентификации и без rate-limiter
- **Ответ 200:** `public/report.html`
- **Ошибки:** нет (только 404/500 самого `sendFile`)
---
## 18. Группы
### `GET /api/groups`
- **Доступ:** `apiLimiter` (300 / 15 мин) + `optionalAuth` — работает и без токена; **филиалы:** при наличии пользователя выборка ограничивается `branchWhere(req.user, 'g')`, без токена фильтра нет
- **Ответ 200:** массив групп (`ORDER BY g.id`), только `deleted_at IS NULL`. Поле `cover_path` = явная обложка либо первое фото группы по `sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`; добавляется `branch_name`
- **Ошибки:** нет явных
- **Примечания:** кэш `cacheWrap('groups:list:' + scopeKey(req.user), PUBLIC_TTL_MS = 60 c)`; `scopeKey` = `all` для админа, иначе отсортированные id филиалов или `none`; без токена — `anon`
### `GET /api/groups/active`
- **Доступ:** `apiLimiter`, аутентификации нет (`(_, res)`), **филиалы:** ограничения НЕТ — отдаёт все подходящие группы
- **Ответ 200:** массив групп, у которых заданы `day_of_week`, `time_start`, `time_end`, день недели совпадает с текущим в зоне `appTimezone()` и текущее время попадает в интервал
- **Ошибки:** нет явных
- **Примечания:** кэш `groups:active`, TTL 60 c; сравнение времени — `now() AT TIME ZONE $1`, поэтому учитывает настройку `timezone`
### `POST /api/groups`
- **Доступ:** `requireAuth`; **филиалы:** не-admin не может задать `branch_id` → 403 «Назначение филиала — только для администратора», иначе филиал принудительно `NULL`
- **Тело (JSON):** `name` (string, обязат.), `branch_id` (int, только для админа), `tutor_id` (int, опц. — должен существовать пользователь с `role = 'tutor'`)
- **Ответ 201:** строка `groups *`
- **Ошибки:** 400 «Name required», 400 «Пользователь не найден или не является тутором», 403 «Назначение филиала — только для администратора», 409 «Duplicate» (уникальное имя)
- **Примечания:** аудит `group.create` ({id, name, branch_id}), `invalidateGroups()` + `invalidateStats()`
### `PUT /api/groups/:id`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches(req.user, :id)` — 403 «Нет доступа к этой группе»
- **Тело (JSON):** `name` (опц., `COALESCE($1, name)`), `day_of_week` (опц.), `time_start`, `time_end`, `branch_id` (только админ, иначе 403), `tutor_id` (передача поля — явное намерение сменить тьютора, `null`/`''` снимает; проверяется `role = 'tutor'`)
- **Ответ 200:** строка `groups *`
- **Ошибки:** 403 «Нет доступа к этой группе» / «Назначение филиала — только для администратора», 400 «Пользователь не найден или не является тутором», 409 «Duplicate name»
- **Примечания:** аудит `group.update` ({id, ...req.body} — **тело аудируется целиком**), `invalidateGroups()` + `invalidateStats()`
### `DELETE /api/groups/:id`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403 «Нет доступа к этой группе»
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Группа не найдена или уже в корзине»
- **Примечания:** **мягкое удаление** — `UPDATE groups SET deleted_at = now() WHERE deleted_at IS NULL`. Аудит `group.soft-delete`, `invalidateGroups()` + `invalidateStats()`. Физического удаления здесь нет (см. `DELETE /api/groups/:id/permanent`)
### `PUT /api/groups/:id/restore`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Группа не найдена или не в корзине»
- **Примечания:** снимает `deleted_at` и `purge_at` (`WHERE deleted_at IS NOT NULL`). Аудит `group.restore`, `invalidateGroups()` + `invalidateStats()`
### `DELETE /api/groups/:id/permanent`
- **Доступ:** `requireAuth` (не `requireAdmin`); **филиалы:** `groupBelongsToBranches` → 403
- **Тело:** пустое
- **Ответ 200:** `{ ok: true, purge_at }` — фактическая метка времени из БД
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Группа не найдена, не в корзине или уже помечена на удаление»
- **Примечания:** **физического удаления не делает** — только ставит `purge_at = now() + trash_purge_days days`, где `trash_purge_days` — настройка (дефолт **30**, валидна 1..3650). Условие `deleted_at IS NOT NULL AND purge_at IS NULL`. Реальное удаление выполняет фоновая задача корзины через `hardDeleteGroup` (server.js:3349, вызов на 1277). Аудит `group.schedule-delete` ({id, days}), `invalidateGroups()` + `invalidateStats()`
### `PUT /api/groups/:id/unschedule`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Группа не найдена или не помечена на удаление»
- **Примечания:** снимает отметку `purge_at = NULL WHERE purge_at IS NOT NULL`, сама группа остаётся в корзине (`deleted_at` не трогается). Аудит `group.unschedule`, `invalidateGroups()` + `invalidateStats()`
---
## 19. Филиалы
### `GET /api/branches`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — только свои `user_branches`, при пустом списке `WHERE 1 = 0` (пустой массив)
- **Ответ 200:** массив `branches *` + `groups_count` (`count(g.id)::int`, `LEFT JOIN groups`), `ORDER BY b.id`
- **Ошибки:** нет явных
- **Примечания:** кэша нет; аудита нет (чтение)
### `POST /api/branches`
- **Доступ:** `requireAdmin`
- **Тело (JSON):** `name` (string, обязат.), `address`, `phone` (опц., пустая строка → `NULL`)
- **Ответ 201:** строка `branches *`
- **Ошибки:** 400 «Название обязательно», 409 «Филиал с таким названием уже существует»
- **Примечания:** аудит `branch.create` ({id, name}), `invalidateGroups()`
### `PUT /api/branches/:id`
- **Доступ:** `requireAdmin`
- **Тело (JSON):** `name` (обязат.), `address`, `phone` — все три поля **перезаписываются**, отсутствующие станут `NULL`
- **Ответ 200:** строка `branches *`
- **Ошибки:** 400 «Название обязательно», 404 «Не найдено», 409 «Филиал с таким названием уже существует»
- **Примечания:** аудит `branch.update` ({id, ...req.body}), `invalidateGroups()`
### `DELETE /api/branches/:id`
- **Доступ:** `requireAdmin`
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 400 «Нельзя удалить филиал: есть привязанные группы» (проверяется наличие групп с `branch_id = $1`); при несуществующем `:id` rowCount не проверяется и ответ всё равно 200
- **Примечания:** жёсткий `DELETE FROM branches`; аудит `branch.delete`, `invalidateGroups()`
---
## 20. Фотохроника группы
### `GET /api/groups/:id/photos`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches(req.user, :id)` → 403 «Нет доступа к этой группе»
- **Query:** `limit`, `offset` — обычный `parseInt(…, 10)`, применяются только если `> 0`; неверные значения молча игнорируются (не `optInt`)
- **Ответ 200:** `{ photos, total }`; порядок `sort_order ASC, taken_at DESC NULLS LAST, created_at DESC`
- **Ошибки:** 403 «Нет доступа к этой группе»
- **Примечания:** кэша и аудита нет; группа может быть в корзине — `deleted_at` не проверяется
### `POST /api/groups/:id/photos`
- **Доступ:** `requireAuth` + `upload.single('photo')`; **филиалы:** проверка `groupBelongsToBranches` выполняется **после** multer; при отказе загруженный файл удаляется (`removeUpload`)
- **Тело:** `multipart/form-data`, поле файла `photo` (только изображения: jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif), текстовые `caption`, `taken_at` (DATE)
- **Ответ 201:** строка `group_photos *`
- **Ошибки:** 400 «Файл обязателен», 400 «Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)», 400 «Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)», 400 `Файл слишком большой (макс. N МБ)` (`UPLOAD_FILE_LIMIT_MB`, дефолт 50), 400 «Недопустимый файл», 403 «Нет доступа к этой группе», 500 при сбое БД/конвертации
- **Примечания:** HEIC автоматически конвертируется (`convertPhoto`); `sort_order` = `MIN(sort_order) - 1` по группе, то есть новое фото становится первым. Аудит `group.photo.create`, `invalidateShare()` + `invalidateGroups()` + `invalidateStats()`. При ошибке файл подчищается `safeUnlink`
### `PUT /api/groups/:id/photos/reorder`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403
- **Тело (JSON):** `order` (массив целых id фото). Порядок не обязан быть полным: не переданные фото дописываются в конец с текущим порядком. Дубликаты запрещены
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 403 «Нет доступа к этой группе», 400 «Некорректный порядок фото», 400 «Порядок фото содержит дубликаты», 404 «Фото не найдено» (id не принадлежит группе)
- **Примечания:** транзакция (`BEGIN`/`COMMIT`/`ROLLBACK`), `sort_order` переписывается как `1..N`. Аудит `group.photo.reorder`, `invalidateShare()` + `invalidateGroups()` (`invalidateStats` не вызывается)
### `PUT /api/groups/:id/photos/:photoId`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403
- **Тело (JSON):** `caption` (string, пустая строка → `NULL`), `taken_at` (DATE, пустое → `NULL`). Оба поля перезаписываются всегда; **`sort_order` здесь не меняется** — только через `reorder`
- **Ответ 200:** строка `group_photos *`
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Не найдено»
- **Примечания:** аудит `group.photo.update`, только `invalidateShare()` (группы и статистика не сбрасываются)
### `DELETE /api/groups/:id/photos/:photoId`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Не найдено»
- **Примечания:** файл удаляется `safeUnlink(photo_path)`; если он был обложкой — `groups.cover_path` сбрасывается в `NULL`. Аудит `group.photo.delete`, `invalidateShare()` + `invalidateGroups()` + `invalidateStats()`
### `PUT /api/groups/:id/photos/:photoId/cover`
- **Доступ:** `requireAuth`; **филиалы:** `groupBelongsToBranches` → 403
- **Тело:** пустое — только `:photoId`
- **Ответ 200:** полная строка `groups *` (после обновления `cover_path`)
- **Ошибки:** 403 «Нет доступа к этой группе», 404 «Не найдено» (фото не найдено в этой группе)
- **Примечания:** **единственный способ задать обложку** — `PUT .../cover`; `POST` и `PUT .../:photoId` её не трогают. Аудит `group.photo.set_cover`, `invalidateShare()` + `invalidateGroups()` (`invalidateStats` не вызывается). Снять обложку отдельным эндпоинтом нельзя — только удалением фото
---
## 21. Модули
### `GET /api/modules`
- **Доступ:** `apiLimiter` (300 / 15 мин), **без аутентификации и без ограничения по филиалам**
- **Query:** `search` (`ILIKE %…%` по `m.name`), `active` (`'1'` или `'true'` → только `is_active = true`), `limit`, `offset` (обычный `parseInt`, применяется при `> 0`)
- **Ответ 200:** `{ modules, total }`; каждый модуль с `entries_count` (`count(e.id)::int`, `LEFT JOIN entries`), порядок `m.is_active DESC, m.id`
- **Ошибки:** нет явных
- **Примечания:** кэша и аудита нет; `total` считается по тем же фильтрам
### `POST /api/modules`
- **Доступ:** `requireAdmin`
- **Тело (JSON):** `name` (string, обязат.), `lessons_count` (`parseLessonsCount`: целое **0..10000**, дефолт **0**, пустая строка → 0)
- **Ответ 201:** строка `modules *` (`is_active = true`)
- **Ошибки:** 400 «Название обязательно», 400 «Количество занятий — целое число от 0 до 10000», 409 «Модуль с таким названием уже существует»
- **Примечания:** аудит `module.create` ({id, name, lessons_count}); кэш не инвалидируется (?)
### `PUT /api/modules/:id`
- **Доступ:** `requireAdmin`
- **Тело (JSON):** `name` (обязат.), `lessons_count` (те же правила 0..10000) — оба поля перезаписываются
- **Ответ 200:** строка `modules *`
- **Ошибки:** 400 «Название обязательно», 400 «Количество занятий — целое число от 0 до 10000», 404 «Не найдено», 409 «Модуль с таким названием уже существует»
- **Примечания:** аудит `module.update` ({id, name, lessons_count}); кэш не инвалидируется (?)
### `DELETE /api/modules/:id`
- **Доступ:** `requireAdmin`
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 «Не найдено»
- **Примечания:** **строка не удаляется** — `UPDATE modules SET is_active = false` (мягкое скрытие). Восстановление — `PUT /api/modules/:id/restore`. Аудит `module.delete`
### `PUT /api/modules/:id/restore`
- **Доступ:** `requireAdmin`
- **Ответ 200:** строка `modules *`
- **Ошибки:** 404 «Не найдено»
- **Примечания:** `UPDATE modules SET is_active = true` (идемпотентно — повторный вызов на активном модуле тоже 200). Аудит `module.restore`
### `POST /api/modules/:id/photo`
- **Доступ:** `requireAdmin` + `upload.single('photo')`
- **Тело:** `multipart/form-data`, поле `photo` (только изображения), заменяет прежнюю картинку модуля
- **Ответ 200:** строка `modules *` (с новым `photo_path`)
- **Ошибки:** 400 «Файл обязателен», 400 «Картинка: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)», 400 «Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)», 400 `Файл слишком большой (макс. N МБ)`, 400 «Недопустимый файл», 404 «Не найдено» (модуля нет — файл удаляется), 500 при сбое
- **Примечания:** HEIC конвертируется (`convertPhoto`); старый `photo_path` удаляется через `safeUnlink` после успешного UPDATE. Аудит `module.photo.create` ({id, photo_path})
### `DELETE /api/modules/:id/photo`
- **Доступ:** `requireAdmin`
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 «Не найдено» (модуля нет)
- **Примечания:** файл удаляется `safeUnlink`, `photo_path` → `NULL`. Аудит `module.photo.delete` ({id, photo_path})
---
## 22. Отчёты о занятиях
### `GET /api/lesson-reports`
- **Доступ:** `requireAuth`
- **Query:** `date_from` (`'YYYY-MM-DD'`), `date_to` (`'YYYY-MM-DD'`), `group_id` (int), `search` (строка, `ILIKE %search%` по `lr.text`), `limit`, `offset`. Все опциональны, валидации нет — значения уходят параметризованными в SQL. `limit`/`offset` — `parseInt`, добавляются только если `> 0`; невалидное значение молча игнорируется.
- **Фильтры:** `lr.group_id = $n`, `lr.lesson_date >= $n::date`, `lr.lesson_date <= $n::date`, `lr.text ILIKE $n`. Филиалы: не-admin → `g.branch_id IN (...)`; при пустом `user_branches` → `1 = 0` (пустой список). `FROM lesson_reports lr JOIN groups g ON g.id = lr.group_id LEFT JOIN users u ON u.id = lr.author_id`.
- **Ответ 200:** `{ items, total }` — `total` из `count(*)::int`; строки: `id, group_id, lesson_date, lesson_time, topic, text, author_id, text_original, text_ai, ai_status, ai_checked_at, ai_error, created_at, updated_at, group_name, author_name, author_username`. Порядок: `lesson_date DESC, lesson_time DESC NULLS LAST, id DESC`.
- **Ошибки:** нет, кроме 401 от `requireAuth`.
- **Примечания:** результат оборачивается в `cacheWrap` с ключом `lessons:list:<scopeKey(user)>:<date_from>:<date_to>:<group_id>:<search>:<limit>:<offset>`, TTL 30 с.
### `GET /api/lesson-reports/:id`
- **Доступ:** `requireAuth`
- **Тело:** нет. **Query:** нет.
- **Ответ 200:** одна строка `lr.*` (`id, group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at`) + `group_name`, `author_name`, `author_username`. Голый объект, не конверт.
- **Ошибки:** 404 «Не найдено» (некорректный id или нет записи); 403 «Нет доступа к этому отчёту» (не-admin, филиал группы вне `user_branches`).
- **Примечания:** не кэшируется.
### `POST /api/lesson-reports`
- **Доступ:** `requireAuth`
- **Тело (JSON):** `group_id` (обязат., через `lessonReportGroup`), `lesson_date` (обязат., `'YYYY-MM-DD'`), `lesson_time` (`'HH:MM'(:SS)`, опц.), `topic` (строка, trim, ≤300; пустая → `NULL`), `text` (обязат., trim, непустой, ≤5000), `ai_check` (строго `=== true`; строка `"true"` не срабатывает).
- **Query:** нет
- **Ответ 201:** строка `INSERT ... RETURNING *` — `id, group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at` (без `group_name`/`author_name`).
- **Ошибки:** 400 «Группа не выбрана» / «Некорректная дата занятия» / «Некорректное время занятия» / «Тема занятия длиннее 300 символов» / «Введите текст отчёта» / «Текст отчёта длиннее 5000 символов»; 404 «Группа не найдена»; 403 «Нет доступа к этой группе»; **409** «За эту группу и дату отчёт уже есть — откройте его для редактирования» + `{ id }` (уникальный индекс `idx_lesson_reports_group_date (group_id, lesson_date)`).
- **Примечания:** при `ai_check === true` и настройке `lesson_ai_enabled !== 'false'` пишет `text_original = text`, `ai_status = 'pending'`, `ai_checked_at = now()`; иначе `text_original = NULL`, `ai_status = 'none'`, `ai_checked_at = NULL`. Роут **не ждёт модель** — только `wakeLessonAiWorker()`. Первая версия сохраняется с `source = 'manual'`. Аудит `lesson_report.create` (`id`, `group_id`, `lesson_date`, `ai_status`). Далее `invalidateLessonReports()` (сбрасывает `lessons:`, `stats:`, `dashboard:`) и `pushNotification({ type: 'lesson.report', title: 'Отчёт о занятии: <group>', body: <text до 300 символов>, link: 'lessons.html', target: { lesson_report_id, group_id, lesson_date }, branchId: group.branch_id })`.
### `PUT /api/lesson-reports/:id`
- **Доступ:** `requireAuth`
- **Тело (JSON):** все поля применяются только при наличии ключа в теле:
- `lesson_date` — если `undefined`/`null`/`''`, остаётся прежняя; иначе `parseLessonReportDate`.
- `lesson_time` — если `undefined`, остаётся прежнее; если передано, валидируется (`null`/`''` → занулить).
- `text` — если `undefined`, текст не меняется (`COALESCE($4, text)`); если передан — trim, должен быть непустым, ≤5000.
- `topic` — если `undefined`, тема не меняется; иначе trim, ≤300, пустая → `NULL`.
- `ai_check` — строго `true`; учитывается только вместе с непустым `text` и при `lesson_ai_enabled !== 'false'`.
- **Query:** нет
- **Ответ 200:** строка `UPDATE ... RETURNING *` (состав полей как у 201).
- **Ошибки:** 404 «Не найдено»; 403 «Нет доступа к этому отчёту»; 400 «Некорректная дата занятия» / «Некорректное время занятия» / «Введите текст отчёта» / «Текст отчёта длиннее 5000 символов» / «Тема занятия длиннее 300 символов»; **409** «За эту группу и дату уже есть другой отчёт» (проверка только если дата реально изменилась, с `id <> $3`).
- **Примечания:** при `ai_check === true` обнуляет `text_ai`, ставит `ai_status = 'pending'`, `ai_checked_at = now()`, `ai_error = NULL` и перезаписывает `text_original` текущим текстом. Версия с `source = 'manual'` сохраняется только если `text` передан. Аудит `lesson_report.update` (`id`, `lesson_date`, `ai_status`). Далее `invalidateLessonReports()` + `wakeLessonAiWorker()`. Уведомление не создаётся.
### `GET /api/lesson-reports/:id/versions`
- **Доступ:** `requireAuth`
- **Тело/Query:** нет
- **Ответ 200:** `{ items, current }` — `items`: `{ id, lesson_report_id, text, source, created_at, author_name, author_username }` (`source` = `manual` | `ai` | `restore`), `ORDER BY v.id DESC LIMIT 50` (`LESSON_AI_VERSION_LIMIT`); `current` = текущий `report.text`.
- **Ошибки:** 404 «Не найдено»; 403 «Нет доступа к этому отчёту».
- **Примечания:** автор версии — `LEFT JOIN users`, при удалённом пользователе `author_name`/`author_username` = `null`. Список не кэшируется, аудита нет.
### `POST /api/lesson-reports/:id/versions/:versionId/restore`
- **Доступ:** `requireAuth`
- **Тело (JSON):** нет (пустое тело игнорируется). **Query:** нет
- **Ответ 200:** строка `UPDATE ... RETURNING *` после `SET text = <version.text>, text_ai = NULL, ai_status = 'reverted', ai_error = NULL, ai_checked_at = now(), updated_at = now()`.
- **Ошибки:** 404 «Не найдено»; 403 «Нет доступа к этому отчёту»; **400** «Некорректный идентификатор версии» (`Number.isInteger(vid) && vid >= 1`); **404** «Версия не найдена» (версия не принадлежит этому отчёту).
- **Примечания:** восстановленный текст сам сохраняется в историю с `source = 'restore'`. Аудит `lesson_report.version.restore` (payload: `id`, `source: 'ai_revert'`, `changed`, `fields: ['text']`, `changes: [{ field: 'description', label: 'Текст отчёта', stats, diff: segments, truncated }]` из `textDiff`). Далее `invalidateLessonReports()`.
### `POST /api/lesson-reports/:id/ai/revert`
- **Доступ:** `requireAuth`
- **Тело (JSON):** нет. **Query:** нет
- **Ответ 200:** строка `RETURNING *` после отката к тексту тьютора: `text = text_original`, `text_ai = NULL`, `ai_status = 'reverted'`, `ai_error = NULL`, `ai_checked_at = now()`, `updated_at = now()`.
- **Ошибки:** 404 «Не найдено»; 403 «Нет доступа к этому отчёту»; **400** «Оригинал текста недоступен» (`text_original IS NULL`, т.е. отчёт создавался без `ai_check`).
- **Примечания:** версия сохраняется с `source = 'restore'`. Аудит `lesson_report.ai.revert` (тот же diff-payload, что и при restore). Далее `invalidateLessonReports()`.
### `DELETE /api/lesson-reports/:id`
- **Доступ:** `requireAdmin` (только `role = admin`; филиалы не проверяются)
- **Тело/Query:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 «Не найдено»; 403 (роль не admin).
- **Примечания:** `DELETE FROM lesson_reports` каскадно удаляет `lesson_report_versions` (`ON DELETE CASCADE`). Аудит `lesson_report.delete` (`id`, `group_id`, `lesson_date`). Далее `invalidateLessonReports()`.
---
---
## 23. Студенты
### `GET /api/students`
- **Доступ:** `apiLimiter`, `optionalAuth` (публичный: 200 и без токена)
- **Query:** нет
- **Ответ 200:** голый массив строк `SELECT s.*, g.name AS group_name FROM students s LEFT JOIN groups g ON g.id = s.group_id ORDER BY s.name` — поля `students`: `id, name, group_id, created_at, photo_path, profile` + `group_name` (`NULL`, если `group_id IS NULL`).
- **Ошибки:** 429 от `apiLimiter`.
- **Примечания:** кэш `students:list:<scopeKey(user)>`, TTL `PUBLIC_TTL_MS` = 60 с. Филиалы: при авторизации — `branchWhere(req.user, 'g')`; для анонимов фильтр не добавляется. В ответе наружу уходят вся `profile` (JSONB) и `photo_path`.
### `GET /api/students/names`
- **Доступ:** `requireAuth`
- **Query:** нет
- **Ответ 200:** голый массив строк — `SELECT DISTINCT e.student_name AS name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.student_name IS NOT NULL AND e.student_name <> '' <branchWhere> ORDER BY name`. Имена берутся из журнала записей, а не из таблицы `students`.
- **Ошибки:** нет.
- **Примечания:** без кэша и без аудита. Мягко удалённые записи не исключаются — в фильтре нет `e.deleted_at IS NULL`.
### `POST /api/students`
- **Доступ:** `requireAuth`
- **Тело (JSON):** `name` (строка, обязат., `trim()`; проверки длины нет), `group_id` (опц.; `Number(group_id)`, при falsy → `null`).
- **Query:** нет
- **Ответ 201:** строка `INSERT INTO students (name, group_id) VALUES ($1,$2) RETURNING *` — `id, name, group_id, created_at, photo_path, profile`.
- **Ошибки:** 400 «Name required»; 403 «Нет доступа к этой группе» (не-admin, `group_id` вне `user_branches`); **409** «Duplicate» при нарушении `students.name UNIQUE`.
- **Примечания:** аудит `student.create` (`id`, `name`). Далее `invalidateStudents()` (префикс `students:`) + `invalidateStats()` (`stats:`, `dashboard:`, `system-info`).
### `PUT /api/students/:id`
- **Доступ:** `requireAuth`
- **Тело (JSON):** `name` (строка, обязат., `trim()`), `group_id` — `undefined`/`null`/`''` → `null` (снятие с группы), иначе `Number(group_id)`.
- **Query:** нет
- **Ответ 200:** строка `UPDATE students SET name = $1, group_id = $2 WHERE id = $3 RETURNING *`.
- **Ошибки:** 400 «Name required»; 404 «Not found» (для не-admin — если ученика нет; для admin проверки нет, но `UPDATE` вернёт 0 строк → 404 «Not found»); 403 «Нет доступа к этому ученику» (текущая группа вне филиала) / «Нет доступа к этой группе» (новая группа вне филиала); **409** «Duplicate».
- **Примечания:** аудит `student.update` (`id`, `name`) — **без `group_id`**, смена группы в аудит не попадает. Далее `invalidateStudents()`.
### `POST /api/students/batch-group`
- **Доступ:** `requireAuth`
- **Тело (JSON):** `group_id` (обяз., truthy; `reqInt` не используется), `student_ids` (массив, обязат., непустой).
- **Query:** нет
- **Ответ 200:** `{ ok: true, updated }` — `updated` = число реально обновлённых строк (из `RETURNING id`).
- **Ошибки:** 400 «group_id and student_ids required» (нет `group_id`, `student_ids` не массив или пуст); 400 «No valid students» (после `map(Number).filter(Boolean)` не осталось валидных id); 403 «Нет доступа к этой группе».
- **Примечания:** id дедуплицируются через `new Set`. `UPDATE students SET group_id = $1 WHERE id IN (...)` **не ограничен филиалами учеников** — проверяется только доступ к целевой группе. Аудит `student.batch-group` (`group_id`, `count`). Далее `invalidateStudents()`.
### `DELETE /api/students/:id`
- **Доступ:** `requireAuth`
- **Тело/Query:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 «Not found» (не-admin, если ученика нет); 403 «Нет доступа к этому ученику» (не-admin, текущая группа вне филиала). Для admin запрос выполняется безусловно — 404 не возвращается даже при 0 удалённых строк.
- **Примечания:** перед удалением читаются `photo_path` из `student_photos`, для каждого вызывается `safeUnlink`; строки `student_photos` удаляются каскадом (`ON DELETE CASCADE`). Фото из `entries.photo_path` / `entry_photos` ученика не трогаются — они привязаны к записям. Аудит `student.delete` (`id`). Далее `invalidateStudents()` + `invalidateStats()`.
### `GET /api/students/:id/profile`
- **Доступ:** `requireAuth`
- **Тело/Query:** нет
- **Ответ 200:** `{ id, name, group_id, photo_path, profile }` — `profile` приводится к `null`, если в БД `NULL`. Голый объект, не конверт.
- **Ошибки:** 400 «Неверный id ученика» (`reqInt`); 404 «Ученик не найден»; 403 «Нет доступа к этому ученику».
- **Примечания:** `studentProfileAccess` (`server.js:4280`) для не-admin проверяет филиал только при непустом `group_id` — ученик без группы доступен всем активным пользователям.
### `PUT /api/students/:id/profile`
- **Доступ:** `requireAuth`
- **Тело (JSON):** `profile` (объект; `sanitizeStudentProfile`, белый список полей), `photo_path` (строка `/uploads/...`; `optUploadPath(photo_path, 255)`; обрабатывается только если ключ присутствует; `null` → сброс в `NULL`).
- **Валидация `profile` (белый список, лишние ключи отбрасываются):** `role` ≤200, `status` ≤60, `status_note` ≤120, `city` ≤120, `mentor` ≤150, `joined` ≤120, `bio` ≤2000, `quote` ≤300; списки: `tags` ≤20 по 40, `achievements` ≤40 по 200, `contacts` ≤20 (`{icon ≤32 по /^[a-z0-9-]+$/ иначе 'link', label ≤120 обязат., href ≤500 по whitelist-регулярке}`), `skills` ≤80 (`{group ≤80 или 'Навыки', name ≤120 обязат., level ≤40, value = optInt 0..100}`), `experience` ≤30 (`{title ≤160 обязат., company ≤160, period ≤80, date ≤40, badge ≤40, description ≤800, tags ≤10 по 40}`), `education` ≤60 (`{module ≤200 обязат., progress = optInt 0..100, grade ≤80, teacher ≤150}`), `stats` ≤12 (`{icon, value ≤20 обязат., suffix ≤20, label ≤80 обязат., hint ≤120, delta ≤60}`). Если все значения пустые → возвращается `null`.
- **Ответ 200:** `{ id, name, group_id, photo_path, profile }` (`RETURNING`).
- **Ошибки:** 400 «Неверный id ученика»; **400 «Неверные данные профиля: <сообщение>»** (например «Ожидался объект профиля», «Слишком длинное значение», «Ожидался список»); 404 «Ученик не найден»; 403 «Нет доступа к этому ученику».
- **Примечания:** `profile = $1` входит в SET **всегда** — если ключа `profile` в теле нет, профиль будет записан как `NULL` (частичное обновление профиля не поддержано). Аудит `student.profile.update` (`id`, `name`, `blocks` = список непустых ключей профиля). Далее `invalidateStudents()`.
---
---
## 24. Фото студентов
Все эндпоинты используют `studentProfileAccess`, поэтому общие коды: 400 «Неверный id ученика» / 404 «Ученик не найден» / 403 «Нет доступа к этому ученику». Таблица `student_photos`: `id, student_id (ON DELETE CASCADE), photo_path, created_at`.
### `GET /api/students/:id/photos`
- **Доступ:** `requireAuth`
- **Тело/Query:** нет
- **Ответ 200:** `{ photos, photo_path }` — `photos`: `SELECT id, photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC`; `photo_path` — текущее «главное» фото из `students.photo_path` (может быть `null`).
- **Ошибки:** общие для хелпера доступа; 404 «Фото не найден» не применяется.
- **Примечания:** без кэша и без аудита.
### `POST /api/students/:id/photos`
- **Доступ:** `requireAuth`
- **Тело:** `multipart/form-data`, поле **`photo`** — `upload.single('photo')` (только изображения, лимит `UPLOAD_FILE_LIMIT_MB`).
- **Query:** нет
- **Ответ 201:** `{ id, photo_path, created_at }` — `INSERT INTO student_photos (student_id, photo_path) ... RETURNING`.
- **Ошибки:** 400 «Файл обязателен»; 400 «Файл слишком большой (макс. N МБ)» (`LIMIT_FILE_SIZE`); 400 «Фото: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif, jfif)»; 400 «Недопустимый тип файла (*.html, *.js, *.svg и т.п. запрещены)»; 400 «Недопустимый файл»; общие коды доступа; 500 `{ error: <err.message> }`.
- **Примечания:** `convertPhoto` (HEIC → JPEG). Если `students.photo_path` пуст, первое фото автоматически становится главным. При ошибке БД/конвертации файл удаляется через `safeUnlink('uploads/<file>')` и отдаётся 500. Аудит `student.photo.create` (`id`, `photo_path`). Далее `invalidateStudents()` + `invalidateShare()` (сброс `share:payload:`). Глобальный хук `res.on('finish')` персистит файл в S3 при `STORAGE_DRIVER=s3`.
### `DELETE /api/students/:id/photos/:pid`
- **Доступ:** `requireAuth`. **Тело/Query:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 400 «Неверный id» (любой из двух параметров не проходит `reqInt`); общие коды доступа; 404 «Фото не найден» (фото не принадлежит ученику); 500 `{ error }`.
- **Примечания:** если удаляемое фото было главным, `students.photo_path` переставляется на следующее оставшееся (`ORDER BY id DESC LIMIT 1`), иначе в `NULL`. Файл удаляется через `safeUnlink` после `DELETE`. Аудит `student.photo.delete` (`id`, `photo_path`). Далее `invalidateStudents()` + `invalidateShare()`.
### `PUT /api/students/:id/photos/:pid/main`
- **Доступ:** `requireAuth`
- **Тело (JSON):** нет (тело игнорируется). **Query:** нет
- **Ответ 200:** `{ ok: true, photo_path }` — `photo_path` взят из `student_photos` и продублирован в `students.photo_path`.
- **Ошибки:** 400 «Неверный id»; общие коды доступа; 404 «Фото не найден».
- **Примечания:** файлы не перемещаются — меняется только ссылка. Аудит `student.photo.main` (`id`, `photo_path`). Далее `invalidateStudents()` + `invalidateShare()`.
---
---
## 25. Экспорт
### `GET /api/export/student`
- **Доступ:** `requireAuth`
- **Query:**
- `name` — обязат., `reqStr(query.name, 150)` (строка, trim, непустая, ≤150)
- `include_entries`, `include_photos`, `include_files`, `include_captions`, `include_reports`, `show_dates` — проверка `!== '0'`, т.е. включено по умолчанию при любом другом значении
- `date_from`, `date_to` — `optDate`, строго `'YYYY-MM-DD'`
- **Ответ 200:** бинарный ZIP. `Content-Type: application/zip`; `Content-Disposition: attachment; filename="student_<safeName>_<YYYY-MM-DD>.zip"; filename*=UTF-8''student_<name>_<YYYY-MM-DD>.zip`, где `safeName = name.replace(/[^a-zA-Z0-9._-]+/g,'_').slice(0,80) || 'student'`, дата — `new Date().toISOString().slice(0,10)`.
- **Состав архива:** `index.html` (`renderStudentReport` из `student-report.js`), `data.json`, каталоги `photos/` (фото `entry_photos` + главные `entries.photo_path` + `student_photos` + аватар) и `files/` (`project_files` с санитизацией имени и разрешением коллизий `имя(2).ext`), `logo.<ext>` (из настройки `system_logo`, если `isSafeUploadPath`).
- **`data.json`:** `exported_at` (ISO с Z), `student {id, name, created_at, group_name, branch_name}`, `profile`, `period`, `totals {entries, modules, photos, files, groupPhotos, studentPhotos, lessonReports}`, `options`, `entries[] {id, group, module, created_at, ai_status, description}`, `photos[] {file, caption, created_at, entry_id}`, `student_photos[] {file, main, created_at}`, `files[] {file, name, size, created_at, entry_id}`, `lesson_reports[] {id, group_id, group_name, lesson_date, lesson_time, topic, text}`.
- **Ошибки:** 400 «Укажите имя резидента» (пустое/не строка/>150); 400 «Выберите, что включать в отчёт» (`include_entries=0` + `include_photos=0` + `include_files=0`); 400 «Неверный период»; 400 «Дата «С» позже даты «По»»; **404** «Нет данных за выбранный период» / «У резидента нет данных для отчёта» (нет ни записей, ни фото, ни файлов); 500 `{ error: err.message }`.
- **Фильтры:** `e.student_name = $1 AND e.deleted_at IS NULL`; границы периода по `e.created_at` через `tzDayStart`/`tzDayEnd` + `bindTz(..., appTimezone())` (TIMESTAMPTZ, зона из настройки `timezone`); филиалы — `branchWhere(req.user, 'g')` (не-admin). Отчёты о занятиях фильтруются по `lr.lesson_date >= date_from` / `<= date_to` (сравнение чистых DATE, зона не применяется) и по филиалам; лимит `LESSON_REPORTS_LIMIT = 60`, при превышении в `data.json` ставится `lessonReportsTruncated: true`.
- **Примечания:** 7 параллельных запросов (`Promise.all`) + отдельные выборки `student_photos` и `lesson_reports`. Файлы читаются через `storage.getBuffer`, пути проверяются `isSafeUploadPath`, дубликаты отсекаются по имени файла. Аудит `export.student` (`student`, `entries`, `photos`, `files`, `lesson_reports`, `opts`, `date_from`, `date_to`). Инвалидация кэша не вызывается (чтение). Период в `period` рендерится `fmtLongDate` по зоне приложения.
### `GET /api/groups/:id/export/files`
- **Доступ:** `requireAuth`
- **Параметр пути:** `id` — `parseInt(...,10)`, требуется целое `>= 1`.
- **Query:** `include_files` (`!== '0'`, default true), `include_photos` (`!== '0'`, default true), `photos_mode` (`'all'` → все `entry_photos`, иначе `latest`), `include_main` (`!== '0'`, учитывается только при `include_photos`), `include_originals` (`=== '1'`, только при `include_photos`), `date_from`, `date_to` — `optDate` `'YYYY-MM-DD'`.
- **Ответ 200:** бинарный ZIP, `Content-Type: application/zip`; `Content-Disposition: attachment; filename="group_<safeGroup>_<YYYY-MM-DD>.zip"; filename*=UTF-8''group_<name>_<YYYY-MM-DD>.zip`, `safeGroup = groupName.replace(/[^a-zA-Z0-9._-]+/g,'_').slice(0,60) || 'group'`.
- **Структура архива:** `data.json` в корне + отдельная папка на каждого студента (имя берётся из `entries.student_name`, санитизация `[\\/:*?"<>|]` → `_`, дедуп `Имя (2)`, срез 80 символов, fallback `Без имени`):
- работы `project_files` → `<Студент>/<файл>` (коллизии → `имя(2).ext`)
- главное фото записи → `<Студент>/Фото записи/photo_<YYYY-MM-DD>.jpg`
- `entry_photos` (при `photos_mode=all`) → `<Студент>/Фото записи/<исходное имя файла>`
- оригиналы до ИИ-обработки (при `include_originals=1`) → `<Студент>/Фото записи/Оригиналы/original_<YYYY-MM-DD>.jpg`
- **`data.json`:** `exported_at` (ISO с Z), `group {id, name}`, `period {date_from, date_to}`, `options {files, photos, main_photo, photos_mode, originals}`, `totals {students, files, works, photos, originals}`, `students[]` (имена из `students WHERE group_id = $1`), `files[] {folder, file, original, size, created_at}`.
- **Ошибки:** 400 «Некорректная группа»; 403 «Нет доступа к этой группе» (`groupBelongsToBranches`; для admin всегда `true`); 400 «Выберите хотя бы одну категорию: работы или фото записи»; 400 «Неверный период»; 400 «Дата «С» позже даты «По»»; 404 «Группа не найдена» (`groups.deleted_at IS NULL`); 500 `{ error: err.message }`.
- **Фильтры:** `e.group_id = $1 AND e.deleted_at IS NULL` + период по `e.created_at` через `tzDayStart`/`tzDayEnd`/`bindTz`; `project_files` дополнительно `pf.detached_at IS NULL`. Содержимое читается через `photoRefKey` + `storage.getBuffer`, дубликаты отсекаются по пути.
- **Примечания:** аудит `export.group_files` (`group_id`, `group_name`, `students`, `files`, `works`, `photos`, `originals`, `photos_mode`, `date_from`, `date_to`). Инвалидация кэша не вызывается. Эндпоинт не обёрнут в `fileLimiter` — ограничение только `requireAuth`, при этом архив собирается целиком в памяти.
---
---
## 26. Записи журнала (чтение)
### `GET /api/entries`
- **Доступ:** `requireAuth` (сессия в `X-Auth-Token`); **филиалы:** не-admin ограничены по `user_branches` — фильтр `g.branch_id IN (…)`, при пустом списке `1 = 0` (пустой ответ)
- **Query:** `group_id` (int), `module_id` (int), `date_from` (`'YYYY-MM-DD'`), `date_to` (`'YYYY-MM-DD'`), `student_name` (точное равенство), `search` (`ILIKE %…%` по `student_name` и `description`), `deleted` (`'1'` — только корзина, иначе/по умолчанию — живые записи), `limit`, `offset`
- **Тело:** нет
- **Ответ 200:** `{ entries, total }` — `entries`: поля `entries e.*` + `group_name`, `module_name`, вложенные `files[]` (`id, entry_id, token, name`) и `photos[]` (`id, entry_id, photo_path, caption, sort_order`); `total` — `count(*)` по тем же условиям
- **Ошибки:** 401 `Unauthorized`; 403 `Нет доступа к этой группе` (не-admin, явный чужой `group_id`)
- **Примечания:** даты фильтруются по `e.created_at` через `tzDayStart`/`tzDayEnd` + `bindTz` (`$TZ$`), зона из `appTimezone()`; `limit`/`offset` — `parseInt`, **без дефолта и максимума**: без `limit` отдаётся вся выборка. Валидации формата дат и типов нет — параметры параметризованы, но не провалидированы. Сортировка `e.created_at DESC`. Кэша нет, `logAudit` не нужен (read-only)
### `GET /api/entries/:id`
- **Доступ:** `requireAuth`; **филиалы:** для не-admin — `entryAccessible(user, id)`: `404 Not found`, если записи нет, `403 Нет доступа к этой записи`, если группа вне филиалов; для admin проверок нет
- **Query:** нет (`:id` — сырой, без валидации)
- **Тело:** нет
- **Ответ 200:** один объект записи (`entries e.*` + `group_name`, `module_name`) с добавленными `files[]` (`project_files`: `id, entry_id, token, name`) и `photos[]` (`entry_photos`: `id, entry_id, photo_path, caption, sort_order`)
- **Ошибки:** 401; 403 (не-admin); 404 `Not found` (нет записи либо для не-admin запись вне его филиалов)
- **Примечания:** фильтра `deleted_at` нет — мягко удалённая запись отдаётся так же, как живая. `JOIN groups` (не LEFT) — запись без группы не найдётся. Не кэшируется
### `GET /api/entries/:id/files`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — `groupBelongsToBranches(user, group_id)`; admin без проверок
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** голый массив `project_files`: `{ id, token, name }` (без `path`, сортировка `ORDER BY id`)
- **Ошибки:** 401; 404 `Запись не найдена` (только для не-admin — проверка идёт по `entries JOIN groups`); 403 `Нет доступа к этой записи` (не-admin)
- **Примечания:** у admin запрос к записям вообще не выполняется, поэтому несуществующий `:id` вернёт `200 []`, а не 404. Возвращает файлы прикреплённых записей
---
## 27. Файлы
### `POST /api/entries/:id/files`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — `groupBelongsToBranches(user, group_id)` по группе записи; admin без проверок
- **Query:** нет
- **Тело:** `multipart/form-data`, поле `files` — максимум **10** файлов, `adminUpload` (расширенный белый список)
- **Ответ 201:** `{ ok: true, count }` — только количество; id/токены не возвращаются
- **Ошибки:** 401; 400 `Файлы не выбраны`, `FILE_TOO_LARGE_ERROR` (лимит на файл), `Недопустимый тип файла` / `Недопустимый файл` (фильтр расширений), `TOTAL_TOO_LARGE_ERROR` (сумма > `MAX_TOTAL_UPLOAD_BYTES`); 404 `Запись не найдена`; 403 `Нет доступа к этой записи`; 500 `{ error: e.message }`
- **Примечания:** при любой ошибке загруженные файлы снимаются через `removeUpload`. Вставка `project_files (entry_id, token, path, name)` в транзакции, `token = crypto.randomBytes(16).toString('hex')`, `path = /uploads/<multer filename>`. После COMMIT — `invalidateShare()` + `invalidateEntries()`. `logAudit` **не вызывается** — см. «Неясности». При `STORAGE_DRIVER=s3` фактическое сохранение делает глобальный хук `res.on('finish')` через `storage.persist`
### `GET /api/files`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — `g.branch_id IN (…)`, при пустом списке `1 = 0`; admin без ограничений
- **Query:** `search` (`ILIKE` по `pf.name`), `student_name` (равенство), `group_id`, `date_from`, `date_to` (по `e.created_at`, `tzDayStart`/`tzDayEnd` + `bindTz`), `limit`, `offset`
- **Тело:** нет
- **Ответ 200:** `{ files, total }` — `files`: `{ id, token, name, student_name, group_name, created_at, size }`, где `size` из `storage.sizeOf(path)` (может быть `null`); `total` — `count(*)` по тем же условиям
- **Ошибки:** 401; 403 `Нет доступа к этой группе` (не-admin, чужой `group_id`)
- **Примечания:** условие `e.deleted_at IS NULL` жёсткое — параметра `deleted` нет, корзина не видна. `limit` без дефолта и максимума. Сортировка `pf.id DESC`. `size` считается **по одному запросу на файл** (N+1 к `storage`), кэша нет. `path` наружу не отдаётся
### `GET /api/files/detached`
- **Доступ:** `requireAdmin` (только админ, филиалы не применяются)
- **Query:** `search` (`ILIKE` по `name`), `limit`, `offset`
- **Тело:** нет
- **Ответ 200:** `{ files, total }` — `files`: `{ id, token, name, created_at, size }`; `total` — `count(*)`
- **Ошибки:** 401; 403 (не-admin)
- **Примечания:** жёсткое условие `entry_id IS NULL` (отсоединённые файлы), фильтров по датам и группе нет вообще. `size` — снова N+1 к `storage`. `limit` без дефолта/максимума, сортировка `created_at DESC`. Кэша нет
### `POST /api/files/:id/detach`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — файл должен быть прикреплён к записи своей филиальной группы
- **Query:** нет
- **Тело:** нет (мутация без тела)
- **Ответ 200:** `{ ok: true }` — сама строка из `RETURNING *` не отдаётся
- **Ошибки:** 401; 404 `Не найдено`; 403 `Нет доступа к этому файлу` (не-admin: файл уже detached или группа вне филиалов)
- **Примечания:** `UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *`; файлы у клиента остаются валидными по `token`. После мутации — `invalidateShare()` + `invalidateEntries()`. `logAudit` **не вызывается**
### `DELETE /api/files/:id`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — та же проверка, что и в `detach` (файл прикреплён + группа в филиалах)
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 401; 404 `Не найдено` (нет строки в `project_files`); 403 `Нет доступа к этому файлу` (не-admin)
- **Примечания:** `safeUnlink(path)` перед `DELETE FROM project_files` — файл удаляется и локально, и из бакета, `safeUnlink` идемпотентен. Не транзакция: при сбое удаления остаётся «висячий» файл (подберёт `sweepOrphanedUploads`). После мутации — `invalidateShare()` + `invalidateEntries()`. `logAudit` **не вызывается**
### `GET /api/files/:token`
- **Доступ:** **публичный** — только `fileLimiter` (rate limit), `requireAuth` нет: доступ даёт сам 32-hex токен из ссылки
- **Query:** `thumb` (для картинок — миниатюра через `sendImageThumb`), `play` (для браузерного видео — inline-воспроизведение)
- **Тело:** нет; заголовок `Range` разбирается **только** в ветке браузерного видео с `?play` (`sendPlayableFile` → `parseByteRange`). Для картинок, для видео без `?play` и для всех прочих файлов `Range` игнорируется — файл отдаётся целиком (200)
- **Ответ 200:** бинарный поток из `storage`; **картинки — inline** (`Content-Disposition` не задан), `Cache-Control: public, max-age=31536000, immutable`; видео с `?play` — `Accept-Ranges: bytes`, `Cache-Control: private, max-age=3600`, при `Range` — **206** с `Content-Range`; все прочие файлы — **attachment** (`download: true, name`)
- **Ошибки:** 404 `Not found` (нет строки по токену), 404 `File missing` (`storage.keyFromPath` пуст или поток не отдал), 416 (диапазон не удовлетворим, `Content-Range: bytes */<size>`)
- **Примечания:** `isPlayableVideoName` = `mp4|m4v|webm|ogv` → `?play=1` даёт inline 206; `mov|mkv|avi|mpeg|mpg|3gp|ts` и всё прочее — только скачивание независимо от `play`. Порядок веток: картинка → `thumb`/стрим; браузерное видео + `play` → `sendPlayableFile`; иначе attachment. Ключ берётся через `storage.keyFromPath(path)`, прямых `fs.*` нет
---
## 28. Фотографии
### `GET /api/photos`
- **Доступ:** `requireAuth`; **филиалы:** не-admin — `t.branch_id IN (…)` по объединённой выборке, при пустом списке `1 = 0`
- **Query:** `search` (`ILIKE` по `t.title`), `student_name` (равенство), `group_id`, `date_from`, `date_to` (по `t.created_at`, `tzDayStart`/`tzDayEnd` + `bindTz`), `limit`, `offset`
- **Тело:** нет
- **Ответ 200:** `{ photos, total }` — `photos`: `{ source_type, source_label, source_id, path, title, student_name, group_id, group_name, created_at }`
- **Ошибки:** 401; 403 `Нет доступа к этой группе` (не-admin, чужой `group_id`)
- **Примечания:** `UNION ALL` из 5 источников — `entry` («Главное фото записи»), `entry_photo` («Фото записи», кроме дубликата главного), `group_photo` («Фотохроника группы»), `student_photo` («Фото ученика»), `module_photo` («Тема модуля»). Для не-admin `module_photo` недоступен **всегда** — там `branch_id = NULL::int` против `IN (…)`; фото учеников без группы (`LEFT JOIN groups`) тоже отсекаются. Сортировка `t.created_at DESC`, `limit` без дефолта/максимума, кэша нет
---
## 29. Статистика
### `GET /api/stats`
- **Доступ:** `requireAuth`; **филиалы:** да — **все** счётчики для не-admin ограничены `user_branches` (пустой список → `1 = 0`)
- **Query:** нет (сводка фиксированная)
- **Тело:** нет
- **Ответ 200:** `{ entries, trash, trash_pending, groups, students, today }` — все целые: живые записи; корзина (записи `deleted_at IS NOT NULL AND purge_at IS NULL` + видимые удалённые группы); корзина к удалению (`purge_at IS NOT NULL`, записи + группы); активные группы; `count(DISTINCT student_name)`; записи за сегодня по `tzWall()`
- **Ошибки:** 401 (handler без `try/catch` — исключение уходит в обработчик Express)
- **Примечания:** кэш `cacheWrap('stats:' + scopeKey(req.user), STATS_TTL_MS = 15 c)`, ключ включает роль и отсортированный список филиалов (`all` / `none` / `1-2-3`). Сегодняшние записи считаются по `${tzWall()}::date` с ручной подстановкой `.replace(/\$TZ\$/g, '$' + p.length)` и `tz` последним параметром — не `bindTz`, но корректно. Кэш сбрасывается только по TTL
---
## 30. Системная информация
### `GET /api/system-info`
- **Доступ:** `requireAdmin`; филиалы не применяются (глобальная сводка по всей БД и хранилищу)
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** два уровня — кэшированный `payload` плюс некэшированный `stack`
- **Верхний уровень:** `database` (`size`, `size_bytes`, `tables[] = { name, size, size_bytes }` по `pg_stat_user_tables`, отсортировано по размеру); `photos` (`group_photos {count,size,size_bytes}`, `entry_photos {count,size,size_bytes}`, `total_count`, `total_size`, `total_size_bytes`); `files` (`project_files { count }`); `uploads` (`count`, `size`, `size_bytes` из `storage.usage()`); `storage` (`driver` `s3`/`local`, `bucket`, `prefix`, `count`, `size`, `size_bytes`); `disk` (`total`/`used`/`free` + `*_bytes`, `used_pct`; `fs.statfsSync`, фолбэк `df -B1 .`, иначе `null`); `cache` (см. `stack.cache`)
- **`stack.app`:** `name`, `version` (из `package.json`), `commit`, `commit_date` (из `public/version.json`), `node`, `pid`, `uptime_s`, `rss`, `heap_used`
- **`stack.deps`:** версии `STACK_DEPS` = `express`, `pg`, `redis`, `sharp`, `multer`, `tar`, `helmet`, `bcrypt`, `@aws-sdk/client-s3` (кэшируются в процессе; отсутствующий пакет → `null`)
- **`stack.runtime`:** `container` (`Docker`/`Kubernetes`/`null`), `os` (`PRETTY_NAME` из `/etc/os-release`), `os_version`, `kernel`, `arch`, `cpus`, `cpu_model`, `cpu_load`, `mem_total`, `mem_free`, `mem_total_bytes`, `mem_free_bytes`, `uptime_s`
- **`stack.database`:** `engine: 'PostgreSQL'`, `version` (`SHOW server_version`), `host` (только `hostname[:port]` из `DATABASE_URL`), `pool_total`, `pool_idle`, `pool_waiting`
- **`stack.cache`:** `engine: 'Redis'`, `driver` (`redis`/`memory`), `version`, `ready`, `enabled`, `host` (hostname[:port] из `REDIS_URL`), `keys`, `used_memory`, `uptime_s`, `hits`, `misses`, `fallback_ops`, `errors`; при недоступном Redis `driver='memory'`, `version`/`keys`/`used_memory`/`uptime_s` = `null`, счётчики — из памяти
- **`stack.storage`:** `engine` (`S3`/`Файловая система`), `driver` (`s3`/`local`), `endpoint` (hostname[:port] из `S3_ENDPOINT`), `bucket`, `region` (только s3), `dir` (`UPLOADS_DIR` для local), `local_fallback`, `keep_local`
- **`stack.photo_ai`:** `engine: 'Real-ESRGAN + GFPGAN'`, `host` (hostname[:port] из `PHOTO_AI_URL`), `configured`, `reachable`, `latency_ms`, `error`, `ready`, `device`, `device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models[]`, `face_models[]`, `loaded[]`
- **Ошибки:** 401; 403 (не-admin); 500 `{ error: e.message }` — единственный эндпоинт диапазона с `try/catch`
- **Примечания:** `cacheWrap('system-info', SYSTEM_TTL_MS = 30 c)` покрывает только `payload`; `stack` собирается каждый запрос через `getStackInfo(payload.cache)`. `photoAiHealth(2000)` вызывается вне кэша — недоступный photo-AI добавляет до 2 с задержки. Секреты в payload не попадают: хосты берутся только через `hostOf(URL)`, логины/пароли из `DATABASE_URL`, `REDIS_URL`, `S3_ENDPOINT` не выводятся. Размеры фото считаются поштучным `storage.sizeOf` по всем `group_photos` и `entries.photo_path`; `entry_photos`, `student_photos`, `modules.photo_path` не учитываются
---
## 31. Дашборд
### `GET /api/dashboard`
- **Доступ:** `requireAuth`; **филиалы:** да — **все** выборки для не-admin ограничены `user_branches` (`AND g.branch_id IN (…)`, при пустом списке `AND 1 = 0`)
- **Query:** нет (лимиты жёстко заданы в коде)
- **Тело:** нет
- **Ответ 200:** `stats {entries, trash, groups, students, today}`; `activity[]` — `{d: 'YYYY-MM-DD', n}` за 14 дней (`to_char(created_at AT TIME ZONE $TZ$,…)`); `active_groups[]` — `{id, name, day_label, time_start, time_end}` (группы, чей `day_of_week`/`time_start`/`time_end` совпадают с текущим `tzWall()`); `recent_entries[]` — 7 последних `{id, student_name, photo_path, created_at, group_name}`; `top_students[]` — 5 `{student_name, n}`; `photos[]` — 8 `{id, photo_path, caption, taken_at, group_name}`; `latest_photo_taken`; `recent_lessons[]` — 5 `{id, group_id, lesson_date, lesson_time, text, group_name}`; `disk` (тот же `getDiskInfo()`)
- **Ошибки:** 401 (handler без `try/catch`)
- **Примечания:** кэш `cacheWrap('dashboard:' + scopeKey(req.user), STATS_TTL_MS = 15 c)`; ключ учитывает роль и филиалы. Восемь запросов параллельно через `Promise.all`. Зона подставляется вручную: `.replace(/\$TZ\$/g, '$' + params.length + 1)` с `tz` последним параметром (не `bindTz`) — для выборок без фильтра по филиалам плейсхолдер корректно получает `$1`. Лимиты `LIMIT 7/5/8/1/5` зашиты, пагинации нет. Мягко удалённые записи и группы отфильтрованы
---
## 32. Записи журнала (создание и правка)
### `POST /api/entries`
- **Доступ:** публичный маршрут — только `entryLimiter` (10 запросов / 15 мин, `cache.rateLimitStore('entry')`), `requireAuth` **не вызывается**; `apiLimiter` не применяется
- **Филиалы:** нет; филиал берётся из `groups.branch_id` указанной группы (связь филиал↔группа)
- **Тело:** `multipart/form-data` через `upload.fields([{name:'photo',maxCount:10},{name:'files',maxCount:10}])` — **два разных поля**; текстовые `student_name`, `group_id`, `description`, `module_id` (опц.), `website` — honeypot
- **Ответ 201:** объект записи `entries` + `files: <кол-во>` и `photos: <кол-во>`
- **Ошибки:** 429 лимита/антиспама `Уже ответили: подождите N минут`; 400 `Spam detected` (honeypot + `recordFailure('honeypot',1,BAN_TTL_MS)`), `All fields required`, `Фото обязательно`, `Группа не найдена`, `Модуль не найден`, `FILE_TOO_LARGE_ERROR`, `TOTAL_TOO_LARGE_ERROR`, `Только images`, `Not allowed extension`; 500 с текстом исключения
- **Примечания:** `convertPhoto` для каждого фото (HEIC→JPEG); при любой ошибке файлы снимаются `removeUpload`; антиспам — `SELECT count(*) … created_at >= now() - (spam_interval_min||' minutes')` по `student_name`; транзакция `BEGIN/COMMIT/ROLLBACK`: `students` (upsert по имени) → `entries` (`description_original = description`) → `entry_photos` (`sort_order` = индекс) → `project_files` (токен `randomBytes(16).hex`); после коммита `entryAutoChecker.notify()`, `invalidateEntries()`, `invalidateStats()`, `broadcastEntryChanged()`
### `PUT /api/entries/:id`
- **Доступ:** `requireAuth` + `upload.array('photo', 10)` — одно поле `photo`
- **Филиалы:** для `role !== 'admin'` — `entryAccessible(req.user, id)`: `!found` → 404 `Not found`, `!allowed` → 403 `Нет доступа к этой записи`; admin пропускается без проверки
- **Тело:** текстовые `student_name`, `group_id`, `description`, `module_id` (наличие ключа `module_id` определяется через `hasOwnProperty`), файлы `photo` (до 10)
- **Ответ 200:** полная строка `entries` (актуальный `photo_path`)
- **Ошибки:** 404 `Not found`; 400 `Модуль не найден` (не-целое или нет строки в `modules`); ошибки multer стандартной обработки (здесь `LIMIT_FILE_SIZE` не перехватывается)
- **Примечания:** `COALESCE` по всем текстовым полям, `module_id` обновляется только если ключ передан; новые фото добавляются в конец (`sort_order = existing + i`); если у записи не было `photo_path` — первое новое фото становится главным; аудит `entry.update` с diff-полями (`buildEntryUpdateTarget`, `photos_added`), затем `invalidateEntries()` + `invalidateStats()`; `broadcastEntryChanged()` не вызывается
### `DELETE /api/entries/:id`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403 как выше)
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Not found` / 403 `Нет доступа к этой записи` — только для не-admin
- **Примечания:** **мягкое удаление** — `UPDATE entries SET deleted_at = now() WHERE id = $1`; `rowCount` не проверяется, поэтому для admin несуществующий id тоже даёт `{ ok: true }`; аудит `entry.soft-delete`, `invalidateEntries()`, `invalidateStats()`; файлы не удаляются
### `PUT /api/entries/:id/restore`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin; при отказе пишется `console.warn('[RESTORE DENIED] …')` с id пользователя, филиалами и `group_id` записи
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Запись не найдена или уже восстановлена` (при `rowCount === 0`); 403/404 — по филиалам
- **Примечания:** `UPDATE entries SET deleted_at = NULL, purge_at = NULL` — снимает и мягкое удаление, и отметку об удалении; успех логируется в консоль (`[RESTORE] … rowCount`); аудит `entry.restore`, `invalidateEntries()`, `invalidateStats()`
### `DELETE /api/entries/:id/permanent`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin + `console.warn('[PERM DELETE DENIED] …')`
- **Тело:** нет; срок — из настройки `trash_purge_days` (`trashPurgeDays()`, дефолт 30, допустимо 1..3650)
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Запись не найдена, не в корзине или уже помечена на удаление`; 403/404 — по филиалам
- **Примечания:** удаления строки нет — ставится `purge_at = now() + (days||' days')::interval` при `deleted_at IS NOT NULL AND purge_at IS NULL`; **физический purge делает фоновый процесс**; аудит `entry.schedule-delete` с `days`, `invalidateEntries()`, `invalidateStats()`
### `PUT /api/entries/:id/unschedule`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Запись не найдена или не помечена на удаление` (при `rowCount === 0`)
- **Примечания:** `UPDATE entries SET purge_at = NULL WHERE id = $1 AND purge_at IS NOT NULL` — отмена отложенного удаления; `deleted_at` не трогается (запись остаётся в корзине); аудит `entry.unschedule`, `invalidateEntries()`, `invalidateStats()`
### `POST /api/entries/:id/ai/recheck`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Запись не найдена` (по `RETURNING id`); 403/404 — по филиалам
- **Примечания:** `UPDATE entries SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL`; `entryAutoChecker.notify()` — только пинок, задачу воркер берёт из БД через `FOR UPDATE SKIP LOCKED`, обработка не гарантирована; аудит `entry.ai.recheck`, `invalidateEntries()`, `invalidateStats()`
### `POST /api/entries/:id/ai/revert`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 400 `Оригинал текста недоступен` (когда `description_original IS NULL`); 403/404 — по филиалам
- **Примечания:** `description = description_original, description_ai = NULL, ai_status='reverted', ai_error=NULL, ai_checked_at=now()`; аудит `entry.ai.revert` с `source:'ai_revert'` и пословным `textDiff` (segments/stats/truncated); `invalidateEntries()`, `invalidateStats()`
---
## 33. Фото записи
### `GET /api/entries/:id/photos`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** массив строк `entry_photos` — `{ id, photo_path, caption, sort_order, created_at }`, `ORDER BY sort_order, id`
- **Ошибки:** 404 `Not found`, 403 `Нет доступа к этой записи` — только для не-admin
- **Примечания:** у admin проверки существования записи нет — для несуществующего `:id` вернётся пустой массив; записи в корзине (`deleted_at`) не фильтруются
### `DELETE /api/entries/:id/photos/:photoId`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Фото не найдено` (нет строки с `id` в этой записи); 404/403 — по филиалам
- **Примечания:** `DELETE FROM entry_photos …` + `safeUnlink(photo_path)`; `sort_order` у оставшихся уменьшается на 1 (`sort_order > $2`); если удалённое фото было главным (`entries.photo_path`), главным становится первое оставшееся или `NULL`; аудит `entry.photo.delete`, `invalidateEntries()`
### `PUT /api/entries/:id/photos/:photoId`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** `{ caption?, sort_order? }` (JSON; оба через `COALESCE`, `null`/undefined = не менять)
- **Ответ 200:** полная строка `entry_photos` (`RETURNING *`)
- **Ошибки:** 404 `Фото не найдено`; 404/403 — по филиалам
- **Примечания:** значения не валидируются и не приводятся к типу (`sort_order` уходит как есть); аудит `entry.photo.update`, `invalidateEntries()`
### `PUT /api/entries/:id/photos/:photoId/main`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true, photo_path: '/uploads/…' }`
- **Ошибки:** 404 `Фото не найдено`; 404/403 — по филиалам
- **Примечания:** только `UPDATE entries SET photo_path = …` — `sort_order` в `entry_photos` не меняется, т.е. фото становится обложкой записи, но не первым в галерее; аудит `entry.photo.set_main`, `invalidateEntries()`
### `PUT /api/entries/:id/photo/enhance`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** два варианта по content-type: `multipart/form-data` — одно поле `photo` (результат обработки на клиенте, `engine:'client'`); `application/json` — серверная обработка через sharp
- **Ответ 200:** `{ ok: true, photo_path: '/uploads/<hex>.jpg', engine: 'client' | 'sharp' }`
- **Ошибки:** 503 `sharp недоступен на сервере`; 404 `Запись не найдена`; 400 `У записи нет фото`, `Файл фото не найден` (объект storage отсутствует), `Нет файла`; 404/403 — по филиалам; 500 `Ошибка замены фото`
- **Примечания:** JSON-параметры клампятся `clampEnhanceParam`: `brightness` 10..300 (100), `contrast` 10..300 (100), `saturate` 0..300 (100), `sharp` 0..100 (0), `denoise` 0..100 (0; >70 → `median(5)`, иначе `median(3)`); результат — `jpeg({quality:92, mozjpeg:true})` + `storage.persist`; далее `swapEntryPhotoFiles`: старый файл копируется в `.originals/<hex>.<ext>`, обновляются `entries.photo_path/photo_original_path` и строки `entry_photos`, `thumbUnlinkFor`, вставляется `photo_jobs` со `status='done'`, аудит `entry.photo.enhance`, `invalidateEntries()`
### `POST /api/entries/:id/photo/restore-original`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true, photo_path: '/uploads/<hex><ext>' }`
- **Ошибки:** 404 `Запись не найдена`; 400 `Оригинал не сохранён` (`photo_original_path IS NULL`), `Некорректный путь оригинала` (имя не `[A-Za-z0-9._-]+`), `Файл оригинала не найден`; 404/403 — по филиалам; 500 `Ошибка восстановления оригинала`
- **мечания:** `copyObject(.originals/<name> → <hex><ext>)` + `storage.del` оригинала, `photo_original_path = NULL`; текущее фото предварительно копируется в `.originals/`; `entry_photos` переписываются по старому пути, `thumbUnlinkFor`; вставляется `photo_jobs` (`action='restore'`, `status='done'`, `finished_at=now()`); аудит `entry.photo.restore_original`, `invalidateEntries()`
---
## 34. ИИ-улучшение фото
### `POST /api/entries/:id/photo/enhance-ai`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** пустое тело (или любые поля) → `action:'ai'`, `params:null`. Иначе `parsePhotoAiRequest`: `model` (из `PHOTO_AI_MODELS` = `x2plus`|`general-x4v3`|`animevideo-v3`, дефолт `x2plus`), `face` (из `PHOTO_AI_FACE_MODES` = `off`|`face`|`all`, дефолт `off`), `face_model` (из `PHOTO_AI_FACE_MODELS`, дефолт env `PHOTO_AI_FACE_MODEL`=`gfpgan`), `strength` (0..1, дефолт `0.7`, только для `codeformer`)
- **Ответ 200:** `{ jobId: <int>, action: 'ai'|'ai_face', params }` — заметьте, ключ `jobId` в camelCase, а в статус-эндпоинте ниже — `job_id`
- **Ошибки:** 503 `ИИ-обработка фото не настроена` (пустой `PHOTO_AI_URL`); 400 с текстом `parsed.error` — про `model`/`face`/`face_model` из списков, `strength должен быть числом от 0 до 1`, «strength применяется только к codeformer…»; 404 `Запись не найдена`; 400 `У записи нет фото`; 404/403 — по филиалам; 500 `Ошибка постановки задания в очередь`
- **Примечания:** создаёт `photo_jobs (entry_id, action, status='pending', params=JSON)`; `photoWorker.notify()` — только пинок, обработка не гарантирована (задача берётся воркером из БД через `FOR UPDATE SKIP LOCKED`); аудит `entry.photo.enhance-ai.queue` (entry_id, job_id, action, params)
### `GET /api/entries/:id/photo/enhance-ai/:jobId`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ status, applied, job_id }`; при `status='error'` добавляется `error`, при `status='done'` — `photo_path` (= `after_path`)
- **Ошибки:** 404 `Задание не найдено` (не-целый `jobId` или нет строки с этой `entry_id`); 404/403 — по филиалам; 500 `Ошибка получения статуса задания`
- **Примечания:** опрос статуса (long-poll нет, SSE нет); выбираются только `id, status, error, after_path, applied`
### `GET /api/entries/:id/photo/jobs`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** массив до 50 последних заданий записи, `ORDER BY id DESC`: `id, action, status, applied, params, before_path, after_path, error, created_at, finished_at`
- **Ошибки:** 404/403 — по филиалам
- **Примечания:** список не ограничен по `status` — попадают и клиентские `enhance`, и `rollback`/`restore`, и ИИ-задания; пагинации нет
### `POST /api/entries/:id/photo/jobs/:jobId/apply`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true, photo_path: '/uploads/…' }` (при повторном вызове, когда `entries.photo_path` уже равен `after_path`, — тот же ответ, но только с `UPDATE photo_jobs SET applied = true`)
- **Ошибки:** 404 `Задание не найдено`; 400 `Результат ещё не готов` (`status !== 'done'`), `Результат уже применён` (`applied = true` — проверяется **до** сравнения путей), `Некорректный путь результата` (`!isSafeUploadPath`), `Файл результата не найден` (`storage.exists` = false); 404 `Запись не найдена`; 404/403 — по филиалам; 500 `Ошибка применения фотографии`
- **Примечания:** всё в одной транзакции: `SELECT … FOR UPDATE` по `photo_jobs`, затем `copyObject` текущего фото в `.originals/`, `UPDATE entries SET photo_path, photo_original_path`, `UPDATE entry_photos … WHERE photo_path = <old>`, `thumbUnlinkFor`, `UPDATE photo_jobs SET applied=true, before_path=COALESCE(before_path,$1)`; при любом ответе с ошибкой — `ROLLBACK`; аудит `entry.photo.apply` (entry_id, job_id, before_path, after_path), `invalidateEntries()`
### `POST /api/entries/:id/photo/jobs/:jobId/reject`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Задание не найдено` (в т.ч. не-целый `jobId`); 400 `Результат уже применён`; 404/403 — по филиалам; 500 `Ошибка отклонения результата`
- **Примечания:** если `status='done'` и `after_path` проходит `isSafeUploadPath` — файл результата удаляется `safeUnlink`; `UPDATE photo_jobs SET status='rejected', error='Отклонено пользователем', finished_at=now() WHERE … AND applied=false`; аудит `entry.photo.reject`; кэш не сбрасывается (фото записи не менялось)
### `POST /api/entries/:id/photo/jobs/:jobId/rollback`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** нет
- **Ответ 200:** `{ ok: true, photo_path: '/uploads/<hex><ext>' }`
- **Ошибки:** 404 `Задание не найдено` (нужен `status='done'`); 400 `Нет сохранённой версии для отката` (`before_path` не из `.originals/`), `Некорректный путь`, `Файл версии не найден` (нет объекта storage или `copyObject` вернул false); 404 `Запись не найдена`; 400 `У записи нет фото`; 404/403 — по филиалам; 500 `Ошибка отката фотографии`
- **Примечания:** восстанавливается версия из `photo_jobs.before_path` (префикс `/uploads/.originals/`, имя по `[A-Za-z0-9._-]+`); текущее фото копируется в `.originals/` как новая точка отката, `thumbUnlinkFor`; вставляется новое задание `photo_jobs (action='rollback', status='done', finished_at=now())` — откат можно откатить; аудит `entry.photo.rollback` (job_id, restored_path), `invalidateEntries()`
### `DELETE /api/entries/:id/photo/enhance-ai/preview`
- **Доступ:** `requireAuth`; **филиалы:** `entryAccessible()` для не-admin (404/403)
- **Тело:** JSON `{ path: '/uploads/…' }` — путь файла-превью (тело обязательно даже для DELETE)
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 400 `Некорректный путь` (`isSafeUploadPath` = false); 404 `Результат не найден`; 404/403 — по филиалам; 500 `Ошибка удаления результата`
- **Примечания:** ищется последнее задание с `after_path = path AND status='done' AND applied=false`; файл удаляется `safeUnlink`, задание переводится в `status='rejected'`, `error='Отклонено пользователем'`; аудит `entry.photo.reject` (тот же, что у `/reject`)
---
## 35. ИИ-профили и очередь
### `POST /api/ai/correct`
- **Доступ:** `requireAuth`; **филиалы:** не проверяются — доступен любому авторизованному пользователю, включая тьютора без доступа к конкретным филиалам
- **Тело:** `{ text: string }` (JSON), лимит 5000 символов; значение читается `reqStr(req.body?.text, 5000)` — хелпер **бросает исключение** при не-строке, пустой строке или длине > 5000
- **Ответ 200:** `{ suggestion: '<исправленный текст>' }`
- **Ошибки:** 502 `Сервис ИИ недоступен: <message>` при ошибке `aiCorrectText`; 400 `Текст не указан` — **недостижимая ветка** (см. «?»)
- **Примечания:** вызов синхронный (блокирует HTTP-запрос до ответа модели); аудита, `invalidate*` и SSE нет — это пробный вызов без побочных эффектов
### `GET /api/ai/profiles`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** нет
- **Ответ 200:** `{ active, native: { id:'native', name:'Нативная (llama.cpp в Docker)', base_url: AI_URL, model: AI_MODEL }, profiles: [...] }`, где `active` — настройка `ai_active_profile` (дефолт `native`)
- **Ошибки:** 401/403 — от `requireAdmin`
- **Примечания:** профили лежат в `settings.ai_profiles` как JSON-массив (`getAiProfiles()`); `api_key` профиля возвращается в ответе (см. «?»)
### `POST /api/ai/profiles`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** `{ name, base_url, model, api_key, max_tokens }`; `validateProfileBody`: имя 1..100 симв., `base_url` по `/^https?:\/\//i` до 300 симв. (срезаются хвостовые `/`), `model` 1..150 симв., `api_key` до 300 симв., `max_tokens` — целое 16..32768 или `null`
- **Ответ 201:** созданный профиль `{ id, name, base_url, model, api_key, max_tokens }`, `id = randomBytes(8).hex`
- **Ошибки:** 400 по каждому правилу `validateProfileBody` («Укажите название профиля (до 100 символов)», «Base URL должен быть корректным http(s)://… (до 300 символов)», «Укажите название модели (до 150 символов)», «API-ключ слишком длинный», «max_tokens должен быть целым числом от 16 до 32768»); 400 `Слишком много профилей (макс. 20)`
- **Примечания:** весь массив перезаписывается в `settings.ai_profiles` через `INSERT … ON CONFLICT (key) DO UPDATE`; аудит `ai.profile.create` (id, name, model), `invalidateSettings()`, `entryAutoChecker.notify()`
### `PUT /api/ai/profiles/:id`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** те же поля, что и при создании, валидация `validateProfileBody` — **замена целиком**, частичного обновления нет
- **Ответ 200:** обновлённый профиль (с исходным `id` из URL)
- **Ошибки:** 404 `Профиль не найден`; 400 — ошибки валидации тела; 401/403 — от `requireAdmin`
- **Примечания:** тот же upsert в `settings.ai_profiles`; аудит `ai.profile.update`, `invalidateSettings()`, `entryAutoChecker.notify()`
### `DELETE /api/ai/profiles/:id`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 404 `Профиль не найден`; 401/403 — от `requireAdmin`
- **Примечания:** профиль вырезается из массива, массив перезаписывается; если удаляемый профиль был активным, `ai_active_profile` сбрасывается в `native`; аудит `ai.profile.delete` (id, name), `invalidateSettings()`, `entryAutoChecker.notify()`
### `POST /api/ai/profiles/activate`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** `{ id: 'native' | <id профиля> }`
- **Ответ 200:** `{ ok: true, active: '<id>' }`
- **Ошибки:** 400 `Профиль не найден` (для любого id, кроме `native`, которого нет в списке); 401/403 — от `requireAdmin`
- **Примечания:** `ai_active_profile` через upsert; аудит `ai.profile.activate`, `invalidateSettings()`, `entryAutoChecker.notify()` — переключение влияет на следующий запрос воркера
### `POST /api/ai/profiles/test`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** `{ name, base_url, model, api_key, max_tokens }` — валидируется целиком, сохранять профиль не нужно
- **Ответ 200 (всегда):** `{ ok: true, latency_ms, sample: '<до 120 симв.>' }` либо `{ ok: false, latency_ms, error }` (`HTTP <code>: <body до 200 симв.>`, `Таймаут 15 с` или текст сетевой ошибки)
- **Ошибки:** 400 — ошибки `validateProfileBody`; ответы 4xx/5xx внешнего сервиса **не пробрасываются**, а возвращаются как `ok:false` в 200
- **Примечания:** прямой `fetch(`${normalizeOpenAiBase(base_url)}/chat/completions`)`, `max_tokens: 8`, `temperature: 0`, prompt «Ответь одним словом: ок», `AbortController` с таймаутом 15 с; `Bearer <api_key>` добавляется только при непустом ключе
### `GET /api/ai/queue`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо (сводка по всем записям с `deleted_at IS NULL`)
- **Тело:** нет
- **Ответ 200:** `{ enabled, counts: { pending, processing, done, skipped, error, reverted }, worker, model }`; `enabled` — `ai_autocheck_enabled !== 'false'`; `worker` = `entryAutoChecker.getStats()` (или `null`); `model` — «Имя (модель)» активного профиля или `AI_MODEL`
- **Ошибки:** 401/403 — от `requireAdmin`
- **Примечания:** `GROUP BY ai_status` — статусы вне списка попадут в объект как дополнительные ключи; списков задач здесь нет (для них `GET /api/ai/status`)
### `GET /api/photo-ai/health`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** нет
- **Ответ 200:** результат `photoAiHealth(5000)`: `{ ...payload сервиса, configured, reachable, latency_ms, error }`; при пустом `PHOTO_AI_URL` — `configured:false`, `error:'PHOTO_AI_URL не настроен'`
- **Ошибки:** HTTP-кодов нет — недоступность отдаётся полями `reachable:false` и `error` (`HTTP <code>` / `timeout` / текст ошибки)
- **Примечания:** `GET ${PHOTO_AI_URL}/health` с таймаутом 5 с; JSON сервиса разбирается мягко (при не-JSON `data = null`)
### `GET /api/ai/status`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо — выборки по всем неудалённым записям
- **Тело:** нет
- **Ответ 200:** `{ enabled, model, ai_url, service, worker, counts, pending, recent, errors }`; `pending` — до 20 записей со `ai_status IN ('pending','processing')` (`id, student_name, created_at, group_name`); `recent` — до 30 последних проверенных (`ai_checked_at`, `ai_status`, `ai_error`, `group_name`, `description_original`, `description_ai`, `changed`); `errors` — до 20 со `ai_status='error'`; `worker` = `entryAutoChecker.getInfo()`
- **Ошибки:** 401/403 — от `requireAdmin`; недоступность модели — в `service.reachable:false`, HTTP-код не меняется
- **Примечания:** `service` — `aiHealthCheck()`: для активного профиля `GET <base>/models`, иначе `GET ${AI_URL}/health`, таймаут 5 с; `counts` по тем же шести статусам
### `POST /api/ai/wake`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 401/403 — от `requireAdmin`
- **Примечания:** только `entryAutoChecker.notify()` + аудит `ai.wake`; **не гарантирует немедленную обработку** — воркер берёт `pending` из БД через `FOR UPDATE SKIP LOCKED` и просыпается по своему backoff-циклу
### `POST /api/ai/enabled`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** `{ enabled: <truthy> }` — приводится через `!!req.body?.enabled`, хелпера `reqBool` в проекте нет (строка `"false"` даст `true`)
- **Ответ 200:** `{ ok: true, enabled: <boolean> }`
- **Ошибки:** 401/403 — от `requireAdmin`; 400 отсутствует — любое тело без `enabled` корректно
- **Примечания:** настройка `ai_autocheck_enabled` (`'true'`/`'false'`) через upsert; при `enabled` вызывается `entryAutoChecker.notify()`; аудит `ai.enabled`; `invalidateSettings()` не вызывается (см. «?»)
### `POST /api/ai/requeue-failed`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо — `UPDATE` идёт по всей таблице (внутренний API, admin-only; на `apiV1` такой же `UPDATE` фильтруется `apiBranchClause`)
- **Тело:** нет
- **Ответ 200:** `{ ok: true, count: <rowCount> }`
- **Ошибки:** 401/403 — от `requireAdmin`
- **Примечания:** `UPDATE entries SET ai_status='pending', ai_error=NULL, ai_checked_at=NULL WHERE ai_status='error' AND deleted_at IS NULL`; `entryAutoChecker.notify()`; аудит `ai.requeue-failed` с `count`; `invalidateEntries()`; `invalidateStats()` не вызывается
---
## 36. Фото-джобы: статус и управление воркером
### `GET /api/photo-jobs/status`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо — сводка по всем `photo_jobs` (без `deleted_at IS NULL`, в отличие от `/api/ai/status`)
- **Тело/Query:** `recent_limit` (целое 1..200, дефолт 15), `recent_offset` (целое ≥ 0, дефолт 0) — читаются `optInt`, который **бросает исключение** на не-целом значении (см. «?»)
- **Ответ 200:** `{ enabled, ai_configured, ai_url, service, worker, counts: { pending, processing, done, error }, pending, recent, recent_total, errors }`; `pending` — до 20 заданий в работе (`id, entry_id, action, created_at, student_name, group_name`); `recent` — страница завершённых с полями `params, before_path, after_path, error, finished_at`; `recent_total` — счётчик завершённых; `errors` — до 20 с `status='error'`; `worker` = `photoWorker.getInfo()`
- **Ошибки:** 401/403 — от `requireAdmin`; недоступность photo-сервиса — в `service` (`configured`, `reachable`, `latency_ms`, `error`), HTTP-код прежний
- **Примечания:** `enabled` — `photo_worker_enabled !== 'false'`; `ai_configured` = `!!PHOTO_AI_URL`, `ai_url` = сам URL; пагинация только для `recent` (у `pending`/`errors` жёсткий `LIMIT 20`)
### `POST /api/photo-jobs/wake`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** нет
- **Ответ 200:** `{ ok: true }`
- **Ошибки:** 401/403 — от `requireAdmin`
- **Примечания:** только `photoWorker.notify()` + аудит `photo-jobs.wake`; **не гарантирует немедленную обработку** (задачи берутся из БД через `FOR UPDATE SKIP LOCKED`)
### `POST /api/photo-jobs/enabled`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо
- **Тело:** `{ enabled: <truthy> }` — `!!req.body?.enabled`, строка `"false"` даст `true`
- **Ответ 200:** `{ ok: true, enabled: <boolean> }`
- **Ошибки:** 401/403 — от `requireAdmin`; 400 отсутствует
- **Примечания:** настройка `photo_worker_enabled` (`'true'`/`'false'`) через upsert; при включении `photoWorker.notify()`; аудит `photo-jobs.enabled`; `invalidateSettings()` не вызывается (см. «?»)
### `POST /api/photo-jobs/requeue-failed`
- **Доступ:** `requireAdmin`; **филиалы:** не применимо — `UPDATE` по всей `photo_jobs` (в `apiV1` этот же путь фильтруется `apiBranchClause` по филиалам через `photo_jobs → entries → groups`)
- **Тело:** нет
- **Ответ 200:** `{ ok: true, count: <rowCount> }`
- **Ошибки:** 401/403 — от `requireAdmin`
- **Примечания:** `UPDATE photo_jobs SET status='pending', error=NULL, finished_at=NULL WHERE status='error'`; `photoWorker.notify()`; аудит `photo-jobs.requeue-failed` с `count`; `invalidateEntries()`; `invalidateStats()` не вызывается
---
## 37. Корзина
### `GET /api/trash`
- **Доступ:** `requireAuth`; **филиалы:** `branchScope(req.user)` — для не-admin к обоим спискам добавляется `AND g.branch_id IN (…)`, при пустом списке — `AND 1 = 0` (пусто); admin видит всё
- **Тело/Query:** `limit`, `offset` — `parseInt` без валидации; при `NaN`/`≤0` `LIMIT`/`OFFSET` просто не добавляются
- **Ответ 200:** `{ entries, total, groups, total_groups }`: записи с `deleted_at IS NOT NULL AND purge_at IS NULL` (`ORDER BY deleted_at DESC`) с добавленным полем `files` = массив `{ id, entry_id, token, name }` из `project_files`; группы с теми же условиями (`ORDER BY deleted_at DESC`, `LEFT JOIN branches` → `branch_name`)
- **Ошибки:** 401 — без сессии; иных кодов нет (пустой результат — валидный 200)
- **Примечания:** данные собирает общий хелпер `trashData(req, 'visible', limit, offset)`; группы не фильтруются по `deleted_at` самой записи — только по своей корзине; файлы записей на диске/S3 не удаляются
### `GET /api/trash/pending`
- **Доступ:** `requireAuth`; **филиалы:** та же логика `branchScope`, что и в `GET /api/trash`
- **Тело/Query:** нет — пагинация не применяется (`trashData(req, 'pending')` вызывается без limit/offset)
- **Ответ 200:** `{ entries, total, groups, total_groups, purge_days }` — записи и группы с `purge_at IS NOT NULL` (`ORDER BY purge_at DESC`), с заполненным `files`; `purge_days` из `trashPurgeDays()` (настройка `trash_purge_days`, дефолт 30, диапазон 1..3650)
- **Ошибки:** 401 — без сессии
- **Примечания:** записи, уже помеченные на удаление, остаются в корзине (`deleted_at IS NOT NULL AND purge_at IS NULL` их больше не показывают); `entries` и `groups` возвращаются целиком
### `DELETE /api/trash`
- **Доступ:** `requireAuth` + `requireAdmin` (двойной middleware — admin-only); **филиалы:** не применимо
- **Тело:** нет; срок — `trashPurgeDays()` (`trash_purge_days`, дефолт 30)
- **Ответ 200:** `{ ok: true, entries: <rowCount>, groups: <rowCount>, days }`
- **Ошибки:** 401/403 — от `requireAuth`/`requireAdmin`
- **Примечания:** ставит `purge_at = now() + (days||' days')::interval` всем, у кого `deleted_at IS NOT NULL AND purge_at IS NULL`, отдельно для `entries` и `groups` — строки не удаляются, **физический purge делает фоновый процесс**; `invalidateEntries()`, `invalidateGroups()`, `invalidateStats()`; аудит `trash.clear` с `entries`, `groups`, `days`
---
## 38. Внешний API api-v1 — обзор
Внешний API смонтирован на отдельный роутер `apiV1` и подключён **только** здесь — `app.use('/api/v1', apiV1)` (`server.js:7037`). Сессионная аутентификация (`X-Auth-Token` / `requireAuth`) к `/api/v1/*` отношения не имеет.
Всего **26 эндпоинтов**: 13 читающих и 13 мутаций/управления воркерами.
### Аутентификация и scopes
- `apiV1.use(requireApiKey('read'))` (`server.js:7024`) — навешено на весь роутер, поэтому **весь** `/api/v1` требует scope `read`. Ни один эндпоинт не доступен без `read`.
- `apiV1.use(apiKeyLimiter)` (`server.js:7025`) — сразу после аутентификации.
- Ключ передаётся в заголовке `X-Api-Key` либо `Authorization: Bearer <key>` (`apiKeyFromRequest`, `server.js:997`). Больше никаких способов аутентификации нет.
- Мутации дополнительно проходят `apiWrite('write')` (`server.js:7291`). Без scope `write` — `403 {"error":"API key lacks scope: write"}`. Ключ с одним `read` читает всё, но на любую запись получает 403.
### Ключ не шире выдавшего
`apiKeyUser(row)` (`server.js:1033`) строит «пользователя» для запроса:
- базовые поля берутся у владельца ключа (`id`, `username`, `name`, `role`, `is_active`, `branch_ids = owner_branch_ids`);
- если `branch_ids` ключа **пустой** — возвращается владелец как есть (admin остаётся admin, ограничений по филиалу нет);
- если `branch_ids` ключа непустой — список филиалов пересекается с филиалами владельца (`limit ∩ ownerScope.ids`, для админ-владельца пересечение со всеми его филиалами), а **роль принудительно понижается до `tutor`**, даже если владелец — `admin`.
Отсюда: `requireApiKey` кладёт в `req.user` (`server.js:1101`) этого пониженного пользователя, и **все** хелперы филиалов (`branchScope`, `branchWhere`, `groupBelongsToBranches`, `entryAccessible`) считают его не-админом. Ключ с `branch_ids` не может читать чужие филиалы и не может получить права админа.
### Ограничение по филиалам
- Чтение списков: `branchWhere(user, alias)` (`server.js:1116`) возвращает `{where, params}` — пустую строку для админа, `AND 1 = 0` для не-админа без филиалов, иначе `AND alias.branch_id IN ($1,...)`.
- Точечные объекты: `groupBelongsToBranches` (`server.js:1130`), `entryAccessible` (`server.js:1141`, при отказе пишет `[ACCESS DENIED]` в консоль), `lessonReportGroup` / `lessonReportById` (`server.js:3866`, `3880`).
- Массовые UPDATE: `apiBranchClause(user, expr, params)` (`server.js:7523`) — для админа `''`, для не-админа без филиалов ` AND FALSE`, иначе ` AND <expr> = ANY($N::int[])`. Используется только в двух эндпоинтах (`/ai/requeue-failed`, `/photo-jobs/requeue-failed`).
- **Исключения:** `/modules` не фильтруется по филиалам вовсе (модули глобальные), а `/students/:id` пропускает проверку, если у ученика `group_id IS NULL`.
### Конверт списков
`apiList(rows, total, limit, offset)` (`server.js:7033`) → `{ items, total, limit, offset }`. Пагинация — `apiPage(req)` (`server.js:7027`): `limit` по умолчанию **50**, зажат в диапазон **1..500**; `offset` по умолчанию 0, отрицательные значения поднимаются до 0.
Конверт используют: `/groups`, `/students`, `/modules`, `/entries`, `/entries/:id/files`, `/lesson-reports`, `/branches`.
Без конверта (плоский ответ) отдают: `/me`, `/stats`, `/groups/:id`, `/students/:id`, `/entries/:id`, `/lesson-reports/:id` и все мутации.
**Оговорка о значениях в конверте:** в `/branches` и `/entries/:id/files` поля `limit` и `offset` кладутся как `rows.length` и `0`, а не как запрошенные значения `apiPage` (`server.js:7065`, `server.js:7253`). Это не влияет на пагинацию по факту (для `/branches` пагинации нет вовсе), но означает, что **`limit`/`offset` в ответе этих двух маршрутов нельзя использовать для построения курсора** — ориентируйтесь на `total`.
### Rate limit
`apiKeyLimiter` (`server.js:980`): окно 60 с, `store: cache.rateLimitStore('apikey', 60 * 1000)`, `standardHeaders: true`, `legacyHeaders: false`. Лимит — функция: `req.apiKey.rpm` (поле `rate_limit_per_min` ключа), если это целое `> 0` — `Math.min(rpm, API_KEY_MAX_RPM=10000)`, иначе `API_KEY_DEFAULT_RPM = 120`. Ключ счёта: `k<id>` для аутентифицированных, `ip<ipKeyGenerator(ipOf(req))>` — для неавторизованных. Ответ при превышении: `429 {"error":"Превышен лимит запросов для API-ключа"}`.
### Аудит
`apiAudit(req, action, target)` (`server.js:1111`) = `logAudit(req, action, { ...target, via_api_key: req.apiKey.id })`. Все 13 мутаций аудируются, действия с префиксом `api.`. В списке аудита по действию видно, каким ключом сделано изменение.
### Прочее
- **404:** собственного обработчика у `apiV1` нет; запрос проваливается до общего `app.use` (`server.js:7611`), который для путей `/api/` отдаёт `404 {"error":"Not found"}`.
- **Ошибки:** async-хендлеры ничем не обёрнуты (Express 4, async-обёртки в проекте нет), поэтому **брошенное** исключение внутри хендлера не попадает в error-middleware (`server.js:7618`) — запрос остаётся без ответа, ошибка уходит в `unhandledRejection`. Это касается, в частности, валидаторов `reqStr`/`optInt` (см. ниже) и невалидных `date_from`/`date_to`/`:id`.
- `api_keys` не входит в бэкап, `POST /api/restore` делает `DELETE FROM api_keys` — после восстановления все внешние ключи мертвы.
### Валидаторы (импортируются из `backup-restore.js`)
- `reqStr(v, max)` (`backup-restore.js:52`) — **`бросает** `Error`, если `v` не строка, строка пустая после `trim()` или длиннее `max`. Возвращает обрезанную строку. Значит ошибка валидации = 500/зависание, а не 400.
- `optInt(v, lo, hi)` (`backup-restore.js:45`) — `null` для `null/undefined/''`, иначе целое в границах, иначе **бросает**.
- `LESSON_REPORT_TEXT_MAX = 5000`, `LESSON_REPORT_TOPIC_MAX = 300` (`backup-restore.js:2-3`).
- `types.setTypeParser(1082, v => v)` (`server.js:44`) — `DATE` (`lesson_date`) приходит из pg строкой, не объектом `Date`.
### Чего во внешнем API НЕТ (проверено по коду)
Отсутствуют целиком:
| Ресурс | Внутренние роуты | Эквивалент в `/api/v1` |
|---|---|---|
| Файлы и загрузки | `POST /api/files`, `POST /api/entries/:id/files`, `GET/DELETE /api/files/:id`, `POST /api/files/:id/detach`, `GET /api/files`, `GET /api/files/detached` | нет |
| Фото и галереи | `*/photos*`, `/photo/jobs*`, `/photo/restore-original`, `/photo/enhance*`, `entry_photos`, `student_photos`, `group_photos` | нет |
| Пользователи | `GET/POST/PUT/DELETE /api/users*`, `/api/users/branches`, `/api/users/tutors` | нет |
| API-ключи | `/api/api-keys*` (CRUD, rotate, meta) | нет |
| Настройки | `GET/PUT /api/settings`, `/api/settings/logo`, `/api/public-settings`, `/api/system-info`, `/api/ai/enabled`, `/api/photo-ai/health` | нет |
| Бэкап/восстановление | `POST /api/backup`, `GET /api/backup/:token`, `POST /api/restore` | нет |
| Корзина | `GET/DELETE /api/trash`, `/api/trash/pending`, `/api/entries/:id/restore`, `/permanent`, `unschedule` | нет (кроме мягкого `DELETE /entries/:id`) |
| Share-ссылки | `/api/links*`, `/api/share/*` | нет |
| ИИ-профили | `/api/ai/profiles*`, `/api/ai/correct`, `/api/ai/status`, `/api/ai/queue` | нет |
| Уведомления | `/api/notifications*`, `/api/events`, SSE `/api/notifications/stream` | нет |
| Student-report (портфолио) | `/api/export/student`, `renderStudentReport`, zip | нет |
| Группы: запись | `POST/PUT/DELETE /api/groups*`, `restore`, `unschedule`, `/export/files`, `active` | нет (только чтение) |
| Модули: запись | `POST/PUT/DELETE /api/modules*`, `/restore`, `/photo` | нет (только чтение) |
| Студенты: удаление | `DELETE /api/students/:id`, `POST /api/students/batch-group` | нет (только создание/правка) |
| Отчёты: версии/ИИ | `/lesson-reports/:id/versions`, `/versions/:versionId/restore`, `/ai/revert`, `POST /api/entries/:id/ai/revert` | нет |
| Статусы и дашборд | `GET /api/ai/status`, `/api/photo-jobs/status`, `/api/dashboard`, `/api/events`, `/api/audit` | нет (кроме `/stats`) |
| Баны | `/api/bans*` | нет |
---
## 39. Эндпоинты
### `GET /api/v1/me`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** `{ key: { id, name, scopes }, user: { id, username, name, role, is_active, branch_ids }, server_time: "<ISO>" }` — `user` проходит через `safeUser()` (`server.js:895`), пароля/хеша там нет
- **Ошибки:** 401 (нет ключа / отозван / истёк / владелец неактивен), 429, 500
- **Примечания:** единственный способ проверить ключ и увидеть свои фактические права. `role` здесь уже понижен до `tutor`, если у ключа задан `branch_ids`.
### `GET /api/v1/branches`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет — пагинации здесь нет вообще, `apiPage` не вызывается
- **Тело:** нет
- **Ответ 200:** конверт `apiList`; `items`: `id, name, address, phone, created_at, groups_count` (int). `total = items.length`, `limit = total`, `offset = 0`
- **Ошибки:** 401, 429, 500
- **Примечания:** для не-админа — только филиалы из `branchScope(req.user)`; если список пуст, сразу возвращается `apiList([], 0, 50, 0)` (жёстко зашитый `limit = 50`, не фактический размер). `groups_count` считается по `LEFT JOIN groups g ON g.branch_id = b.id` **без** фильтра `deleted_at`, то есть включает удалённые группы. Аудита нет.
### `GET /api/v1/groups`
- **Доступ:** API-ключ, scope `read`
- **Query:** `limit` (int, дефолт 50, диапазон 1..500), `offset` (int, дефолт 0), `deleted` (`'1'` → только мягко удалённые `deleted_at IS NOT NULL`; любое другое значение/отсутствие → только активные `deleted_at IS NULL`)
- **Тело:** нет
- **Ответ 200:** `{ items, total, limit, offset }`; `items`: `id, name, branch_id, branch_name, day_of_week, time_start, time_end, cover_path, tutor_id, created_at, deleted_at`; `ORDER BY g.id`
- **Ошибки:** 401, 429, 500
- **Примечания:** филиалы — `branchWhere(req.user, 'g')`. `total` считается отдельным `count(*)` с тем же `WHERE`. Аудита нет.
### `GET /api/v1/groups/:id`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** **плоский объект** (без конверта) — те же поля, что в списке: `id, name, branch_id, branch_name, day_of_week, time_start, time_end, cover_path, tutor_id, created_at, deleted_at`
- **Ошибки:** 403 `{"error":"Нет доступа к этой группе"}` (не-админ и группа вне его филиалов), 404 `{"error":"Not found"}`, 401, 429, 500
- **Примечания:** проверка доступа идёт **до** выборки (`groupBelongsToBranches`), поэтому для чужой группы приходит 403, а не 404. В выдаче есть `deleted_at`, т.е. мягко удалённая группа читается так же. `:id` не валидируется как число → нечисловой `:id` даст ошибку Postgres (зависший запрос). Аудита нет.
### `GET /api/v1/students`
- **Доступ:** API-ключ, scope `read`
- **Query:** `limit` (дефолт 50, 1..500), `offset` (дефолт 0), `search` (строка, `%search%` → `s.name ILIKE`)
- **Тело:** нет
- **Ответ 200:** `{ items, total, limit, offset }`; `items`: `id, name, group_id, group_name, photo_path, created_at`; `ORDER BY s.name`
- **Ошибки:** 401, 429, 500
- **Примечания:** фильтр по филиалам сделан через `LEFT JOIN groups g ON g.id = s.group_id` + `branchWhere(req.user, 'g')` — студент **без группы** (`group_id IS NULL`) не привязан к филиалу и виден всем не-админам. Аудита нет.
### `GET /api/v1/students/:id`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** **плоский объект**: `id, name, group_id, group_name, photo_path, profile, created_at`
- **Ошибки:** 403 `{"error":"Нет доступа к этому ученику"}`, 404 `{"error":"Not found"}`, 401, 429, 500
- **Примечания:** 404 проверяется первым, затем 403 — но только если `group_id` непустой. Поле `profile` доступно целиком (сантайз при записи, не при чтении). Аудита нет.
### `GET /api/v1/modules`
- **Доступ:** API-ключ, scope `read`
- **Query:** `limit` (дефолт 50, 1..500), `offset` (дефолт 0), `search` (строка, `m.name ILIKE`), `active` (`'1'` или `'true'` → `m.is_active = true`)
- **Тело:** нет
- **Ответ 200:** `{ items, total, limit, offset }`; `items`: `id, name, lessons_count, is_active, created_at, entries_count` (int); `ORDER BY m.is_active DESC, m.id`
- **Ошибки:** 401, 429, 500
- **Примечания:** **филиалы не применяются** — каталог модулей глобальный и отдаётся целиком любому валидному ключу. `entries_count` считается по `LEFT JOIN entries` без фильтра `deleted_at`, то есть включает удалённые записи. Аудита нет.
### `GET /api/v1/stats`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** **плоский объект** `{ entries, groups, students, today }` — все int. `entries` = записи (`deleted_at IS NULL`), `groups` = активные группы, `students` = `COUNT(DISTINCT e.student_name)` **из entries**, а не из таблицы `students`, `today` = записи с `e.created_at >= (now() AT TIME ZONE <tz>)::date`
- **Ошибки:** 401, 429, 500
- **Примечания:** не-админу всё считается в пределах его филиалов; если филиалов нет — сразу `{ entries: 0, groups: 0, students: 0, today: 0 }`. Зона берётся из настройки `timezone` (`appTimezone()`), четыре запроса идут параллельно через `Promise.all`. Конверта списка нет — это сводка, а не список. Аудита нет.
### `GET /api/v1/entries`
- **Доступ:** API-ключ, scope `read`
- **Query:** `limit` (дефолт 50, 1..500), `offset` (дефолт 0), `group_id` (int, точное равенство), `module_id` (int), `student_name` (строка, **точное равенство**, не ILIKE), `search` (`%s%` → `student_name ILIKE … OR description ILIKE …`), `date_from` (`'YYYY-MM-DD'`, включительно), `date_to` (`'YYYY-MM-DD'`, **включительно** — граница считается как `< date_to + 1 day`)
- **Тело:** нет
- **Ответ 200:** `{ items, total, limit, offset }`; `items`: `id, student_name, group_id, group_name, module_id, module_name, description, photo_path, created_at`; `ORDER BY e.created_at DESC`
- **Ошибки:** 401, 429, 500
- **Примечания:** в `WHERE` всегда жёстко `e.deleted_at IS NULL` — удалённые записи через список не видны. Фильтры по датам применяются к `created_at` через `tzDayStart`/`tzDayEnd` + `bindTz` (зона настроек), т.е. границы считаются в часовом поясе сервера-настройки. Филиалы — `branchScope` + `g.branch_id IN (...)`; не-админ без филиалов получает условие `1 = 0`. `date_from`/`date_to` **не валидируются** как даты — нестрока приведёт к ошибке Postgres и зависшему запросу. Аудита нет.
### `GET /api/v1/entries/:id`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** **плоский объект**: `id, student_name, group_id, group_name, module_id, module_name, description, description_original, photo_path, ai_status, created_at, deleted_at` **+ поле `files`**: массив `{ id, token, name }` из `project_files` (`ORDER BY id`)
- **Ошибки:** 404 `{"error":"Not found"}` (не найдена — только для не-админа; для админа 404 из пустой выборки), 403 `{"error":"Нет доступа к этой записи"}`, 401, 429, 500
- **Примечания:** доступ проверяется через `entryAccessible` только для `role !== 'admin'`. Фильтра `deleted_at IS NULL` нет — мягко удалённая запись читается, и её `deleted_at` возвращается. Поле `files.token` открыто отдаётся, но скачать файл по нему внешним ключом нельзя: `GET /api/files/:token` обслуживается сессионным `requireAuth`, а не `requireApiKey`. `:id` не валидируется → нечисловой `:id` даст зависший запрос. Аудита нет.
### `GET /api/v1/entries/:id/files`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет — пагинации и фильтров нет, отдаётся вся выборка
- **Тело:** нет
- **Ответ 200:** конверт `apiList`, где `total = limit = rows.length`, `offset = 0`; `items`: `id, token, name, created_at` (`project_files`, `ORDER BY id`)
- **Ошибки:** 404, 403, 401, 429, 500
- **Примечания:** та же проверка `entryAccessible`. `limit` в конверте нереальный (равен длине массива), клиентский `limit`/`offset` игнорируются — при записи с большим числом файлов ответ будет большим. Скачивание файла по `token` во внешнем API недоступно. Аудита нет.
### `GET /api/v1/lesson-reports`
- **Доступ:** API-ключ, scope `read`
- **Query:** `limit` (дефолт 50, 1..500), `offset` (дефолт 0), `group_id` (int), `date_from` / `date_to` (`'YYYY-MM-DD'`, сравнение с `lr.lesson_date::date`, **обе границы включительные**), `search` (строка, `%s%` → `lr.text ILIKE`)
- **Тело:** нет
- **Ответ 200:** `{ items, total, limit, offset }`; `items`: `id, group_id, lesson_date, lesson_time, topic, text, ai_status, author_id, created_at, updated_at, group_name`; `ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST, lr.id DESC`
- **Ошибки:** 401, 429, 500
- **Примечания:** даты фильтруются по колонке `lesson_date` (`DATE`), а не по `created_at`, поэтому зона не применяется и сравнение идёт напрямую. Филиалы — `branchScope` + `g.branch_id IN (...)` через `JOIN groups`. В ответе нет `text_ai`, `text_original`, `ai_error`, `branch_id` и истории версий — они доступны только в `/lesson-reports/:id`. Аудита нет.
### `GET /api/v1/lesson-reports/:id`
- **Доступ:** API-ключ, scope `read`
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** **плоский объект** — `lr.*` целиком (включая `text`, `text_original`, `text_ai`, `ai_status`, `ai_checked_at`, `ai_error`, `lesson_date`, `lesson_time`, `topic`, `author_id`, `branch_id`, `created_at`, `updated_at`) + `group_name`, `author_name`, `author_username`
- **Ошибки:** 404 `{"error":"Не найдено"}` (в т.ч. при нечисловом `:id`), 403 `{"error":"Нет доступа к этому отчёту"}`, 401, 429, 500
- **Примечания:** проверка через `lessonReportById` — здесь `:id` валидируется как целое `>= 1`, в отличие от `/groups/:id` и `/entries/:id`. История версий (`/versions`) и откат ИИ (`/ai/revert`) во внешнем API отсутствуют. Аудита нет.
### `POST /api/v1/entries`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело:** `student_name` (строка, **обязательна**, `reqStr(…, 150)`), `description` (строка, **обязательна**, `reqStr(…, 20000)`), `group_id` (int, **обязателен** — проверяется `lessonReportGroup`), `module_id` (опц.; `null`/`''` → NULL, иначе `optInt(…, 1)` + проверка существования в `modules`)
- **Ответ 201:** полная строка `entries` (`RETURNING *`)
- **Ошибки:** 400 (`Группа не выбрана` / `Модуль не найден`), 403 (`Нет доступа к этой группе`), 404 (`Группа не найдена`), 401, 429, 500
- **Примечания:** транзакция `BEGIN/COMMIT` (при ошибке — `ROLLBACK`, клиент освобождается в `finally`). Внутри транзакции сначала `INSERT INTO students (name) VALUES ($1) ON CONFLICT (name) DO NOTHING` — студент создаётся **без группы**, если такого имени ещё нет. В `entries` пишутся `student_name, group_id, module_id, description`, причём `description_original = description`. Аудит `api.entry.create` → `{ id, group_id, student_name }` + `via_api_key`. После мутации: `invalidateEntries()` + `invalidateStats()` + `broadcastEntryChanged()`. Ошибки `reqStr` бросаются наружу (не 400).
### `PUT /api/v1/entries/:id`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело (все поля опциональны, семантика «не передал — оставь как есть»):** `student_name` (`reqStr(…, 150)`), `description` (`reqStr(…, 20000)`), `group_id` (через `lessonReportGroup`), `module_id` (специальный флаг `hasModule`: `null`/`''` → установить NULL, иначе `optInt(…, 1)` + проверка существования)
- **Ответ 200:** полная строка `entries` (`RETURNING *`)
- **Ошибки:** 404, 403 `{"error":"Нет доступа к этой записи"}`, 400 (`Модуль не найден` / ошибки группы), 401, 429, 500
- **Примечания:** реализовано через `COALESCE($n, колонка)`, поэтому `null`-значение поля **не** очищает колонку (кроме `module_id`, у которого отдельный булев флаг). Переданный `description` одновременно записывается в `description_original` — это сбрасывает «оригинал тьютора» и отправляет запись на повторную ИИ-проверку неявно (сам `ai_status` тут **не** трогается). Перед UPDATE делается SELECT `before` для аудита. Аудит `api.entry.update` → `{ id, before }` + `via_api_key`. После: `invalidateEntries()` + `invalidateStats()` + `broadcastEntryChanged()`. Проверка `entryAccessible` — для не-админа; удалённую запись тоже можно обновить (нет фильтра `deleted_at`).
### `DELETE /api/v1/entries/:id`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** `{"ok": true}`
- **Ошибки:** 404, 403, 401, 429, 500
- **Примечания:** **мягкое удаление** — `UPDATE entries SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`. Физического удаления во внешнем API нет (корзина недоступна). Ответ `{ok:true}` возвращается даже если `rowCount === 0` (повторное удаление или несуществующий `:id` для админа) — код ответа это не отражает. Файлы записей не удаляются; их снесёт `purgeScheduledDeletions` по `purge_at`. Аудит `api.entry.delete` → `{ id }` + `via_api_key`. После: `invalidateEntries()` + `invalidateStats()` + `broadcastEntryChanged()`.
### `POST /api/v1/lesson-reports`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело:** `group_id` (int, **обязателен**), `lesson_date` (строка `'YYYY-MM-DD'`, **обязательна**, `parseLessonReportDate`), `lesson_time` (опц.; `'HH:MM'` или `'HH:MM:SS'`, нормализуется к `'HH:MM:SS'`; `null`/`''` → NULL; мусор → 400), `topic` (строка, опц., `trim()`, ≤ **300**; пустая → NULL), `text` (строка, **обязательна**, `trim()`, непустая, ≤ **5000**), `ai_check` (строго boolean `true`)
- **Ответ 201:** полная строка `lesson_reports` (`RETURNING *`)
- **Ошибки:** 400 (`Группа не выбрана` / `Некорректная дата занятия` / `Некорректное время занятия` / `Введите текст отчёта` / превышение лимитов символов / ошибки группы), 403, 404 (`Группа не найдена`), **409** `{"error":"За эту группу и дату отчёт уже есть — откройте его для редактирования", id: <id>}` (уникальность по `group_id + lesson_date`, в ответе возвращается id существующего), 401, 429, 500
- **Примечания:** `aiWanted = req.body.ai_check === true && (await getSetting('lesson_ai_enabled','true')) !== 'false'`. Если да — `text_original = text`, `text_ai = NULL`, `ai_status = 'pending'`, `ai_checked_at = now()`; иначе `text_original = NULL`, `ai_status = 'none'`, `ai_checked_at = NULL`. Строка `"true"` вместо boolean не срабатывает. Заполняются `author_id = req.user.id` и `branch_id` группы. Первая версия текста сохраняется через `saveLessonReportVersion(id, text, 'manual', req.user.id)`. Аудит `api.lesson_report.create` → `{ id, group_id, lesson_date }` + `via_api_key`. После: `invalidateLessonReports()` и `wakeLessonAiWorker()` — **`broadcastEntryChanged()` здесь нет**. Ответ возвращается сразу, не дожидаясь модели.
### `PUT /api/v1/lesson-reports/:id`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело (все поля опц., кроме правил ниже):** `lesson_date` (если передан — валидный `'YYYY-MM-DD'`, иначе остаётся текущий), `lesson_time` (если передан — `'HH:MM[:SS]'`), `text` (если передан — обязателен: непустая строка ≤ 5000; если передан `""` → 400), `topic` (если передан — строка ≤ 300; пустая → NULL), `ai_check` (строго boolean `true`)
- **Ответ 200:** полная строка `lesson_reports` (`RETURNING *`)
- **Ошибки:** 404, 403 (`Нет доступа к этому отчёту`), 400 (некорректные дата/время/пустой текст/превышение лимитов), **409** `{"error":"За эту группу и дату уже есть другой отчёт"}` (только если дата реально изменилась на занятую), 401, 429, 500
- **Примечания:** доступ — через `lessonReportById` (`:id` валидируется). Логика ИИ: `aiWanted` требует **и** непустой `body`, **и** `ai_check === true`, **и** включённую настройку. Тогда `text_original = text`, `text_ai = NULL`, `ai_status = 'pending'`, `ai_checked_at = now()`, `ai_error = NULL`. `topic` пишется только если поле присутствует в теле (отдельный булев флаг), `text` — через `COALESCE`. При изменении текста сохраняется версия `saveLessonReportVersion(..., 'manual', ...)`. Всегда обновляется `updated_at`. Аудит `api.lesson_report.update` → `{ id, lesson_date }` + `via_api_key`. После: `invalidateLessonReports()` и `wakeLessonAiWorker()` при `aiWanted`; `broadcastEntryChanged()` **не** вызывается.
### `DELETE /api/v1/lesson-reports/:id`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело:** нет
- **Ответ 200:** `{"ok": true}`
- **Ошибки:** 404, 403, 401, 429, 500
- **Примечания:** **жёсткое удаление** — `DELETE FROM lesson_reports WHERE id = $1` (в отличие от `DELETE /entries/:id`, который мягкий). История версий удаляется каскадом; восстановить отчёт через внешний API нельзя, в корзине его тоже нет. Аудит `api.lesson_report.delete` → `{ id }` + `via_api_key`. После: `invalidateLessonReports()`.
### `POST /api/v1/students`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело:** `name` (строка, **обязательна**, `reqStr(…, 150)`), `group_id` (опц.; не передан / `null` / `''` → NULL, иначе проверяется `lessonReportGroup`)
- **Ответ 201:** полная строка `students` (`RETURNING *`)
- **Ошибки:** 400 (`Группа не выбрана`), 403, 404 (`Группа не найдена`), 401, 429, 500
- **Примечания:** простой `INSERT`, без транзакции и без `ON CONFLICT` — в отличие от `POST /entries`, дубли имён тут не допускаются на уровне запроса. Аудит `api.student.create` → `{ id, name }` + `via_api_key`. После: `invalidateStudents()` + `invalidateStats()`. `broadcastEntryChanged()` не вызывается.
### `PUT /api/v1/students/:id`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет
- **Тело:** `name` (строка, **обязательна**, `reqStr(…, 150)`), `group_id` (опц.; не передан / `null` / `''` → NULL, иначе проверяется `lessonReportGroup`)
- **Ответ 200:** полная строка `students` (`RETURNING *`)
- **Ошибки:** 404 `{"error":"Not found"}`, 403 `{"error":"Нет доступа к этому ученику"}`, 400, 401, 429, 500
- **Примечания:** **важная особенность** — UPDATE без `COALESCE`: `SET name = $1, group_id = $2`. Если `group_id` не передан, связь с группой **молча сбрасывается в NULL**, и студент перестаёт быть виден в фильтрах по филиалам. `name` передать обязательно — хотя бы текущее значение. Проверка доступа — по текущей группе (`cur.rows[0].group_id`), студент без группы доступен любому не-админу. Аудит `api.student.update` → `{ id, name }` + `via_api_key`. После: `invalidateStudents()`. Удаления студента во внешнем API нет.
### `POST /api/v1/ai/wake`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет · **Тело:** нет
- **Ответ 200:** `{"ok": true}`
- **Ошибки:** 403 (нет `write`), 401, 429, 500
- **Примечания:** только `entryAutoChecker.notify()`. Это **пинок**, а не команда «обработать сейчас»: воркер берёт `pending` из БД через `FOR UPDATE SKIP LOCKED` и просыпается по своему backoff-циклу. Если воркер не инициализирован (`entryAutoChecker` null) — тихо `{ok:true}` без эффекта. Инвалидация кэша и broadcast не делаются. Аудит `api.ai.wake` → `{}` + `via_api_key`.
### `POST /api/v1/ai/requeue-failed`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет · **Тело:** нет
- **Ответ 200:** `{"ok": true, count: <rowCount>}`
- **Ошибки:** 403, 401, 429, 500
- **Примечания:** `UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE e.ai_status = 'error' AND e.deleted_at IS NULL` + `apiBranchClause(req.user, '(SELECT g.branch_id FROM groups g WHERE g.id = e.group_id)', params)` — ключ **не может** затронуть чужие филиалы; для не-админа без филиалов условие ` AND FALSE` и `count = 0`. Затем `entryAutoChecker.notify()`. Аудит `api.ai.requeue-failed` → `{ count }` + `via_api_key`. После: `invalidateEntries()` + `invalidateStats()`. `broadcastEntryChanged()` не вызывается.
### `POST /api/v1/photo-jobs/wake`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет · **Тело:** нет
- **Ответ 200:** `{"ok": true}`
- **Ошибки:** 403, 401, 429, 500
- **Примечания:** только `photoWorker.notify()` — тот же пинок, без гарантии немедленной обработки; при `photoWorker == null` тихо `{ok:true}`. Инвалидация и broadcast не делаются. Аудит `api.photo-jobs.wake` → `{}` + `via_api_key`.
### `POST /api/v1/photo-jobs/requeue-failed`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет · **Тело:** нет
- **Ответ 200:** `{"ok": true, count: <rowCount>}`
- **Ошибки:** 403, 401, 429, 500
- **Примечания:** `UPDATE photo_jobs p SET status = 'pending', error = NULL, finished_at = NULL WHERE p.status = 'error'` + `apiBranchClause(req.user, '(SELECT g.branch_id FROM groups g JOIN entries e ON e.id = p.entry_id WHERE g.id = e.group_id)', params)`. Отличие от `/ai/requeue-failed`: **нет** фильтра `deleted_at IS NULL`, поэтому в пересчёт попадают джобы мягко удалённых записей. Затем `photoWorker.notify()`. Аудит `api.photo-jobs.requeue-failed` → `{ count }` + `via_api_key`. После: `invalidateEntries()`.
### `POST /api/v1/entries/:id/ai/recheck`
- **Доступ:** API-ключ, scopes `read` + **`write`**
- **Query:** нет · **Тело:** нет
- **Ответ 200:** `{"ok": true}`
- **Ошибки:** 404 `{"error":"Запись не найдена"}` (если `rowCount === 0`), 403 `{"error":"Нет доступа к этой записи"}` (для не-админа), 401, 429, 500
- **Примечания:** `UPDATE entries SET ai_status = 'pending', ai_error = NULL, ai_checked_at = NULL WHERE id = $1` — точечный сброс одной записи (в отличие от массового `/ai/requeue-failed`, который берёт только `status = 'error'`). Фильтра `deleted_at IS NULL` нет. Затем `entryAutoChecker.notify()`; обработка всё равно асинхронная. Аудит `api.entry.ai.recheck` → `{ id }` + `via_api_key`. После: `invalidateEntries()` + `invalidateStats()` + `broadcastEntryChanged()`.
---
## 40. Матрица соответствия
«Внутренний API» — сессионный (`X-Auth-Token`), «Внешний» — `/api/v1` по API-ключу.
### Есть полный аналог
| Внутренний | Внешний | Совпадение контракта |
|---|---|---|
| `GET /api/groups` | `GET /v1/groups` | фильтры другие: только `limit`/`offset`/`deleted=1`; конверт `{items,total,limit,offset}` вместо `{groups,total}` |
| `GET /api/groups/:id` | `GET /v1/groups/:id` | набор колонок тот же |
| `GET /api/students` | `GET /v1/students` | внешний фильтр — только `search` (ILIKE по имени) |
| `GET /api/students/:id` | `GET /v1/students/:id` | внешний дополнительно отдаёт `profile` |
| `GET /api/modules` | `GET /v1/modules` | внешний: `search`, `active`; конверт вместо `{modules,total}` |
| `GET /api/entries` | `GET /v1/entries` | набор фильтров совпадает (`group_id`, `module_id`, `student_name`, `search`, `date_from`, `date_to`); внешний всегда `deleted_at IS NULL` |
| `GET /api/entries/:id` | `GET /v1/entries/:id` | внешний шире: добавляет `description_original`, `ai_status`, `deleted_at` и вложенный `files` |
| `GET /api/entries/:id/files` | `GET /v1/entries/:id/files` | внешний отдаёт только метаданные (`id, token, name, created_at`), без скачивания |
| `GET /api/lesson-reports` | `GET /v1/lesson-reports` | фильтры те же, сортировка та же |
| `GET /api/lesson-reports/:id` | `GET /v1/lesson-reports/:id` | внешний дополнительно `author_name` / `author_username` |
| `POST /api/entries` | `POST /v1/entries` | те же обязательные поля; внешний дополнительно автосоздаёт студента |
| `PUT /api/entries/:id` | `PUT /v1/entries/:id` | внешний: тот же COALESCE, но **нет** полей ИИ/фото |
| `DELETE /api/entries/:id` | `DELETE /v1/entries/:id` | оба мягкие (`deleted_at`) |
| `POST /api/lesson-reports` | `POST /v1/lesson-reports` | та же валидация, тот же `ai_check === true`, тот же конфликт 409 |
| `PUT /api/lesson-reports/:id` | `PUT /v1/lesson-reports/:id` | та же семантика `ai_check`, тот же конфликт 409 |
| `DELETE /api/lesson-reports/:id` | `DELETE /v1/lesson-reports/:id` | оба жёсткие |
| `POST /api/students` | `POST /v1/students` | контракт совпадает |
| `PUT /api/students/:id` | `PUT /v1/students/:id` | контракт совпадает, включая сброс `group_id` в NULL |
| `POST /api/ai/wake` | `POST /v1/ai/wake` | только пинок воркера |
| `POST /api/ai/requeue-failed` | `POST /v1/ai/requeue-failed` | внешний дополнительно ограничен филиалами ключа |
| `POST /api/photo-jobs/wake` | `POST /v1/photo-jobs/wake` | только пинок воркера |
| `POST /api/photo-jobs/requeue-failed` | `POST /v1/photo-jobs/requeue-failed` | внешний ограничен филиалами ключа и **не** фильтрует `deleted_at` |
| `POST /api/entries/:id/ai/recheck` | `POST /v1/entries/:id/ai/recheck` | идентично |
### Частичный аналог / урезан
| Внутренний | Внешний | Что потеряно |
|---|---|---|
| `GET /api/stats` | `GET /v1/stats` | наружу отдаются только 4 счётчика; `students` считается по `entries.student_name`, а не по таблице `students` |
| `GET /api/dashboard` | — | нет (сводок нет вообще) |
| `GET /api/branches` | `GET /v1/branches` | без пагинации; `groups_count` включает удалённые группы |
| `GET /api/ai/status`, `GET /api/photo-jobs/status` | — | нет способа проверить состояние очередей |
| `POST /api/entries/:id/ai/revert` | — | нет отката ИИ-правки |
| `POST /api/lesson-reports/:id/versions/:versionId/restore` | — | истории версий во внешнем API нет вообще |
| `GET /api/lesson-reports/:id/versions` | — | нет |
### Нет эквивалента
- **Файлы:** `POST /api/files`, `POST /api/entries/:id/files` (загрузка), `GET/DELETE /api/files/:id`, `POST /api/files/:id/detach`, `GET /api/files`, `GET /api/files/detached`, `GET /api/files/:token`, `GET /api/groups/:id/export/files`. Внешний API отдаёт только метаданные файлов — **загрузить или скачать файл ключом нельзя**.
- **Фото и галереи:** все `*/photos*`, `/photo/jobs*`, `/photo/enhance*`, `/photo/restore-original`, выбора главного фото, обложки и reorder.
- **Пользователи, роли, филиалы:** `/api/users*`, `/api/users/branches`, `/api/users/tutors`.
- **API-ключи:** `/api/api-keys`, `/:id`, `/:id/rotate`, `/meta` — внешний ключ не может управлять ключами.
- **Настройки и диагностика:** `/api/settings`, `/api/settings/logo`, `/api/public-settings`, `/api/system-info`, `/api/ai/enabled`, `/api/photo-ai/health`, `/api/events`.
- **Бэкап/восстановление:** `/api/backup`, `/api/backup/:token`, `/api/restore`.
- **Корзина:** `/api/trash`, `/api/trash/pending`, `POST /api/entries/:id/restore`, `/permanent`, `/unschedule`.
- **Share-ссылки:** `/api/links*`, `/api/share/*`.
- **ИИ-профили:** `/api/ai/profiles*`, `/api/ai/correct`, `/api/ai/queue`, `/api/ai/status`.
- **Уведомления:** `/api/notifications*` (включая SSE `/api/notifications/stream`).
- **Student-report (портфолио/экспорт):** `/api/export/student`, zip-выгрузка.
- **Аудит:** `/api/audit`, `/api/audit/:id` — писать через `apiAudit` можно, читать нет.
- **Баны IP:** `/api/bans*`.
- **Запись групп и модулей:** `POST/PUT/DELETE /api/groups*` (включая `restore`, `unschedule`, `active`), `POST/PUT/DELETE /api/modules*` (включая `restore`, `photo`) — во внешнем API только чтение.
- **Удаление студентов и массовые операции:** `DELETE /api/students/:id`, `POST /api/students/batch-group`, `*/profile`.
- **Wake воркера отчётов о занятии** — эндпоинта нет ни во внутреннем API, ни в `/api/v1`; будится неявно через `wakeLessonAiWorker()` из `POST`/`PUT /lesson-reports`.
---
## Приложение A. Индекс всех эндпоинтов
Документировано **173 уникальных маршрута**. В `server.js` объявлено 176 роутов — остальные три присутствуют в тексте как перекрёстные ссылки (например, `GET /api/public-settings` описан и в общих соглашениях, и в своём разделе).
| Маршрут | Раздел |
|---|---|
| `GET /api/v1/me` | 3. |
| `GET /api/public-settings` | 6. |
| `GET /api/events` | 8. |
| `GET /api/notifications/stream` | 8. |
| `POST /api/auth/login` | 9. |
| `POST /api/auth/logout` | 9. |
| `GET /api/auth/me` | 9. |
| `GET /api/bans` | 10. |
| `POST /api/bans` | 10. |
| `DELETE /api/bans/:ip` | 10. |
| `GET /api/notifications` | 11. |
| `GET /api/notifications/meta` | 11. |
| `POST /api/notifications/read-all` | 11. |
| `POST /api/notifications/:id/read` | 11. |
| `POST /api/notifications/test` | 11. |
| `DELETE /api/notifications/:id` | 11. |
| `DELETE /api/notifications` | 11. |
| `GET /api/users` | 12. |
| `GET /api/users/tutors` | 12. |
| `GET /api/users/branches` | 12. |
| `POST /api/users` | 12. |
| `PUT /api/users/:id` | 12. |
| `DELETE /api/users/:id` | 12. |
| `GET /api/api-keys/meta` | 13. |
| `GET /api/api-keys` | 13. |
| `POST /api/api-keys` | 13. |
| `PUT /api/api-keys/:id` | 13. |
| `DELETE /api/api-keys/:id` | 13. |
| `POST /api/api-keys/:id/rotate` | 13. |
| `GET /api/settings` | 14. |
| `PUT /api/settings` | 14. |
| `POST /api/settings/logo` | 14. |
| `DELETE /api/settings/logo` | 14. |
| `GET /api/audit` | 15. |
| `GET /api/audit/:id` | 15. |
| `POST /api/backup` | 16. |
| `GET /api/backup/:token` | 16. |
| `GET /api/backup` | 16. |
| `POST /api/restore` | 16. |
| `GET /api/links` | 17. |
| `POST /api/links` | 17. |
| `PUT /api/links/:id` | 17. |
| `DELETE /api/links/:id` | 17. |
| `GET /api/share/:token` | 17. |
| `GET /api/share/:shareToken/files/:fileToken` | 17. |
| `GET /s/:token` | 17. |
| `GET /r/:token` | 17. |
| `GET /api/groups` | 18. |
| `GET /api/groups/active` | 18. |
| `POST /api/groups` | 18. |
| `PUT /api/groups/:id` | 18. |
| `DELETE /api/groups/:id` | 18. |
| `PUT /api/groups/:id/restore` | 18. |
| `DELETE /api/groups/:id/permanent` | 18. |
| `PUT /api/groups/:id/unschedule` | 18. |
| `GET /api/branches` | 19. |
| `POST /api/branches` | 19. |
| `PUT /api/branches/:id` | 19. |
| `DELETE /api/branches/:id` | 19. |
| `GET /api/groups/:id/photos` | 20. |
| `POST /api/groups/:id/photos` | 20. |
| `PUT /api/groups/:id/photos/reorder` | 20. |
| `PUT /api/groups/:id/photos/:photoId` | 20. |
| `DELETE /api/groups/:id/photos/:photoId` | 20. |
| `PUT /api/groups/:id/photos/:photoId/cover` | 20. |
| `GET /api/modules` | 21. |
| `POST /api/modules` | 21. |
| `PUT /api/modules/:id` | 21. |
| `DELETE /api/modules/:id` | 21. |
| `PUT /api/modules/:id/restore` | 21. |
| `POST /api/modules/:id/photo` | 21. |
| `DELETE /api/modules/:id/photo` | 21. |
| `GET /api/lesson-reports` | 22. |
| `GET /api/lesson-reports/:id` | 22. |
| `POST /api/lesson-reports` | 22. |
| `PUT /api/lesson-reports/:id` | 22. |
| `GET /api/lesson-reports/:id/versions` | 22. |
| `POST /api/lesson-reports/:id/versions/:versionId/restore` | 22. |
| `POST /api/lesson-reports/:id/ai/revert` | 22. |
| `DELETE /api/lesson-reports/:id` | 22. |
| `GET /api/students` | 23. |
| `GET /api/students/names` | 23. |
| `POST /api/students` | 23. |
| `PUT /api/students/:id` | 23. |
| `POST /api/students/batch-group` | 23. |
| `DELETE /api/students/:id` | 23. |
| `GET /api/students/:id/profile` | 23. |
| `PUT /api/students/:id/profile` | 23. |
| `GET /api/students/:id/photos` | 24. |
| `POST /api/students/:id/photos` | 24. |
| `DELETE /api/students/:id/photos/:pid` | 24. |
| `PUT /api/students/:id/photos/:pid/main` | 24. |
| `GET /api/export/student` | 25. |
| `GET /api/groups/:id/export/files` | 25. |
| `GET /api/entries` | 26. |
| `GET /api/entries/:id` | 26. |
| `GET /api/entries/:id/files` | 26. |
| `POST /api/entries/:id/files` | 27. |
| `GET /api/files` | 27. |
| `GET /api/files/detached` | 27. |
| `POST /api/files/:id/detach` | 27. |
| `DELETE /api/files/:id` | 27. |
| `GET /api/files/:token` | 27. |
| `GET /api/photos` | 28. |
| `GET /api/stats` | 29. |
| `GET /api/system-info` | 30. |
| `GET /api/dashboard` | 31. |
| `POST /api/entries` | 32. |
| `PUT /api/entries/:id` | 32. |
| `DELETE /api/entries/:id` | 32. |
| `PUT /api/entries/:id/restore` | 32. |
| `DELETE /api/entries/:id/permanent` | 32. |
| `PUT /api/entries/:id/unschedule` | 32. |
| `POST /api/entries/:id/ai/recheck` | 32. |
| `POST /api/entries/:id/ai/revert` | 32. |
| `GET /api/entries/:id/photos` | 33. |
| `DELETE /api/entries/:id/photos/:photoId` | 33. |
| `PUT /api/entries/:id/photos/:photoId` | 33. |
| `PUT /api/entries/:id/photos/:photoId/main` | 33. |
| `PUT /api/entries/:id/photo/enhance` | 33. |
| `POST /api/entries/:id/photo/restore-original` | 33. |
| `POST /api/entries/:id/photo/enhance-ai` | 34. |
| `GET /api/entries/:id/photo/enhance-ai/:jobId` | 34. |
| `GET /api/entries/:id/photo/jobs` | 34. |
| `POST /api/entries/:id/photo/jobs/:jobId/apply` | 34. |
| `POST /api/entries/:id/photo/jobs/:jobId/reject` | 34. |
| `POST /api/entries/:id/photo/jobs/:jobId/rollback` | 34. |
| `DELETE /api/entries/:id/photo/enhance-ai/preview` | 34. |
| `POST /api/ai/correct` | 35. |
| `GET /api/ai/profiles` | 35. |
| `POST /api/ai/profiles` | 35. |
| `PUT /api/ai/profiles/:id` | 35. |
| `DELETE /api/ai/profiles/:id` | 35. |
| `POST /api/ai/profiles/activate` | 35. |
| `POST /api/ai/profiles/test` | 35. |
| `GET /api/ai/queue` | 35. |
| `GET /api/photo-ai/health` | 35. |
| `GET /api/ai/status` | 35. |
| `POST /api/ai/wake` | 35. |
| `POST /api/ai/enabled` | 35. |
| `POST /api/ai/requeue-failed` | 35. |
| `GET /api/photo-jobs/status` | 36. |
| `POST /api/photo-jobs/wake` | 36. |
| `POST /api/photo-jobs/enabled` | 36. |
| `POST /api/photo-jobs/requeue-failed` | 36. |
| `GET /api/trash` | 37. |
| `GET /api/trash/pending` | 37. |
| `DELETE /api/trash` | 37. |
| `GET /api/v1/branches` | 39. |
| `GET /api/v1/groups` | 39. |
| `GET /api/v1/groups/:id` | 39. |
| `GET /api/v1/students` | 39. |
| `GET /api/v1/students/:id` | 39. |
| `GET /api/v1/modules` | 39. |
| `GET /api/v1/stats` | 39. |
| `GET /api/v1/entries` | 39. |
| `GET /api/v1/entries/:id` | 39. |
| `GET /api/v1/entries/:id/files` | 39. |
| `GET /api/v1/lesson-reports` | 39. |
| `GET /api/v1/lesson-reports/:id` | 39. |
| `POST /api/v1/entries` | 39. |
| `PUT /api/v1/entries/:id` | 39. |
| `DELETE /api/v1/entries/:id` | 39. |
| `POST /api/v1/lesson-reports` | 39. |
| `PUT /api/v1/lesson-reports/:id` | 39. |
| `DELETE /api/v1/lesson-reports/:id` | 39. |
| `POST /api/v1/students` | 39. |
| `PUT /api/v1/students/:id` | 39. |
| `POST /api/v1/ai/wake` | 39. |
| `POST /api/v1/ai/requeue-failed` | 39. |
| `POST /api/v1/photo-jobs/wake` | 39. |
| `POST /api/v1/photo-jobs/requeue-failed` | 39. |
| `POST /api/v1/entries/:id/ai/recheck` | 39. |
---
## Приложение B. Неясности, «острые углы» и особенности, найденные при разборе кода
Ниже — места, которые требуют внимания при работе через API: подтверждённые расхождения в поведении, потенциальные баги и пункты, которые не удалось полностью проверить по коду. Пометка **“?”** означает «нужно подтвердить рантаймом».
### Открытые вопросы
Ниже — то, что **не удалось подтвердить кодом** или что в коде противоречиво. Не додумывать при использовании.
1. **409 Conflict используется только для уникальности.** Все найденные `res.status(409)` — это `e.code === '23505'` (violation unique constraint) в хендлерах пользователей/групп/филиалов. Других конфликтных ситуаций (например, «занято» вне unique-индекса) в коде нет.
2. **`API_KEY_DEFAULT_RPM` не читается из env.** В `server.js:976` это жёсткая константа `120`. В `.env.example` переменной `API_KEY_DEFAULT_RPM` нет. Значение из prompt'а («дефолт из env `API_KEY_DEFAULT_RPM`») кодом **не подтверждается** — потребитель env не может его переопределить.
3. **`ADMIN_PASSWORD` в `.env.example` есть, но как auth-механизм не используется** — только автосоздание первого админа в пустой БД (`server.js:567-571`). В API он не принимается ни в каком виде.
4. **Глобальный error middleware есть**, хотя в правилах проекта написано «no global error handler». Фактически в конце `server.js` два глобальных обработчика: 404 (`7611`) и error-500 (`7618`). Тело ответа на 500 всегда `{ error: 'Internal server error' }` — реальные тексты ошибок из хендлеров теряются.
5. **Расхождение по Tailscale:** `start-tailscale.sh:16` указывает funnel на `http://127.0.0.1:3003`, а `AGENTS.md` и `docker-compose.yml:130` говорят про HTTPS 3443. Какая схема актуальна — по скрипту 3003.
6. **`?play` не проверяет значение** — принимается любая непустая строка, включая `?play=0`. Формально контракт «`?play=1`» в коде не выражен.
7. **`X-Admin-Token` не поддерживается** — проверено: 0 вхождений `x-admin-token` в `server.js` (упоминание есть только в устаревшем `SECURITY_AUDIT_RU.md`).
8. **Хранение токена на фронте — `sessionStorage`, а не `localStorage`** (в prompt'е указано localStorage). Ключ `authToken`; очищается при logout. Следствие: после закрытия вкладки сессия на клиенте теряется, хотя серверная сессия живёт 30 суток.
9. **Пагинация внутреннего API не имеет дефолтного `limit`** — при отсутствии параметра отдаётся вся выборка. Для больших таблиц (`/api/entries`, `/api/photos`, `/api/files`) это потенциально тяжёлые ответы; верхнего предела нет.
10. **Форма `date_to` не включительная** — реализована как `< (дата + 1 день)`. Клиент обязан сам прибавлять единицу, иначе потеряет последний день. Это неочевидно и не отражено в схеме ответа.
11. **`getStackInfo` (`server.js:815`) — вне кэша ответа** и используется только в `GET /api/system-info` (`server.js:5594`). Как независимый публичный контракт не документируется; состав блоков `app/deps/runtime/database/cache/storage` следует из `AGENTS.md`, точные поля требуют сверки с самим хелпером.
12. **Тексты SSE-события `lessons_changed` не существует** — внутренний тип есть, наружу уходит `entries_changed`. Клиент, различающий типы, работать не будет.
13. **Состав SSE `ready`-события** точно — `{ total, unread }` (взято из `GET /api/notifications`, где используется `counts.total`/`counts.unread`, `server.js:1738`); полный набор полей `notificationsCounts` не проверялся.
14. **Полей ответа `POST /api/entries` в части файлов** — `{ ...rows[0], files: <count>, photos: <count> }` (`server.js:5799`), т.е. **числа**, а не массивы объектов. Токены файлов в этом ответе нет — за ними нужно идти через `GET /api/entries/:id/files`. У `POST /api/entries/:id/files` ответ `{ ok, count }` — тоже без токенов.
15. **`res.on('finish')` persist-hook не срабатывает при `statusCode >= 400`** — загруженные файлы остаются в `uploads/` (чистка — `sweepOrphanedUploads`). Это ожидаемо, но стоит учитывать при отладке «файл не появился».
16. **Загрузка файлов на `/api/v1/*` не поддержана** — на `apiV1` нет ни одного multipart-роута, только JSON. Файлы создаются только через внутренний API.
17. **`notice` про авторизацию `optionalAuth` на `/api/students` и `/api/groups`**: при анонимном запросе кэш ключа `scopeKey(req.user)` даёт `'anon'`, то есть персонализированный и общий ответы лежат в разных ключах кэша — проблемы инвалидации не видно, но проверить не удалось.
Если `limit` не передан — **выборка не ограничена по объёму**. Отрицательные и нечисловые значения игнорируются.
**3. Частные случаи:**
- `GET /api/notifications` (`1720-1721`): `limit` дефолт 30, максимум 100; `offset` от 0; дополнительно `unread=1`.
- `GET /api/audit` (`2282`): `Math.min(parseInt(req.query.limit, 10) || 100, 1000)` — дефолт 100, максимум 1000.
- `GET /api/share/:token` (`2940`): `optInt(req.query.limit, 1, 200)` — дефолт 1, максимум 200.
- `GET /api/photos`, `GET /api/files` — мягкий шаблон без дефолта (`5438`).
---
| `entryLimiter` | 15 мин | **10** | `rateLimitStore('entry', 900000)` | по IP | `POST /api/entries` (`5693`) — публичная форма отправки |
| `fileLimiter` | 15 мин | **300** | `rateLimitStore('file', 900000)` | по IP | `GET /uploads/thumb/:name` (`654`), `GET /uploads/.originals/:name` (`660`), `GET /api/share/:token` (`3084`), `GET /api/share/:shareToken/files/:fileToken` (`3180`), `GET /api/files/:token` (`5352`) |
| `notificationLimiter` | 15 мин | **600** | `rateLimitStore('notify', 900000)` | по IP | `GET /api/notifications` (`1719`), `POST /api/notifications/read-all` (`1776`), `POST /api/notifications/:id/read` (`1791`), `POST /api/notifications/test` (`1807`) |
| `apiKeyLimiter` | 60 с | динамический: `rate_limit_per_min` ключа, кап `API_KEY_MAX_RPM=10000`, дефолт `API_KEY_DEFAULT_RPM=120` | `rateLimitStore('apikey', 60000)` | `'k' + req.apiKey.id` для аутентифицированных, `'ip' + ipKeyGenerator(ipOf(req))` иначе | весь `/api/v1/*` через `apiV1.use(apiKeyLimiter)` (`7025`) |
Тексты `message` (тело ответа 429):
- `apiLimiter` / `notificationLimiter` / `fileLimiter`: `{ error: 'Слишком много запросов. Попробуйте позже.' }` (`546`, `1716`, `564`)
- `entryLimiter`: `{ error: 'Слишком много запросов. Подождите немного.' }` (`555`)
- `apiKeyLimiter`: `{ error: 'Превышен лимит запросов для API-ключа' }` (`990`)
Дополнительно (не rate limit, а бан IP через `ipGuard`, см. раздел «Бан IP»): пороги `login-bruteforce` 10, `honeypot` 1, `apikey-bruteforce` 30, `share-password-bruteforce` 10.
Роутов под `apiLimiter`/`fileLimiter` **нет** для `GET /uploads/*` — прямая отдача объектов (`server.js:674-684`) не ограничена.
---
- `keyGenerator`: `'k' + req.apiKey.id` для аутентифицированных, `'ip' + ipKeyGenerator(ipOf(req))` для неавторизованных.
- Ответ при превышении: **429** `{ error: 'Превышен лимит запросов для API-ключа' }`. `standardHeaders: true`, `legacyHeaders: false`.
- Подбор ключа: `recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS)` (`server.js:1094`).
---
### «?» / неясности
- `reqStr`/`optStr` (импорт из `backup-restore.js`) **бросают** `Error('Invalid string' | 'Invalid string length')`, а не возвращают 400. В async-хендлере Express 4 такой throw не пробрасывается в error-handler (`next(err)` не вызывается) → по коду запрос, вероятно, остаётся без ответа, срабатывает только `process.on('unhandledRejection')` (`server.js:7631`). Фактический статус/поведение **не подтверждены кодом**; затронуты `POST /api/users`, `PUT /api/users/:id`, `POST|PUT /api/api-keys`.
- `GET /api/audit`: `limit` клипуется только сверху (`Math.min(..., 1000)`), нижняя граница не проверяется — поведение при `limit <= 0` кодом не задано.
- `DELETE /api/users/:id` не возвращает 404 для несуществующего id (похоже на недосмотр, но так и есть).
- `GET /api/notifications/stream` — единственный маршрут `/api/notifications` без rate limit.
- Точные ключи объектов `counts` (`POST /api/backup`) и `restored` (`POST /api/restore`) задаются в `backup-restore.js` (`BACKUP_TABLES`, `restoredCounts`) — в этом диапазоне `server.js` не перечислены.
- `POST /api/settings/logo`: два текста ошибки об изображениях расходятся (со `jfif` и без) — какая копия актуальна, по коду не определено.
- `GET /api/backup` (строка 2624) не был в списке задания, но попал в диапазон и описан.
### Неясности и замечания к коду
- **`optInt` в `GET /api/links`** (server.js:2940–2941) бросит исключение вне диапазона 1..200. Роут — `async` без `try/catch`; Express 4 не передаёт rejected-promise в error-middleware (server.js:7618), а обработчик `unhandledRejection` только пишет в лог. Ожидаемое поведение — 500 «Internal server error», фактическое — вероятный зависший запрос. Проверить не удалось, отмечено «?».
- **`GET /api/groups/active`** не фильтрует по филиалам и не требует авторизации — группы любых филиалов видны анонимно. Это соответствует коду, но расходится с политикой `branchScope`.
- **`GET /api/groups`** без токена тоже отдаёт все группы без ограничения по филиалам (`req.user` отсутствует → `branchWhere` не вызывается) — кэш-ключ при этом `groups:list:anon`.
- **Модули (`POST`/`PUT /api/modules`, фото модуля)** не вызывают `invalidateGroups()`/`invalidateEntries()`, хотя `GET /api/modules` не кэшируется — вероятно безопасно, но единообразие с группами нарушено (помечено «?»).
- **`DELETE /api/branches/:id`** при несуществующем `:id` отвечает `200 { ok: true }` (rowCount не проверяется), тогда как `PUT` в той же группе отдаёт 404 «Не найдено».
- **`PUT /api/groups/:id`** пишет в аудит всё тело запроса (`...req.body`) — payload не ограничен; в фото-эндпоинтах аудит ограничен id.
- **`logAudit` / `cache.publish`**: в этом диапазоне мутации вызывают только `invalidate*()` (сброс кэша) — прямых вызовов `cache.publish` для share/groups/branches/photos/modules нет, SSE-события (`broadcastEntryChanged` и др.) здесь не вызываются.
- **Кэш `GET /api/groups`** ключуется по `scopeKey(req.user)`; при смене филиалов пользователя старый ключ не сбрасывается и живёт до истечения TTL (60 c) — инвалидации кэша групп при мутации `user_branches` здесь нет.
- **`GET /s/:token` и `GET /r/:token`** — единственные маршруты диапазона без rate-limiter'а и без какой-либо валидации токена.
- **`GET /api/groups/:id/photos`** не проверяет, что группа не в корзине (`deleted_at`), и не ограничивает `limit` (в отличие от `optInt` в `/api/links`) — неверный `limit` молча игнорируется.
### «?» — неясности и замечания по коду
1. `GET /api/export/student`: выборки «шапки» (строка ученика из `students`, `student_photos`, `group_photos`) **не ограничены филиалами** — `branchWhere` применяется только к записям, фото записей, файлам и отчётам. Ученик из чужого филиала может попасть в имя, группу, филиал и фотографии отчёта.
2. `GET /api/export/student`: `group_photos` берутся по `group_id` ученика **без фильтра периода и филиала** — в архив попадут все фото хроники группы.
3. `sanitizeStudentProfile` (`backup-restore.js:205-270`) **не сохраняет** `profile.photo_path`, хотя экспорт читает `student.profile && student.profile.photo_path` (`server.js:4644`, `4656`). Эта ветка всегда даёт `null` и откатывается на `students.photo_path` — либо подразумевается недокументированное поле профиля.
4. `DELETE /api/students/:id` для `admin`: проверки существования нет — `{ ok: true }` и аудит `student.delete` возвращаются даже при 0 удалённых строк.
5. `POST /api/students` / `PUT /api/students/:id`: длина имени не проверяется (только `name?.trim()`), ограничение даёт БД (`VARCHAR(150)` + `UNIQUE`); `group_id` приводится `Number()` без проверки целого — нечисловое значение уйдёт в FK.
6. `POST /api/students/batch-group`: `group_id` не проходит `reqInt`; `student_ids` фильтруется как `Number(x) && truthy` — `0` отбрасывается, дробные значения остаются. `UPDATE` не ограничен филиалами переносимых учеников (проверяется только целевая группа).
7. `GET /api/lesson-reports`: `limit`/`offset` разбираются `parseInt` без валидации — невалидное значение молча отключает `LIMIT`/`OFFSET`, 400 не возвращается.
8. `GET /api/lesson-reports` отдаёт наружу `text_original`, `text_ai`, `ai_error` и полный `text` — ограничения на объём ответа нет.
9. `POST`/`PUT /api/lesson-reports` при `ai_check === true` только вызывают `wakeLessonAiWorker()`: на момент ответа состояние отчёта `ai_status = 'pending'`, роут модель не ждёт.
10. `POST /api/lesson-reports/:id/versions/:versionId/restore`: в аудите пишется `source: 'ai_revert'` (`server.js:4109`) — по смыслу для восстановления версии ожидалось бы `'restore'`; вероятно, копипаст из соседнего роута.
11. `PUT /api/students/:id/profile`: `profile` перезаписывается всегда (в `NULL`, если ключ не передан), а `photo_path` — только при наличии ключа. Частичное обновление профиля не поддержано.
12. `GET /api/students/names`: не исключает мягко удалённые записи (`e.deleted_at IS NULL` в фильтре нет) — в выдачу попадут имена из удалённых записей.
13. `PUT /api/students/:id`: аудит `student.update` не содержит `group_id`, поэтому перевод ученика в другую группу в журнале аудита не виден.
14. `GET /api/export/student` и `GET /api/groups/:id/export/files` собирают весь архив в памяти (`zip.toBuffer()`) и отдают без стриминга; лимита на суммарный размер нет, `fileLimiter` не применяется.
### Неясности и замечания по коду
- **Нет аудита мутаций.** Ни `POST /api/entries/:id/files`, ни `POST /api/files/:id/detach`, ни `DELETE /api/files/:id` не вызывают `logAudit` — все три меняют файлы/БД и только инвалидируют кэш (`invalidateShare()`, `invalidateEntries()`). Это расходится с правилом проекта «мутации аудируются `logAudit(...)`». `?` — возможно, аудит ведётся на стороне вызывающего фронтенда; в строках 5000–5700 вызовов `logAudit` нет.
- **Валидация параметров отсутствует почти везде:** `limit`/`offset` без дефолта и максимума (0, `abc`, отрицательные и огромные значения просто отбрасываются или уходят в SQL как есть), `date_from`/`date_to` не проверяются на формат, `group_id`/`module_id`/`id`/`token` не приводятся к int. SQL параметризован корректно — риска инъекции нет, но есть риск дорогого запроса без `LIMIT` (`GET /api/entries`, `GET /api/photos`, `GET /api/files/detached`).
- **`indexOf` при построении плейсхолдеров в `GET /api/stats`** (`s.ids.map(id => '$' + s.ids.indexOf(id) + 1)`): при дубликатах в `user_branches` повторяющиеся филиалы дадут один номер плейсхолдера, а `params` сохранит исходную длину → сдвиг параметров и ошибка Postgres. В `GET /api/dashboard` и в списках используется `.map((_, i) => '$' + (i + 1))` — там корректно. `?` — уникальность `user_branches.branch_id` вероятно гарантирована схемой, но код на это явно не опирается.
- **Разное отношение к 404 в `GET /api/entries/:id/files`:** у не-admin проверка существования записи есть, у admin — нет, и при загрузке в несуществующую запись `POST /api/entries/:id/files` ответит `201 { ok: true, count: N }` и вставит строки в `project_files` со ссылкой на несуществующий `entry_id`. `?` — защищено ли это внешним ключом в `db/init.sql`.
- **N+1 к хранилищу:** `GET /api/files` и `GET /api/files/detached` вызывают `storage.sizeOf` для каждой строки (для `detached` — по всем отсоединённым файлам, т.к. `limit` опционален); `GET /api/system-info` считает размер каждого `group_photos.photo_path` и каждого `entries.photo_path`. Батчинга и кэша нет.
- **Удаление файла не в транзакции:** `safeUnlink(path)` идёт до `DELETE FROM project_files`; при сбое удаления остаётся «висячий» файл (его подберёт `sweepOrphanedUploads`). Порядок выбран, чтобы не оставить строку БД без файла.
- **`GET /api/files/:token` публичный:** доступ к файлу даёт знание 32-hex токена, защита — только rate limit `fileLimiter` (300/15 мин). Фильтра по филиалам нет по построению — это осознанная ссылка для шаринга.
- **Кэш `stats`/`dashboard` живёт до 15 секунд** и не инвалидируется мутациями (`invalidateStats()` в этом диапазоне не вызывается) — после правки записи цифры обновятся не сразу.
- **`?` play-режим:** условие `req.query.play` проверяется на истинность (любое непустое значение, включая `play=0`), документирован только `?play=1`.
- **`GET /api/photos` и `module_photo`:** фото тем модулей недоступны не-admin всегда (`branch_id = NULL::int` в подзапросе против `IN (…)`), и то же касается фото учеников без группы (`LEFT JOIN groups`). Похоже на осознанное ограничение, но в UI выглядит как «часть фото пропала».
### Сводка неясностей («?»)
1. **`POST /api/ai/correct` — необработанный throw из `reqStr` (вероятный баг).** `reqStr(req.body?.text, 5000)` вызывается **до** `try`, а хелпер из `backup-restore.js` бросает `Error('Invalid string')`. В проекте нет глобального error-handler, а Express 4 не ловит rejected-promise из async-хендлера → при отсутствующем/нестроковом/пустом `text` запрос, вероятно, зависнет без ответа. Отсюда же — 400 `Текст не указан` недостижим: `reqStr` бросает на пустой строке раньше, чем сработает проверка. **Требует подтверждения рантаймом.**
2. **`GET /api/photo-jobs/status` — та же проблема с `optInt`.** `optInt(req.query.recent_limit, 1, 200)` и `optInt(req.query.recent_offset, 0, …)` бросают исключение вне `try` при нецелом значении (`?recent_limit=abc`). Значения по умолчанию при этом заменены `?? 15` / `?? 0`, но до них выполнение не дойдёт.
3. **`invalidateSettings()` отсутствует в `POST /api/ai/enabled` и `POST /api/photo-jobs/enabled`,** хотя в `PUT/DELETE /api/ai/profiles*` он есть. Нужно ли сбрасывать кэш `setting:` после смены `ai_autocheck_enabled` / `photo_worker_enabled` — по коду не видно (значения читаются напрямую из БД через `getSetting`).
4. **`GET /api/ai/profiles` возвращает `api_key` профилей открытым текстом** (профили целиком лежат в `settings.ai_profiles` и приходят в ответе). Это by-design (нужно для формы редактирования) или утечка — из кода не следует.
5. **`PUT /api/entries/:id` не вызывает `broadcastEntryChanged()`** и не проверяет существование `group_id` (только `module_id`) — в отличие от публичного `POST /api/entries`. Осознанно или упущение — не подтверждено.
6. **`DELETE /api/entries/:id` не проверяет `rowCount`** → для admin удаление несуществующей записи вернёт `{ ok: true }`. Аналогично `PUT /api/entries/:id/ai/recheck` (там 404 есть через `RETURNING id`).
7. **Разные имена ключа job id:** `POST …/enhance-ai` возвращает `jobId`, а `GET …/enhance-ai/:jobId` — `job_id`. Фронтенд обязан знать оба; в коде константы нет.
8. **`GET /api/entries/:id/photos` для admin не проверяет существование записи** (только филиальную доступность для не-admin) — несуществующий `:id` даст `200 []`, а не 404.
9. **`DELETE /api/entries/:id/photo/enhance-ai/preview` и `/reject` пишут один и тот же аудит `entry.photo.reject`** и не вызывают `invalidateEntries()` — сознательно (фото не применялось) или нет.
10. **`PUT /api/entries/:id/photos/:photoId/main` не меняет `sort_order`** — обложка записи и первый фото в галерее могут различаться; фронтенд так и запрашивает.
11. **`POST /api/entries/:id/photo/jobs/:jobId/apply`:** ветка «уже применён» (400 `Результат уже применён`) проверяется **до** сравнения `entries.photo_path === job.after_path`, хотя следующая строка явно рассчитана на идемпотентный возврат 200 — порядок проверок выглядит противоречивым.
12. **`POST /api/entries/:id/photo/enhance-ai` при пустом теле:** `hasParams` считается по всем ключам body; при отсутствии body создаётся задание с `action='ai'` и `params = null` (без модели/лица) — воркер подставит дефолты. Подтверждено кодом, но не документацией.