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
|
|
@ -3,12 +3,25 @@ import { readFileSync } from 'node:fs'
|
|||
import { resolve } from 'node:path'
|
||||
|
||||
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', () => ({
|
||||
default: {
|
||||
findOneAndUpdate: vi.fn()
|
||||
findOneAndUpdate: vi.fn(),
|
||||
find: vi.fn(),
|
||||
deleteMany: vi.fn()
|
||||
},
|
||||
ADMIN_ALERT_TYPES: [
|
||||
'slack_invite_failed',
|
||||
|
|
@ -28,9 +41,12 @@ vi.mock('../../../server/utils/mongoose.js', () => ({
|
|||
}))
|
||||
|
||||
vi.stubGlobal('adminAlertDismissSchema', {})
|
||||
vi.stubGlobal('adminAlertRestoreSchema', {})
|
||||
|
||||
import getHandler from '../../../server/api/admin/alerts/index.get.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 AdminAlertDismissal from '../../../server/models/adminAlertDismissal.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 })
|
||||
})
|
||||
})
|
||||
|
||||
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/': [
|
||||
'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.updateMany',
|
||||
'computeAllAlerts(',
|
||||
'AdminAlertDismissal.findOneAndUpdate'
|
||||
'AdminAlertDismissal.findOneAndUpdate',
|
||||
'AdminAlertDismissal.find',
|
||||
'AdminAlertDismissal.deleteMany'
|
||||
]
|
||||
|
||||
describe('Admin endpoint auth guards', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue