diff --git a/server/utils/adminAlerts.js b/server/utils/adminAlerts.js new file mode 100644 index 0000000..8b5cb17 --- /dev/null +++ b/server/utils/adminAlerts.js @@ -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. diff --git a/tests/server/utils/adminAlerts.test.js b/tests/server/utils/adminAlerts.test.js new file mode 100644 index 0000000..bbcea8c --- /dev/null +++ b/tests/server/utils/adminAlerts.test.js @@ -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'])) + }) + }) +})