diff --git a/server/utils/adminAlerts.js b/server/utils/adminAlerts.js index 90999f6..3a24113 100644 --- a/server/utils/adminAlerts.js +++ b/server/utils/adminAlerts.js @@ -257,4 +257,49 @@ export async function detectPendingTagSuggestions() { } } -// Aggregator lands in task 8. +const DETECTORS = [ + detectSlackInviteFailed, + detectNoSlackHandleAfterWeek, + detectStuckPendingPayment, + detectSuspendedMembers, + detectPreRegistrantSelectedNotInvited, + detectPreRegistrantExpired, + detectDraftEventsImminent, + detectEventsNearCapacity, + detectPendingTagSuggestions +] + +function signatureForAlert(alert) { + if (Array.isArray(alert.signatureIds)) { + return computeSignature(alert.signatureIds) + } + return computeSignature(alert.items.map((item) => item.id)) +} + +export async function computeAllAlerts(adminId) { + await connectDB() + + const [rawAlerts, dismissals] = await Promise.all([ + Promise.all(DETECTORS.map((fn) => fn())), + AdminAlertDismissal.find({ adminId }).lean() + ]) + + const dismissedByType = new Map() + for (const d of dismissals) { + dismissedByType.set(d.alertType, d.signature) + } + + const result = [] + for (const alert of rawAlerts) { + if (!alert.items || alert.items.length === 0) continue + const signature = signatureForAlert(alert) + if (dismissedByType.get(alert.type) === signature) continue + const { signatureIds, ...publicFields } = alert + result.push({ + ...publicFields, + count: alert.items.length, + signature + }) + } + return result +} diff --git a/tests/server/utils/adminAlerts.test.js b/tests/server/utils/adminAlerts.test.js index 1462bc0..aae36e2 100644 --- a/tests/server/utils/adminAlerts.test.js +++ b/tests/server/utils/adminAlerts.test.js @@ -391,3 +391,107 @@ describe('adminAlerts module shell', () => { }) }) }) + +import { computeAllAlerts } from '../../../server/utils/adminAlerts.js' +import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js' + +describe('computeAllAlerts aggregator', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: every detector returns empty + Member.find.mockReturnValue({ + select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) + }) + Event.find.mockReturnValue({ + select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) + }) + PreRegistration.find.mockReturnValue({ + select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) + }) + TagSuggestion.find.mockReturnValue({ + select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) + }) + AdminAlertDismissal.find.mockReturnValue({ + lean: vi.fn().mockResolvedValue([]) + }) + }) + + it('returns an empty list when nothing matches', async () => { + const alerts = await computeAllAlerts('admin-1') + expect(alerts).toEqual([]) + }) + + it('returns alerts that have items and have not been dismissed', async () => { + Member.find.mockImplementation((query) => { + if (query.slackInviteStatus === 'failed') { + return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ + { _id: 'm1', name: 'Alex', email: 'alex@example.com' } + ]) }) } + } + return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) } + }) + + const alerts = await computeAllAlerts('admin-1') + expect(alerts).toHaveLength(1) + expect(alerts[0].type).toBe('slack_invite_failed') + expect(alerts[0].signature).toMatch(/^[a-f0-9]+$/) + }) + + it('hides alerts whose signature matches an existing dismissal', async () => { + Member.find.mockImplementation((query) => { + if (query.slackInviteStatus === 'failed') { + return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ + { _id: 'm1', name: 'Alex', email: 'alex@example.com' } + ]) }) } + } + return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) } + }) + + const sig = computeSignature(['m1']) + AdminAlertDismissal.find.mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { adminId: 'admin-1', alertType: 'slack_invite_failed', signature: sig } + ]) + }) + + const alerts = await computeAllAlerts('admin-1') + expect(alerts).toEqual([]) + }) + + it('shows an alert again when the underlying set changes', async () => { + Member.find.mockImplementation((query) => { + if (query.slackInviteStatus === 'failed') { + return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ + { _id: 'm1', name: 'Alex', email: 'alex@example.com' }, + { _id: 'm2', name: 'Bea', email: 'bea@example.com' } + ]) }) } + } + return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) } + }) + + const staleSig = computeSignature(['m1']) // dismissal was for the old single-member state + AdminAlertDismissal.find.mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { adminId: 'admin-1', alertType: 'slack_invite_failed', signature: staleSig } + ]) + }) + + const alerts = await computeAllAlerts('admin-1') + expect(alerts).toHaveLength(1) + expect(alerts[0].count).toBe(2) + }) + + it('uses signatureIds for tag_suggestions_pending', async () => { + TagSuggestion.find.mockReturnValue({ + select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([ + { _id: 't1' }, { _id: 't2' } + ]) }) + }) + + const alerts = await computeAllAlerts('admin-1') + const tagAlert = alerts.find((a) => a.type === 'tag_suggestions_pending') + expect(tagAlert.signature).toBe(computeSignature(['t1', 't2'])) + // The synthetic item id should not appear in the signature + expect(tagAlert.signature).not.toBe(computeSignature(['tag-suggestions'])) + }) +})