diff --git a/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts b/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts index 28b7233..dc8a3c1 100644 --- a/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts +++ b/src/app/api/v1/expenses/[id]/clear-duplicate/route.ts @@ -9,7 +9,7 @@ export const POST = withAuth( try { const id = params.id; if (!id) throw new ValidationError('id is required'); - await clearDuplicate(id, ctx.portId); + await clearDuplicate(id, ctx.portId, ctx.userId); return NextResponse.json({ ok: true }); } catch (error) { return errorResponse(error); diff --git a/src/app/api/v1/expenses/[id]/merge/route.ts b/src/app/api/v1/expenses/[id]/merge/route.ts index 1e0ecd2..cfbc746 100644 --- a/src/app/api/v1/expenses/[id]/merge/route.ts +++ b/src/app/api/v1/expenses/[id]/merge/route.ts @@ -17,7 +17,7 @@ export const POST = withAuth( const sourceId = params.id; if (!sourceId) throw new ValidationError('id is required'); const body = await parseBody(req, mergeSchema); - await mergeDuplicate(sourceId, body.targetId, ctx.portId); + await mergeDuplicate(sourceId, body.targetId, ctx.portId, ctx.userId); return NextResponse.json({ ok: true }); } catch (error) { return errorResponse(error); diff --git a/src/lib/audit.ts b/src/lib/audit.ts index b3296a3..749bdbe 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -16,7 +16,14 @@ export type AuditAction = | 'revoke_invite' | 'resend_invite' | 'request_gdpr_export' - | 'send_gdpr_export'; + | 'send_gdpr_export' + | 'password_change' + | 'portal_invite' + | 'portal_activate' + | 'portal_password_reset_request' + | 'portal_password_reset' + | 'send' + | 'view'; /** * Common shape passed to service functions so they can stamp audit logs and @@ -43,8 +50,10 @@ export interface AuditLogParams { oldValue?: Record; newValue?: Record; metadata?: Record; - ipAddress: string; - userAgent: string; + /** Optional. Services that don't have request context (e.g. background + * jobs, internal helpers) may omit. */ + ipAddress?: string; + userAgent?: string; } const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']); @@ -104,8 +113,8 @@ export async function createAuditLog(params: AuditLogParams): Promise { oldValue: maskSensitiveFields(params.oldValue) ?? null, newValue: maskSensitiveFields(params.newValue) ?? null, metadata: params.metadata ?? null, - ipAddress: params.ipAddress, - userAgent: params.userAgent, + ipAddress: params.ipAddress ?? null, + userAgent: params.userAgent ?? null, }); } catch (err) { // Strip old/new values from the log to avoid secondary exposure of the data diff --git a/src/lib/services/alerts.service.ts b/src/lib/services/alerts.service.ts index 07ca05b..c2d9fbb 100644 --- a/src/lib/services/alerts.service.ts +++ b/src/lib/services/alerts.service.ts @@ -12,6 +12,7 @@ import { createHash } from 'crypto'; import { db } from '@/lib/db'; import { alerts, type Alert, type AlertSeverity, type AlertRuleId } from '@/lib/db/schema/insights'; import { emitToRoom } from '@/lib/socket/server'; +import { createAuditLog } from '@/lib/audit'; export interface AlertCandidate { ruleId: AlertRuleId; @@ -108,6 +109,14 @@ export async function dismissAlert(alertId: string, portId: string, userId: stri .returning({ id: alerts.id, portId: alerts.portId }); if (row) { emitToRoom(`port:${row.portId}`, 'alert:dismissed', { alertId: row.id, portId: row.portId }); + void createAuditLog({ + portId: row.portId, + userId, + action: 'update', + entityType: 'alert', + entityId: row.id, + metadata: { kind: 'dismiss' }, + }); } } @@ -116,10 +125,21 @@ export async function acknowledgeAlert( portId: string, userId: string, ): Promise { - await db + const [row] = await db .update(alerts) .set({ acknowledgedAt: sql`now()`, acknowledgedBy: userId }) - .where(and(eq(alerts.id, alertId), eq(alerts.portId, portId))); + .where(and(eq(alerts.id, alertId), eq(alerts.portId, portId))) + .returning({ id: alerts.id, portId: alerts.portId }); + if (row) { + void createAuditLog({ + portId: row.portId, + userId, + action: 'update', + entityType: 'alert', + entityId: row.id, + metadata: { kind: 'acknowledge' }, + }); + } } export interface ListAlertsOptions { diff --git a/src/lib/services/expense-dedup.service.ts b/src/lib/services/expense-dedup.service.ts index 83b2e96..14ead2c 100644 --- a/src/lib/services/expense-dedup.service.ts +++ b/src/lib/services/expense-dedup.service.ts @@ -9,6 +9,7 @@ import { and, between, eq, ne, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { expenses } from '@/lib/db/schema/financial'; import { NotFoundError, ValidationError } from '@/lib/errors'; +import { createAuditLog } from '@/lib/audit'; const DEDUP_WINDOW_DAYS = 3; @@ -75,11 +76,23 @@ export async function markBestDuplicate(expenseId: string): Promise { +export async function clearDuplicate( + expenseId: string, + portId: string, + userId: string | null = null, +): Promise { await db .update(expenses) .set({ duplicateOf: null, dedupScannedAt: sql`now()` }) .where(and(eq(expenses.id, expenseId), eq(expenses.portId, portId))); + void createAuditLog({ + portId, + userId, + action: 'update', + entityType: 'expense', + entityId: expenseId, + metadata: { kind: 'duplicate_cleared' }, + }); } /** @@ -92,6 +105,7 @@ export async function mergeDuplicate( sourceId: string, targetId: string, portId: string, + userId: string | null = null, ): Promise { if (sourceId === targetId) { throw new ValidationError('Cannot merge an expense into itself'); @@ -125,4 +139,13 @@ export async function mergeDuplicate( .set({ archivedAt: sql`now()`, duplicateOf: null }) .where(eq(expenses.id, sourceId)); }); + + void createAuditLog({ + portId, + userId, + action: 'merge', + entityType: 'expense', + entityId: targetId, + metadata: { mergedFromExpenseId: sourceId }, + }); }