feat(activity): per-entity Activity timeline (clients/yachts/companies/berths)

Until now only the global /admin/audit page surfaced audit_logs. Each
entity detail page either lacked the Activity tab entirely or rendered
"Activity log coming soon" text.

- entity-activity.service.loadEntityActivity wraps searchAuditLogs
  with actor-email resolution; reused by all 5 endpoints.
- New endpoints: /api/v1/{clients,yachts,companies,berths,interests}/[id]/activity,
  each gated on the per-entity .view permission and tenant-checked
  against ctx.portId.
- EntityActivityFeed renders a timeline with action verb ("Updated",
  "Archived"), actor name, relative time, and field old→new diff.
- client-tabs, yacht-tabs, company-tabs, berth-tabs now mount the feed
  on their Activity tab. Interest already has the richer
  InterestTimeline component.
- yacht-tabs YachtInterestsTab also gets a friendlier empty state with
  guidance copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 14:57:51 +02:00
parent d19b74b935
commit 8cdee99310
11 changed files with 362 additions and 5 deletions

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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: <StubTab label="Activity" />,
content: (
<EntityActivityFeed
endpoint={`/api/v1/berths/${berth.id}/activity`}
emptyText="No activity recorded for this berth yet."
/>
),
},
];
}

View File

@@ -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: (
<div className="text-center py-12 text-muted-foreground">
<p>Activity log coming soon.</p>
</div>
<EntityActivityFeed
endpoint={`/api/v1/clients/${clientId}/activity`}
emptyText="No activity recorded for this client yet."
/>
),
},
];

View File

@@ -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({
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
),
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/companies/${companyId}/activity`}
emptyText="No activity recorded for this company yet."
/>
),
},
];
}

View File

@@ -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<string, unknown> | null;
createdAt: string;
actor: { id: string; email: string; name: string | null } | null;
}
const ACTION_LABELS: Record<string, string> = {
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 <div className="text-sm text-muted-foreground py-6">Loading activity</div>;
}
if (error) {
return (
<div className="text-sm text-red-600 py-6">
Failed to load activity: {error instanceof Error ? error.message : 'unknown error'}
</div>
);
}
const rows = data?.data ?? [];
if (rows.length === 0) {
return <div className="text-sm text-muted-foreground py-6">{emptyText}</div>;
}
return (
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
{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 (
<li key={row.id} className="relative">
<span className="absolute -left-[31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
<div className="text-sm">
<span className="font-medium">{summarize(row)}</span>
<span className="text-muted-foreground"> · {actor}</span>
</div>
<div className="text-xs text-muted-foreground" title={created.toISOString()}>
{ago}
</div>
{row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? (
<div className="mt-1 text-xs space-x-2">
{row.oldValue !== null && row.oldValue !== undefined ? (
<span className="line-through text-muted-foreground">
{String(JSON.stringify(row.oldValue)).slice(0, 80)}
</span>
) : null}
{row.newValue !== null && row.newValue !== undefined ? (
<span className="text-foreground">
{String(JSON.stringify(row.newValue)).slice(0, 80)}
</span>
) : null}
</div>
) : null}
</li>
);
})}
</ol>
);
}

View File

@@ -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 <p className="text-sm text-muted-foreground">Loading</p>;
if (interests.length === 0) {
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
return (
<div className="rounded-md border border-dashed bg-muted/30 p-6 text-center">
<p className="text-sm font-medium">No interests linked to this yacht</p>
<p className="text-xs text-muted-foreground mt-1">
Interests for this yacht will appear here once a sales rep links them. Add an interest
from the Interests tab on the owner&rsquo;s client page.
</p>
</div>
);
}
return (
@@ -298,5 +307,15 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
label: 'Notes',
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/yachts/${yachtId}/activity`}
emptyText="No activity recorded for this yacht yet."
/>
),
},
];
}

View File

@@ -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,
}));
}