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 } // Single source of truth for alert presentation. Used by detectors AND the // dismissed-list endpoint (which has no access to a detector run's output). export const ALERT_METADATA = { no_slack_handle_week: { title: 'Active members without a Slack handle', severity: 'attention' }, stuck_pending_payment: { title: 'Members stuck in pending payment', severity: 'attention' }, member_suspended: { title: 'Suspended members', severity: 'attention' }, preregistrant_selected_not_invited: { title: 'Pre-registrants selected but not invited', severity: 'attention' }, preregistrant_expired: { title: 'Expired pre-registrant invitations', severity: 'attention' }, event_draft_imminent: { title: 'Draft events with imminent start', severity: 'critical' }, event_near_capacity: { title: 'Events approaching capacity', severity: 'attention' }, tag_suggestions_pending: { title: 'Pending tag suggestions', severity: 'attention' } } function alertShell(type) { const meta = ALERT_METADATA[type] return { type, severity: meta.severity, title: meta.title } } 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') } function memberItem(member, sublabel) { return { id: String(member._id), label: member.name, sublabel: sublabel ?? member.email, href: `/admin/members/${member._id}` } } export async function detectNoSlackHandleAfterWeek() { await connectDB() const cutoff = daysAgo(ALERT_THRESHOLDS.NO_SLACK_DAYS) const members = await Member .find({ status: 'active', createdAt: { $lte: cutoff }, $or: [ { slackUserId: { $exists: false } }, { slackUserId: null }, { slackUserId: '' } ] }) .select('name email createdAt') .lean() return { ...alertShell('no_slack_handle_week'), items: members.map((m) => memberItem(m, `${daysSince(m.createdAt)} days since joining`) ) } } export async function detectStuckPendingPayment() { await connectDB() const cutoff = daysAgo(ALERT_THRESHOLDS.STUCK_PAYMENT_DAYS) const members = await Member .find({ status: 'pending_payment', createdAt: { $lte: cutoff } }) .select('name email createdAt') .lean() return { ...alertShell('stuck_pending_payment'), items: members.map((m) => memberItem(m, `${daysSince(m.createdAt)} days stuck`) ) } } export async function detectSuspendedMembers() { await connectDB() const members = await Member .find({ status: 'suspended' }) .select('name email') .lean() return { ...alertShell('member_suspended'), items: members.map((m) => memberItem(m)) } } function preRegItem(preReg, sublabel) { return { id: String(preReg._id), label: preReg.name || preReg.email, sublabel, href: '/admin/pre-registrants' } } export async function detectPreRegistrantSelectedNotInvited() { await connectDB() const cutoff = daysAgo(ALERT_THRESHOLDS.PREREG_SELECTED_DAYS) const preRegs = await PreRegistration .find({ status: 'selected', updatedAt: { $lte: cutoff } }) .select('name email updatedAt') .lean() return { ...alertShell('preregistrant_selected_not_invited'), items: preRegs.map((p) => preRegItem(p, `${p.email} — ${daysSince(p.updatedAt)} days selected`) ) } } export async function detectPreRegistrantExpired() { await connectDB() const preRegs = await PreRegistration .find({ status: 'expired' }) .select('name email updatedAt') .lean() return { ...alertShell('preregistrant_expired'), items: preRegs.map((p) => preRegItem(p, `${p.email} — expired ${daysSince(p.updatedAt)} days ago`) ) } } 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 { ...alertShell('event_draft_imminent'), 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 { ...alertShell('event_near_capacity'), items: matched } } export async function detectPendingTagSuggestions() { await connectDB() const suggestions = await TagSuggestion .find({ status: 'pending' }) .select('_id') .lean() if (suggestions.length === 0) { return { ...alertShell('tag_suggestions_pending'), items: [] } } return { ...alertShell('tag_suggestions_pending'), items: [ { id: 'tag-suggestions', label: `${suggestions.length} pending tag suggestion${suggestions.length === 1 ? '' : 's'}`, sublabel: 'Review and approve in Tags', href: '/admin/tags' } ], // signature is computed from the underlying suggestion ids, not the rendered item id signatureIds: suggestions.map((s) => String(s._id)) } } const DETECTORS = [ 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 }