import { NextResponse } from 'next/server'; import { and, eq, desc, inArray } from 'drizzle-orm'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { errorResponse, NotFoundError } from '@/lib/errors'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { auditLogs } from '@/lib/db/schema/system'; import { documents, documentEvents } from '@/lib/db/schema/documents'; import { user } from '@/lib/db/schema/users'; import { stageLabel } from '@/lib/constants'; const OUTCOME_LABELS: Record = { won: 'Won', lost_other_marina: 'Lost — went to another marina', lost_unqualified: 'Lost — unqualified', lost_no_response: 'Lost — no response', cancelled: 'Cancelled', }; const DOC_EVENT_LABELS: Record = { sent: 'sent for signing', completed: 'fully signed', signed: 'signed by recipient', rejected: 'rejected', expired: 'expired', cancelled: 'cancelled', reminder_sent: 'reminder sent', }; interface TimelineEvent { id: string; type: 'audit' | 'document_event'; action: string; description: string; userId: string | null; /** Resolved display name for `userId`. `'system'` for auto-events; null when * the user has been deleted or the event has no actor. Falls back to * email-localpart if the user has no display name. */ userName: string | null; createdAt: Date; metadata: Record; } // GET /api/v1/interests/[id]/timeline export const GET = withAuth( withPermission('interests', 'view', async (req, ctx, params) => { try { const interestId = params.id!; const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, ctx.portId)), }); if (!interest) throw new NotFoundError('Interest'); // Fetch audit logs for this interest const auditRows = await db .select() .from(auditLogs) .where(and(eq(auditLogs.entityType, 'interest'), eq(auditLogs.entityId, interestId))) .orderBy(desc(auditLogs.createdAt)) .limit(50); // Fetch document events for documents linked to this interest const interestDocs = await db .select({ id: documents.id, title: documents.title }) .from(documents) .where(eq(documents.interestId, interestId)); const docIds = interestDocs.map((d) => d.id); const docEventRows = docIds.length > 0 ? await db .select({ id: documentEvents.id, documentId: documentEvents.documentId, eventType: documentEvents.eventType, eventData: documentEvents.eventData, createdAt: documentEvents.createdAt, }) .from(documentEvents) .where(inArray(documentEvents.documentId, docIds)) .orderBy(desc(documentEvents.createdAt)) .limit(50) : []; const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title])); // Resolve display names for any `userId` that is a real user row (the // sentinel value 'system' is used for auto-events and isn't joined). const realUserIds = Array.from( new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')), ); const userRows = realUserIds.length > 0 ? await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) .where(inArray(user.id, realUserIds)) : []; const userNameById = new Map( userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']), ); const resolveUserName = (userId: string | null): string | null => { if (!userId) return null; if (userId === 'system') return 'system'; return userNameById.get(userId) ?? null; }; // Union and sort const auditEvents: TimelineEvent[] = auditRows.map((row) => ({ id: row.id, type: 'audit', action: row.action, description: buildAuditDescription( row.action, row.newValue as Record | null, (row.metadata as Record) ?? {}, row.userId, ), userId: row.userId, userName: resolveUserName(row.userId), createdAt: row.createdAt, metadata: (row.metadata as Record) ?? {}, })); const docEvents: TimelineEvent[] = docEventRows.map((row) => { const title = docTitles[row.documentId] ?? row.documentId; const action = DOC_EVENT_LABELS[row.eventType] ?? row.eventType; return { id: row.id, type: 'document_event', action: row.eventType, description: `Document "${title}" ${action}`, userId: null, userName: null, createdAt: row.createdAt, metadata: (row.eventData as Record) ?? {}, }; }); const allEvents = [...auditEvents, ...docEvents]; // Fallback: when no audit-log entries exist for this interest (typical // for seed/imported data inserted directly into the table without going // through the service), synthesize a "Created at " event so the // tab isn't empty when the interest is clearly past `open`. const hasCreateAudit = allEvents.some((e) => e.action === 'create'); if (!hasCreateAudit) { const stage = stageLabel(interest.pipelineStage); const created = interest.createdAt ?? new Date(); allEvents.push({ id: `synth-${interest.id}-create`, type: 'audit', action: 'create', description: interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`, userId: null, userName: null, createdAt: created, metadata: { synthetic: true }, }); } allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return NextResponse.json({ data: allEvents.slice(0, 50) }); } catch (error) { return errorResponse(error); } }), ); function buildAuditDescription( action: string, newValue: Record | null, metadata: Record, userId: string | null, ): string { if (action === 'create') return 'Interest created'; if (action === 'archive') return 'Interest archived'; if (action === 'restore') return 'Interest restored'; const type = metadata.type; if (type === 'outcome_set') { const outcomeKey = (newValue?.outcome as string | undefined) ?? ''; const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed'; const reason = (newValue?.reason as string | undefined) ?? ''; return reason ? `Marked as ${label} — ${reason}` : `Marked as ${label}`; } if (type === 'outcome_cleared') { const stage = (newValue?.pipelineStage as string | undefined) ?? ''; return stage ? `Reopened to ${stageLabel(stage)}` : 'Reopened'; } if (type === 'stage_change' && newValue?.pipelineStage) { const stage = stageLabel(newValue.pipelineStage as string); const reason = (newValue.reason as string | undefined) ?? ''; const auto = userId === 'system'; if (auto) { return reason ? `${stage} (auto-advanced — ${reason})` : `Stage advanced to ${stage}`; } return reason ? `Stage changed to ${stage} — ${reason}` : `Stage changed to ${stage}`; } if (action === 'update' && newValue?.pipelineStage) { return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`; } if (action === 'update') return 'Interest updated'; return action; }