${[...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);
+});