diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index 5debfa8..d0582d7 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -15,7 +15,7 @@ export async function GET(request: NextRequest) { const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - const [summaryRows, totalRows] = await Promise.all([ + const [summaryRows, actorSummaryRows, totalRows, latestEvents] = await Promise.all([ prisma.auditEvent.groupBy({ by: ["action"], where: { @@ -23,19 +23,91 @@ export async function GET(request: NextRequest) { }, _count: { _all: true }, }), + prisma.auditEvent.groupBy({ + by: ["actorType"], + where: { + createdAt: { gte: since }, + }, + _count: { _all: true }, + }), prisma.auditEvent.count({ where: { createdAt: { gte: since } }, }), + prisma.auditEvent.findMany({ + where: { + createdAt: { gte: since }, + }, + orderBy: { + createdAt: "desc", + }, + take: 80, + select: { + id: true, + action: true, + actorType: true, + actorId: true, + entityType: true, + entityId: true, + reason: true, + metadata: true, + createdAt: true, + }, + }), ]); const actionSummary = Object.fromEntries( summaryRows.map((row) => [row.action, row._count._all]) ); + const actorSummary = Object.fromEntries( + actorSummaryRows.map((row) => [row.actorType, row._count._all]) + ); + + const channelSummary = Object.entries(actionSummary).reduce( + (acc, [action, count]) => { + if (action.startsWith("EXTERNAL_")) { + acc.external += count; + } else { + acc.internal += count; + } + return acc; + }, + { external: 0, internal: 0 } as Record + ); + + const externalEventTypes = Object.entries(actionSummary).filter(([action]) => action.startsWith("EXTERNAL_")).map(([action, count]) => ({ action, count })); + const internalEventTypes = Object.entries(actionSummary).filter(([action]) => !action.startsWith("EXTERNAL_")).map(([action, count]) => ({ action, count })); + + const recentEvents = latestEvents.map((event) => { + // metadata is JSONB in Prisma; read only known optional keys for monitoring views. + const metadata = + event.metadata && typeof event.metadata === "object" && !Array.isArray(event.metadata) + ? (event.metadata as Record) + : undefined; + + return { + id: event.id, + action: event.action, + actorType: event.actorType, + actorId: event.actorId, + entityType: event.entityType, + entityId: event.entityId, + reason: event.reason, + createdAt: event.createdAt, + surface: metadata?.surface, + level: metadata?.level, + }; + }); + return NextResponse.json({ period_hours: 24, total_events: totalRows, action_summary: actionSummary, + channel_summary: channelSummary, + actor_summary: actorSummary, + external_event_types: externalEventTypes, + internal_event_types: internalEventTypes, + recent_events: recentEvents, updated_at: new Date().toISOString(), }); } diff --git a/apps/web/src/lib/traffic-alert.ts b/apps/web/src/lib/traffic-alert.ts index 2f6a40f..6d8b070 100644 --- a/apps/web/src/lib/traffic-alert.ts +++ b/apps/web/src/lib/traffic-alert.ts @@ -1,3 +1,6 @@ +import { prisma } from "./prisma"; +import { Prisma } from "../../prisma/generated/client"; + export type TrafficAlertEvent = { level: "info" | "warning" | "error"; action: string; @@ -11,7 +14,47 @@ export type TrafficAlertEvent = { const TRAFFIC_WEBHOOK_URL = process.env.VIBEWORK_TRAFFIC_WEBHOOK_URL?.trim(); +function resolveEntityFromTrafficEvent(event: TrafficAlertEvent) { + if (event.taskId) { + return { entityType: "TASK", entityId: event.taskId }; + } + + return { + entityType: "TASK", + entityId: `surface:${event.surface}`, + }; +} + +async function writeTrafficAuditEvent(event: TrafficAlertEvent) { + const { entityType, entityId } = resolveEntityFromTrafficEvent(event); + + try { + await prisma.auditEvent.create({ + data: { + actorType: event.actorType, + actorId: event.actorId, + action: event.action, + entityType, + entityId, + beforeState: Prisma.JsonNull, + afterState: Prisma.JsonNull, + reason: event.message, + metadata: { + ...event.metadata, + level: event.level, + surface: event.surface, + source: "traffic-alert", + }, + }, + }); + } catch (error) { + console.error("[Traffic alert persistence error]", error); + } +} + export async function sendTrafficAlert(event: TrafficAlertEvent): Promise { + void writeTrafficAuditEvent(event); + if (!TRAFFIC_WEBHOOK_URL) return; const payload = {