feat(uat-batch-13): activity feed resolves user UUIDs to display names

Audit-log rows with user-FK diffs (assignedTo, ownerId, reassignedTo,
createdBy, addedBy, changedBy, transferredBy) previously rendered the
raw user UUID in the activity feed (e.g. "→ mEcsLxo5kyFMyhbOSehxJjY…").
Same gap on the row's actor — the rep had no idea who did what.

- getRecentActivity collects all userIds referenced by either the row's
  actor (auditLogs.userId) or by user-FK diff values, then bulk-fetches
  user_profiles in a single query. Output rows now carry an
  `actorName` field and have their `oldValue`/`newValue` swapped for
  display names on user-FK fields.
- Unknown / deleted users fall back to "Unknown user (#short-uuid)" so
  the audit trail stays useful for forensics.
- ActivityItem client type extended with `actorName`. Existing
  consumers still read the raw `userId` for forensics + deep-link.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:14:21 +02:00
parent f99d2cd9ec
commit 2cb0b99314
2 changed files with 65 additions and 4 deletions

View File

@@ -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<string, unknown> | null;

View File

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