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
This commit is contained in:
parent
a2af4e31ff
commit
92e7dae74c
7 changed files with 423 additions and 41 deletions
23
server/api/admin/alerts/dismissed.get.js
Normal file
23
server/api/admin/alerts/dismissed.get.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import AdminAlertDismissal from '../../../models/adminAlertDismissal.js'
|
||||
import { ALERT_METADATA } from '../../../utils/adminAlerts.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const admin = await requireAdmin(event)
|
||||
await connectDB()
|
||||
|
||||
const rows = await AdminAlertDismissal.find({ adminId: admin._id.toString() })
|
||||
.sort({ dismissedAt: -1 })
|
||||
.lean()
|
||||
|
||||
const dismissed = rows
|
||||
.filter((r) => ALERT_METADATA[r.alertType]) // defensive: skip any orphaned legacy slugs
|
||||
.map((r) => ({
|
||||
alertType: r.alertType,
|
||||
title: ALERT_METADATA[r.alertType].title,
|
||||
severity: ALERT_METADATA[r.alertType].severity,
|
||||
dismissedAt: r.dismissedAt
|
||||
}))
|
||||
|
||||
return { dismissed }
|
||||
})
|
||||
15
server/api/admin/alerts/restore.post.js
Normal file
15
server/api/admin/alerts/restore.post.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import AdminAlertDismissal from '../../../models/adminAlertDismissal.js'
|
||||
import { connectDB } from '../../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const admin = await requireAdmin(event)
|
||||
const { alertTypes } = await validateBody(event, adminAlertRestoreSchema)
|
||||
await connectDB()
|
||||
|
||||
const result = await AdminAlertDismissal.deleteMany({
|
||||
adminId: admin._id.toString(),
|
||||
alertType: { $in: alertTypes }
|
||||
})
|
||||
|
||||
return { ok: true, restored: result.deletedCount ?? 0 }
|
||||
})
|
||||
|
|
@ -14,6 +14,25 @@ export const ALERT_THRESHOLDS = {
|
|||
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) {
|
||||
|
|
@ -50,9 +69,7 @@ export async function detectSlackInviteFailed() {
|
|||
.select('name email')
|
||||
.lean()
|
||||
return {
|
||||
type: 'slack_invite_failed',
|
||||
severity: 'critical',
|
||||
title: 'Slack invites failed',
|
||||
...alertShell('slack_invite_failed'),
|
||||
items: members.map((m) => memberItem(m))
|
||||
}
|
||||
}
|
||||
|
|
@ -73,9 +90,7 @@ export async function detectNoSlackHandleAfterWeek() {
|
|||
.select('name email createdAt')
|
||||
.lean()
|
||||
return {
|
||||
type: 'no_slack_handle_week',
|
||||
severity: 'attention',
|
||||
title: 'Active members without a Slack handle',
|
||||
...alertShell('no_slack_handle_week'),
|
||||
items: members.map((m) =>
|
||||
memberItem(m, `${daysSince(m.createdAt)} days since joining`)
|
||||
)
|
||||
|
|
@ -93,9 +108,7 @@ export async function detectStuckPendingPayment() {
|
|||
.select('name email createdAt')
|
||||
.lean()
|
||||
return {
|
||||
type: 'stuck_pending_payment',
|
||||
severity: 'attention',
|
||||
title: 'Members stuck in pending payment',
|
||||
...alertShell('stuck_pending_payment'),
|
||||
items: members.map((m) =>
|
||||
memberItem(m, `${daysSince(m.createdAt)} days stuck`)
|
||||
)
|
||||
|
|
@ -109,9 +122,7 @@ export async function detectSuspendedMembers() {
|
|||
.select('name email')
|
||||
.lean()
|
||||
return {
|
||||
type: 'member_suspended',
|
||||
severity: 'attention',
|
||||
title: 'Suspended members',
|
||||
...alertShell('member_suspended'),
|
||||
items: members.map((m) => memberItem(m))
|
||||
}
|
||||
}
|
||||
|
|
@ -136,9 +147,7 @@ export async function detectPreRegistrantSelectedNotInvited() {
|
|||
.select('name email updatedAt')
|
||||
.lean()
|
||||
return {
|
||||
type: 'preregistrant_selected_not_invited',
|
||||
severity: 'attention',
|
||||
title: 'Pre-registrants selected but not invited',
|
||||
...alertShell('preregistrant_selected_not_invited'),
|
||||
items: preRegs.map((p) =>
|
||||
preRegItem(p, `${p.email} — ${daysSince(p.updatedAt)} days selected`)
|
||||
)
|
||||
|
|
@ -152,9 +161,7 @@ export async function detectPreRegistrantExpired() {
|
|||
.select('name email updatedAt')
|
||||
.lean()
|
||||
return {
|
||||
type: 'preregistrant_expired',
|
||||
severity: 'attention',
|
||||
title: 'Expired pre-registrant invitations',
|
||||
...alertShell('preregistrant_expired'),
|
||||
items: preRegs.map((p) =>
|
||||
preRegItem(p, `${p.email} — expired ${daysSince(p.updatedAt)} days ago`)
|
||||
)
|
||||
|
|
@ -183,9 +190,7 @@ export async function detectDraftEventsImminent() {
|
|||
.select('title startDate')
|
||||
.lean()
|
||||
return {
|
||||
type: 'event_draft_imminent',
|
||||
severity: 'critical',
|
||||
title: 'Draft events with imminent start',
|
||||
...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`)
|
||||
|
|
@ -219,9 +224,7 @@ export async function detectEventsNearCapacity() {
|
|||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
type: 'event_near_capacity',
|
||||
severity: 'attention',
|
||||
title: 'Events approaching capacity',
|
||||
...alertShell('event_near_capacity'),
|
||||
items: matched
|
||||
}
|
||||
}
|
||||
|
|
@ -234,16 +237,12 @@ export async function detectPendingTagSuggestions() {
|
|||
.lean()
|
||||
if (suggestions.length === 0) {
|
||||
return {
|
||||
type: 'tag_suggestions_pending',
|
||||
severity: 'attention',
|
||||
title: 'Pending tag suggestions',
|
||||
...alertShell('tag_suggestions_pending'),
|
||||
items: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'tag_suggestions_pending',
|
||||
severity: 'attention',
|
||||
title: 'Pending tag suggestions',
|
||||
...alertShell('tag_suggestions_pending'),
|
||||
items: [
|
||||
{
|
||||
id: 'tag-suggestions',
|
||||
|
|
|
|||
|
|
@ -410,3 +410,10 @@ export const adminAlertDismissSchema = z.object({
|
|||
alertType: z.enum(ADMIN_ALERT_TYPES),
|
||||
signature: z.string().min(1).max(128)
|
||||
})
|
||||
|
||||
export const adminAlertRestoreSchema = z.object({
|
||||
alertTypes: z
|
||||
.array(z.enum(ADMIN_ALERT_TYPES))
|
||||
.min(1)
|
||||
.max(ADMIN_ALERT_TYPES.length)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue