diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx index 0bdc62eb..7a50e322 100644 --- a/src/components/dashboard/activity-feed.tsx +++ b/src/components/dashboard/activity-feed.tsx @@ -28,7 +28,14 @@ interface ActivityItem { * underlying entity still exists. Falls back to the id prefix in the UI. */ label: string | null; userId: string | null; + /** Server-resolved actor display name (from user_profiles). When null, + * the actor row no longer exists — render falls back to a "Unknown + * user" sentinel rather than the raw UUID prefix. */ + actorName: string | null; fieldChanged: string | null; + /** For user-FK diff rows (assignedTo, ownerId, etc.) the service + * already replaces these with display names. Non-user-FK rows pass + * through verbatim. */ oldValue: unknown; newValue: unknown; metadata: Record | null; diff --git a/src/lib/services/dashboard.service.ts b/src/lib/services/dashboard.service.ts index de6e51ba..46ac0ecc 100644 --- a/src/lib/services/dashboard.service.ts +++ b/src/lib/services/dashboard.service.ts @@ -11,6 +11,7 @@ import { documents } from '@/lib/db/schema/documents'; import { reminders } from '@/lib/db/schema/operations'; import { ports } from '@/lib/db/schema/ports'; import { systemSettings, auditLogs } from '@/lib/db/schema/system'; +import { userProfiles } from '@/lib/db/schema/users'; import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants'; import { activeInterestsWhere } from '@/lib/services/active-interest'; import { convert as convertCurrency } from '@/lib/services/currency'; @@ -454,8 +455,61 @@ export async function getRecentActivity(portId: string, limit = 20) { ), ]); - return rows.map((r) => ({ - ...r, - label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null, - })); + // Resolve user UUIDs that appear as the actor (auditLogs.userId) and + // as oldValue/newValue on user-FK diff rows (assignedTo, ownerId, + // reassignedTo, createdBy). Activity-feed audit-log rows previously + // rendered the raw UUID prefix, which was unreadable. + const USER_FK_FIELDS = new Set([ + 'assignedTo', + 'ownerId', + 'reassignedTo', + 'createdBy', + 'addedBy', + 'changedBy', + 'transferredBy', + ]); + const userIds = new Set(); + for (const r of rows) { + if (r.userId) userIds.add(r.userId); + if (r.fieldChanged && USER_FK_FIELDS.has(r.fieldChanged)) { + if (typeof r.oldValue === 'string') userIds.add(r.oldValue); + if (typeof r.newValue === 'string') userIds.add(r.newValue); + } + } + const userNames = new Map(); + if (userIds.size > 0) { + const profiles = await db + .select({ + userId: userProfiles.userId, + displayName: userProfiles.displayName, + firstName: userProfiles.firstName, + lastName: userProfiles.lastName, + }) + .from(userProfiles) + .where(inArray(userProfiles.userId, Array.from(userIds))); + for (const p of profiles) { + const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName; + userNames.set(p.userId, name); + } + } + function resolveUser(id: unknown): unknown { + if (typeof id !== 'string') return id; + const name = userNames.get(id); + if (name) return name; + return `Unknown user (#${id.slice(0, 8)})`; + } + + return rows.map((r) => { + const isUserFk = r.fieldChanged && USER_FK_FIELDS.has(r.fieldChanged); + return { + ...r, + label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null, + // Replace user UUIDs with display names; non-user-FK rows pass through. + oldValue: isUserFk ? resolveUser(r.oldValue) : r.oldValue, + newValue: isUserFk ? resolveUser(r.newValue) : r.newValue, + // Surfaces the actor's name to the renderer; original userId stays + // available for forensics / deep-link if a later UI needs it. + actorName: r.userId ? (userNames.get(r.userId) ?? null) : null, + }; + }); }