Добавлен плавающий виджет «Поля карточки» в журнале: набор видимых полей карточки отчёта по ученику (фото, фамилия, имя, группа, модуль, тема, дата, файлы) задаётся пользователем и сохраняется в localStorage. Имя теперь рендерится двумя спанами, чтобы скрывать частично; добавлен блок мета-информации с датой и чипами файлов — картинки открываются в лайтбоксе, играбельное видео воспроизводится в #videoModal через ?play=1, остальное скачивается. Каталог полей вынесен в REPORT_CARD_FIELDS как единственный источник правды, без дублирования в HTML. Также добавлена документация HTTP API (API.md, 45 разделов): адреса и транспорт, аутентификация сессиями и API-ключами, лимиты частоты, дата/время/часовой пояс, матрица соответствия и полный индекс эндпоинтов с ссылками на строки server.js.
279 KiB
WhatIDo — HTTP API
Полная документация HTTP-API приложения WhatIDo (учебный центр: журнал посещений, проекты студентов, галерея групп, файлы, публичные витрины).
Документ описывает всё, что реально принимает и отдаёт сервер, и собран напрямую из кода server.js (7728 строк), backup-restore.js, storage.js, redis.js. Ссылки вида server.js:5693 указывают на точные строки, из которых взят факт.
Что покрыто:
| Часть | Содержание |
|---|---|
| Разделы 1–9 | Общие соглашения: транспорт, аутентификация, API-ключи, rate limits, ошибки, даты и таймзона, загрузка файлов, SSE |
| Разделы 10–40 | 176 маршрутов внутреннего API (/api/*) и внешнего API (/api/v1/*) — столько же, сколько объявлено в server.js |
| Приложение A | Индекс всех маршрутов с указанием раздела |
| Приложение B | Известные особенности, «острые углы» и неясности, найденные при разборе кода |
Внутри одного приложения работают два независимых механизма аутентификации — их нельзя смешивать:
| Механизм | Заголовок | Где работает | Кто может использовать |
|---|---|---|---|
| Сессия | X-Auth-Token: <token> |
весь /api/* |
любой активный пользователь (роль admin — для админских маршрутов) |
| API-ключ | X-Api-Key: wsk_… или Authorization: Bearer wsk_… |
только /api/v1/* |
внешние системы; ключ не даёт доступа к UI |
X-Admin-Tokenне поддерживается — это legacy-заголовок, возвращающий401.ADMIN_PASSWORDиз.env— только для автосоздания первого администратора в пустой БД, это не механизм авторизации API.
Быстрый старт
1. Получить сессионный токен
BASE=http://localhost:3003
TOKEN=$(curl -s -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"ВАШ_ПАРОЛЬ"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
Ответ: { "token": "<64 hex>", "expires_at": "2026-11-09T…Z" }. Срок сессии — 30 дней.
curl -s "$BASE/api/auth/me" -H "X-Auth-Token: $TOKEN"
# {"id":1,"username":"admin","name":"…","role":"admin","is_active":true,"branch_ids":[]}
2. Работать с журналом
# Последние 20 записей
curl -s "$BASE/api/entries?limit=20&offset=0" -H "X-Auth-Token: $TOKEN"
# Создать запись (multipart: фото + файлы проекта)
curl -s -X POST "$BASE/api/entries" \
-H "X-Auth-Token: $TOKEN" \
-F student_name="Иван Иванов" \
-F group_id=1 \
-F description="Готовая работа" \
-F photo=@work.png \
-F files=@source.psd
# Мягко удалить / восстановить / отменить удаление
curl -s -X DELETE "$BASE/api/entries/42" -H "X-Auth-Token: $TOKEN"
curl -s -X PUT "$BASE/api/entries/42/restore" -H "X-Auth-Token: $TOKEN"
curl -s -X PUT "$BASE/api/entries/42/unschedule" -H "X-Auth-Token: $TOKEN"
3. Выпустить API-ключ для внешней системы
# Сессией администратора
curl -s -X POST "$BASE/api/api-keys" \
-H "X-Auth-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Интеграция 1С","scopes":["read","write"],"rate_limit_per_min":120,"branch_ids":[1]}'
# {"id":3,"key":"wsk_9f2c…","prefix":"wsk_9f2c1ab34…","scopes":["read","write"],…}
Секрет wsk_… возвращается один раз и больше не восстанавливается — в БД лежит только sha256(ключа).
KEY=wsk_9f2c…
curl -s "$BASE/api/v1/entries?limit=10" -H "X-Api-Key: $KEY"
curl -s "$BASE/api/v1/groups" -H "Authorization: Bearer $KEY"
4. Подписаться на события
curl -N "$BASE/api/notifications/stream?token=$TOKEN"
curl -N "$BASE/api/events?token=$TOKEN"
Как читать этот документ
Доступ:—публичный/requireAuth(любой активный пользователь) /requireAdmin/API-ключ read|write. ГлобальныйipGuardдополнительно возвращает403 {"error":"Доступ заблокирован"}для забаненных IP на любом маршруте.Филиалы:— появляется там, где запрос ограниченuser_branchesдля не-админов. Админ ограничений не имеет.Тело (JSON):/Тело (multipart):/Query:— поля с типами, обязательностью, дефолтами и диапазонами ровно так, как их проверяет код.Ответ:— фактическая форма ответа (конверты списков в проекте разные — см. раздел «Формат ответов, ошибки и пагинация»).Ошибки:— реальные тексты{"error": "…"}из кода, а не абстрактные описания.Примечания:— аудит, инвалидация кэша, SSE-события, работа фоновых воркеров, побочные эффекты.
Обозначения ? в конце пунктов означают «проверено не полностью, требует подтверждения» — такие места собраны в Приложении B.
Оглавление
- 1. Базовые адреса и транспорт
- 2. Аутентификация (сессии): как это работает
- 3. Внешний API: API-ключи (общие правила)
- 4. Ограничения частоты
- 5. Формат ответов, ошибки и пагинация
- 6. Даты, время и часовой пояс
- 7. Загрузка файлов (multipart)
- 8. Потоки событий (SSE)
- 9. Аутентификация: эндпоинты
- 10. Баны IP
- 11. Уведомления
- 12. Пользователи
- 13. API-ключи (внутренний UI)
- 14. Настройки
- 15. Аудит
- 16. Бэкап и восстановление
- 17. Публичные ссылки (share)
- 18. Группы
- 19. Филиалы
- 20. Фотохроника группы
- 21. Модули
- 22. Отчёты о занятиях
- 23. Студенты
- 24. Фото студентов
- 25. Экспорт
- 26. Записи журнала (чтение)
- 27. Файлы
- 28. Фотографии
- 29. Статистика
- 30. Системная информация
- 31. Дашборд
- 32. Записи журнала (создание и правка)
- 33. Фото записи
- 34. ИИ-улучшение фото
- 35. ИИ-профили и очередь
- 36. Фото-джобы: статус и управление воркером
- 37. Корзина
- 38. Внешний API api-v1 — обзор
- 39. Эндпоинты
- 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_MSheadersTimeout = UPLOAD_REQUEST_TIMEOUT_MS + 60000UPLOAD_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:685app.use(express.static(path.join(__dirname, 'public')))—server.js:686, без явногоmaxAgeCache-Controlmiddleware (server.js:666-672): для путей, не начинающихся с/uploadsи/vendor, ставитсяno-cacheGET /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 (порядок важен)
app.set('trust proxy', 'loopback')—server.js:538;req.ipберётся изX-Forwarded-Forтолько для loopback.ipOf(req)—server.js:453.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 в проекте нет.app.use(express.json({ limit: '1mb' }))—server.js:589. Лимит тела JSON — 1 МБ; multipart разбирает multer.app.use(ipGuard)—server.js:590, телоserver.js:494-504: читает бан из кэша поbanKey(ipOf(req)); еслиbanned_until > now()→ 403{ error: 'Доступ заблокирован' }на любой маршрут.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), порядок приоритета:
X-Api-Key: <raw>Authorization: Bearer <raw>(regex/^Bearer\s+(\S+)$/i)- иначе
''
Работает только на /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>, TTLSESSION_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>с TTLAPI_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. Storecache.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. Даты, время и часовой пояс
Три семейства данных
- 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. Зона отображения применяется на клиенте. - чистая DATE — колонки
DATE(lesson_date,taken_at,date_from). Отдаются строкой'YYYY-MM-DD', без приведения кDate()на клиенте, иначе UTC-полночь сдвинет дату на день назад. - чистая TIME — колонки
TIME(lesson_time,time_start/time_end). Отдаются строкой'HH:MM'или'HH:MM:SS'; зона не применяется.
Подтверждение со стороны БД: types.setTypeParser(1082, v => v) (server.js:44) — тип date (OID 1082) отдаётся драйвером как строка, без преобразования в JS-объект.
Настройки
| Ключ | Значение по умолчанию | Валидация | Где |
|---|---|---|---|
timezone |
DEFAULT_TIMEZONE |
validTimezone() через new Intl.DateTimeFormat('ru-RU', { timeZone: tz }), иначе откат на дефолт |
server.js:1239-1252; проверка в PUT /api/settings — 2198-2200 (400) |
time_format |
'24h' |
только '24h' или '12h', иначе 400 { error: 'time_format должен быть 24h или 12h' } |
server.js:1254-1256; проверка 2201-2202 |
DEFAULT_TIMEZONE = process.env.TZ, если он проходит validTimezone, иначе жёстко 'Europe/Moscow' (server.js:1239-1242). В Dockerfile:2 задано ENV TZ=Europe/Moscow — это только фолбэк; фактическая зона берётся из настройки в БД.
Серверные хелперы: appTimezone() (1249-1252), appHour12() (1254-1256).
Границы дней в SQL
Фильтры по датам в query-параметрах
| Параметр | Смысл | Где применяется |
|---|---|---|
date_from |
включительно, начало дня в зоне настройки | GET /api/entries (5008), /api/photos (5381), /api/files (5244), /api/share/:token (3117), /api/v1/entries (7198) |
date_to |
не включительно: реализован как < tzDayEnd, т.е. < (дата + 1 день) 00:00 |
те же |
Следствие: клиент, которому нужен включительный верх, должен передать date_to на единицу больше.
Прочие фильтры внутреннего API: group_id, module_id, student_name, search, deleted=1, unread=1. В /api/v1: group_id, module_id, student_name, search (ILIKE по student_name и description), date_from, date_to (server.js:7190-7198).
GET /api/public-settings
server.js:2117-2139, под apiLimiter, без авторизации, ответ кэшируется на PUBLIC_TTL_MS = 60 * 1000.
Белый список ключей (server.js:2119):
system_name, system_logo, footer_left, footer_right,
share_show_student_message, share_show_entry_date, share_show_student_names,
share_show_group_photos, cookie_notice_text, spam_interval_min,
photo_capture_resolution, photo_capture_quality, photo_enhance_engine,
camera_enabled, photo_ai_face_mode, photo_ai_face_model, photo_ai_device_pref,
timezone, time_format
Дефолты (server.js:2120): system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_device_pref: 'auto', timezone: DEFAULT_TIMEZONE, time_format: '24h'.
Дополнительно вычисляются/добавляются:
| Поле | Как получено | Источник |
|---|---|---|
photo_capture_width / photo_capture_height |
разбор photo_capture_resolution regex /^(\d{2,5})x(\d{2,5})$/, при неудаче '640' / '480' |
2123-2130 |
photo_capture_quality (нормализованная) |
parseFloat, допускается только [0.5, 1], иначе '0.92' |
2131-2132 |
photo_ai_enabled |
'true' если PHOTO_AI_URL задан, иначе 'false' |
2135 |
upload_file_limit_mb |
String(UPLOAD_FILE_LIMIT_MB) |
2136 |
upload_total_limit_mb |
String(UPLOAD_TOTAL_LIMIT_MB) |
2137 |
Значения лимитов отдаются строками. Фронт обязан читать их отсюда, а не хардкодить.
TIMESTAMPTZ-колонки фильтруются только через tzDayStart / tzDayEnd / tzWall + bindTz (server.js:1258-1266):
tzDayStart(idx) → ( $N::date::timestamp AT TIME ZONE $TZ$ )
tzDayEnd(idx) → ( $N::date::timestamp + interval '1 day') AT TIME ZONE $TZ$ )
tzWall() → ( now() AT TIME ZONE $TZ$ )
bindTz(sql, params, tz) подставляет зону только если в SQL есть литерал $TZ$ — иначе Postgres ответит bind message supplies 1 parameters, but prepared statement requires 0. Это критично: при отсутствии фильтра по датам $TZ$ в SQL нет, и зона в params не добавляется.
DATE-колонки (lr.lesson_date) сравниваются напрямую, зона не нужна.
7. Загрузка файлов (multipart)
Транспорт — multipart/form-data. Все загрузки проходят через multer, который всегда пишет на диск в uploads/ с именем <timestamp>-<random6><ext> (server.js:1174-1199, 1202-1223). Путь, сохраняемый в БД, — всегда /uploads/<name>; ключ объекта в S3 — тот же <name> (плюс .originals/<name> для оригиналов фото).
Лимиты
UPLOAD_FILE_LIMIT_MB = Math.max(1, env.UPLOAD_FILE_LIMIT_MB || 50) // server.js:1166
UPLOAD_TOTAL_LIMIT_MB = Math.max(UPLOAD_FILE_LIMIT_MB, env.UPLOAD_TOTAL_LIMIT_MB || 200) // server.js:1167
MAX_FILE_UPLOAD_BYTES = UPLOAD_FILE_LIMIT_MB * 1024 * 1024
MAX_TOTAL_UPLOAD_BYTES = UPLOAD_TOTAL_LIMIT_MB * 1024 * 1024
Тексты ошибок формируются динамически (server.js:1170-1171):
Файл слишком большой (макс. ${UPLOAD_FILE_LIMIT_MB} МБ)— приerr.code === 'LIMIT_FILE_SIZE'Суммарный размер файлов слишком велик (макс. ${UPLOAD_TOTAL_LIMIT_MB} МБ)— при превышении суммы
Значения отдаются в GET /api/public-settings как upload_file_limit_mb и upload_total_limit_mb (строки, server.js:2136-2137). Лимит бэкапа отдельно: BACKUP_UPLOAD_LIMIT_MB, дефолт 500 (server.js:2457, .env.example:5).
Три конфигурации multer
| Конфиг | Назначение | Фильтр расширений |
|---|---|---|
upload (server.js:1174) |
фото | поле photo — только изображения (ALLOWED_IMAGE_EXT: jpg, jpeg, png, gif, webp, bmp, avif, ico, heic, heif, jfif); остальное — проверка BLOCKED_EXT; ошибки 'Only images', 'Not allowed extension' |
adminUpload (server.js:1202) |
файлы проекта | ADMIN_ALLOWED_EXT: pdf, doc, docx, txt, md, html, htm, zip, rar, 7z + изображения. BLOCKED_EXT блокируется, если расширения нет в списке |
uploadBackup (server.js:2460) |
восстановление | только \.(?:tar\.gz|tgz|gz)$; пишет во временный каталог os.tmpdir()/wido-up-XXXX, не в uploads/ |
BLOCKED_EXT (server.js:1164) — regexp, запрещающий html, htm, js, mjs, cjs, svg, xml, json, map, wasm, php*, phtml, asp*, jsp, sh, bat, cmd, cgi, exe, dll, com, msi, scr, hta, vbs, py, r, rb, htaccess.
Эндпоинты и имена полей form-data
| Эндпоинт | Multer | Имя поля | maxCount | Ответ |
|---|---|---|---|---|
POST /api/entries |
upload.fields |
photo + files |
по 10 на каждое | 201 { ...row, files: <count>, photos: <count> } (server.js:5799) |
PUT /api/entries/:id |
upload.array |
photo |
10 | обновлённая запись |
POST /api/entries/:id/files |
adminUpload.array |
files |
10 | 201 { ok: true, count: <count> } (server.js:5161) |
POST /api/groups/:id/photos |
upload.single |
photo |
1 | { photos: rows, photo_path } (server.js:3542, 3545) |
POST /api/modules/:id/photo |
upload.single |
photo |
1 | — (server.js:3771) |
POST /api/students/:id/photos |
upload.single |
photo |
1 | { photos: rows, photo_path } (server.js:4360, 4343) |
POST /api/settings/logo |
upload.single |
logo |
1 | пишет настройку system_logo = /uploads/<name> (server.js:2233-2260) |
POST /api/restore |
uploadBackup.single |
backup |
1 | результат restore с version |
POST /api/entries/:id/photo/enhance-ai |
upload.single |
photo |
1 | (server.js:6032) |
Обработка ошибок multer везде одинаковая: LIMIT_FILE_SIZE → 400 с FILE_TOO_LARGE_ERROR, 'Only images' → 400 с текстом про изображения, 'Not allowed extension' → 400 'Недопустимый тип файла', иначе → 400 'Недопустимый файл'. Примеры блоков: server.js:5694-5701 (записи), 5114-5119 (файлы), 2236-2240 (логотип).
Что возвращается и как доставать файл
Клиенту не возвращается путь в S3/bucket. Возвращаются:
- Токен файла —
project_files.token, 32 hex (crypto.randomBytes(16).toString('hex'),server.js:5789). Ссылка на скачивание:GET /api/files/:token. - Путь вида
/uploads/<timestamp>-<rand6>.<ext>— вentries.photo_path,entry_photos.photo_path,project_files.path,groups.cover_path,students.photo_path,modules.photo_path, настройкеsystem_logo. Открывается напрямую:GET /uploads/<name>. - Списки:
GET /api/entries/:id/files→ голый массив{ id, token, name }(server.js:5106); внутри записи — полеfiles(server.js:5043).
Отдача файлов, Range и ?play=1
GET /api/files/:token (server.js:5352-5372), под fileLimiter:
- Поиск
project_filesпоtoken→ нет: 404{ error: 'Not found' }. storage.keyFromPath(r.path)не дал ключа → 404{ error: 'File missing' }.- Картинка (
isImageName: jpg, jpeg, jfif, png, gif, webp, bmp, avif, ico) →Cache-Control: public, max-age=31536000, immutable; при?thumb— миниатюра 480px WebP. - Видео в браузере (
BROWSER_VIDEO_EXT= mp4, m4v, webm, ogv) и задан?play→sendPlayableFile(). - Иначе —
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= ISOnow + 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= ISOnow + 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(envPHOTO_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, код multerLIMIT_FILE_SIZE); 400 «Логотип: допустимы только изображения (jpg, png, gif, webp, bmp, avif, ico, heic, heif)» (ошибка multerOnly 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 JOINusers(может быть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-memoryMapна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(токен файла не подходит под фильтры ссылки), 404File 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); при несуществующем:idrowCount не проверяется и ответ всё равно 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)>, TTLPUBLIC_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(нет строки по токену), 404File 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(«Тема модуля»). Для не-adminmodule_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(drivers3/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_usedstack.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_sstack.database:engine: 'PostgreSQL',version(SHOW server_version),host(толькоhostname[:port]изDATABASE_URL),pool_total,pool_idle,pool_waitingstack.cache:engine: 'Redis',driver(redis/memory),version,ready,enabled,host(hostname[:port] изREDIS_URL),keys,used_memory,uptime_s,hits,misses,fallback_ops,errors; при недоступном Redisdriver='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_localstack.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 минут; 400Spam 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→ 404Not 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:brightness10..300 (100),contrast10..300 (100),saturate0..300 (100),sharp0..100 (0),denoise0..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, дефолт envPHOTO_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 симв. (срезаются хвостовые/),model1..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 сервиса разбирается мягко (при не-JSONdata = 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/≤0LIMIT/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требует scoperead. Ни один эндпоинт не доступен безread.apiV1.use(apiKeyLimiter)(server.js:7025) — сразу после аутентификации.- Ключ передаётся в заголовке
X-Api-KeyлибоAuthorization: Bearer <key>(apiKeyFromRequest,server.js:997). Больше никаких способов аутентификации нет. - Мутации дополнительно проходят
apiWrite('write')(server.js:7291). Без scopewrite—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 делается SELECTbeforeдля аудита. Аудит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(строго booleantrue) - Ответ 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(строго booleantrue) - Ответ 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-циклу. Если воркер не инициализирован (entryAutoCheckernull) — тихо{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: подтверждённые расхождения в поведении, потенциальные баги и пункты, которые не удалось полностью проверить по коду. Пометка “?” означает «нужно подтвердить рантаймом».
Открытые вопросы
Ниже — то, что не удалось подтвердить кодом или что в коде противоречиво. Не додумывать при использовании.
- 409 Conflict используется только для уникальности. Все найденные
res.status(409)— этоe.code === '23505'(violation unique constraint) в хендлерах пользователей/групп/филиалов. Других конфликтных ситуаций (например, «занято» вне unique-индекса) в коде нет. API_KEY_DEFAULT_RPMне читается из env. Вserver.js:976это жёсткая константа120. В.env.exampleпеременнойAPI_KEY_DEFAULT_RPMнет. Значение из prompt'а («дефолт из envAPI_KEY_DEFAULT_RPM») кодом не подтверждается — потребитель env не может его переопределить.ADMIN_PASSWORDв.env.exampleесть, но как auth-механизм не используется — только автосоздание первого админа в пустой БД (server.js:567-571). В API он не принимается ни в каком виде.- Глобальный error middleware есть, хотя в правилах проекта написано «no global error handler». Фактически в конце
server.jsдва глобальных обработчика: 404 (7611) и error-500 (7618). Тело ответа на 500 всегда{ error: 'Internal server error' }— реальные тексты ошибок из хендлеров теряются. - Расхождение по Tailscale:
start-tailscale.sh:16указывает funnel наhttp://127.0.0.1:3003, аAGENTS.mdиdocker-compose.yml:130говорят про HTTPS 3443. Какая схема актуальна — по скрипту 3003. ?playне проверяет значение — принимается любая непустая строка, включая?play=0. Формально контракт «?play=1» в коде не выражен.X-Admin-Tokenне поддерживается — проверено: 0 вхожденийx-admin-tokenвserver.js(упоминание есть только в устаревшемSECURITY_AUDIT_RU.md).- Хранение токена на фронте —
sessionStorage, а неlocalStorage(в prompt'е указано localStorage). КлючauthToken; очищается при logout. Следствие: после закрытия вкладки сессия на клиенте теряется, хотя серверная сессия живёт 30 суток. - Пагинация внутреннего API не имеет дефолтного
limit— при отсутствии параметра отдаётся вся выборка. Для больших таблиц (/api/entries,/api/photos,/api/files) это потенциально тяжёлые ответы; верхнего предела нет. - Форма
date_toне включительная — реализована как< (дата + 1 день). Клиент обязан сам прибавлять единицу, иначе потеряет последний день. Это неочевидно и не отражено в схеме ответа. getStackInfo(server.js:815) — вне кэша ответа и используется только вGET /api/system-info(server.js:5594). Как независимый публичный контракт не документируется; состав блоковapp/deps/runtime/database/cache/storageследует изAGENTS.md, точные поля требуют сверки с самим хелпером.- Тексты SSE-события
lessons_changedне существует — внутренний тип есть, наружу уходитentries_changed. Клиент, различающий типы, работать не будет. - Состав SSE
ready-события точно —{ total, unread }(взято изGET /api/notifications, где используетсяcounts.total/counts.unread,server.js:1738); полный набор полейnotificationsCountsне проверялся. - Полей ответа
POST /api/entriesв части файлов —{ ...rows[0], files: <count>, photos: <count> }(server.js:5799), т.е. числа, а не массивы объектов. Токены файлов в этом ответе нет — за ними нужно идти черезGET /api/entries/:id/files. УPOST /api/entries/:id/filesответ{ ok, count }— тоже без токенов. res.on('finish')persist-hook не срабатывает приstatusCode >= 400— загруженные файлы остаются вuploads/(чистка —sweepOrphanedUploads). Это ожидаемо, но стоит учитывать при отладке «файл не появился».- Загрузка файлов на
/api/v1/*не поддержана — наapiV1нет ни одного multipart-роута, только JSON. Файлы создаются только через внутренний API. 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молча игнорируется.
«?» — неясности и замечания по коду
GET /api/export/student: выборки «шапки» (строка ученика изstudents,student_photos,group_photos) не ограничены филиалами —branchWhereприменяется только к записям, фото записей, файлам и отчётам. Ученик из чужого филиала может попасть в имя, группу, филиал и фотографии отчёта.GET /api/export/student:group_photosберутся поgroup_idученика без фильтра периода и филиала — в архив попадут все фото хроники группы.sanitizeStudentProfile(backup-restore.js:205-270) не сохраняетprofile.photo_path, хотя экспорт читаетstudent.profile && student.profile.photo_path(server.js:4644,4656). Эта ветка всегда даётnullи откатывается наstudents.photo_path— либо подразумевается недокументированное поле профиля.DELETE /api/students/:idдляadmin: проверки существования нет —{ ok: true }и аудитstudent.deleteвозвращаются даже при 0 удалённых строк.POST /api/students/PUT /api/students/:id: длина имени не проверяется (толькоname?.trim()), ограничение даёт БД (VARCHAR(150)+UNIQUE);group_idприводитсяNumber()без проверки целого — нечисловое значение уйдёт в FK.POST /api/students/batch-group:group_idне проходитreqInt;student_idsфильтруется какNumber(x) && truthy—0отбрасывается, дробные значения остаются.UPDATEне ограничен филиалами переносимых учеников (проверяется только целевая группа).GET /api/lesson-reports:limit/offsetразбираютсяparseIntбез валидации — невалидное значение молча отключаетLIMIT/OFFSET, 400 не возвращается.GET /api/lesson-reportsотдаёт наружуtext_original,text_ai,ai_errorи полныйtext— ограничения на объём ответа нет.POST/PUT /api/lesson-reportsприai_check === trueтолько вызываютwakeLessonAiWorker(): на момент ответа состояние отчётаai_status = 'pending', роут модель не ждёт.POST /api/lesson-reports/:id/versions/:versionId/restore: в аудите пишетсяsource: 'ai_revert'(server.js:4109) — по смыслу для восстановления версии ожидалось бы'restore'; вероятно, копипаст из соседнего роута.PUT /api/students/:id/profile:profileперезаписывается всегда (вNULL, если ключ не передан), аphoto_path— только при наличии ключа. Частичное обновление профиля не поддержано.GET /api/students/names: не исключает мягко удалённые записи (e.deleted_at IS NULLв фильтре нет) — в выдачу попадут имена из удалённых записей.PUT /api/students/:id: аудитstudent.updateне содержитgroup_id, поэтому перевод ученика в другую группу в журнале аудита не виден.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 limitfileLimiter(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 выглядит как «часть фото пропала».
Сводка неясностей («?»)
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бросает на пустой строке раньше, чем сработает проверка. Требует подтверждения рантаймом.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, но до них выполнение не дойдёт.invalidateSettings()отсутствует вPOST /api/ai/enabledиPOST /api/photo-jobs/enabled, хотя вPUT/DELETE /api/ai/profiles*он есть. Нужно ли сбрасывать кэшsetting:после сменыai_autocheck_enabled/photo_worker_enabled— по коду не видно (значения читаются напрямую из БД черезgetSetting).GET /api/ai/profilesвозвращаетapi_keyпрофилей открытым текстом (профили целиком лежат вsettings.ai_profilesи приходят в ответе). Это by-design (нужно для формы редактирования) или утечка — из кода не следует.PUT /api/entries/:idне вызываетbroadcastEntryChanged()и не проверяет существованиеgroup_id(толькоmodule_id) — в отличие от публичногоPOST /api/entries. Осознанно или упущение — не подтверждено.DELETE /api/entries/:idне проверяетrowCount→ для admin удаление несуществующей записи вернёт{ ok: true }. АналогичноPUT /api/entries/:id/ai/recheck(там 404 есть черезRETURNING id).- Разные имена ключа job id:
POST …/enhance-aiвозвращаетjobId, аGET …/enhance-ai/:jobId—job_id. Фронтенд обязан знать оба; в коде константы нет. GET /api/entries/:id/photosдля admin не проверяет существование записи (только филиальную доступность для не-admin) — несуществующий:idдаст200 [], а не 404.DELETE /api/entries/:id/photo/enhance-ai/previewи/rejectпишут один и тот же аудитentry.photo.rejectи не вызываютinvalidateEntries()— сознательно (фото не применялось) или нет.PUT /api/entries/:id/photos/:photoId/mainне меняетsort_order— обложка записи и первый фото в галерее могут различаться; фронтенд так и запрашивает.POST /api/entries/:id/photo/jobs/:jobId/apply: ветка «уже применён» (400Результат уже применён) проверяется до сравненияentries.photo_path === job.after_path, хотя следующая строка явно рассчитана на идемпотентный возврат 200 — порядок проверок выглядит противоречивым.POST /api/entries/:id/photo/enhance-aiпри пустом теле:hasParamsсчитается по всем ключам body; при отсутствии body создаётся задание сaction='ai'иparams = null(без модели/лица) — воркер подставит дефолты. Подтверждено кодом, но не документацией.