Files
WhatIDo/AGENTS.md
T
dev 5667198c9b feat(api): управление ИИ-воркерами через внешний API
Внешние системы не могли разбудить воркер, переочередить упавшие
задания или отправить запись на повторную ИИ-проверку: все эти роуты
существовали только во внутреннем API под requireAdmin.

Добавлено на apiV1 (все под apiWrite('write')):
- POST /ai/wake, /photo-jobs/wake — пинок воркеров
- POST /ai/requeue-failed, /photo-jobs/requeue-failed — error -> pending
- POST /entries/:id/ai/recheck — повторная проверка конкретной записи

Филиальная изоляция (главное в этом изменении):
- внутренние requeue-failed делают UPDATE по всей таблице; перенос их
  как есть позволил бы ключу с ограничением по филиалу переочередить
  чужие задания, что ломает правило «ключ не шире выдавшего»
- добавлен хелпер apiBranchClause(user, expr, params): пустая строка
  для admin, AND FALSE при пустом списке филиалов, иначе
  AND <expr> = ANY($N::int[]); применён к обоим массовым UPDATE
- entries фильтруется через groups.branch_id, photo_jobs — через
  photo_jobs -> entries -> groups

Аудит через apiAudit() с префиксом api., метки добавлены в
public/js/audit.js; после мутаций invalidateEntries/invalidateStats
и broadcastEntryChanged.

Воркер отчётов о занятии wake-эндпоинта не получает: он будится сам
из POST/PUT /lesson-reports при ai_check === true.

Документация: таблица эндпоинтов и раздел про воркеров в README.md,
правило apiBranchClause в AGENTS.md 3f.

Проверено: изолированный тест на двух филиалах — requeue-failed
ключом одного филиала вернул count 1 из двух ошибочных заданий,
запись и фото-джоб чужого филиала остались в error, recheck чужой
записи 403; api-keys.selftest.js 61 PASS, api.smoketest.js 76 PASS,
регрессий нет.

Замечание: server.js запечён в образ, compose монтирует только
uploads/, поэтому restart правку не подхватит — нужен
./scripts/deploy.sh или docker compose up -d --build app.
2026-10-05 00:06:38 +03:00

50 KiB
Raw Blame History

AGENT.md — Developer Agent Guidelines for WhatIDo

This document defines how AI agents should work with the WhatIDo codebase. Follow these rules strictly.

Обязательное правило: разведку, чтение и анализ репозитория делают субагенты, а не основной агент — контекст основного агента не должен раздуваться простынями кода. См. Agent Workflow.


Project Overview

WhatIDo — Accounting system for an educational center: attendance journal, student project works, group gallery, detached files, and public showcase pages (share links).

  • Stack: Node.js 20 + Express, PostgreSQL 16, Redis 7, Docker Compose, S3-совместимое хранилище файлов, Tailscale (Serve/Funnel)
  • Architecture: Single Express server (server.js) + storage abstraction (storage.js) + cache/pub-sub abstraction (redis.js) + static frontend in public/
  • Deployment: Docker Compose (app + db + s3 + tailscale), bind-mounted uploads, named volumes for Postgres and S3 data
  • Auth: сессии в БД. POST /api/auth/login (bcrypt) → токен в заголовке X-Auth-Token. Второй способ для внешних систем — API-ключи в X-Api-Key (см. 3f), они не дают доступа к UI и живут только в /api/v1/*. Роли: admin и не-admin, ограниченные филиалами (user_branches). ADMIN_PASSWORD используется только для автосоздания первого админа в пустой БД — это не механизм авторизации API

Agent Workflow (обязательные правила работы)

Главное правило: исследование репозитория выполняют субагенты, а не основной агент. Контекст основного агента — самый дорогой ресурс проекта: он живёт дольше одной задачи и должен содержать план, решения и точные точки правки, а не выгрузку файлов. Любой поиск, чтение и анализ файлов, которые пользователь явно не назвал, отдаются субагенту (spawn_agent).

Что делегировать суагенту (обязательно)

  • Разведку: «где реализовано X», «какие эндпоинты трогают Y», «кто вызывает Z» — search_codebase + точечное чтение.
  • Чтение крупных файлов: server.js, worker.js, storage.js, redis.js, public/js/*.js, README.md, AGENTS.md целиком. Субагент возвращает релевантные куски, основной агент файл целиком не читает.
  • Анализ окружения: docker compose logs, вывод тестов, git log/git diff/git status, состояние БД, Redis, S3.
  • Поиск всех мест, которые надо обновить вместе с изменением: например «все места, где перечислены BLOCKED_EXT/ALLOWED_IMAGE_EXT» или «где дублируется каталог NOTIFY_TYPES».
  • Ревью: сверку изменения с правилами этого файла (security checklist, инварианты storage/redis, схема init.sql + migration.sql).
  • Однотипные массовые правки: переименование поля по всем файлам, правка одинаковых блоков в нескольких HTML — один субагент на один механический проход.

Что основной агент делает сам

  • Формулирует план и разбивает задачу на узкие подзадачи.
  • Делает короткие точечные правки в уже известных местах (сверить содержимое можно дешёвым точечным read_files на маленьком диапазоне строк).
  • Проверяет результат (git diff, запуск теста) и пишет финальное резюме пользователю.

Как ставить задачу субагенту

  • Один субагент — одна узкая подзадача. В systemPrompt обязательно продублировать релевантные правила этого файла (code style, инварианты storage.*/redis.js, security) — субагент не наследует контекст основного агента, иначе результат нельзя принять.
  • Требовать формат ответа: путь:строка + короткая выдержка + вывод. Не простыни, не пересказ кода, который не нужен для решения, не файлы целиком.
  • Независимые разведки запускать параллельно, а не последовательно.
  • Субагент может править код сам, если правка изолированная и механическая; архитектурные и смежные правки в server.js / db/*.sql делает основной агент.
  • Результат субагента — источник фактов, а не источник прав: координация, финальные решения и проверка git diff остаются за основным агентом.

Чего не делать

  • ❌ Читать и искать по репозиторию в основном агенте там, где задачу можно делегировать.
  • ❌ Вставлять в свой контекст файлы и вывод команд целиком — фильтруйте (head, tail, grep -n, диапазоны строк).
  • ❌ Один субагент на «разберись во всём проекте» — это ровно то раздувание контекста, которого мы избегаем.

Development Rules

1. Code Style

  • No comments unless explicitly requested
  • ES modules not used — CommonJS (require) throughout
  • Error handling: try/catch with explicit status codes, no global error handler
  • Validation: Inline helper functions (reqInt, reqStr, optInt, etc.) — use them
  • Security first: All uploads validated, path traversal blocked, rate limits on public routes

2. Database

  • Schema: Defined in db/init.sql (runs on first container start)
  • Migrations: db/migration.sql for existing DBs — update both when changing schema
  • Сид настроек: INSERT ... ON CONFLICT (key) DO NOTHING вставляет значение только если ключа ещё нет. Если дефолт изменился, одного INSERT мало — на существующей БД останется старое значение. Обязательно добавляй в db/migration.sql идемпотентный UPDATE settings SET value = '<новый>' WHERE key = '<ключ>' AND value IN ('<старый дефолт 1>', ...), как это сделано для ai_prompt и lesson_ai_prompt. Условие по value IN (...) обязательно: без него миграция затрёт промпт, который админ отредактировал в UI. В db/init.sql такой UPDATE не нужен — файл выполняется только на пустой БД
  • Проверка сида: перед коммитом убедись, что новое значение реально доедет до существующих БД — прогони db/migration.sql в транзакции с откатом (BEGIN; + файл + SELECT + ROLLBACK;) и убедись, что ON_ERROR_STOP=1 не дал ошибок
  • Connection: Single Pool from pg, DATABASE_URL from env
  • Queries: Parameterized only ($1, $2...), never string interpolation
  • Transactions: Use client.query('BEGIN') / COMMIT / ROLLBACK for multi-statement ops

3. File Uploads

  • Multer configs: upload (images only), adminUpload (wider allowed ext), uploadBackup (restore)
  • Видео в интерфейсе: mp4/m4v/webm/ogv играются в модалке #videoModal в journal.html (data-video в filesHTML); остальные видео (mov, mkv, avi, …) остаются обычными ссылками на скачивание. Отдача — GET /api/files/:token?play=1 inline с Accept-Ranges; без ?play=1 файл по-прежнему уходит как attachment, чтобы старые ссылки не поменяли поведение
  • Limits: UPLOAD_FILE_LIMIT_MB per file (default 50), UPLOAD_TOTAL_LIMIT_MB per entry (default 200) — both env-driven; UPLOAD_REQUEST_TIMEOUT_MS overrides the auto-computed request timeout. The frontend reads the two MB values from GET /api/public-settings (upload_file_limit_mb, upload_total_limit_mb) — do not hardcode them again in public/js/index.js
  • Staging: Multer always writes to uploads/ (timestamp-random.ext); a global res.on('finish') hook persists each uploaded file through storage.persist on successful responses (only when STORAGE_DRIVER=s3)
  • HEIC: Auto-converted to JPEG via heic-convert
  • Cleanup: safeUnlink / sweepOrphanedUploads — never delete outside uploads/ or the configured bucket

3a. Storage (storage.js)

  • Drivers: local (default, files in uploads/) and s3 (S3-compatible: SeaweedFS by default, MinIO via docker-compose.minio.yml)
  • Keys are stable: DB stores /uploads/<name>; S3 object keys are the same <name> (plus .originals/<name>). Never change key format — it would break existing DB rows and URLs
  • API: put, putFile, head, exists, sizeOf, getStream, getBuffer, getRange, del, copyObject, listAll, localize, persist, streamTo, streamRangeTo, downloadAll, uploadTree, ensureBucket, usage, pruneCache
  • Диапазоны: getRange(key, start, end) и streamRangeTo(res, key, start, end, opts) отдают 206 с Content-Range/Accept-Ranges — только для медиа, разбор Range на стороне сервера (parseByteRange)
  • Rules: never call fs.* on uploads/ directly in request/worker code — use storage.*. safeUnlink is the only deletion helper (local + remote, idempotent)
  • Read path: STORAGE_LOCAL_FALLBACK=1 prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
  • Cache: .thumbs (WebP miniatures) and .cache (originals localized for sharp/zip) live inside uploads/ and are pruned hourly (STORAGE_CACHE_MAX_AGE_HOURS)
  • Never publish the S3 API port: only 127.0.0.1 on the host, file access stays behind app auth/rate limits

3b. Redis (redis.js)

  • Единственная точка доступа: createRedis({ url, prefix }) — все операции кэша/счётчиков/pub-sub идут через неё
  • API: get, set, del, dropPrefix, dropMatch, clear, wrap, incr, publish, on, rateLimitStore, info, connect, close
  • Graceful fallback — обязательное требование: при недоступном Redis все операции уходят в in-memory backend с той же семантикой. Приложение обязано стартовать и работать без Redis
  • Первое подключение ограничено по времени (REDIS_CONNECT_TIMEOUT_MS, 5 с): node-redis не отклоняет connect() при недоступном сервере, а повторяет попытки бесконечно — без таймаута старт приложения зависнет навсегда
  • Переподключение: node-redis переподключается сам; по событию ready подписки и subscriber-клиент восстанавливаются (ensureSubscriber). Не пересоздавать subscriber через destroy() — это гонка с внутренним teardown node-redis
  • Ключи: get/set сами добавляют namespace (REDIS_PREFIX, по умолчанию whatido), dropPrefix/dropMatch тоже. В rateLimitStore префикс добавляется один раз в base — не применяйте fullKey повторно
  • scanDelete: курсор SCAN в node-redis v5 обязан быть строкой, числовой 0 вызовет TypeError. Возвращаемое значение курсора — тоже строка, сравнивайте с '0'
  • resetTime в rateLimitStore.increment обязан быть Date — express-rate-limit v8 вызывает resetTime.getTime()
  • Пабликация всегда отдаёт подписчикам строку (JSON), независимо от бэкенда — иначе fallback и Redis расходятся по формату
  • Инвалидация — по префиксу (SCAN + DEL), точечного удаления по ключу избегайте
  • Ключевые пространства: setting:, groups:, students:, entries:, lessons:, stats:, dashboard:, share:payload:, public-settings, system-info, session:, ban:, fail:, rl:
  • Сессии: loadUserByToken кэширует пользователя на 30 с. Любая мутация users / sessions / user_branches обязана вызывать invalidateSessions() или удалять session:<token>, иначе деактивированный пользователь сохранит доступ
  • Секреты: пароль только в REDIS_URL / REDIS_PASSWORD, порт 6379 публикуется лишь на 127.0.0.1

3c. Уведомления (server.js, worker.js, public/)

  • Каталог событий — только NOTIFY_TYPES в server.js (тип → label, hint, icon, level, enabled по умолчанию, admin); фронтенд берёт список из GET /api/notifications/meta, дублировать каталог в HTML нельзя
  • Таблицы: notifications (событие, admin_only, branch_id) + notification_reads (прочтение на пользователя). Изменения схемы — в db/init.sql и db/migration.sql и в ensureNotificationsTable()
  • Настройки: notify_enabled (общий), notify_retention_days (1–365), notify_<тип> (точки типа заменяются на _, см. notifySettingKey). Значения только 'true' / 'false' — PUT /api/settings это валидирует
  • Создание события — только через pushNotification() / notifyEntry(); они сами проверяют переключатели и при выключенном типе возвращают null. notifyEntry подставляет {student} и {group} и определяет филиал по группе записи
  • Хук в фото-воркере называется notifyEvent — имя notify внутри createPhotoEnhanceWorker уже занято будильником воркера (photoWorker.notify() из POST /api/photo-jobs/wake), объявление функции перекрыло бы параметр
  • Видимость: админ видит всё; остальные — admin_only = false и branch_id IS NULL или филиал из user_branches
  • Доставка: запись в БД → cache.publish('whatido:notifications', row) → SSE GET /api/notifications/stream (клиенты фильтруются по notificationVisible). Redis недоступен — работает in-memory pub/sub
  • Очистка: purgeOldNotifications() при старте и раз в час по notify_retention_days; чтения удаляются каскадом
  • Новое событие добавляется вместе с: записью в NOTIFY_TYPES, строками INSERT INTO settings в db/init.sql + db/migration.sql, вызовом pushNotification/notifyEntry в точке события и парой icon из Lucide
  • Аудит: удаление/очистка уведомлений логируется (notifications.delete, notifications.clear)

3d. Отчёты о занятии и проверка по шаблону (server.js, worker.js, public/)

  • Таблицы: lesson_reports (+ text_original, text_ai, ai_status, ai_checked_at, ai_error) и lesson_report_versions (история версий: text, source = manual | ai | restore). Схема — в db/init.sql, db/migration.sql и ensureLessonReportsTable()
  • Настройки: lesson_ai_enabled ('true' / 'false' — общий выклюжатель) и lesson_ai_prompt (промпт редактора сообщений тьютора + правила, пример вставляется в db/init.sql, db/migration.sql и в LESSON_AI_DEFAULT_PROMPT в server.js). Раздел в UI — sec-lesson-ai на public/settings.html Лимит промпта в PUT /api/settings — 8000 символов
  • Начало фразы зависит от номера темы: lesson_reports.topic — свободный текст, номер вида N/M тьютор пишет в конце строки (Photoshop 3/5). Позицию разбирает воркер, а не модель: lessonTopicPosition() в worker.js возвращает { kind, n, total, label } (kind = first | middle | last; N = M проверяется раньше N = 1, поэтому 1/1 — это last) и добавляет в контекст готовую строку Позиция темы: <label> (например последнее занятие модуля (2 из 2)). Модель только выбирает формулировку по этой строке. Проверено на локальной модели: без явной подсказки 2/2 читается как «продолжали» вместо «завершили», поэтому полагаться на разбор номера моделью нельзя
  • Хелпер экспортируется в module.exports worker.js ради юнит-проверок: N > M, M = 0, пустая тема → null, строка Позиция темы не добавляется, и промпт требует нейтрального начала фразы
  • Смена шаблона промпта — это четыре правки, а не три: новое значение в LESSON_AI_DEFAULT_PROMPT (server.js), db/init.sql, db/migration.sql (там же INSERT для свежих БД) и обязательно UPDATE settings SET value = '<новый>' WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>') в db/migration.sql — без него правка в SQL-файлах действует только на свежие установки, а у всех, кто уже пользовался разделом sec-lesson-ai, в settings останется старый промпт (см. «Сид настроек» в разделе 2). Все три текстовые копии должны быть побайтово идентичны LESSON_AI_DEFAULT_PROMPT
  • Флаг из UI: чекбокс #lessonAiCheck в модалке #lessonModal — включён при создании, выключен при редактировании (resetLessonModalFields / fillLessonModalFromReport). Уходит в теле как ai_check
  • Роут не ждёт модель: POST/PUT /api/lesson-reports при ai_check: true сохраняют отчёт как есть и ставят ai_status = 'pending', затем wakeLessonAiWorker(). Ответ возвращается сразу — не блокируйте HTTP-запрос вызовом модели
  • Воркер: createLessonReportChecker в worker.js забирает pending через FOR UPDATE OF lr SKIP LOCKED, шлёт в модель текст + контекст (группа, дата, время, тема, позиция темы), результат: без изменений → skipped, переписан → done (новый текст в text и text_ai), сбой → до 3 попыток, затем error
  • Доставка результата: onDone в server.js пишет версию (saveLessonReportVersion), аудит с diff (lesson_report.ai.format), уведомление lesson.ai.formatted и SSE lesson_report_status на EVENTS_CHANNEL
  • История версий: GET /api/lesson-reports/:id/versions, восстановление — POST /api/lesson-reports/:id/versions/:versionId/restore, откат к тексту тьютора — POST /api/lesson-reports/:id/ai/revert. Хранится последние LESSON_AI_VERSION_LIMIT версий на отчёт
  • Хуки фронтенда: openLessonVersions(id) и restoreLessonVersion(...) живут в public/admin.js (модалка доступна с журнала, отчётов и дашборда), список и бейджи статусов — в public/js/lessons.js

3e. Дата, время и часовой пояс (server.js, public/js/datetime.js)

  • Настройки: timezone (IANA, валидируется через validTimezone) и time_format ('24h' | '12h'). Сид — в db/init.sql + db/migration.sql. DEFAULT_TIMEZONE берётся из process.env.TZ, иначе Europe/Moscow
  • Единая точка: public/js/datetime.js подключается на каждой странице (public/*.html) перед admin.js/js/*.js, включая публичные share.html, report.html, index.html. Инициализация — await initDateTime() (грузят timezone/time_format из GET /api/public-settings). На админ-страницах вызов встроен в checkAuth() в admin.js
  • Запрещено в public/: прямые toLocaleString/toLocaleDateString/toLocaleTimeString, new Date().getFullYear() и new Date().toISOString().slice(0,10) для показа/вычисления дат. Только хелперы datetime.js
  • Три семейства данных — не путать:
    • instant (TIMESTAMPTZ, ISO c Z) → fmtFull / fmtDateTime / fmtDateFull / fmtDayMonth / fmtDateShort / fmtDateLong / fmtTimeOnly. Зона применяется
    • чистая DATE-строка 'YYYY-MM-DD' (lesson_date, taken_at, date_from) → fmtDateOnlyIso / fmtShortIso / fmtDayMonthIso / fmtDateOnlyLongIso. Без Date() — иначе new Date('YYYY-MM-DD') (UTC-полночь) сдвинет дату на день назад
    • чистая TIME-строка 'HH:MM(:SS)' (lesson_time, time_start/time_end) → fmtHmStr. Учитывает 12h/24h, зону не применяет
  • Текущие значения: todayIso(), nowHm() (всегда 24h — для <input type="time">), nowYear(), todayDow(), isoAddDays(iso, n) (арифметика по ISO без зоны)
  • Границы дней в SQL: TIMESTAMPTZ-колонки фильтруются только через tzDayStart/tzDayEnd + bindTz (плейсхолдер $TZ$ → $N, зона добавляется в params последней). Прямой $n::date по created_at считает дни в UTC (у контейнера TimeZone=UTC) и молча ломает границы — такого кода быть не должно. DATE-колонки (lr.lesson_date) сравниваются напрямую, зона не нужна
  • now() по зоне: tzWall() для «сейчас» и для day_of_week/расписания. Хардкод 'Europe/Moscow' в SQL запрещён
  • bindTz добавляет параметр только если в SQL есть $TZ$: иначе Postgres отвечает bind message supplies 1 parameters, but prepared statement requires 0, а без global error handler запрос висит вечно (страница остаётся «Загрузка...»). Поэтому запрос с фильтрами дат работает, а без них — падает: проверяй оба варианта. Регрессия закрыта в api.smoketest.js
  • TZ в compose (docker-compose.yml, 5 мест) — только фолбэк для DEFAULT_TIMEZONE; фактическая зона берётся из настройки

3f. Внешний API и API-ключи (server.js, public/apikeys.html, public/js/apikeys.js)

  • Два независимых способа аутентификации: сессии (X-Auth-Token → requireAuth) и API-ключи (X-Api-Key или Authorization: Bearer → requireApiKey). Это разные middleware: не смешивайте их, иначе поедет контракт из api.smoketest.js. requireApiKey обслуживает только /api/v1/*
  • Таблица: api_keys (user_id, name, prefix, key_hash, scopes TEXT[], branch_ids INT[], rate_limit_per_min, last_used_at/ip, expires_at, revoked_at). DDL — в db/init.sql, db/migration.sql и ensureApiKeysTable() (server.js), вызывается из ensureUsersAndFirstAdmin()
  • Ключ не хранится: в БД лежит только sha256(ключ) в key_hash + первые 12 символов в prefix для отображения. Секрет возвращается один раз при POST /api/api-keys и POST /api/api-keys/:id/rotate — восстановить его нельзя, только выпустить новый. Формат wsk_<64 hex>
  • Поиск ключа — по хешу (key_hash UNIQUE), не по префиксу; сравнение строк не TimingSafe, поэтому и не делается: вход идёт через индекс по хешу
  • Права (scopes): read и write. Весь /api/v1/* требует read (в apiV1.use), мутации дополнительно проходят apiWrite('write') — без write ключ читает, но получает 403 на записи
  • Филиалы ключа сужают, но не расширяют права: apiKeyUser() пересекает branch_ids ключа с филиалами владельца и понижает роль до tutor, даже если владелец — админ. Ключ не может стать шире возможностей того, кто его выдал
  • Кэш: apikey:<sha256> в Redis, TTL SESSION_CACHE_TTL_MS (30 с). Любая мутация ключа, а также смена роли/активности/филиалов пользователя (PUT/DELETE /api/users/:id) обязана звать invalidateApiKeys() — иначе отозванный ключ продолжит работать до истечения кэша
  • last_used_at троттлится маркером apikey:touch:<id> (5 мин), а не пишется на каждый запрос
  • Rate limit: отдельный apiKeyLimiter на cache.rateLimitStore('apikey', 60s); лимит — функция от rate_limit_per_min ключа (по умолчанию API_KEY_DEFAULT_RPM = 120). Ключ счёта — k<id> по keyGenerator, для неавторизованных — ip через ipKeyGenerator(ipOf(req)) (обязателен, иначе IPv6-клиенты обходят лимит)
  • Подбор ключа считается через recordFailure(req, 'apikey-bruteforce', 30, BAN_TTL_MS); метка причины есть в BAN_REASON_LABELS
  • CRUD ключей (/api/api-keys, /meta, /:id, /:id/rotate) — под requireAuth, requireAdmin. Не-admin видит и правит только свои ключи. Валидация: reqStr для имени, normalizeApiScopes, apiKeyRateValue (1..10000), apiKeyExpiry, apiKeyAllowedBranches (возвращает null при чужом/несуществующем филиале)
  • Формат ответов /api/v1: списки возвращают единый конверт { items, total, limit, offset } (apiList) — в отличие от внутреннего API, где формы ответа разные ({entries,total}, {modules,total}, голый массив). Не смешивайте с внутренними хелперами
  • Мутации через API аудитятся через apiAudit() — он добавляет via_api_key: <id> в audit_log.target, поэтому в аудите видно, каким ключом сделано изменение. После мутаций обязательны invalidateEntries() / invalidateLessonReports() / invalidateStudents() / invalidateStats() + broadcastEntryChanged(), иначе фронтенд не обновится
  • DELETE /api/v1/entries/:id — мягкое удаление (deleted_at), как и во внутреннем API; физическое удаление живёт только в корзине
  • Управление воркерами на apiV1 (POST /ai/wake, /photo-jobs/wake, /ai/requeue-failed, /photo-jobs/requeue-failed, /entries/:id/ai/recheck) — все под apiWrite('write'), аудит через apiAudit() с префиксом api.. wake — только пинок worker.notify(), он не гарантирует немедленную обработку: воркер берёт задачи из БД через FOR UPDATE SKIP LOCKED и просыпается по своему backoff-циклу, поэтому wake — оптимизация, а не условие работы
  • Массовые операции на apiV1 обязаны учитывать филиалы ключа. Внутренние POST /api/ai/requeue-failed и /api/photo-jobs/requeue-failed делают UPDATE ... WHERE status='error' по всей таблице — для apiV1 такой код ломает правило «ключ не шире выдавшего». Фильтр строится хелпером apiBranchClause(user, expr, params) (server.js): возвращает пустую строку для admin'а, AND FALSE при пустом списке филиалов и AND <expr> = ANY($N::int[]) иначе. Выражение для записей — (SELECT g.branch_id FROM groups g WHERE g.id = e.group_id), для фото-джобов — через photo_jobs → entries → groups. Не переносите внутренний UPDATE в apiV1 без этого фильтра
  • Воркер отчётов о занятии не имеет wake-эндпоинта ни во внутреннем API, ни в apiV1 — он будится неявно через wakeLessonAiWorker() из POST/PUT /lesson-reports, когда ai_check === true (строго boolean; строка "true" не срабатывает) и lesson_ai_enabled ≠ 'false'
  • api_keys НЕ входит в бэкап (как sessions), а POST /api/restore делает DELETE FROM api_keys — после восстановления все внешние ключи мертвы, их надо выпустить заново

4. API Patterns

  • Middleware: requireAuth — читает X-Auth-Token, 401 без валидной активной сессии. requireAdmin — самодостаточный (внутри вызывает requireAuth, если req.user ещё нет), 403 при role !== 'admin'. optionalAuth — для публичных страниц с персонализацией
  • Филиалы: branchScope(user) / branchWhere(user, alias) — для не-admin user.branch_ids (из user_branches) ограничивают выборку; у admin ids = null и фильтр не добавляется
  • Public routes: apiLimiter (300/15min), entryLimiter (10/15min), fileLimiter (300/15min) — все на cache.rateLimitStore(...), не на MemoryStore
  • Responses: JSON, { error: 'message' } on failure, data directly on success
  • Pagination: limit / offset query params, return { items, total } or { entries, total }
  • Filters: group_id, date_from, date_to, student_name, search, deleted

5. Frontend (public/)

  • Vanilla HTML/CSS/JS, no build step
  • Each page = single HTML file + shared admin.js / admin.css
  • API calls via fetch with X-Auth-Token (токен из localStorage); X-Admin-Token больше не используется и не работает
  • Share pages (share.html, links.html) work without auth

6. Docker / Compose

  • Dockerfile: Node 22 Alpine, installs deps, generates self-signed TLS cert
  • docker-compose.yml: сервисы db, app, redis, s3 (+ опционально tailscale, cloudflared, text-corrector, photo-ai)
    • db: postgres:16-alpine, healthcheck, init.sql mounted
    • redis: redis:7-alpine, --requirepass, AOF, maxmemory + allkeys-lru, healthcheck, том redis-data, порт только на 127.0.0.1
    • app: builds from Dockerfile, exposes 3003/3443, mounts uploads
    • tailscale: host network, NET_ADMIN, runs start-tailscale.sh (funnel to 127.0.0.1:3443)
  • Env vars (required): ADMIN_PASSWORD, DB_PASSWORD, REDIS_PASSWORD
  • Env vars (optional): REDIS_PREFIX (default whatido), REDIS_MAXMEMORY (default 256mb), REDIS_CONNECT_TIMEOUT_MS (default 5000)
  • Port 443 on host must be free (tailscale listens directly)

7. Tailscale Publication

  • No external IP / port forwarding needed
  • Access: https://whatido.<tailnet>.ts.net (inside tailnet + internet via Funnel)
  • First run: docker exec -it whatido-tailscale-1 tailscale up --hostname=whatido → authorize in browser
  • Enable Serve/Funnel in Tailscale admin console for the node
  • Cert: app generates self-signed cert at build (certs/cert.pem), mounted into tailscale container

8. Backup / Restore

  • Admin UI: POST /api/backup → тикет + GET /api/backup/:token (ссылка живёт BACKUP_TTL_MS, 30 мин, скачивание можно повторять — в том числе после обрыва связи и F5; не «сжигать» тикет HEAD-пробой, Express 4 отдаёт HEAD через GET-хендлер), POST /api/restore (upload .tar.gz)
  • Хранение тикетов: in-memory Map (server.js), поэтому после рестарта приложения ссылка даёт 404 — это ожидаемо. Одновременно живых тикетов не больше BACKUP_TICKETS_MAX, лишние и истёкшие вычищаются pruneBackupTickets(); файлы доживают sweepBackupStorage() с запасом в 5 минут сверх TTL
  • Scripts: scripts/backup.sh, scripts/restore.sh (host-level)
  • Формат архива: tar.gz с data.json + uploads/. Версия формата — BACKUP_FORMAT_VERSION в backup-restore.js (сейчас 2), принимаются версии 1..2; версия пишется в data.json.version и возвращается в ответе POST /api/backup и POST /api/restore
  • data.json содержит version, created_at, app (версия/коммит), counts (строки по таблицам + files) и сами данные. Таблицы перечислены в BACKUP_TABLES — при добавлении таблицы править её и в buildBackupArchive, и здесь
  • sessions в бэкап не входит намеренно: после restore все токены должны умереть. audit_log, notifications, notification_reads, banned_ips — входят
  • Файлы: storage.downloadAll кладёт в архив всё, кроме регенерируемых .thumbs/ и .cache/; .originals/ (оригиналы фото до ИИ-обработки) входят и восстанавливаются через uploadTree
  • Restore: валидация всего через normalizeRestoreData (backup-restore.js), транзакция с DELETE в FK-безопасном порядке → INSERT → setval по BACKUP_SEQUENCE_TABLES → файлы → sweepOrphanedUploads() → loadBans() → invalidateAll()
  • Колонки, которые normalizeRestoreData обязана сохранять: groups.deleted_at/purge_at, entries.purge_at. Потеря deleted_at воскрешает мягко удалённые группы как активные — это не «мелочь», а порча данных
  • sweepOrphanedUploads() считает ссылками фото из entries.photo_path/photo_original_path, project_files.path, group_photos, entry_photos, student_photos, modules.photo_path, students.photo_path, groups.cover_path, photo_jobs.before_path/after_path, settings.system_logo. Новая колонка с путём к файлу → добавить сюда, иначе sweep снесёт файл сразу после restore

Common Tasks

Каждый рецепт ниже начинается с субагента-разведки (см. Agent Workflow): пусть он найдёт нужные места и вернёт путь:строка, а правки вносит основной агент.

Add a new API endpoint

  1. Add route in server.js (group with related routes)
  2. Use requireAdmin for admin, apiLimiter/fileLimiter for public
  3. Validate input with helper functions
  4. Use parameterized queries, transactions if multi-table
  5. Call logAudit(req, 'action.name', { ... }) for mutations
  6. Return JSON, handle errors with appropriate status codes

Add a database column/table

  1. Update db/init.sql (CREATE TABLE / ALTER TABLE)
  2. Update db/migration.sql (idempotent ALTERs)
  3. Update server.js queries that SELECT/INSERT the table
  4. Test: docker compose down && docker compose up -d --build

Add a frontend page

  1. Create public/newpage.html (copy structure from existing)
  2. Link in public/admin.html navigation if admin page
  3. Use admin.js utilities: api(), requireAuth(), formatDate(), etc.
  4. No build step — just refresh browser

Modify file upload rules

  • Edit BLOCKED_EXT, ALLOWED_IMAGE_EXT, ADMIN_ALLOWED_EXT constants
  • Update Multer fileFilter functions
  • Keep MAX_TOTAL_UPLOAD_BYTES and per-file limit in sync

Migrate files to S3 / switch storage driver

  1. docker compose up -d s3
  2. docker compose exec -T app node scripts/migrate-to-s3.js --dry-run then without the flag (idempotent, size-checked, keeps local files)
  3. docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
  4. Set STORAGE_DRIVER=s3 in .env, docker compose up -d app
  5. After verification: docker compose exec -T app node scripts/migrate-to-s3.js --delete-local
  • Rollback: STORAGE_DRIVER=local + docker compose up -d app
  • Do not run --delete-local before the app serves reads from S3 and the verification passes

Testing & Verification

No automated test suite exists. Verify manually:

# Start stack
docker compose up -d --build

# Check logs
docker compose logs -f app

# Test API (replace LOGIN/PASS; X-Admin-Token больше не работает)
TOKEN=$(curl -s -X POST http://localhost:3003/api/auth/login \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"$LOGIN\",\"password\":\"$PASS\"}" | sed -E 's/.*"token":"([a-f0-9]+)".*/\1/')
curl -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/auth/me
# /api/groups — публичный (optionalAuth), 200 даже без токена:
# для проверки авторизации берите /api/auth/me или /api/users

# Run backup/restore scripts
./scripts/backup.sh
./scripts/restore.sh backups/whatido-backup-<date>.tar.gz

# Storage checks
docker compose up -d s3
docker compose exec -T app node scripts/migrate-to-s3.js --dry-run
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only

# Redis checks
node redis.selftest.js                                  # unit + degradation, needs redis on 127.0.0.1:6379
node api.smoketest.js                                   # e2e, needs running stack
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning INFO
docker compose stop redis && node api.smoketest.js      # app must keep working in-memory

# Backup checks
node backup.selftest.js                                 # unit, no stack needed
curl -sk -X POST https://127.0.0.1:3443/api/backup -H "X-Auth-Token: $TOKEN"   # -> counts по всем таблицам
docker compose start redis                              # app reconnects on its own

# Audit diff checks
node diff.selftest.js                                   # unit, no stack needed

# External API checks
node api-keys.selftest.js                               # e2e, needs running stack
curl -H "X-Api-Key: wsk_..." http://localhost:3003/api/v1/me

diff.js builds the audit payload for text changes: word-level segments (eq/del/add), per-step stats (added_words, removed_words, chars_before/after) and a light summarizeChanges/stripDiffs pair for the audit list. Two rules to keep: the diff payload stored in audit_log.target must stay capped (it is rendered raw in the audit UI), and GET /api/audit must keep stripping diff while GET /api/audit/:id returns it — otherwise the list endpoint ships kilobytes of text per row.

Verify Redis state through GET /api/system-info → cache (driver, ready, hits, misses, fallbackOps, used_memory_human, keys).

GET /api/system-info also returns a stack block (built by getStackInfo(), outside the response cache so versions and load stay fresh): app (Node, PID, RSS/heap, uptime, version from package.json + public/version.json), deps (installed versions of the main packages), runtime (OS from /etc/os-release, kernel, arch, CPU count/model, loadavg, memory, container detection), database (PostgreSQL version, host, pool counters), cache (driver, version, ready, keys, memory, hits/misses, fallback ops) and storage (driver, endpoint, bucket). Hosts come from URL.hostname only — credentials from DATABASE_URL/REDIS_URL must never reach the payload. The «Статус стека» block on public/settings.html renders exactly this payload.

api.smoketest.js also locks the auth contract: only X-Auth-Token with a session token authenticates, while X-Admin-Token, Authorization: Bearer and ADMIN_PASSWORD used as a token must all be rejected with 401. If you change the auth scheme, update this test and the Auth notes in this file together — a doc that drifts from the code is the failure mode this guards against.


Security Checklist (before any change)

  • No SQL interpolation — only $1, $2...
  • Upload path validation via isSafeUploadPath / safeUnlink
  • File I/O через storage.*, ключи объектов не выходят за пределы бакета/uploads/
  • Rate limiter on new public routes
  • Admin routes behind requireAdmin
  • New auth paths checked against the contract in api.smoketest.js, docs updated in the same change
  • API-ключи: в БД только хеш+префикс, секрет возвращается один раз; любая мутация ключа зовёт invalidateApiKeys()
  • No secrets in code — only via env vars
  • Helmet headers present (already global)
  • CORS disabled (no cors middleware)

File Map (key files)

File Purpose
server.js Entire backend (Express, routes, DB, uploads, backup)
storage.js Storage abstraction: local and s3 drivers, key normalization, cache/thumb helpers
redis.js Redis abstraction: cache, counters, rate-limit store, pub/sub, in-memory fallback
redis.selftest.js Self-tests for redis.js, including behaviour with Redis unavailable
diff.js / diff.selftest.js Word-level text diff and audit change payload; self-tests
backup-restore.js / backup.selftest.js Backup format version, normalizeRestoreData validation of restore payloads, backup table lists; self-tests
api.smoketest.js End-to-end API smoke test against a running stack
api-keys.selftest.js Self-tests for external API: X-Api-Key auth, scopes, branch scoping, rate limit, rotate/revoke
worker.js Background AI auto-check workers: entry messages, lesson-report template check, photo enhance
db/init.sql Initial schema (runs on fresh DB)
db/migration.sql Idempotent migrations for existing DBs
docker-compose.yml Service definitions (app, db, s3, tailscale)
docker-compose.minio.yml Override: S3 service backed by MinIO instead of SeaweedFS
Dockerfile App image build
public/*.html Frontend pages
public/admin.js Shared frontend logic, модалка отчёта о занятии (openLessonModal)
public/js/datetime.js Единая точка форматирования дат/времени: часовой пояс + 24h/12h. Подключается на всех страницах
public/lessons.html Отчёты о занятиях: список, фильтры, редактирование
scripts/backup.sh Host-level backup script (DB dump + storage export)
scripts/restore.sh Host-level restore script (DB dump + storage import)
scripts/storage-sync.js Export/import all storage objects (used by backup/restore)
scripts/migrate-to-s3.js One-off/idempotent migration uploads/ -> S3 bucket
scripts/deploy.sh Deploy script (pull master, build image with commit version, restart app)
start-tailscale.sh Tailscale container entrypoint
.env.example Env var template

Do Not

  • ❌ Add dependencies without updating package.json and rebuilding
  • ❌ Write files outside uploads/ or certs/
  • ❌ Touch uploads/ with fs.* in request/worker code — use storage.* (files may live only in S3)
  • ❌ Run migrate-to-s3.js --delete-local before verification and cutover
  • ❌ Expose the S3 API port publicly (only 127.0.0.1 in compose)
  • ❌ Expose the Redis port publicly (only 127.0.0.1 in compose)
  • ❌ Call fs.*/pg directly for cache, counters or pub/sub — use redis.js
  • ❌ Make Redis a hard dependency: any new Redis-backed path must keep the in-memory fallback
  • ❌ await client.connect() without a timeout — it never rejects while Redis is unreachable
  • ❌ Cache authorization-relevant data without an invalidation path on the mutation
  • ❌ Commit .env, certs/, uploads/, backups/, node_modules/
  • ❌ Expose DB port (5432) outside docker network
  • ❌ Use eval, Function constructor, or dynamic code execution
  • ❌ Add comments to code (this file excepted)
  • ❌ Read/search the repo in the main agent when the work can be delegated to a subagent
  • ❌ Load whole files (server.js, worker.js, storage.js, redis.js, public/js/*.js, README.md) or raw command output into the main context — delegate and filter (grep -n, head, line ranges)
  • ❌ Give a subagent an open-ended "explore the whole project" task, or omit the project rules from its systemPrompt — it does not inherit the main context
  • ❌ Treat a subagent report as the final word: coordinate, edit and verify the result yourself (git diff)

Quick Commands

# Full rebuild
docker compose down && docker compose up -d --build

# Обновление на сервере (pull master + сборка образа с версией коммита + перезапуск app)
./scripts/deploy.sh

# App logs
docker compose logs -f app

# DB shell
docker compose exec db psql -U app -d whereldo

# Redis status and cache keys
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning DBSIZE
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" --no-auth-warning KEYS 'whatido:*'

# S3 storage status and migration verification
docker compose up -d s3
docker compose exec -T app node scripts/migrate-to-s3.js --verify-only
docker compose exec -T app node scripts/migrate-to-s3.js --delete-local

# Tailscale status
docker exec -it whatido-tailscale-1 tailscale status

# Manual funnel restart
docker exec whatido-tailscale-1 tailscale funnel --bg --yes https://127.0.0.1:3443