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:
@@ -28,7 +28,14 @@ interface ActivityItem {
|
|||||||
* underlying entity still exists. Falls back to the id prefix in the UI. */
|
* underlying entity still exists. Falls back to the id prefix in the UI. */
|
||||||
label: string | null;
|
label: string | null;
|
||||||
userId: 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;
|
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;
|
oldValue: unknown;
|
||||||
newValue: unknown;
|
newValue: unknown;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { documents } from '@/lib/db/schema/documents';
|
|||||||
import { reminders } from '@/lib/db/schema/operations';
|
import { reminders } from '@/lib/db/schema/operations';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
||||||
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
|
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
|
||||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||||
import { convert as convertCurrency } from '@/lib/services/currency';
|
import { convert as convertCurrency } from '@/lib/services/currency';
|
||||||
@@ -454,8 +455,61 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
// 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,
|
...r,
|
||||||
label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user