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:
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