From be9fdfa70324fd2a7ec3113c71a54e7003c55059 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 23 Jun 2026 16:12:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(wishes):=20=D1=82=D1=80=D0=B5=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D0=B6=D0=B5=D0=BB=D0=B0=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание); видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам (новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор получает уведомление при смене статуса (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) --- backend/src/controllers/adminController.js | 2 +- backend/src/controllers/wishController.js | 104 +++++++++ backend/src/db/migrations/080_wishes.sql | 15 ++ backend/src/routes/wishes.js | 15 ++ backend/src/server.js | 1 + backend/tests/wishes.test.js | 119 ++++++++++ frontend/js/admin/sections/games.js | 1 + frontend/wishes.html | 242 +++++++++++++++++++++ js/api.js | 7 + js/sidebar.js | 1 + 10 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/wishController.js create mode 100644 backend/src/db/migrations/080_wishes.sql create mode 100644 backend/src/routes/wishes.js create mode 100644 backend/tests/wishes.test.js create mode 100644 frontend/wishes.html diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index cfcea37..8df3ff9 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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 = ?"); diff --git a/backend/src/controllers/wishController.js b/backend/src/controllers/wishController.js new file mode 100644 index 0000000..99aaa6b --- /dev/null +++ b/backend/src/controllers/wishController.js @@ -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 }; diff --git a/backend/src/db/migrations/080_wishes.sql b/backend/src/db/migrations/080_wishes.sql new file mode 100644 index 0000000..2f753bd --- /dev/null +++ b/backend/src/db/migrations/080_wishes.sql @@ -0,0 +1,15 @@ +-- 080_wishes.sql — трекер пожеланий по улучшению системы. +-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам. +CREATE TABLE IF NOT EXISTS wishes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT, + category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other + status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined + admin_note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id); +CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status); diff --git a/backend/src/routes/wishes.js b/backend/src/routes/wishes.js new file mode 100644 index 0000000..e3ff9db --- /dev/null +++ b/backend/src/routes/wishes.js @@ -0,0 +1,15 @@ +'use strict'; +const router = require('express').Router(); +const { authMiddleware, requireRole } = require('../middleware/auth'); +const ctrl = require('../controllers/wishController'); + +router.use(authMiddleware); + +router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере) +router.post('/', ctrl.create); // любой авторизованный + +// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере) +router.patch('/:id', requireRole('admin'), ctrl.update); +router.delete('/:id', ctrl.remove); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index e90bdca..8e25f03 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -198,6 +198,7 @@ app.use('/api/lab', labRoutes); app.use('/api/materials', require('./routes/materials')); app.use('/api/custom-sims', require('./routes/customSims')); app.use('/api/game', require('./routes/game')); +app.use('/api/wishes', require('./routes/wishes')); app.use('/api/prep', require('./routes/prep')); app.use('/api/dashboard', require('./routes/dashboard')); diff --git a/backend/tests/wishes.test.js b/backend/tests/wishes.test.js new file mode 100644 index 0000000..0f0bf53 --- /dev/null +++ b/backend/tests/wishes.test.js @@ -0,0 +1,119 @@ +'use strict'; +/** + * Integration tests: /api/wishes — трекер пожеланий по улучшению. + * Covers: auth-only; создание (валидация); приватность (автор видит только свои, + * админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление + * (автор «новое» / админ; чужое нельзя). + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, inject, getToken, cleanup } = require('./setup'); + +app.use('/api/wishes', require('../src/routes/wishes')); +after(() => cleanup()); + +describe('/api/wishes', () => { + let s1, s2, admin; + + before(async () => { + s1 = await getToken('student'); + s2 = await getToken('student'); + admin = await getToken('admin'); + }); + + it('POST /wishes requires auth (401)', async () => { + const res = await inject('POST', '/api/wishes', { title: 'x' }, null); + assert.equal(res.status, 401); + }); + + it('создание: пустой заголовок → 400', async () => { + const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token); + assert.equal(res.status, 400); + }); + + let wishId; + it('создание пожелания учеником → 201, статус new', async () => { + const res = await inject('POST', '/api/wishes', + { title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token); + assert.equal(res.status, 201, JSON.stringify(res.body)); + assert.equal(res.body.status, 'new'); + assert.equal(res.body.category, 'ui'); + assert.equal(res.body.user_id, s1.userId); + wishId = res.body.id; + }); + + it('неизвестная категория → other', async () => { + const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token); + assert.equal(res.body.category, 'other'); + }); + + it('приватность: автор видит свои', async () => { + const res = await inject('GET', '/api/wishes', null, s1.token); + assert.equal(res.status, 200); + assert.ok(res.body.wishes.some(w => w.id === wishId)); + assert.equal(res.body.isAdmin, false); + }); + + it('приватность: другой ученик НЕ видит чужое', async () => { + const res = await inject('GET', '/api/wishes', null, s2.token); + assert.ok(!res.body.wishes.some(w => w.id === wishId)); + }); + + it('админ видит все + counts', async () => { + const res = await inject('GET', '/api/wishes', null, admin.token); + assert.equal(res.status, 200); + assert.equal(res.body.isAdmin, true); + assert.ok(res.body.wishes.some(w => w.id === wishId)); + assert.ok(res.body.counts && typeof res.body.counts.new === 'number'); + // у админа в списке есть имя автора + const w = res.body.wishes.find(x => x.id === wishId); + assert.ok(w.author_name); + }); + + it('триаж учеником запрещён (403)', async () => { + const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token); + assert.equal(res.status, 403); + }); + + it('админ меняет статус + ответ → 200', async () => { + const res = await inject('PATCH', `/api/wishes/${wishId}`, + { status: 'planned', admin_note: 'Запланировано на лето' }, admin.token); + assert.equal(res.status, 200, JSON.stringify(res.body)); + assert.equal(res.body.status, 'planned'); + assert.equal(res.body.admin_note, 'Запланировано на лето'); + }); + + it('неверный статус → 400', async () => { + const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token); + assert.equal(res.status, 400); + }); + + it('фильтр по статусу у админа', async () => { + const res = await inject('GET', '/api/wishes?status=planned', null, admin.token); + assert.ok(res.body.wishes.every(w => w.status === 'planned')); + assert.ok(res.body.wishes.some(w => w.id === wishId)); + }); + + it('автор НЕ может удалить уже обработанное (не new) → 403', async () => { + const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token); + assert.equal(res.status, 403); + }); + + it('чужой ученик не может удалить → 403', async () => { + const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token); + assert.equal(res.status, 403); + }); + + it('автор удаляет своё «новое» → 200', async () => { + const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token); + const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token); + assert.equal(res.status, 200); + }); + + it('админ удаляет любое → 200', async () => { + const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token); + assert.equal(res.status, 200); + const gone = await inject('GET', '/api/wishes', null, admin.token); + assert.ok(!gone.body.wishes.some(w => w.id === wishId)); + }); +}); diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js index 1863a4f..8ae0169 100644 --- a/frontend/js/admin/sections/games.js +++ b/frontend/js/admin/sections/games.js @@ -23,6 +23,7 @@ { key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' }, { key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' }, { key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' }, + { key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' }, ]; const FS_FEATURES = [ diff --git a/frontend/wishes.html b/frontend/wishes.html new file mode 100644 index 0000000..6e2ec23 --- /dev/null +++ b/frontend/wishes.html @@ -0,0 +1,242 @@ + + + + + + Пожелания — LearnSpace + + + + + + + +
+ +
+
+
+
Пожелания по улучшению
+
Предложите, что улучшить в системе — мы это увидим и ответим.
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + + + +
Загрузка…
+
+
+
+ + + + + + + + + diff --git a/js/api.js b/js/api.js index e964401..b180de2 100644 --- a/js/api.js +++ b/js/api.js @@ -191,6 +191,11 @@ async function kickMember(classId, userId) { return req('DELETE', `/classes/ async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); } async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); } async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); } +/* ── Пожелания по улучшению ── */ +async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); } +async function wishCreate(data) { return req('POST', '/wishes', data); } +async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); } +async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); } async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); } async function createDirectAssignment(data) { return req('POST', '/assignments', data); } async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); } @@ -853,6 +858,7 @@ const FEATURE_HREFS = { quantik: ['/quantik', '/quantik.html'], theory: ['/theory', '/theory.html'], sitemap: ['/sitemap', '/sitemap.html'], + wishes: ['/wishes', '/wishes.html'], }; /* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard). @@ -1127,6 +1133,7 @@ window.LS = { getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, regenerateInviteCode, classJournal, classOutstanding, + wishesList, wishCreate, wishUpdate, wishDelete, joinClass, myClasses, getStudents, classFeed, getAnnouncements, createAnnouncement, deleteAnnouncement, getNotifications, markNotifRead, markAllNotifsRead, connectSSE, diff --git a/js/sidebar.js b/js/sidebar.js index a8d2154..226d509 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -62,6 +62,7 @@ ${L('/dashboard', 'home', 'Дашборд')} ${L('/sitemap', 'map', 'Путеводитель')} + ${L('/wishes', 'lightbulb', 'Пожелания')} ${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })} ${G('learning', 'Учебный процесс', `