diff --git a/frontend/admin.html b/frontend/admin.html index ceb629c..268dd62 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -532,6 +532,7 @@

Reported lines

Listeners flag lines that look wrong. Fix one in the editor and its report closes itself; or mark it resolved if it was fine.

+
@@ -1320,7 +1321,8 @@ async function loadReports() { const box = $('repList'); box.innerHTML = '
Loading…
'; - const j = await api('/api/admin/flags?status=' + repStatus); + const age = Number($('reportAge')?.value || 0); + const j = await api(`/api/admin/flags?status=${repStatus}&olderThanDays=${age}`); if (!j.ok) { box.innerHTML = `
${esc(j.error || 'failed')}
`; return; } setBadge(j.open); if (!j.flags.length) { @@ -1341,6 +1343,7 @@

${esc(s.title || vid)}

${esc(s.channel || '')} ${esc(vid)}
+ ${repStatus === 'open' ? `` : ''}
${[...s.lines].map(([text, fl]) => `
@@ -1362,6 +1365,12 @@ $('repList').addEventListener('click', async (e) => { const b = e.target.closest('button'); if (!b) return; if (b.dataset.fix) { leOpen(b.dataset.fix, b.dataset.text); return; } + if (b.dataset.resolveSong) { + if (!confirm(`Resolve every open report for ${b.dataset.resolveSong}?`)) return; + const result = await api(`/api/admin/flags/song/${encodeURIComponent(b.dataset.resolveSong)}/resolve`, { method: 'POST', body: {} }); + if (!result.ok) alert(result.error || 'Could not resolve reports'); + await loadReports(); leReloadFlagsOnly(); return; + } const each = async (ids, fn) => { for (const id of ids.split(',')) await fn(id); await loadReports(); leReloadFlagsOnly(); }; if (b.dataset.resall) each(b.dataset.resall, (id) => api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'resolved' } })); else if (b.dataset.reopen) each(b.dataset.reopen, (id) => api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'open' } })); @@ -1375,6 +1384,7 @@ } $('fOpen').addEventListener('click', () => setFilter('open')); $('fRes').addEventListener('click', () => setFilter('resolved')); + $('reportAge').addEventListener('change', loadReports); $('repRefresh').addEventListener('click', loadReports); /* ======================================================================== diff --git a/playwright.admin-audit.config.js b/playwright.admin-audit.config.js index 9ae45ba..a537d91 100644 --- a/playwright.admin-audit.config.js +++ b/playwright.admin-audit.config.js @@ -1,3 +1,3 @@ const { defineConfig } = require('@playwright/test'); const classic = require('./playwright.classic.config'); -module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index)\.spec\.js/ }); +module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index|reports)\.spec\.js/ }); diff --git a/server/db.js b/server/db.js index 491973d..ddc6782 100644 --- a/server/db.js +++ b/server/db.js @@ -493,11 +493,15 @@ export async function listFlagsForVideo(videoId, status = 'open') { } // Newest first across every video; status 'all' returns both. -export async function listAllFlags({ status = 'open', limit = 200 } = {}) { - const where = status === 'all' ? '' : 'WHERE status = ?'; +export async function listAllFlags({ status = 'open', limit = 200, olderThan = 0 } = {}) { + const filters = []; + const args = []; + if (status !== 'all') { filters.push('status = ?'); args.push(status); } + if (Number(olderThan) > 0) { filters.push('created_at <= ?'); args.push(Math.floor(Number(olderThan))); } + const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const r = await db.execute({ sql: `SELECT * FROM lyric_flags ${where} ORDER BY created_at DESC, id DESC LIMIT ?`, - args: status === 'all' ? [limit] : [status, limit], + args: [...args, limit], }); return r.rows.map(flagRow); } diff --git a/server/notes.js b/server/notes.js index 99000e8..c89f895 100644 --- a/server/notes.js +++ b/server/notes.js @@ -762,12 +762,24 @@ export function registerNoteRoutes(app, deps) { app.get('/api/admin/flags', requireAdminOrToken, async (c) => { const status = ['open', 'resolved', 'all'].includes(c.req.query('status')) ? c.req.query('status') : 'open'; + const olderThanDays = Math.max(0, Math.min(3650, Number(c.req.query('olderThanDays')) || 0)); + const olderThan = olderThanDays ? Math.floor(Date.now() / 1000) - olderThanDays * 86400 : 0; const cache = new Map(); const flags = []; - for (const f of await db.listAllFlags({ status, limit: 300 })) { + for (const f of await db.listAllFlags({ status, limit: 300, olderThan })) { flags.push({ ...f, ...(await songLabel(f.videoId, cache)) }); } - return c.json({ ok: true, open: await db.countOpenFlags(), flags }); + return c.json({ ok: true, open: await db.countOpenFlags(), flags, olderThanDays }); + }); + + app.post('/api/admin/flags/song/:videoId/resolve', requireAdmin, async (c) => { + const videoId = c.req.param('videoId'); + if (badId(videoId)) return c.json({ ok: false, error: 'invalid video id' }, 400); + const flags = await db.listFlagsForVideo(videoId, 'open'); + let resolved = 0; + for (const flag of flags) if (await db.setFlagStatus(flag.id, 'resolved', 'admin')) resolved++; + if (resolved) await audit(db, 'admin', 'report.song.resolve', videoId, { resolved }); + return c.json({ ok: true, resolved, open: await db.countOpenFlags() }); }); app.post('/api/admin/flags/:fid', requireAdmin, async (c) => { diff --git a/server/notes.test.js b/server/notes.test.js index 65a9d7a..0f86468 100644 --- a/server/notes.test.js +++ b/server/notes.test.js @@ -284,3 +284,18 @@ test('admin song list includes metadata-only phone songs for lyrics lookup', asy expect((await app.request(`/api/admin/media/${id}/redownload`, { method: 'POST', headers: { Cookie: cookie } })).status).toBe(202); expect((await app.request('/api/admin/media/phoneSong01/redownload', { method: 'POST' })).status).toBe(401); }); + +test('report triage filters aging flags and resolves every open report for one song', async () => { + const id = VID; + const a = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'words', note: '', reporter: 'listener-a' }); + const b = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'typo', note: '', reporter: 'listener-b' }); + await dbmod.db.execute({ sql: 'UPDATE lyric_flags SET created_at=? WHERE id IN (?,?)', args: [Math.floor(Date.now() / 1000) - 10 * 86400, a.id, b.id] }); + const cookie = await adminCookie(); + const aged = await (await app.request('/api/admin/flags?status=open&olderThanDays=7', { headers: { Cookie: cookie } })).json(); + expect(aged.flags.filter(flag => flag.videoId === id)).toHaveLength(2); + const unauthorized = await app.request(`/api/admin/flags/song/${id}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + expect(unauthorized.status).toBe(401); + const response = await app.request(`/api/admin/flags/song/${id}/resolve`, json('POST', {}, { Cookie: cookie })); + expect(await response.json()).toMatchObject({ ok: true, resolved: 2 }); + expect((await dbmod.listFlagsForVideo(id, 'open'))).toHaveLength(0); +}); diff --git a/tests/admin-reports.spec.js b/tests/admin-reports.spec.js new file mode 100644 index 0000000..8c8ed76 --- /dev/null +++ b/tests/admin-reports.spec.js @@ -0,0 +1,25 @@ +const { test, expect } = require('@playwright/test'); + +test('report triage filters age and resolves a song batch with confirmation', async ({ page }) => { + const requests = []; + await page.route('**/api/**', async route => { + const request = route.request(); const url = new URL(request.url()); requests.push({ path: url.pathname, search: url.search, method: request.method() }); + let body = { ok: true }; + if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true }; + if (url.pathname === '/api/admin/flags' && request.method() === 'GET') body = { ok: true, open: 2, flags: [ + { id: 1, videoId: 'dQw4w9WgXcQ', title: 'Sample song', channel: 'Choir', text: 'Flagged line', reporter: 'Listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'Wrong lyric' }, + { id: 2, videoId: 'dQw4w9WgXcQ', title: 'Sample song', channel: 'Choir', text: 'Flagged line', reporter: 'Second listener', reason: 'typo', status: 'open', createdAt: 1791300000, note: '' }, + ] }; + if (url.pathname.endsWith('/resolve') && request.method() === 'POST') body = { ok: true, resolved: 2, open: 0 }; + if (url.pathname === '/api/notes/dQw4w9WgXcQ') body = { ok: true, lyrics: null, chapters: null }; + if (url.pathname === '/api/admin/notes/recent') body = { ok: true, notes: [] }; + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); + }); + page.on('dialog', dialog => dialog.accept()); + await page.goto('/admin.html#reports'); + await expect(page.locator('#repList')).toContainText('Second listener'); + await page.locator('#reportAge').selectOption('30'); + await expect.poll(() => requests.some(r => r.path === '/api/admin/flags' && r.search.includes('olderThanDays=30'))).toBe(true); + await page.locator('[data-resolve-song="dQw4w9WgXcQ"]').click(); + await expect.poll(() => requests.some(r => r.path === '/api/admin/flags/song/dQw4w9WgXcQ/resolve' && r.method === 'POST')).toBe(true); +});