ghostguild-org/server/utils/adminAlerts.js
Jennie Robinson Faber 92e7dae74c
Some checks failed
Test / vitest (push) Successful in 11m48s
Test / playwright (push) Failing after 9m50s
Test / visual (push) Failing after 9m19s
Test / Notify on failure (push) Successful in 2s
feat(admin): add restore dismissed alerts flow
Admins can now surface dismissed alert types without waiting for the
underlying data to change. Adds a collapsible "Restore dismissed"
section below the active alerts with per-type checkboxes.

- ALERT_METADATA map in adminAlerts.js as the single source of truth
  for slug → title/severity; detectors refactored to reference it
- GET /api/admin/alerts/dismissed returns this admin's dismissals
  joined with metadata (title, severity, dismissedAt)
- POST /api/admin/alerts/restore deletes dismissals by alertType[],
  returns the deleted count
- AdminAlertsPanel fetches both active + dismissed; stays visible
  when either is non-empty; checkboxes + "Restore selected" button
- adminAlertRestoreSchema validates the POST body against the enum
- Auth guards test covers both new routes
2026-04-08 12:22:35 +01:00

304 lines
8.4 KiB
JavaScript

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 = {
slack_invite_failed: { title: 'Slack invites failed', severity: 'critical' },
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 detectSlackInviteFailed() {
await connectDB()
const members = await Member
.find({ slackInviteStatus: 'failed' })
.select('name email')
.lean()
return {
...alertShell('slack_invite_failed'),
items: members.map((m) => memberItem(m))
}
}
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 = [
detectSlackInviteFailed,
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
}