Wave-based onboarding makes the auto-invite + polling path obsolete. - Removes SlackService.inviteUserToSlack — admins now send invites through Slack's UI and flip the flag in our admin endpoint. - Removes the slack_invite_failed admin alert + its detector. The alert no longer has a meaningful trigger (we don't attempt invites). - Archives server/utils/checkSlackJoins.js (and its test) under _archive/ in case the polling pattern is needed again post-pilot. - Deletes the Nitro plugin that scheduled checkSlackJoins on boot + hourly. Nothing in nitro.config / nuxt.config / package.json registered it elsewhere. - Drops the slack_invite_failed branch from adminAlerts.test; the enum slug stays in adminAlertDismissal so historical dismissal rows continue to validate. notifyNewMember (vetting-channel notification) and findUserByEmail (used by the auto-flag helper) are retained.
290 lines
8 KiB
JavaScript
290 lines
8 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 = {
|
|
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
|
|
}
|