feat(wishes): трекер пожеланий по улучшению системы

Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание);
видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам
(новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор
получает уведомление при смене статуса (pushNotif).

Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с
валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE —
автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15.

Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры,
смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг
feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ
видит всегда). Клиентские врапперы LS.wish*.

⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 16:12:10 +03:00
parent 758e1bf6cb
commit be9fdfa703
10 changed files with 506 additions and 1 deletions
+1 -1
View File
@@ -528,7 +528,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap'];
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const { pushNotif } = require('../utils/notifications');
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
const STATUS_LABEL = {
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
done: 'Готово', declined: 'Отклонено',
};
function clampStr(v, max) {
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
}
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
function list(req, res) {
const isAdmin = req.user.role === 'admin';
const { status, category } = req.query;
const where = [];
const args = [];
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
const sql = `
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
w.created_at, w.updated_at,
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
0 AS _pad
FROM wishes w
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
w.updated_at DESC`;
const rows = db.prepare(sql).all(...args);
let counts = null;
if (isAdmin) {
counts = {};
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
}
res.json({ wishes: rows, counts, isAdmin });
}
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
function create(req, res) {
const title = clampStr(req.body?.title, 200);
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
const body = clampStr(req.body?.body, 4000);
let category = String(req.body?.category || 'other');
if (!CATEGORIES.includes(category)) category = 'other';
const info = db.prepare(
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
).run(req.user.id, title, body || null, category);
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
res.status(201).json(row);
}
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
function update(req, res) {
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const fields = [];
const args = [];
let newStatus = null;
if (req.body?.status !== undefined) {
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
if (req.body.status !== wish.status) newStatus = req.body.status;
fields.push('status = ?'); args.push(req.body.status);
}
if (req.body?.admin_note !== undefined) {
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
}
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
fields.push("updated_at = datetime('now')");
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
// Уведомить автора при смене статуса (durable + SSE).
if (newStatus && wish.user_id !== req.user.id) {
try {
pushNotif(wish.user_id, 'wish_update',
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
} catch { /* notif не критичен */ }
}
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
}
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
function remove(req, res) {
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
if (!wish) return res.status(404).json({ error: 'Не найдено' });
const isAdmin = req.user.role === 'admin';
const isOwner = wish.user_id === req.user.id;
if (!isAdmin && !(isOwner && wish.status === 'new'))
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
res.json({ ok: true });
}
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };