From 6c4490f6536790352dcae55619c61f7bc4492a7f Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 17 Jun 2026 17:53:12 +0200 Subject: [PATCH] feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI) Co-Authored-By: Claude Fable 5 --- src/app/api/v1/alerts/dismiss-all/route.ts | 23 +++++++ src/components/alerts/alert-card.tsx | 2 +- src/components/alerts/alerts-page-shell.tsx | 16 ++++- src/components/alerts/use-alerts.ts | 9 +++ src/components/layout/inbox.tsx | 34 +++++++--- src/lib/services/alerts.service.ts | 36 ++++++++++ tests/integration/alerts-dismiss-all.test.ts | 70 ++++++++++++++++++++ 7 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 src/app/api/v1/alerts/dismiss-all/route.ts create mode 100644 tests/integration/alerts-dismiss-all.test.ts diff --git a/src/app/api/v1/alerts/dismiss-all/route.ts b/src/app/api/v1/alerts/dismiss-all/route.ts new file mode 100644 index 00000000..0d46bb11 --- /dev/null +++ b/src/app/api/v1/alerts/dismiss-all/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { ALERT_RULES } from '@/lib/db/schema/insights'; +import { dismissAllForPort } from '@/lib/services/alerts.service'; + +const bodySchema = z.object({ + ruleId: z.enum(ALERT_RULES).optional(), + severity: z.enum(['info', 'warning', 'critical']).optional(), +}); + +export const POST = withAuth(async (req, ctx) => { + try { + const { ruleId, severity } = await parseBody(req, bodySchema); + const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity }); + return NextResponse.json({ data: { dismissed } }); + } catch (error) { + return errorResponse(error); + } +}); diff --git a/src/components/alerts/alert-card.tsx b/src/components/alerts/alert-card.tsx index a8e090df..ac0465d8 100644 --- a/src/components/alerts/alert-card.tsx +++ b/src/components/alerts/alert-card.tsx @@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) { {!readOnly ? ( -
+
{!acknowledged ? ( +
+ ) : null} {isLoading ? (
diff --git a/src/components/alerts/use-alerts.ts b/src/components/alerts/use-alerts.ts index 016ae060..c6016d65 100644 --- a/src/components/alerts/use-alerts.ts +++ b/src/components/alerts/use-alerts.ts @@ -41,6 +41,15 @@ export function useAlertActions() { return { acknowledge, dismiss }; } +export function useDismissAll() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (filter: { ruleId?: string; severity?: string } = {}) => + apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }), + }); +} + export function useAlertRealtime() { useRealtimeInvalidation({ 'alert:created': [['alerts']], diff --git a/src/components/layout/inbox.tsx b/src/components/layout/inbox.tsx index 3fcaa6fd..32a4e7b4 100644 --- a/src/components/layout/inbox.tsx +++ b/src/components/layout/inbox.tsx @@ -37,7 +37,12 @@ import { cn } from '@/lib/utils'; import { useNotifications } from '@/hooks/use-notifications'; import { NotificationItem } from '@/components/notifications/notification-item'; import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card'; -import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts'; +import { + useAlertCount, + useAlertList, + useAlertRealtime, + useDismissAll, +} from '@/components/alerts/use-alerts'; interface NotificationListResponse { data: Array<{ @@ -66,6 +71,7 @@ export function Inbox() { const systemCritical = alertCount?.bySeverity.critical ?? 0; const systemAlerts = alertList?.data ?? []; const systemTop = systemAlerts.slice(0, 8); + const dismissAll = useDismissAll(); // ── Personal (notifications) ── const { unreadCount: personalUnread } = useNotifications(); @@ -230,13 +236,25 @@ export function Inbox() {

Active alerts

- - View all - +
+ {systemAlerts.length > 0 ? ( + + ) : null} + + View all + +
diff --git a/src/lib/services/alerts.service.ts b/src/lib/services/alerts.service.ts index c2d9fbbb..d0fc2c12 100644 --- a/src/lib/services/alerts.service.ts +++ b/src/lib/services/alerts.service.ts @@ -120,6 +120,42 @@ export async function dismissAlert(alertId: string, portId: string, userId: stri } } +/** + * Bulk-dismiss every open (non-dismissed, non-resolved) alert for a port, + * optionally narrowed to a single rule and/or severity. Returns the count + * dismissed. Port-scoped so it can never touch another tenant's alerts. + */ +export async function dismissAllForPort( + portId: string, + userId: string, + filter: { ruleId?: AlertRuleId; severity?: AlertSeverity } = {}, +): Promise { + const conds = [eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt)]; + if (filter.ruleId) conds.push(eq(alerts.ruleId, filter.ruleId)); + if (filter.severity) conds.push(eq(alerts.severity, filter.severity)); + + const rows = await db + .update(alerts) + .set({ dismissedAt: sql`now()`, dismissedBy: userId }) + .where(and(...conds)) + .returning({ id: alerts.id }); + + for (const r of rows) { + emitToRoom(`port:${portId}`, 'alert:dismissed', { alertId: r.id, portId }); + } + if (rows.length > 0) { + void createAuditLog({ + portId, + userId, + action: 'update', + entityType: 'alert', + entityId: portId, // port-wide bulk action — no single alert subject + metadata: { kind: 'dismiss_all', count: rows.length, filter }, + }); + } + return rows.length; +} + export async function acknowledgeAlert( alertId: string, portId: string, diff --git a/tests/integration/alerts-dismiss-all.test.ts b/tests/integration/alerts-dismiss-all.test.ts new file mode 100644 index 00000000..02f88940 --- /dev/null +++ b/tests/integration/alerts-dismiss-all.test.ts @@ -0,0 +1,70 @@ +/** + * Bulk-dismiss service: dismissAllForPort must respect the optional rule/ + * severity filter and never touch another port's alerts. + */ + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { and, eq, isNull } from 'drizzle-orm'; + +vi.mock('@/lib/socket/server', () => ({ + emitToRoom: vi.fn(), +})); + +import { db } from '@/lib/db'; +import { alerts } from '@/lib/db/schema/insights'; +import { user } from '@/lib/db/schema/users'; +import { dismissAllForPort } from '@/lib/services/alerts.service'; +import { makePort } from '../helpers/factories'; + +let USER_ID = ''; + +beforeAll(async () => { + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + USER_ID = u.id; +}); + +async function seedAlert(portId: string, ruleId: string, severity = 'info') { + const [row] = await db + .insert(alerts) + .values({ + portId, + ruleId, + severity, + title: `t-${ruleId}`, + link: '/x', + fingerprint: `fp-${Math.random().toString(36).slice(2)}`, + metadata: {}, + }) + .returning({ id: alerts.id }); + return row!.id; +} + +async function openCount(portId: string) { + const rows = await db + .select() + .from(alerts) + .where(and(eq(alerts.portId, portId), isNull(alerts.dismissedAt), isNull(alerts.resolvedAt))); + return rows.length; +} + +describe('dismissAllForPort', () => { + it('dismisses only the filtered rule, scoped to the port, then all', async () => { + const portA = await makePort(); + const portB = await makePort(); + await seedAlert(portA.id, 'interest.stale'); + await seedAlert(portA.id, 'interest.stale'); + await seedAlert(portA.id, 'document.signer_overdue', 'warning'); + await seedAlert(portB.id, 'interest.stale'); + + const filtered = await dismissAllForPort(portA.id, USER_ID, { ruleId: 'interest.stale' }); + expect(filtered).toBe(2); + expect(await openCount(portA.id)).toBe(1); // signer_overdue remains + expect(await openCount(portB.id)).toBe(1); // other port untouched + + const rest = await dismissAllForPort(portA.id, USER_ID); + expect(rest).toBe(1); + expect(await openCount(portA.id)).toBe(0); + expect(await openCount(portB.id)).toBe(1); + }); +});