feat(admin): add event alert detectors
This commit is contained in:
parent
4bae4b0ec3
commit
ab3f0a8b39
2 changed files with 164 additions and 1 deletions
|
|
@ -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.
|
// Aggregator lands in task 8.
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,13 @@ import {
|
||||||
detectStuckPendingPayment,
|
detectStuckPendingPayment,
|
||||||
detectSuspendedMembers,
|
detectSuspendedMembers,
|
||||||
detectPreRegistrantSelectedNotInvited,
|
detectPreRegistrantSelectedNotInvited,
|
||||||
detectPreRegistrantExpired
|
detectPreRegistrantExpired,
|
||||||
|
detectDraftEventsImminent,
|
||||||
|
detectEventsNearCapacity
|
||||||
} from '../../../server/utils/adminAlerts.js'
|
} from '../../../server/utils/adminAlerts.js'
|
||||||
import Member from '../../../server/models/member.js'
|
import Member from '../../../server/models/member.js'
|
||||||
import PreRegistration from '../../../server/models/preRegistration.js'
|
import PreRegistration from '../../../server/models/preRegistration.js'
|
||||||
|
import Event from '../../../server/models/event.js'
|
||||||
|
|
||||||
describe('adminAlerts module shell', () => {
|
describe('adminAlerts module shell', () => {
|
||||||
describe('ALERT_THRESHOLDS', () => {
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue