# TODO: Раздел «Обратная связь» (feedbacks) Цель: добавить новый раздел **«Обратная связь»** по образцу `lessons`, но с отдельным воркером и отдельной таблицей `feedbacks`. ## Принятые решения - [ ] **Имя сущности**: `feedbacks` (API `/api/feedbacks`, страница `public/feedback.html`, JS `public/js/feedback.js`) - [ ] **«Прошлый отзыв» / короткие сообщения**: брать `GET /api/shorts?search=<ФИО резидента>` (все отмечены по умолчанию). Отдельное поле `past_review` в модалке (чекбокс «Добавить в промпт прошлый отзыв» → textarea) - [ ] **Помечать `short_messages.used`**: **да**, при создании отзыва (`POST /api/feedbacks`) для выбранных `short_message_ids` проставить `used=true, used_at = CURRENT_DATE`. Вызывать `invalidateShorts()` после этого - [ ] **`ai_profile`**: **per-row** хранить в `feedbacks.ai_profile`. Воркер берёт его с фоллбеком на `ai_active_profile` - [ ] **История версий**: **не добавлять** в MVP (без `feedback_versions`) - [ ] **apiV1**: **не добавлять** сейчас --- ## Этап 1. Константы, промпт по умолчанию (server.js) **Файл:** `server.js` - [ ] Добавить константы рядом с `LESSON_AI_*` (~строка 4890): - `const FEEDBACK_AI_VERSION_LIMIT = 50;` - `const FEEDBACK_AI_TIMEOUT_MS = 120000;` - `const FEEDBACK_LIST_TTL_MS = 30 * 1000;` - `const FEEDBACK_TEXT_MAX = 5000;` - `const FEEDBACK_TOPIC_MAX = 500;` - `const FEEDBACK_RESIDENT_MAX = 200;` - [ ] Добавить `FEEDBACK_AI_DEFAULT_PROMPT` рядом с `LESSON_AI_DEFAULT_PROMPT` (~4892–4927). В промпте учесть: Дата занятия, Группа, Резидент, Тема занятия, «Прошлый отзыв» (`past_review`), выбранные короткие сообщения (`дата – тема – сообщение`), нейтральный деловой стиль 1–3 предложения --- ## Этап 2. БД — таблица `feedbacks` (3 места) ### 2.1 `db/init.sql` (после блока `short_messages`, после ~482) - [ ] Добавить `CREATE TABLE feedbacks` со всеми полями и индексами: - `id SERIAL PRIMARY KEY` - `feedback_date DATE NOT NULL` - `group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE` - `resident VARCHAR(200) NOT NULL` - `topic VARCHAR(500)` - `past_review TEXT` - `short_message_ids INT[] DEFAULT '{}'::int[]` - `text TEXT NOT NULL DEFAULT ''` - `ai_status VARCHAR(20) NOT NULL DEFAULT 'none'` (`none|pending|processing|done|error|skipped`) - `ai_checked_at TIMESTAMPTZ` - `ai_error TEXT` - `ai_profile VARCHAR(100)` - `author_id INT REFERENCES users(id) ON DELETE SET NULL` - `branch_id INT REFERENCES branches(id) ON DELETE SET NULL` - `deleted_at TIMESTAMPTZ`, `purge_at TIMESTAMPTZ` - `created_at TIMESTAMPTZ DEFAULT now()`, `updated_at TIMESTAMPTZ DEFAULT now()` - [ ] Индексы: `idx_feedbacks_date(feedback_date DESC)`, `idx_feedbacks_deleted(deleted_at)`, `idx_feedbacks_pending(ai_status, id)` - [ ] Сиды настроек (~рядом с `notify_lesson_ai_formatted`, ~461): - `('feedback_ai_enabled','true') ON CONFLICT DO NOTHING` - `('feedback_ai_prompt','') ON CONFLICT DO NOTHING` - `('notify_feedback_ai_ready','false') ON CONFLICT DO NOTHING` ### 2.2 `db/migration.sql` (после блока `short_messages`, после ~591) - [ ] `DO $$ ... CREATE TABLE IF NOT EXISTS feedbacks ... $$` с теми же индексами - [ ] `ALTER TABLE feedbacks ADD COLUMN IF NOT EXISTS ...` для всех колонок (идемпотентно) - [ ] Те же 3 сида `INSERT ... ON CONFLICT DO NOTHING` - [ ] `UPDATE settings SET value = '<новый промпт>' WHERE key = 'feedback_ai_prompt' AND value IN ('<старый>')` (заготовка по образцу строки 507) ### 2.3 `server.js` — `ensureFeedbacksTable()` (после `ensureShortMessagesTable()`, после ~2645) - [ ] Создать `async function ensureFeedbacksTable()` по образцу `ensureLessonReportsTable()` (`1570–1609`): CREATE TABLE IF NOT EXISTS + все `ADD COLUMN IF NOT EXISTS` + индексы + сиды `settings` - [ ] В bootstrap (`~8757`) добавить вызов: ```js try { await ensureFeedbacksTable(); } catch (err) { console.error('Feedbacks table:', err); } ``` --- ## Этап 3. Инвалидация, каналы, wake-функции (server.js) - [ ] `const FEEDBACK_WAKE_CHANNEL = 'whatido:wake:feedback'` (~131–133) - [ ] `let feedbackWorker = null;` (~134) - [ ] `function invalidateFeedbacks() { cacheDrop('feedbacks:'); broadcastFeedbackChanged(); }` (~121–124) - [ ] `function broadcastFeedbackChanged() { publish(EVENTS_CHANNEL, { type: 'feedbacks_changed', ts: Date.now() }); }` (~189–192) - [ ] `function wakeFeedbackWorker() { if (feedbackWorker) feedbackWorker.notify(); }` (~3367–3369) - [ ] `function onFeedbackDone(row, status, text, aiText, error)` по образцу `onLessonAiDone` (~3371–3405): `invalidateFeedbacks()`, publish `feedback_status`, при `done` — `logAudit('feedback.ai.generate', ...)`, `pushNotification('feedback.ai.ready', ..., branchId)` --- ## Этап 4. Воркер `createFeedbackWriter` (worker.js) - [ ] (Опц.) Рефактор: вынести `resolveActiveProfile`+`callModel` в `createAiCaller` и переиспользовать в `createLessonReportChecker` и новом воркере. **Или** просто скопировать в новый воркер - [ ] Добавить `createFeedbackWriter(opts)` после `createLessonReportChecker` (~перед экспортом 1052) - Параметры: `{ pool, getSetting, logAudit, aiUrl, defaultPrompt, bus, onDone }` - Константы: `MAX_ATTEMPTS=3`, `IDLE_MIN_MS=500`, `IDLE_MAX_MS=30000`, `MIN_CHARS=1`, `MAX_INPUT_CHARS=6000`, `TEXT_MAX=FEEDBACK_TEXT_MAX` - `isEnabled()` → `getSetting('feedback_ai_enabled') !== 'false'` - `notify()` + `bus.subscribe(FEEDBACK_WAKE_CHANNEL, notify)` - `claimNext()` — SELECT `feedbacks f` с `ai_status='pending' AND deleted_at IS NULL`, `FOR UPDATE OF f SKIP LOCKED`, UPDATE → `processing`, вернуть строку (+JOIN `groups` при желании) - `processOne(row)`: собрать контекст (Группа/Дата/Резидент/Тема/Прошлый отзыв), загрузить выбранные `short_messages` по `short_message_ids` (только `deleted_at IS NULL`), форматировать блок, вызвать модель с `profileId = row.ai_profile` (фолбек на активный), `normalizeOutput`, принять решение `done|skipped|error` - `finish()` — UPDATE `feedbacks` (`ai_status`, `ai_checked_at`, `ai_error`, `text` при `done`, `updated_at`) - `resetStale()` — `processing` → `pending` - `loop()` с backoff, `start/stop/getInfo()` - [ ] Добавить `createFeedbackWriter` в `module.exports` (~1054) - [ ] В `server.js` bootstrap после `lessonAiChecker.start()` (~8805–8806) запустить `feedbackWorker` --- ## Этап 5. Роуты API `/api/feedbacks` (server.js) Расположить **после** блока `/api/shorts` (~после 2843) - [ ] `GET /api/feedbacks` — пагинация `limit/offset`, фильтры `date_from/date_to/group_id/resident/search/status/deleted`, `branchScope` (`f`), кэш `feedbacks:list:*` с `FEEDBACK_LIST_TTL_MS`, ответ `{ items, total }` - [ ] `GET /api/feedbacks/:id` — с JOIN `groups`, проверка филиала - [ ] `POST /api/feedbacks` — валидация полей (дата `YYYY-MM-DD`, `resident` max 200, `topic` max 500, `text` max 5000, `group_id` int, `past_review` max 2000, `short_message_ids` массив int уникальные, `ai_check` boolean, `ai_profile` opt). Проверка доступа к группе. INSERT. Если `ai_check && feedback_ai_enabled!=='false'` → `ai_status='pending'`, `text=text||''`. **Пометить выбранные short_messages** `used=true, used_at=CURRENT_DATE` (только `deleted_at IS NULL`, можно `AND used=false`). `logAudit('feedback.create', {..., short_count})`. `invalidateFeedbacks()` + `invalidateShorts()`. Если `pending` → `wakeFeedbackWorker()`. 201 - [ ] `PUT /api/feedbacks/:id` — редактирование по образцу, без смены `ai_status` по умолчанию. `logAudit`, `invalidateFeedbacks()` - [ ] `DELETE /api/feedbacks/:id` — мягкое удаление (`deleted_at`, `purge_at = now()+trash_purge_days`) по образцу `short_messages:2794–2810` - [ ] `PUT /api/feedbacks/:id/restore` (requireAdmin) — снять `deleted_at`, `purge_at` (2811–2827) - [ ] `DELETE /api/feedbacks/:id/permanent` (requireAdmin) — плановое удаление (2828–2843) - [ ] `POST /api/feedbacks/:id/ai/retry` (requireAuth) — `ai_status='error'→'pending'`, сброс `ai_error/ai_checked_at`, `wakeFeedbackWorker()`, `invalidateFeedbacks()` - [ ] `POST /api/feedbacks/requeue-failed` (requireAuth, requireAdmin) — массовый перевод `error→pending`, wake + invalidate - [ ] Хелпер `feedbackById(user,id)` по образцу `lessonReportById` (JOIN `groups g`, `branchScope`) --- ## Этап 6. SSE, уведомления, аудит (server.js) - [ ] `dispatchEvent` (~154–163): добавить ветку `payload.type === 'feedback_status'` → `writeFrame('feedback_status', payload); return;` - [ ] `NOTIFY_TYPES` (~234–246): добавить `'feedback.ai.ready'` с иконкой `message-square-heart`, `label: 'Отзыв сгенерирован'`, `hint`, `level:'info'`, `enabled:true`, `admin:false` - [ ] `ensureFeedbacksTable()` уже добавил сид `notify_feedback_ai_ready='false'` (ок) - [ ] Аудит-имена `feedback.create`, `feedback.ai.generate` используются выше --- ## Этап 7. Бэкап/restore (`backup-restore.js`, `server.js`) - [ ] `backup-restore.js:6–12` (`BACKUP_TABLES`) — добавить `'feedbacks'` - [ ] `backup-restore.js:13–18` (`BACKUP_SEQUENCE_TABLES`) — добавить `'feedbacks'` - [ ] `backup-restore.js` блок `normalizeRestoreData` (~505–519) — добавить маппинг `feedbacks` с нормализацией `short_message_ids` (массив), `deleted_at`, `purge_at` - [ ] `server.js:3574` (дамп) — добавить `feedbacks` в цикл выборки - [ ] `server.js:3758` (restore DELETE) — `DELETE FROM feedbacks;` в FK-безопасном порядке (до `groups`) - [ ] `server.js:3915–3918` (restore INSERT) — цикл INSERT для `feedbacks` --- ## Этап 8. Фронтенд — `public/feedback.html` - [ ] Создать по образцу `public/lessons.html` (`data-page="feedback"`) - [ ] `.page-head`: `h2 «Обратная связь»`, `.page-sub «Отзывы тьютора по итогам занятий»` - [ ] `.filters`: `#fbSearch`, `#fbGroupFilter`, `#fbStatusFilter` (Все/Ожидает/В обработке/Готово/Ошибка/Пропущен), `#fbDateFrom`, `#fbDateTo`, `#fbDeletedToggle` (опц.), `#addFeedbackBtn` - [ ] `#feedbackState`, `#feedbackList`, `#pager` - [ ] Скрипты: `vendor/lucide.min.js`, `js/datetime.js`, `admin.js`, `js/feedback.js` Модалка `#feedbackModal` — динамическая в JS (по образцу `ensureLessonModal`) либо статичная. Рекомендуется динамическая. --- ## Этап 9. Фронтенд — `public/js/feedback.js` - [ ] Стейт: `PAGE_SIZE=20`, `page=1`, `total=0`, `searchTimer`, `groupsLoaded`, `feedbacks=[]`, `shortsListCache`, `shortsFetchTimer` - [ ] Форматтеры: `fbDate(d)` через `fmtShortIso`, `fbDay(d)` (короткий день недели) — безопасно через `new Date(iso+'T12:00:00')` - [ ] `fbItemHtml(f)` — строка таблицы: Дата, Тема (дата+тема), Резидент, День, Группа, Сам отзыв (обрезка), Действия (`data-edit`, `data-del`, `data-retry` при `ai_status==='error'`) - [ ] `loadFeedbacks()` — GET `/api/feedbacks` с фильтрами, рендер + `renderPager` - [ ] `loadGroups()` — использовать `loadLessonGroups()` из `admin.js` - [ ] `ensureFeedbackModal()` — динамическая модалка с полями: `#fbId`, `#fbDate` (default `todayIso()`), `#fbGroup`, `#fbResident`, `#fbTopic`, `#fbPastCheck`, `#fbPastReviewWrap`, `#fbPastReview`, `#fbShortsWrap`, `#fbShortsList`, `#fbText`, `#fbAiRow`, `#fbAiCheck`, `#fbModel`, `#fbAiStatus` - Дебаунс ввода `#fbResident` (>=2) → `fetchShorts` → рендер чекбоксов **все отмечены** - `#fbPastCheck` toggle - [ ] `fetchShorts(q)` — `GET /api/shorts?search=...&limit=50&deleted=0`, кэш 5–10с - [ ] `renderShortsList(items)` — чекбоксы `name="fbShort"` все `checked=true` - [ ] `openFeedbackModal({id})` — reset + заполнение при редактировании - [ ] `submitFeedbackModal(e)` — собрать все поля, POST/PUT, close + reload + тост - [ ] `connectFeedbackLive()` — SSE `/api/events`, обработка `feedbacks_changed` (debounce) и `feedback_status` (обновить строку) - [ ] Делегирование кликов `[data-edit]/[data-del]/[data-retry]` - [ ] Фильтры с debounce 350мс, `page=1` при смене - [ ] `initFeedback()` — `checkAuth()`, `buildSidebar('feedback')`, `loadGroups()`, загрузка профилей ИИ в `#fbModel` (`GET /api/ai/profiles`, первый `native`), `loadFeedbacks()`, `connectFeedbackLive()`, `lucide.createIcons()` --- ## Этап 10. Сайдбар (public/admin.js) - [ ] В массив `base` (~50–63) добавить `{ page: 'feedback', label: 'Обратная связь', icon: 'message-square-heart' }` **после** `lessons` (строка 53) --- ## Этап 11. Документация (AGENTS.md) - [ ] Добавить раздел **3h. Обратная связь (feedbacks)** после 3g. Чат: таблица, настройки (`feedback_ai_enabled`, `feedback_ai_prompt`, `notify_feedback_ai_ready`), воркер `createFeedbackWriter`, wake `wakeFeedbackWorker()`, SSE `feedbacks_changed`/`feedback_status`, уведомление `feedback.ai.ready`, per-row `ai_profile`, отметка `short_messages.used` - [ ] В **File Map** добавить `public/feedback.html`, `public/js/feedback.js`. Упомянуть `feedbacks` в backup-restore контексте --- ## Этап 12. Проверка - [ ] `node diff.selftest.js` — ок - [ ] `node backup.selftest.js` — должен пройти с `feedbacks` в normalizeRestoreData - [ ] `docker compose down && docker compose up -d --build` - [ ] `docker compose logs -f app` — нет ошибок при старте (ensureFeedbacksTable + feedbackWorker) - [ ] БД: `docker compose exec db psql -U app -d whereldo -c '\d feedbacks'` — все колонки есть - [ ] Настройки: `SELECT key,value FROM settings WHERE key LIKE 'feedback_%';` — 3 записи есть - [ ] `node api.smoketest.js` — регрессий нет - [ ] Ручной UI: `/admin/feedback.html` → список грузится, «Добавить отзыв» → модалка, ввод ФИО ≥2 → список short_messages **все чекнуты**, выбор модели из профилей, «Отправить в очередь» → `ai_status=pending`, воркер забирает, статус меняется на `done` (если модель доступна) - [ ] После создания отзыва проверить `short_messages.used` у выбранных --- ## Критичные моменты - [ ] **Три копии схемы** синхронизированы: `db/init.sql`, `db/migration.sql`, `server.js::ensureFeedbacksTable()` - [ ] **Промпт в трёх местах + UPDATE в migration.sql** (как `lesson_ai_prompt`, правило §2 AGENTS) - [ ] **`short_message_ids` — INT[]**: в `normalizeRestoreData` приводить к массиву, в INSERT передавать `::int[]` - [ ] **Филиалы**: `feedbackById` и `GET /api/feedbacks` используют `branchScope`/проверку филиала - [ ] **SSE**: ветка `feedback_status` добавлена в `dispatchEvent` **до** возврата - [ ] **`invalidateShorts()`** после POST feedback (изменение `used`) - [ ] **wakeFeedbackWorker()** вызывается только при `ai_status==='pending'` - [ ] **FK-безопасный порядок DELETE при restore**: `DELETE FROM feedbacks;` до `groups`