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:
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal file
23
src/app/api/v1/alerts/dismiss-all/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
{!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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useState } from 'react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
||||
|
||||
const total = count?.total ?? 0;
|
||||
const alerts = data?.data ?? [];
|
||||
const dismissAll = useDismissAll();
|
||||
|
||||
return (
|
||||
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
||||
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
|
||||
</TabsList>
|
||||
|
||||
<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 ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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,6 +236,17 @@ export function Inbox() {
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Active alerts
|
||||
</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
{systemAlerts.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissAll.mutate({})}
|
||||
disabled={dismissAll.isPending}
|
||||
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)}
|
||||
@@ -238,6 +255,7 @@ export function Inbox() {
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
{alertsLoading ? (
|
||||
|
||||
@@ -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(
|
||||
alertId: string,
|
||||
portId: string,
|
||||
|
||||
70
tests/integration/alerts-dismiss-all.test.ts
Normal file
70
tests/integration/alerts-dismiss-all.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user