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
|
|
@ -1,7 +1,8 @@
|
||||||
<!-- app/components/admin/AdminAlertsPanel.vue -->
|
<!-- app/components/admin/AdminAlertsPanel.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div v-if="visibleAlerts.length" class="alerts-panel">
|
<div v-if="hasContent" class="alerts-panel">
|
||||||
<div class="section-label">Needs Attention</div>
|
<div class="section-label">Needs Attention</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="alert in visibleAlerts"
|
v-for="alert in visibleAlerts"
|
||||||
:key="alert.type"
|
:key="alert.type"
|
||||||
|
|
@ -33,16 +34,77 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!visibleAlerts.length" class="empty-active">
|
||||||
|
No active alerts.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dismissedAlerts.length" class="restore-section">
|
||||||
|
<button
|
||||||
|
v-if="!restoreOpen"
|
||||||
|
type="button"
|
||||||
|
class="restore-toggle"
|
||||||
|
@click="restoreOpen = true"
|
||||||
|
>
|
||||||
|
Restore dismissed ({{ dismissedAlerts.length }})
|
||||||
|
</button>
|
||||||
|
<div v-else class="restore-panel">
|
||||||
|
<div class="section-label restore-label">Restore dismissed alerts</div>
|
||||||
|
<ul class="restore-list">
|
||||||
|
<li v-for="d in dismissedAlerts" :key="d.alertType">
|
||||||
|
<label class="restore-option">
|
||||||
|
<input
|
||||||
|
v-model="selectedRestore"
|
||||||
|
type="checkbox"
|
||||||
|
:value="d.alertType"
|
||||||
|
/>
|
||||||
|
<span>{{ d.title }}</span>
|
||||||
|
<span class="restore-when">dismissed {{ formatDismissedAt(d.dismissedAt) }}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="restore-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dismiss-btn"
|
||||||
|
:disabled="!selectedRestore.length || restoring"
|
||||||
|
@click="restoreSelected"
|
||||||
|
>
|
||||||
|
Restore selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dismiss-btn"
|
||||||
|
@click="cancelRestore"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const maxItems = 5
|
const maxItems = 5
|
||||||
const dismissing = reactive({})
|
const dismissing = reactive({})
|
||||||
|
const restoreOpen = ref(false)
|
||||||
|
const selectedRestore = ref([])
|
||||||
|
const restoring = ref(false)
|
||||||
|
|
||||||
const { data, refresh } = await useFetch('/api/admin/alerts', { default: () => ({ alerts: [] }) })
|
const { data, refresh } = await useFetch('/api/admin/alerts', {
|
||||||
|
default: () => ({ alerts: [] })
|
||||||
|
})
|
||||||
|
const { data: dismissedData, refresh: refreshDismissed } = await useFetch(
|
||||||
|
'/api/admin/alerts/dismissed',
|
||||||
|
{ default: () => ({ dismissed: [] }) }
|
||||||
|
)
|
||||||
|
|
||||||
const visibleAlerts = computed(() => data.value?.alerts || [])
|
const visibleAlerts = computed(() => data.value?.alerts || [])
|
||||||
|
const dismissedAlerts = computed(() => dismissedData.value?.dismissed || [])
|
||||||
|
const hasContent = computed(
|
||||||
|
() => visibleAlerts.value.length > 0 || dismissedAlerts.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
function displayItems(alert) {
|
function displayItems(alert) {
|
||||||
return alert.items.slice(0, maxItems)
|
return alert.items.slice(0, maxItems)
|
||||||
|
|
@ -55,18 +117,51 @@ async function dismissAlert(alert) {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { alertType: alert.type, signature: alert.signature }
|
body: { alertType: alert.type, signature: alert.signature }
|
||||||
})
|
})
|
||||||
// Optimistic local removal
|
// Refetch both lists so the dismissed alert appears in the restore list
|
||||||
data.value = {
|
await Promise.all([refresh(), refreshDismissed()])
|
||||||
alerts: visibleAlerts.value.filter((a) => a.type !== alert.type)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to dismiss alert', err)
|
console.error('Failed to dismiss alert', err)
|
||||||
// On error, refetch to get authoritative state
|
|
||||||
await refresh()
|
await refresh()
|
||||||
} finally {
|
} finally {
|
||||||
dismissing[alert.type] = false
|
dismissing[alert.type] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreSelected() {
|
||||||
|
if (!selectedRestore.value.length) return
|
||||||
|
restoring.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/admin/alerts/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { alertTypes: selectedRestore.value }
|
||||||
|
})
|
||||||
|
selectedRestore.value = []
|
||||||
|
restoreOpen.value = false
|
||||||
|
await Promise.all([refresh(), refreshDismissed()])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to restore alerts', err)
|
||||||
|
} finally {
|
||||||
|
restoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRestore() {
|
||||||
|
selectedRestore.value = []
|
||||||
|
restoreOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDismissedAt(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
const d = new Date(value)
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMin = Math.round((now - d.getTime()) / 60000)
|
||||||
|
if (diffMin < 1) return 'just now'
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`
|
||||||
|
const diffH = Math.round(diffMin / 60)
|
||||||
|
if (diffH < 24) return `${diffH}h ago`
|
||||||
|
const diffD = Math.round(diffH / 24)
|
||||||
|
return `${diffD}d ago`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -170,4 +265,76 @@ async function dismissAlert(alert) {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-active {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: underline dashed;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-toggle:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-panel {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-list li {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-option input[type='checkbox'] {
|
||||||
|
accent-color: var(--candle);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-when {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
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
|
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
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
function daysAgo(days) {
|
function daysAgo(days) {
|
||||||
|
|
@ -50,9 +69,7 @@ export async function detectSlackInviteFailed() {
|
||||||
.select('name email')
|
.select('name email')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'slack_invite_failed',
|
...alertShell('slack_invite_failed'),
|
||||||
severity: 'critical',
|
|
||||||
title: 'Slack invites failed',
|
|
||||||
items: members.map((m) => memberItem(m))
|
items: members.map((m) => memberItem(m))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -73,9 +90,7 @@ export async function detectNoSlackHandleAfterWeek() {
|
||||||
.select('name email createdAt')
|
.select('name email createdAt')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'no_slack_handle_week',
|
...alertShell('no_slack_handle_week'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Active members without a Slack handle',
|
|
||||||
items: members.map((m) =>
|
items: members.map((m) =>
|
||||||
memberItem(m, `${daysSince(m.createdAt)} days since joining`)
|
memberItem(m, `${daysSince(m.createdAt)} days since joining`)
|
||||||
)
|
)
|
||||||
|
|
@ -93,9 +108,7 @@ export async function detectStuckPendingPayment() {
|
||||||
.select('name email createdAt')
|
.select('name email createdAt')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'stuck_pending_payment',
|
...alertShell('stuck_pending_payment'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Members stuck in pending payment',
|
|
||||||
items: members.map((m) =>
|
items: members.map((m) =>
|
||||||
memberItem(m, `${daysSince(m.createdAt)} days stuck`)
|
memberItem(m, `${daysSince(m.createdAt)} days stuck`)
|
||||||
)
|
)
|
||||||
|
|
@ -109,9 +122,7 @@ export async function detectSuspendedMembers() {
|
||||||
.select('name email')
|
.select('name email')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'member_suspended',
|
...alertShell('member_suspended'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Suspended members',
|
|
||||||
items: members.map((m) => memberItem(m))
|
items: members.map((m) => memberItem(m))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,9 +147,7 @@ export async function detectPreRegistrantSelectedNotInvited() {
|
||||||
.select('name email updatedAt')
|
.select('name email updatedAt')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'preregistrant_selected_not_invited',
|
...alertShell('preregistrant_selected_not_invited'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Pre-registrants selected but not invited',
|
|
||||||
items: preRegs.map((p) =>
|
items: preRegs.map((p) =>
|
||||||
preRegItem(p, `${p.email} — ${daysSince(p.updatedAt)} days selected`)
|
preRegItem(p, `${p.email} — ${daysSince(p.updatedAt)} days selected`)
|
||||||
)
|
)
|
||||||
|
|
@ -152,9 +161,7 @@ export async function detectPreRegistrantExpired() {
|
||||||
.select('name email updatedAt')
|
.select('name email updatedAt')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'preregistrant_expired',
|
...alertShell('preregistrant_expired'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Expired pre-registrant invitations',
|
|
||||||
items: preRegs.map((p) =>
|
items: preRegs.map((p) =>
|
||||||
preRegItem(p, `${p.email} — expired ${daysSince(p.updatedAt)} days ago`)
|
preRegItem(p, `${p.email} — expired ${daysSince(p.updatedAt)} days ago`)
|
||||||
)
|
)
|
||||||
|
|
@ -183,9 +190,7 @@ export async function detectDraftEventsImminent() {
|
||||||
.select('title startDate')
|
.select('title startDate')
|
||||||
.lean()
|
.lean()
|
||||||
return {
|
return {
|
||||||
type: 'event_draft_imminent',
|
...alertShell('event_draft_imminent'),
|
||||||
severity: 'critical',
|
|
||||||
title: 'Draft events with imminent start',
|
|
||||||
items: events.map((ev) => {
|
items: events.map((ev) => {
|
||||||
const days = Math.max(0, Math.ceil((new Date(ev.startDate).getTime() - Date.now()) / DAY_MS))
|
const days = Math.max(0, Math.ceil((new Date(ev.startDate).getTime() - Date.now()) / DAY_MS))
|
||||||
return eventItem(ev, `Starts in ${days} days`)
|
return eventItem(ev, `Starts in ${days} days`)
|
||||||
|
|
@ -219,9 +224,7 @@ export async function detectEventsNearCapacity() {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'event_near_capacity',
|
...alertShell('event_near_capacity'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Events approaching capacity',
|
|
||||||
items: matched
|
items: matched
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -234,16 +237,12 @@ export async function detectPendingTagSuggestions() {
|
||||||
.lean()
|
.lean()
|
||||||
if (suggestions.length === 0) {
|
if (suggestions.length === 0) {
|
||||||
return {
|
return {
|
||||||
type: 'tag_suggestions_pending',
|
...alertShell('tag_suggestions_pending'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Pending tag suggestions',
|
|
||||||
items: []
|
items: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: 'tag_suggestions_pending',
|
...alertShell('tag_suggestions_pending'),
|
||||||
severity: 'attention',
|
|
||||||
title: 'Pending tag suggestions',
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'tag-suggestions',
|
id: 'tag-suggestions',
|
||||||
|
|
|
||||||
|
|
@ -410,3 +410,10 @@ export const adminAlertDismissSchema = z.object({
|
||||||
alertType: z.enum(ADMIN_ALERT_TYPES),
|
alertType: z.enum(ADMIN_ALERT_TYPES),
|
||||||
signature: z.string().min(1).max(128)
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,25 @@ import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
vi.mock('../../../server/utils/adminAlerts.js', () => ({
|
vi.mock('../../../server/utils/adminAlerts.js', () => ({
|
||||||
computeAllAlerts: vi.fn()
|
computeAllAlerts: vi.fn(),
|
||||||
|
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' }
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../../server/models/adminAlertDismissal.js', () => ({
|
vi.mock('../../../server/models/adminAlertDismissal.js', () => ({
|
||||||
default: {
|
default: {
|
||||||
findOneAndUpdate: vi.fn()
|
findOneAndUpdate: vi.fn(),
|
||||||
|
find: vi.fn(),
|
||||||
|
deleteMany: vi.fn()
|
||||||
},
|
},
|
||||||
ADMIN_ALERT_TYPES: [
|
ADMIN_ALERT_TYPES: [
|
||||||
'slack_invite_failed',
|
'slack_invite_failed',
|
||||||
|
|
@ -28,9 +41,12 @@ vi.mock('../../../server/utils/mongoose.js', () => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.stubGlobal('adminAlertDismissSchema', {})
|
vi.stubGlobal('adminAlertDismissSchema', {})
|
||||||
|
vi.stubGlobal('adminAlertRestoreSchema', {})
|
||||||
|
|
||||||
import getHandler from '../../../server/api/admin/alerts/index.get.js'
|
import getHandler from '../../../server/api/admin/alerts/index.get.js'
|
||||||
import dismissHandler from '../../../server/api/admin/alerts/dismiss.post.js'
|
import dismissHandler from '../../../server/api/admin/alerts/dismiss.post.js'
|
||||||
|
import dismissedHandler from '../../../server/api/admin/alerts/dismissed.get.js'
|
||||||
|
import restoreHandler from '../../../server/api/admin/alerts/restore.post.js'
|
||||||
import { computeAllAlerts } from '../../../server/utils/adminAlerts.js'
|
import { computeAllAlerts } from '../../../server/utils/adminAlerts.js'
|
||||||
import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js'
|
import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js'
|
||||||
import { createMockEvent } from '../helpers/createMockEvent.js'
|
import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
@ -151,3 +167,154 @@ describe('POST /api/admin/alerts/dismiss', () => {
|
||||||
await expect(dismissHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
await expect(dismissHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GET /api/admin/alerts/dismissed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('source inspection', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
resolve(import.meta.dirname, '../../../server/api/admin/alerts/dismissed.get.js'),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
it('calls requireAdmin before querying dismissals', () => {
|
||||||
|
const adminIdx = source.indexOf('requireAdmin(event)')
|
||||||
|
const findIdx = source.indexOf('AdminAlertDismissal.find')
|
||||||
|
expect(adminIdx).toBeGreaterThan(-1)
|
||||||
|
expect(findIdx).toBeGreaterThan(-1)
|
||||||
|
expect(adminIdx).toBeLessThan(findIdx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns dismissed alerts with title and severity joined from metadata', async () => {
|
||||||
|
AdminAlertDismissal.find.mockReturnValue({
|
||||||
|
sort: () => ({
|
||||||
|
lean: () => Promise.resolve([
|
||||||
|
{ alertType: 'slack_invite_failed', signature: 'abc', dismissedAt: new Date('2026-04-08T10:00:00Z') },
|
||||||
|
{ alertType: 'member_suspended', signature: 'def', dismissedAt: new Date('2026-04-07T10:00:00Z') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
||||||
|
const result = await dismissedHandler(event)
|
||||||
|
|
||||||
|
expect(AdminAlertDismissal.find).toHaveBeenCalledWith({ adminId: 'admin-1' })
|
||||||
|
expect(result.dismissed).toEqual([
|
||||||
|
{
|
||||||
|
alertType: 'slack_invite_failed',
|
||||||
|
title: 'Slack invites failed',
|
||||||
|
severity: 'critical',
|
||||||
|
dismissedAt: new Date('2026-04-08T10:00:00Z')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alertType: 'member_suspended',
|
||||||
|
title: 'Suspended members',
|
||||||
|
severity: 'attention',
|
||||||
|
dismissedAt: new Date('2026-04-07T10:00:00Z')
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty list when no dismissals exist', async () => {
|
||||||
|
AdminAlertDismissal.find.mockReturnValue({
|
||||||
|
sort: () => ({ lean: () => Promise.resolve([]) })
|
||||||
|
})
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
||||||
|
const result = await dismissedHandler(event)
|
||||||
|
expect(result).toEqual({ dismissed: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters out orphaned slugs not present in ALERT_METADATA', async () => {
|
||||||
|
AdminAlertDismissal.find.mockReturnValue({
|
||||||
|
sort: () => ({
|
||||||
|
lean: () => Promise.resolve([
|
||||||
|
{ alertType: 'slack_invite_failed', signature: 'abc', dismissedAt: new Date() },
|
||||||
|
{ alertType: 'legacy_removed_alert', signature: 'xyz', dismissedAt: new Date() }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
||||||
|
const result = await dismissedHandler(event)
|
||||||
|
expect(result.dismissed).toHaveLength(1)
|
||||||
|
expect(result.dismissed[0].alertType).toBe('slack_invite_failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin requests', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
|
||||||
|
const event = createMockEvent({ method: 'GET', path: '/api/admin/alerts/dismissed' })
|
||||||
|
await expect(dismissedHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/admin/alerts/restore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
requireAdmin.mockResolvedValue({ _id: { toString: () => 'admin-1' } })
|
||||||
|
validateBody.mockResolvedValue({ alertTypes: ['slack_invite_failed', 'member_suspended'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('source inspection', () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
resolve(import.meta.dirname, '../../../server/api/admin/alerts/restore.post.js'),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
it('calls requireAdmin before validateBody', () => {
|
||||||
|
const adminIdx = source.indexOf('requireAdmin(event)')
|
||||||
|
const validateIdx = source.indexOf('validateBody(event')
|
||||||
|
expect(adminIdx).toBeGreaterThan(-1)
|
||||||
|
expect(validateIdx).toBeGreaterThan(-1)
|
||||||
|
expect(adminIdx).toBeLessThan(validateIdx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes dismissals matching the provided alertTypes and returns count', async () => {
|
||||||
|
AdminAlertDismissal.deleteMany.mockResolvedValue({ deletedCount: 2 })
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/alerts/restore',
|
||||||
|
body: { alertTypes: ['slack_invite_failed', 'member_suspended'] }
|
||||||
|
})
|
||||||
|
const result = await restoreHandler(event)
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, restored: 2 })
|
||||||
|
expect(AdminAlertDismissal.deleteMany).toHaveBeenCalledWith({
|
||||||
|
adminId: 'admin-1',
|
||||||
|
alertType: { $in: ['slack_invite_failed', 'member_suspended'] }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns restored: 0 when deleteMany returns no deletedCount', async () => {
|
||||||
|
AdminAlertDismissal.deleteMany.mockResolvedValue({})
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/alerts/restore',
|
||||||
|
body: { alertTypes: ['slack_invite_failed'] }
|
||||||
|
})
|
||||||
|
const result = await restoreHandler(event)
|
||||||
|
expect(result).toEqual({ ok: true, restored: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin requests', async () => {
|
||||||
|
requireAdmin.mockRejectedValue(createError({ statusCode: 403, statusMessage: 'Forbidden' }))
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/alerts/restore',
|
||||||
|
body: { alertTypes: ['slack_invite_failed'] }
|
||||||
|
})
|
||||||
|
await expect(restoreHandler(event)).rejects.toMatchObject({ statusCode: 403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid body via validateBody', async () => {
|
||||||
|
validateBody.mockRejectedValue(createError({ statusCode: 400, statusMessage: 'Validation failed' }))
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/admin/alerts/restore',
|
||||||
|
body: { alertTypes: [] }
|
||||||
|
})
|
||||||
|
await expect(restoreHandler(event)).rejects.toMatchObject({ statusCode: 400 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,9 @@ const adminRoutes = {
|
||||||
],
|
],
|
||||||
'admin/alerts/': [
|
'admin/alerts/': [
|
||||||
'alerts/index.get.js',
|
'alerts/index.get.js',
|
||||||
'alerts/dismiss.post.js'
|
'alerts/dismiss.post.js',
|
||||||
|
'alerts/dismissed.get.js',
|
||||||
|
'alerts/restore.post.js'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,7 +70,9 @@ const businessLogicPatterns = [
|
||||||
'PreRegistration.aggregate',
|
'PreRegistration.aggregate',
|
||||||
'PreRegistration.updateMany',
|
'PreRegistration.updateMany',
|
||||||
'computeAllAlerts(',
|
'computeAllAlerts(',
|
||||||
'AdminAlertDismissal.findOneAndUpdate'
|
'AdminAlertDismissal.findOneAndUpdate',
|
||||||
|
'AdminAlertDismissal.find',
|
||||||
|
'AdminAlertDismissal.deleteMany'
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('Admin endpoint auth guards', () => {
|
describe('Admin endpoint auth guards', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue