feat(admin): add adminAlerts module shell with thresholds and signature helper
This commit is contained in:
parent
7544424484
commit
d3a961f765
2 changed files with 125 additions and 0 deletions
39
server/utils/adminAlerts.js
Normal file
39
server/utils/adminAlerts.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import Member from '../models/member.js'
|
||||||
|
import Event from '../models/event.js'
|
||||||
|
import PreRegistration from '../models/preRegistration.js'
|
||||||
|
import TagSuggestion from '../models/tagSuggestion.js'
|
||||||
|
import AdminAlertDismissal, { ADMIN_ALERT_TYPES } from '../models/adminAlertDismissal.js'
|
||||||
|
import { connectDB } from './mongoose.js'
|
||||||
|
|
||||||
|
export const ALERT_THRESHOLDS = {
|
||||||
|
NO_SLACK_DAYS: 7,
|
||||||
|
STUCK_PAYMENT_DAYS: 7,
|
||||||
|
PREREG_SELECTED_DAYS: 3,
|
||||||
|
DRAFT_IMMINENT_DAYS: 14,
|
||||||
|
NEAR_CAPACITY_RATIO: 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function daysAgo(days) {
|
||||||
|
return new Date(Date.now() - days * DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysSince(date) {
|
||||||
|
if (!date) return null
|
||||||
|
return Math.floor((Date.now() - new Date(date).getTime()) / DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSignature(ids) {
|
||||||
|
const normalized = ids
|
||||||
|
.map((id) => (id == null ? '' : String(id)))
|
||||||
|
.sort()
|
||||||
|
const hash = createHash('sha1')
|
||||||
|
hash.update(JSON.stringify(normalized))
|
||||||
|
return hash.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert functions land here in tasks 4–7.
|
||||||
|
|
||||||
|
// Aggregator lands in task 8.
|
||||||
86
tests/server/utils/adminAlerts.test.js
Normal file
86
tests/server/utils/adminAlerts.test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
|
connectDB: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/member.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn(),
|
||||||
|
countDocuments: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/event.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/preRegistration.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/tagSuggestion.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../server/models/adminAlertDismissal.js', () => ({
|
||||||
|
default: {
|
||||||
|
find: vi.fn()
|
||||||
|
},
|
||||||
|
ADMIN_ALERT_TYPES: [
|
||||||
|
'slack_invite_failed',
|
||||||
|
'no_slack_handle_week',
|
||||||
|
'stuck_pending_payment',
|
||||||
|
'member_suspended',
|
||||||
|
'preregistrant_selected_not_invited',
|
||||||
|
'preregistrant_expired',
|
||||||
|
'event_draft_imminent',
|
||||||
|
'event_near_capacity',
|
||||||
|
'tag_suggestions_pending'
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { ALERT_THRESHOLDS, computeSignature } from '../../../server/utils/adminAlerts.js'
|
||||||
|
|
||||||
|
describe('adminAlerts module shell', () => {
|
||||||
|
describe('ALERT_THRESHOLDS', () => {
|
||||||
|
it('exposes the four documented thresholds', () => {
|
||||||
|
expect(ALERT_THRESHOLDS.NO_SLACK_DAYS).toBe(7)
|
||||||
|
expect(ALERT_THRESHOLDS.STUCK_PAYMENT_DAYS).toBe(7)
|
||||||
|
expect(ALERT_THRESHOLDS.PREREG_SELECTED_DAYS).toBe(3)
|
||||||
|
expect(ALERT_THRESHOLDS.DRAFT_IMMINENT_DAYS).toBe(14)
|
||||||
|
expect(ALERT_THRESHOLDS.NEAR_CAPACITY_RATIO).toBe(0.8)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeSignature', () => {
|
||||||
|
it('returns a hex string', () => {
|
||||||
|
const sig = computeSignature(['a', 'b', 'c'])
|
||||||
|
expect(typeof sig).toBe('string')
|
||||||
|
expect(sig).toMatch(/^[a-f0-9]+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is order-independent', () => {
|
||||||
|
expect(computeSignature(['a', 'b', 'c'])).toBe(computeSignature(['c', 'a', 'b']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes when membership changes', () => {
|
||||||
|
expect(computeSignature(['a', 'b'])).not.toBe(computeSignature(['a', 'b', 'c']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a stable empty-set signature', () => {
|
||||||
|
expect(computeSignature([])).toBe(computeSignature([]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces non-string ids to strings', () => {
|
||||||
|
expect(computeSignature([{ toString: () => 'x' }, 'y']))
|
||||||
|
.toBe(computeSignature(['x', 'y']))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue