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>
|
||||||
</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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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']],
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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