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