INSERT ... ON CONFLICT (key) DO NOTHING вставляет значение только если
ключа ещё нет, поэтому новый шаблон промпта доезжал лишь до свежих
установок — у всех, кто уже пользовался разделом sec-lesson-ai, в settings
оставался старый промпт.
- в db/migration.sql добавлен идемпотентный
UPDATE settings SET value = '<новый промпт>'
WHERE key = 'lesson_ai_prompt' AND value IN ('<старый дефолт>')
по образцу уже существующей миграции для ai_prompt
- условие по value IN (...) обязательно: без него миграция затёрла бы
промпт, отредактированный админом в UI
- в db/init.sql UPDATE не добавляется: файл выполняется только на пустой БД
- AGENTS.md, раздел 2: правило «Сид настроек» (DO NOTHING не обновляет
существующие значения) и «Проверка сида» (прогон в транзакции с откатом)
- AGENTS.md, раздел 3d: смена шаблона промпта — четыре правки, а не три;
все текстовые копии должны быть побайтово идентичны LESSON_AI_DEFAULT_PROMPT
Проверено на postgres:16 в транзакции с откатом, ON_ERROR_STOP=1
- старый дефолт (935 симв.) -> миграция -> новый (2348 симв.)
- повторный прогон ничего не меняет (идемпотентно)
- кастомный промпт админа миграцией не затрагивается
366 lines
34 KiB
Markdown
366 lines
34 KiB
Markdown
# 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](#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`. Роли: `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`
|
||
- **Смена шаблона промпта — это четыре правки, а не три**: новое значение в `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`
|
||
|
||
### 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**: `/api/backup` (download tar.gz), `/api/restore` (upload tar.gz)
|
||
- **Scripts**: `scripts/backup.sh`, `scripts/restore.sh` (host-level)
|
||
- Backup format: `data.json` (all tables) + `uploads/` directory
|
||
- Restore validates all data, resets sequences, sweeps orphans
|
||
|
||
---
|
||
|
||
## Common Tasks
|
||
|
||
Каждый рецепт ниже начинается с субагента-разведки (см. [Agent Workflow](#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:
|
||
|
||
```bash
|
||
# 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
|
||
docker compose start redis # app reconnects on its own
|
||
|
||
# Audit diff checks
|
||
node diff.selftest.js # unit, no stack needed
|
||
```
|
||
|
||
`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
|
||
- [ ] 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 |
|
||
| `api.smoketest.js` | End-to-end API smoke test against a running stack |
|
||
| `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/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
|
||
|
||
```bash
|
||
# 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
|
||
```
|