diff --git a/app/components/admin/AdminAlertsPanel.vue b/app/components/admin/AdminAlertsPanel.vue index 3bb4ff1..e41fa45 100644 --- a/app/components/admin/AdminAlertsPanel.vue +++ b/app/components/admin/AdminAlertsPanel.vue @@ -1,7 +1,8 @@ diff --git a/server/api/admin/alerts/dismissed.get.js b/server/api/admin/alerts/dismissed.get.js new file mode 100644 index 0000000..b02ac77 --- /dev/null +++ b/server/api/admin/alerts/dismissed.get.js @@ -0,0 +1,23 @@ +import AdminAlertDismissal from '../../../models/adminAlertDismissal.js' +import { ALERT_METADATA } from '../../../utils/adminAlerts.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + const admin = await requireAdmin(event) + await connectDB() + + const rows = await AdminAlertDismissal.find({ adminId: admin._id.toString() }) + .sort({ dismissedAt: -1 }) + .lean() + + const dismissed = rows + .filter((r) => ALERT_METADATA[r.alertType]) // defensive: skip any orphaned legacy slugs + .map((r) => ({ + alertType: r.alertType, + title: ALERT_METADATA[r.alertType].title, + severity: ALERT_METADATA[r.alertType].severity, + dismissedAt: r.dismissedAt + })) + + return { dismissed } +}) diff --git a/server/api/admin/alerts/restore.post.js b/server/api/admin/alerts/restore.post.js new file mode 100644 index 0000000..52e8fee --- /dev/null +++ b/server/api/admin/alerts/restore.post.js @@ -0,0 +1,15 @@ +import AdminAlertDismissal from '../../../models/adminAlertDismissal.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + const admin = await requireAdmin(event) + const { alertTypes } = await validateBody(event, adminAlertRestoreSchema) + await connectDB() + + const result = await AdminAlertDismissal.deleteMany({ + adminId: admin._id.toString(), + alertType: { $in: alertTypes } + }) + + return { ok: true, restored: result.deletedCount ?? 0 } +}) diff --git a/server/utils/adminAlerts.js b/server/utils/adminAlerts.js index 3a24113..c8de63f 100644 --- a/server/utils/adminAlerts.js +++ b/server/utils/adminAlerts.js @@ -14,6 +14,25 @@ export const ALERT_THRESHOLDS = { NEAR_CAPACITY_RATIO: 0.8 } +// Single source of truth for alert presentation. Used by detectors AND the +// dismissed-list endpoint (which has no access to a detector run's output). +export const ALERT_METADATA = { + slack_invite_failed: { title: 'Slack invites failed', severity: 'critical' }, + no_slack_handle_week: { title: 'Active members without a Slack handle', severity: 'attention' }, + stuck_pending_payment: { title: 'Members stuck in pending payment', severity: 'attention' }, + member_suspended: { title: 'Suspended members', severity: 'attention' }, + preregistrant_selected_not_invited: { title: 'Pre-registrants selected but not invited', severity: 'attention' }, + preregistrant_expired: { title: 'Expired pre-registrant invitations', severity: 'attention' }, + event_draft_imminent: { title: 'Draft events with imminent start', severity: 'critical' }, + event_near_capacity: { title: 'Events approaching capacity', severity: 'attention' }, + tag_suggestions_pending: { title: 'Pending tag suggestions', severity: 'attention' } +} + +function alertShell(type) { + const meta = ALERT_METADATA[type] + return { type, severity: meta.severity, title: meta.title } +} + const DAY_MS = 24 * 60 * 60 * 1000 function daysAgo(days) { @@ -50,9 +69,7 @@ export async function detectSlackInviteFailed() { .select('name email') .lean() return { - type: 'slack_invite_failed', - severity: 'critical', - title: 'Slack invites failed', + ...alertShell('slack_invite_failed'), items: members.map((m) => memberItem(m)) } } @@ -73,9 +90,7 @@ export async function detectNoSlackHandleAfterWeek() { .select('name email createdAt') .lean() return { - type: 'no_slack_handle_week', - severity: 'attention', - title: 'Active members without a Slack handle', + ...alertShell('no_slack_handle_week'), items: members.map((m) => memberItem(m, `${daysSince(m.createdAt)} days since joining`) ) @@ -93,9 +108,7 @@ export async function detectStuckPendingPayment() { .select('name email createdAt') .lean() return { - type: 'stuck_pending_payment', - severity: 'attention', - title: 'Members stuck in pending payment', + ...alertShell('stuck_pending_payment'), items: members.map((m) => memberItem(m, `${daysSince(m.createdAt)} days stuck`) ) @@ -109,9 +122,7 @@ export async function detectSuspendedMembers() { .select('name email') .lean() return { - type: 'member_suspended', - severity: 'attention', - title: 'Suspended members', + ...alertShell('member_suspended'), items: members.map((m) => memberItem(m)) } } @@ -136,9 +147,7 @@ export async function detectPreRegistrantSelectedNotInvited() { .select('name email updatedAt') .lean() return { - type: 'preregistrant_selected_not_invited', - severity: 'attention', - title: 'Pre-registrants selected but not invited', + ...alertShell('preregistrant_selected_not_invited'), items: preRegs.map((p) => preRegItem(p, `${p.email} — ${daysSince(p.updatedAt)} days selected`) ) @@ -152,9 +161,7 @@ export async function detectPreRegistrantExpired() { .select('name email updatedAt') .lean() return { - type: 'preregistrant_expired', - severity: 'attention', - title: 'Expired pre-registrant invitations', + ...alertShell('preregistrant_expired'), items: preRegs.map((p) => preRegItem(p, `${p.email} — expired ${daysSince(p.updatedAt)} days ago`) ) @@ -183,9 +190,7 @@ export async function detectDraftEventsImminent() { .select('title startDate') .lean() return { - type: 'event_draft_imminent', - severity: 'critical', - title: 'Draft events with imminent start', + ...alertShell('event_draft_imminent'), items: events.map((ev) => { const days = Math.max(0, Math.ceil((new Date(ev.startDate).getTime() - Date.now()) / DAY_MS)) return eventItem(ev, `Starts in ${days} days`) @@ -219,9 +224,7 @@ export async function detectEventsNearCapacity() { .filter(Boolean) return { - type: 'event_near_capacity', - severity: 'attention', - title: 'Events approaching capacity', + ...alertShell('event_near_capacity'), items: matched } } @@ -234,16 +237,12 @@ export async function detectPendingTagSuggestions() { .lean() if (suggestions.length === 0) { return { - type: 'tag_suggestions_pending', - severity: 'attention', - title: 'Pending tag suggestions', + ...alertShell('tag_suggestions_pending'), items: [] } } return { - type: 'tag_suggestions_pending', - severity: 'attention', - title: 'Pending tag suggestions', + ...alertShell('tag_suggestions_pending'), items: [ { id: 'tag-suggestions', diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 565f36d..d9ff396 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -410,3 +410,10 @@ export const adminAlertDismissSchema = z.object({ alertType: z.enum(ADMIN_ALERT_TYPES), signature: z.string().min(1).max(128) }) + +export const adminAlertRestoreSchema = z.object({ + alertTypes: z + .array(z.enum(ADMIN_ALERT_TYPES)) + .min(1) + .max(ADMIN_ALERT_TYPES.length) +}) diff --git a/tests/server/api/admin-alerts.test.js b/tests/server/api/admin-alerts.test.js index 58d7d23..558ed11 100644 --- a/tests/server/api/admin-alerts.test.js +++ b/tests/server/api/admin-alerts.test.js @@ -3,12 +3,25 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' vi.mock('../../../server/utils/adminAlerts.js', () => ({ - computeAllAlerts: vi.fn() + computeAllAlerts: vi.fn(), + ALERT_METADATA: { + slack_invite_failed: { title: 'Slack invites failed', severity: 'critical' }, + no_slack_handle_week: { title: 'Active members without a Slack handle', severity: 'attention' }, + stuck_pending_payment: { title: 'Members stuck in pending payment', severity: 'attention' }, + member_suspended: { title: 'Suspended members', severity: 'attention' }, + preregistrant_selected_not_invited: { title: 'Pre-registrants selected but not invited', severity: 'attention' }, + preregistrant_expired: { title: 'Expired pre-registrant invitations', severity: 'attention' }, + event_draft_imminent: { title: 'Draft events with imminent start', severity: 'critical' }, + event_near_capacity: { title: 'Events approaching capacity', severity: 'attention' }, + tag_suggestions_pending: { title: 'Pending tag suggestions', severity: 'attention' } + } })) vi.mock('../../../server/models/adminAlertDismissal.js', () => ({ default: { - findOneAndUpdate: vi.fn() + findOneAndUpdate: vi.fn(), + find: vi.fn(), + deleteMany: vi.fn() }, ADMIN_ALERT_TYPES: [ 'slack_invite_failed', @@ -28,9 +41,12 @@ vi.mock('../../../server/utils/mongoose.js', () => ({ })) vi.stubGlobal('adminAlertDismissSchema', {}) +vi.stubGlobal('adminAlertRestoreSchema', {}) import getHandler from '../../../server/api/admin/alerts/index.get.js' import dismissHandler from '../../../server/api/admin/alerts/dismiss.post.js' +import dismissedHandler from '../../../server/api/admin/alerts/dismissed.get.js' +import restoreHandler from '../../../server/api/admin/alerts/restore.post.js' import { computeAllAlerts } from '../../../server/utils/adminAlerts.js' import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js' import { createMockEvent } from '../helpers/createMockEvent.js' @@ -151,3 +167,154 @@ describe('POST /api/admin/alerts/dismiss', () => { await expect(dismissHandler(event)).rejects.toMatchObject({ statusCode: 400 }) }) }) + +describe('GET /api/admin/alerts/dismissed', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } }) + }) + + describe('source inspection', () => { + const source = readFileSync( + resolve(import.meta.dirname, '../../../server/api/admin/alerts/dismissed.get.js'), + 'utf-8' + ) + it('calls requireAdmin before querying dismissals', () => { + const adminIdx = source.indexOf('requireAdmin(event)') + const findIdx = source.indexOf('AdminAlertDismissal.find') + expect(adminIdx).toBeGreaterThan(-1) + expect(findIdx).toBeGreaterThan(-1) + expect(adminIdx).toBeLessThan(findIdx) + }) + }) + + it('returns dismissed alerts with title and severity joined from metadata', async () => { + AdminAlertDismissal.find.mockReturnValue({ + sort: () => ({ + lean: () => Promise.resolve([ + { alertType: 'slack_invite_failed', signature: 'abc', dismissedAt: new Date('2026-04-08T10:00:00Z') }, + { alertType: 'member_suspended', signature: 'def', dismissedAt: new Date('2026-04-07T10:00:00Z') } + ]) + }) + }) + + const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' }) + const result = await dismissedHandler(event) + + expect(AdminAlertDismissal.find).toHaveBeenCalledWith({ adminId: 'admin-1' }) + expect(result.dismissed).toEqual([ + { + alertType: 'slack_invite_failed', + title: 'Slack invites failed', + severity: 'critical', + dismissedAt: new Date('2026-04-08T10:00:00Z') + }, + { + alertType: 'member_suspended', + title: 'Suspended members', + severity: 'attention', + dismissedAt: new Date('2026-04-07T10:00:00Z') + } + ]) + }) + + it('returns empty list when no dismissals exist', async () => { + AdminAlertDismissal.find.mockReturnValue({ + sort: () => ({ lean: () => Promise.resolve([]) }) + }) + const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' }) + const result = await dismissedHandler(event) + expect(result).toEqual({ dismissed: [] }) + }) + + it('filters out orphaned slugs not present in ALERT_METADATA', async () => { + AdminAlertDismissal.find.mockReturnValue({ + sort: () => ({ + lean: () => Promise.resolve([ + { alertType: 'slack_invite_failed', signature: 'abc', dismissedAt: new Date() }, + { alertType: 'legacy_removed_alert', signature: 'xyz', dismissedAt: new Date() } + ]) + }) + }) + const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' }) + const result = await dismissedHandler(event) + expect(result.dismissed).toHaveLength(1) + expect(result.dismissed[0].alertType).toBe('slack_invite_failed') + }) + + it('rejects non-admin requests', async () => { + requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' })) + const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' }) + await expect(dismissedHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) +}) + +describe('POST /api/admin/alerts/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } }) + validateBody.mockResolvedValue({ alertTypes: ['slack_invite_failed', 'member_suspended'] }) + }) + + describe('source inspection', () => { + const source = readFileSync( + resolve(import.meta.dirname, '../../../server/api/admin/alerts/restore.post.js'), + 'utf-8' + ) + it('calls requireAdmin before validateBody', () => { + const adminIdx = source.indexOf('requireAdmin(event)') + const validateIdx = source.indexOf('validateBody(event') + expect(adminIdx).toBeGreaterThan(-1) + expect(validateIdx).toBeGreaterThan(-1) + expect(adminIdx).toBeLessThan(validateIdx) + }) + }) + + it('deletes dismissals matching the provided alertTypes and returns count', async () => { + AdminAlertDismissal.deleteMany.mockResolvedValue({ deletedCount: 2 }) + + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/alerts/restore', + body: { alertTypes: ['slack_invite_failed', 'member_suspended'] } + }) + const result = await restoreHandler(event) + + expect(result).toEqual({ ok: true, restored: 2 }) + expect(AdminAlertDismissal.deleteMany).toHaveBeenCalledWith({ + adminId: 'admin-1', + alertType: { $in: ['slack_invite_failed', 'member_suspended'] } + }) + }) + + it('returns restored: 0 when deleteMany returns no deletedCount', async () => { + AdminAlertDismissal.deleteMany.mockResolvedValue({}) + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/alerts/restore', + body: { alertTypes: ['slack_invite_failed'] } + }) + const result = await restoreHandler(event) + expect(result).toEqual({ ok: true, restored: 0 }) + }) + + it('rejects non-admin requests', async () => { + requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' })) + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/alerts/restore', + body: { alertTypes: ['slack_invite_failed'] } + }) + await expect(restoreHandler(event)).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('rejects invalid body via validateBody', async () => { + validateBody.mockRejectedValue(createError({ statusCode: 400, statusMessage: 'Validation failed' })) + const event = createMockEvent({ + method: 'POST', + path: '/api/admin/alerts/restore', + body: { alertTypes: [] } + }) + await expect(restoreHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) +}) diff --git a/tests/server/api/admin-auth-guards.test.js b/tests/server/api/admin-auth-guards.test.js index 2bd1886..0bcb6e7 100644 --- a/tests/server/api/admin-auth-guards.test.js +++ b/tests/server/api/admin-auth-guards.test.js @@ -42,7 +42,9 @@ const adminRoutes = { ], 'admin/alerts/': [ 'alerts/index.get.js', - 'alerts/dismiss.post.js' + 'alerts/dismiss.post.js', + 'alerts/dismissed.get.js', + 'alerts/restore.post.js' ] } @@ -68,7 +70,9 @@ const businessLogicPatterns = [ 'PreRegistration.aggregate', 'PreRegistration.updateMany', 'computeAllAlerts(', - 'AdminAlertDismissal.findOneAndUpdate' + 'AdminAlertDismissal.findOneAndUpdate', + 'AdminAlertDismissal.find', + 'AdminAlertDismissal.deleteMany' ] describe('Admin endpoint auth guards', () => {