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:
31
src/app/api/v1/berths/[id]/activity/route.ts
Normal file
31
src/app/api/v1/berths/[id]/activity/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
31
src/app/api/v1/clients/[id]/activity/route.ts
Normal file
31
src/app/api/v1/clients/[id]/activity/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
31
src/app/api/v1/companies/[id]/activity/route.ts
Normal file
31
src/app/api/v1/companies/[id]/activity/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
31
src/app/api/v1/interests/[id]/activity/route.ts
Normal file
31
src/app/api/v1/interests/[id]/activity/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
31
src/app/api/v1/yachts/[id]/activity/route.ts
Normal file
31
src/app/api/v1/yachts/[id]/activity/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
122
src/components/shared/entity-activity-feed.tsx
Normal file
122
src/components/shared/entity-activity-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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’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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
42
src/lib/services/entity-activity.service.ts
Normal file
42
src/lib/services/entity-activity.service.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user