import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { user } from '@/lib/db/schema/users'; import { searchAuditLogs, type AuditSearchOptions } 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, })); } /** * Aggregated activity for a client — includes audit logs for the * client itself + every interest belonging to that client. Used by * the Client overview's Activity tab so the rep sees the whole * timeline without clicking into each interest individually. * * Two queries (one per entityType) merged + sorted in JS rather than * a UNION because the auditLogs.entityType field would need to match * different values in the same SELECT — cleaner to keep the search * helper's per-entity-type semantics intact and merge here. */ export async function loadClientActivityAggregated(args: { portId: string; clientId: string; limit?: number; }) { const limit = args.limit ?? 50; // Resolve interest ids upfront so we know what to fetch in parallel. const interestRows = await db .select({ id: interests.id }) .from(interests) .where(and(eq(interests.clientId, args.clientId), eq(interests.portId, args.portId))); const interestIds = interestRows.map((r) => r.id); const baseOpts = (entityType: string, entityId?: string, entityIds?: string[]) => ({ portId: args.portId, entityType, entityId, entityIds, // Fetch up to `limit` per slice; we'll resort + slice to limit // after merging. Slight over-fetch keeps the merged window honest // when the activity is unbalanced (e.g. mostly interest events). limit, }) satisfies AuditSearchOptions; const [clientPage, interestPage] = await Promise.all([ searchAuditLogs(baseOpts('client', args.clientId)), interestIds.length > 0 ? searchAuditLogs(baseOpts('interest', undefined, interestIds)) : Promise.resolve({ rows: [], nextCursor: null }), ]); const merged = [...clientPage.rows, ...interestPage.rows] .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) .slice(0, limit); // Resolve actor names in one round-trip across the merged set. const userIds = Array.from( new Set(merged.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 merged.map((r) => ({ ...r, actor: r.userId ? (userMap.get(r.userId) ?? null) : null, })); }