feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:53:12 +02:00
parent 13efe177a5
commit 6c4490f653
7 changed files with 180 additions and 10 deletions

View File

@@ -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);
}
});

View File

@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
</div> </div>
</div> </div>
{!readOnly ? ( {!readOnly ? (
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100"> <div className="flex shrink-0 items-start gap-1 text-muted-foreground">
{!acknowledged ? ( {!acknowledged ? (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import { ShieldAlert } from 'lucide-react'; import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card'; import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts'; import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
import type { AlertStatus } from './types'; import type { AlertStatus } from './types';
/** /**
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
const total = count?.total ?? 0; const total = count?.total ?? 0;
const alerts = data?.data ?? []; const alerts = data?.data ?? [];
const dismissAll = useDismissAll();
return ( return (
<div className={embedded ? 'space-y-3' : 'space-y-6'}> <div className={embedded ? 'space-y-3' : 'space-y-6'}>
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
</TabsList> </TabsList>
<TabsContent value={tab} className="mt-4 space-y-2"> <TabsContent value={tab} className="mt-4 space-y-2">
{tab === 'open' && alerts.length > 0 ? (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
>
Dismiss all
</Button>
</div>
) : null}
{isLoading ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-20 w-full" /> <Skeleton className="h-20 w-full" />

View File

@@ -41,6 +41,15 @@ export function useAlertActions() {
return { acknowledge, dismiss }; 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() { export function useAlertRealtime() {
useRealtimeInvalidation({ useRealtimeInvalidation({
'alert:created': [['alerts']], 'alert:created': [['alerts']],

View File

@@ -37,7 +37,12 @@ import { cn } from '@/lib/utils';
import { useNotifications } from '@/hooks/use-notifications'; import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from '@/components/notifications/notification-item'; import { NotificationItem } from '@/components/notifications/notification-item';
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card'; 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 { interface NotificationListResponse {
data: Array<{ data: Array<{
@@ -66,6 +71,7 @@ export function Inbox() {
const systemCritical = alertCount?.bySeverity.critical ?? 0; const systemCritical = alertCount?.bySeverity.critical ?? 0;
const systemAlerts = alertList?.data ?? []; const systemAlerts = alertList?.data ?? [];
const systemTop = systemAlerts.slice(0, 8); const systemTop = systemAlerts.slice(0, 8);
const dismissAll = useDismissAll();
// ── Personal (notifications) ── // ── Personal (notifications) ──
const { unreadCount: personalUnread } = useNotifications(); const { unreadCount: personalUnread } = useNotifications();
@@ -230,13 +236,25 @@ export function Inbox() {
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active alerts Active alerts
</h4> </h4>
<Link <div className="flex items-center gap-3">
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ {systemAlerts.length > 0 ? (
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)} <button
className="text-xs text-muted-foreground hover:text-foreground" type="button"
> onClick={() => dismissAll.mutate({})}
View all disabled={dismissAll.isPending}
</Link> className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Dismiss all
</button>
) : null}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
</div>
</div> </div>
<Separator /> <Separator />
<ScrollArea className="max-h-[400px]"> <ScrollArea className="max-h-[400px]">

View File

@@ -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<number> {
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( export async function acknowledgeAlert(
alertId: string, alertId: string,
portId: string, portId: string,

View File

@@ -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);
});
});