diff --git a/server/utils/adminAlerts.js b/server/utils/adminAlerts.js index 58b7bd5..ec778d2 100644 --- a/server/utils/adminAlerts.js +++ b/server/utils/adminAlerts.js @@ -161,4 +161,69 @@ export async function detectPreRegistrantExpired() { } } +function eventItem(ev, sublabel) { + return { + id: String(ev._id), + label: ev.title, + sublabel, + href: '/admin/events' + } +} + +export async function detectDraftEventsImminent() { + await connectDB() + const now = new Date() + const horizon = new Date(now.getTime() + ALERT_THRESHOLDS.DRAFT_IMMINENT_DAYS * DAY_MS) + const events = await Event + .find({ + isVisible: false, + isCancelled: { $ne: true }, + startDate: { $gte: now, $lte: horizon } + }) + .select('title startDate') + .lean() + return { + type: 'event_draft_imminent', + severity: 'critical', + title: 'Draft events with imminent start', + 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`) + }) + } +} + +export async function detectEventsNearCapacity() { + await connectDB() + const now = new Date() + const events = await Event + .find({ + 'tickets.enabled': true, + 'tickets.capacity.total': { $gt: 0 }, + isCancelled: { $ne: true }, + startDate: { $gte: now } + }) + .select('title startDate tickets registrations') + .lean() + + const matched = events + .map((ev) => { + const total = ev.tickets?.capacity?.total + if (!total) return null + const reserved = ev.tickets?.capacity?.reserved || 0 + const taken = (ev.registrations?.length || 0) + reserved + const ratio = taken / total + if (ratio < ALERT_THRESHOLDS.NEAR_CAPACITY_RATIO) return null + return eventItem(ev, `${taken} / ${total} seats taken`) + }) + .filter(Boolean) + + return { + type: 'event_near_capacity', + severity: 'attention', + title: 'Events approaching capacity', + items: matched + } +} + // Aggregator lands in task 8. diff --git a/tests/server/utils/adminAlerts.test.js b/tests/server/utils/adminAlerts.test.js index 120d586..f26ac81 100644 --- a/tests/server/utils/adminAlerts.test.js +++ b/tests/server/utils/adminAlerts.test.js @@ -54,10 +54,13 @@ import { detectStuckPendingPayment, detectSuspendedMembers, detectPreRegistrantSelectedNotInvited, - detectPreRegistrantExpired + detectPreRegistrantExpired, + detectDraftEventsImminent, + detectEventsNearCapacity } from '../../../server/utils/adminAlerts.js' import Member from '../../../server/models/member.js' import PreRegistration from '../../../server/models/preRegistration.js' +import Event from '../../../server/models/event.js' describe('adminAlerts module shell', () => { describe('ALERT_THRESHOLDS', () => { @@ -251,4 +254,99 @@ describe('adminAlerts module shell', () => { }) }) }) + + describe('event alerts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function mockEventFind(result) { + Event.find.mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(result) + }) + }) + } + + describe('detectDraftEventsImminent', () => { + it('returns hidden, non-cancelled events starting in the next 14 days', async () => { + const startDate = new Date(Date.now() + 5 * 86400000) + mockEventFind([ + { _id: 'e1', title: 'Workshop', startDate } + ]) + + const alert = await detectDraftEventsImminent() + + expect(alert.type).toBe('event_draft_imminent') + expect(alert.severity).toBe('critical') + expect(alert.items).toHaveLength(1) + expect(alert.items[0]).toEqual({ + id: 'e1', + label: 'Workshop', + sublabel: expect.stringMatching(/starts in 5 days/i), + href: '/admin/events' + }) + + const [query] = Event.find.mock.calls[0] + expect(query.isVisible).toBe(false) + expect(query.isCancelled).toEqual({ $ne: true }) + expect(query.startDate.$gte).toBeInstanceOf(Date) + expect(query.startDate.$lte).toBeInstanceOf(Date) + }) + }) + + describe('detectEventsNearCapacity', () => { + it('returns events at >= 80% of capacity', async () => { + mockEventFind([ + { + _id: 'e2', + title: 'Sold Out Soon', + startDate: new Date(Date.now() + 3 * 86400000), + tickets: { + enabled: true, + capacity: { total: 10, reserved: 1 } + }, + registrations: [ + { name: 'a' }, { name: 'b' }, { name: 'c' }, + { name: 'd' }, { name: 'e' }, { name: 'f' }, + { name: 'g' } + ] + }, + { + _id: 'e3', + title: 'Plenty of Room', + startDate: new Date(Date.now() + 3 * 86400000), + tickets: { + enabled: true, + capacity: { total: 10, reserved: 0 } + }, + registrations: [{ name: 'a' }] + } + ]) + + const alert = await detectEventsNearCapacity() + + expect(alert.type).toBe('event_near_capacity') + expect(alert.severity).toBe('attention') + expect(alert.items).toHaveLength(1) + expect(alert.items[0].id).toBe('e2') + expect(alert.items[0].sublabel).toMatch(/8\s*\/\s*10/) + }) + + it('ignores events with no capacity set', async () => { + mockEventFind([ + { + _id: 'e4', + title: 'Uncapped', + startDate: new Date(Date.now() + 3 * 86400000), + tickets: { enabled: true, capacity: {} }, + registrations: [] + } + ]) + + const alert = await detectEventsNearCapacity() + expect(alert.items).toEqual([]) + }) + }) + }) })