diff --git a/src/app/api/v1/berths/[id]/activity/route.ts b/src/app/api/v1/berths/[id]/activity/route.ts new file mode 100644 index 0000000..09beead --- /dev/null +++ b/src/app/api/v1/berths/[id]/activity/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { eq, and } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { berths } from '@/lib/db/schema/berths'; +import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('berths', 'view', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('berth'); + const exists = await db + .select({ id: berths.id }) + .from(berths) + .where(and(eq(berths.id, id), eq(berths.portId, ctx.portId))) + .limit(1); + if (exists.length === 0) throw new NotFoundError('berth'); + const data = await loadEntityActivity({ + portId: ctx.portId, + entityType: 'berth', + entityId: id, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/[id]/activity/route.ts b/src/app/api/v1/clients/[id]/activity/route.ts new file mode 100644 index 0000000..fe38276 --- /dev/null +++ b/src/app/api/v1/clients/[id]/activity/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { eq, and } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { clients } from '@/lib/db/schema/clients'; +import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('clients', 'view', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('client'); + const exists = await db + .select({ id: clients.id }) + .from(clients) + .where(and(eq(clients.id, id), eq(clients.portId, ctx.portId))) + .limit(1); + if (exists.length === 0) throw new NotFoundError('client'); + const data = await loadEntityActivity({ + portId: ctx.portId, + entityType: 'client', + entityId: id, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/companies/[id]/activity/route.ts b/src/app/api/v1/companies/[id]/activity/route.ts new file mode 100644 index 0000000..182de99 --- /dev/null +++ b/src/app/api/v1/companies/[id]/activity/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { eq, and } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { companies } from '@/lib/db/schema/companies'; +import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('companies', 'view', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('company'); + const exists = await db + .select({ id: companies.id }) + .from(companies) + .where(and(eq(companies.id, id), eq(companies.portId, ctx.portId))) + .limit(1); + if (exists.length === 0) throw new NotFoundError('company'); + const data = await loadEntityActivity({ + portId: ctx.portId, + entityType: 'company', + entityId: id, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/[id]/activity/route.ts b/src/app/api/v1/interests/[id]/activity/route.ts new file mode 100644 index 0000000..8180d88 --- /dev/null +++ b/src/app/api/v1/interests/[id]/activity/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { eq, and } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { interests } from '@/lib/db/schema/interests'; +import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('interests', 'view', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('interest'); + const exists = await db + .select({ id: interests.id }) + .from(interests) + .where(and(eq(interests.id, id), eq(interests.portId, ctx.portId))) + .limit(1); + if (exists.length === 0) throw new NotFoundError('interest'); + const data = await loadEntityActivity({ + portId: ctx.portId, + entityType: 'interest', + entityId: id, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/yachts/[id]/activity/route.ts b/src/app/api/v1/yachts/[id]/activity/route.ts new file mode 100644 index 0000000..1253bc1 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/activity/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { eq, and } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { yachts } from '@/lib/db/schema/yachts'; +import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +export const GET = withAuth( + withPermission('yachts', 'view', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('yacht'); + const exists = await db + .select({ id: yachts.id }) + .from(yachts) + .where(and(eq(yachts.id, id), eq(yachts.portId, ctx.portId))) + .limit(1); + if (exists.length === 0) throw new NotFoundError('yacht'); + const data = await loadEntityActivity({ + portId: ctx.portId, + entityType: 'yacht', + entityId: id, + }); + return NextResponse.json({ data }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index 89e6e26..f057419 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -3,6 +3,7 @@ import { type DetailTab } from '@/components/shared/detail-layout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { TagBadge } from '@/components/shared/tag-badge'; +import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { BerthReservationsTab } from './berth-reservations-tab'; import { BerthInterestsTab } from './berth-interests-tab'; import { BerthInterestPulse } from './berth-interest-pulse'; @@ -250,7 +251,12 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] { { id: 'activity', label: 'Activity', - content: , + content: ( + + ), }, ]; } diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index b4ca8e4..05b4243 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -16,6 +16,7 @@ import { ClientCompaniesTab } from '@/components/clients/client-companies-tab'; import { ClientReservationsTab } from '@/components/clients/client-reservations-tab'; import { ContactsEditor } from '@/components/clients/contacts-editor'; import { AddressesEditor, type Address } from '@/components/shared/addresses-editor'; +import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { apiFetch } from '@/lib/api/client'; type ClientPatchField = @@ -280,9 +281,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt id: 'activity', label: 'Activity', content: ( -
-

Activity log coming soon.

-
+ ), }, ]; diff --git a/src/components/companies/company-tabs.tsx b/src/components/companies/company-tabs.tsx index a779552..59b147b 100644 --- a/src/components/companies/company-tabs.tsx +++ b/src/components/companies/company-tabs.tsx @@ -9,6 +9,7 @@ import { InlineCountryField } from '@/components/shared/inline-country-field'; import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; +import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { CompanyMembersTab } from '@/components/companies/company-members-tab'; import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab'; import { AddressesEditor, type Address } from '@/components/shared/addresses-editor'; @@ -238,5 +239,15 @@ export function getCompanyTabs({ ), }, + { + id: 'activity', + label: 'Activity', + content: ( + + ), + }, ]; } diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx new file mode 100644 index 0000000..661c0c0 --- /dev/null +++ b/src/components/shared/entity-activity-feed.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; + +import { apiFetch } from '@/lib/api/client'; + +interface AuditRow { + id: string; + action: string; + entityType: string; + entityId: string | null; + fieldChanged: string | null; + oldValue: unknown; + newValue: unknown; + metadata: Record | null; + createdAt: string; + actor: { id: string; email: string; name: string | null } | null; +} + +const ACTION_LABELS: Record = { + create: 'Created', + update: 'Updated', + delete: 'Deleted', + archive: 'Archived', + restore: 'Restored', + merge: 'Merged', + revert: 'Reverted', +}; + +function formatAction(action: string): string { + return ACTION_LABELS[action] ?? action.charAt(0).toUpperCase() + action.slice(1); +} + +function formatField(field: string | null): string | null { + if (!field) return null; + return field.replace(/_/g, ' '); +} + +function summarize(row: AuditRow): string { + const verb = formatAction(row.action); + const field = formatField(row.fieldChanged); + if (field) return `${verb} ${field}`; + return verb; +} + +interface Props { + endpoint: string; // e.g. /api/v1/clients/{id}/activity + emptyText?: string; +} + +export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: Props) { + const [now, setNow] = useState(() => Date.now()); + + // Re-render every minute so "x minutes ago" stays accurate without a + // refetch. + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(t); + }, []); + + const { data, isLoading, error } = useQuery({ + queryKey: ['entity-activity', endpoint], + queryFn: () => apiFetch<{ data: AuditRow[] }>(endpoint), + staleTime: 30_000, + }); + + if (isLoading) { + return
Loading activity…
; + } + if (error) { + return ( +
+ Failed to load activity: {error instanceof Error ? error.message : 'unknown error'} +
+ ); + } + + const rows = data?.data ?? []; + if (rows.length === 0) { + return
{emptyText}
; + } + + return ( +
    + {rows.map((row) => { + const created = new Date(row.createdAt); + const ago = formatDistanceToNow(created, { addSuffix: true }); + // touch `now` so the closure participates in the minute re-render + void now; + const actor = row.actor?.name || row.actor?.email || 'System'; + return ( +
  1. + +
    + {summarize(row)} + · {actor} +
    +
    + {ago} +
    + {row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? ( +
    + {row.oldValue !== null && row.oldValue !== undefined ? ( + + {String(JSON.stringify(row.oldValue)).slice(0, 80)} + + ) : null} + {row.newValue !== null && row.newValue !== undefined ? ( + + → {String(JSON.stringify(row.newValue)).slice(0, 80)} + + ) : null} +
    + ) : null} +
  2. + ); + })} +
+ ); +} diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx index 7459513..e97e6b2 100644 --- a/src/components/yachts/yacht-tabs.tsx +++ b/src/components/yachts/yacht-tabs.tsx @@ -7,6 +7,7 @@ import type { DetailTab } from '@/components/shared/detail-layout'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; +import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list'; import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history'; import { apiFetch } from '@/lib/api/client'; @@ -225,7 +226,15 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) { if (isLoading) return

Loading…

; if (interests.length === 0) { - return

No interests linked to this yacht.

; + return ( +
+

No interests linked to this yacht

+

+ Interests for this yacht will appear here once a sales rep links them. Add an interest + from the Interests tab on the owner’s client page. +

+
+ ); } return ( @@ -298,5 +307,15 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions label: 'Notes', content: , }, + { + id: 'activity', + label: 'Activity', + content: ( + + ), + }, ]; } diff --git a/src/lib/services/entity-activity.service.ts b/src/lib/services/entity-activity.service.ts new file mode 100644 index 0000000..15b2471 --- /dev/null +++ b/src/lib/services/entity-activity.service.ts @@ -0,0 +1,42 @@ +import { inArray } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { user } from '@/lib/db/schema/users'; +import { searchAuditLogs } from '@/lib/services/audit-search.service'; + +/** + * Shared loader for the per-entity Activity tab. Wraps `searchAuditLogs` + * with actor-email resolution so each row can render `who did what`. + * + * Tenant gate happens at the API route — this service trusts the caller + * to pass an entityId that belongs to `portId`. + */ +export async function loadEntityActivity(args: { + portId: string; + entityType: string; + entityId: string; + limit?: number; +}) { + const { rows } = await searchAuditLogs({ + portId: args.portId, + entityType: args.entityType, + entityId: args.entityId, + limit: args.limit ?? 50, + }); + + const userIds = Array.from( + new Set(rows.map((r) => r.userId).filter((u): u is string => Boolean(u))), + ); + const userRows = userIds.length + ? await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(inArray(user.id, userIds)) + : []; + const userMap = new Map(userRows.map((u) => [u.id, u])); + + return rows.map((r) => ({ + ...r, + actor: r.userId ? (userMap.get(r.userId) ?? null) : null, + })); +}