Files
edr-platform/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Nathnael 7143ba1040 feat(customers): notify marketing on returned changes, name actors in history
Three gaps on the backoffice customer detail page:

- Rejecting a change request or sending it back for correction notified
  nobody. Adds CompanyNotifierService.changeRequestReturned, which pings
  the customer desk with the reviewer, the outcome and the note. Marketing
  joins that desk via customers:view + customers:get_notification in the
  role preset — grants still come from the IAM UI, the preset only sets
  the default for new environments.
- submitted_by / reviewed_by / actor_id were stored but never resolved, so
  the History tab could say what changed but never who asked or who sent
  it back. Resolves them through a shared iam-user-name util (deduped from
  the private copy in contract-document-history.service) and renders
  "Requested by" / "Sent back to marketing by" lines. The
  changes_requested badge is relabelled to match the workflow.
- "View" opened an in-page modal one document at a time. Adds
  openFileInNewTab, which opens the tab inside the click gesture and fills
  it once the authenticated fetch resolves, and an "Open all" button that
  loops over the documents table so every file lands in its own tab.
2026-08-17 12:38:06 +00:00

50 lines
1.7 KiB
TypeScript

import { DataSource } from "typeorm";
/**
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
* plain `String(name)` there yields "[object Object]" in an audit trail.
*/
export interface IamUserRow {
name?: Record<string, string> | string | null;
username?: string | null;
email?: string | null;
}
/** Best display name for a user row: English label → any locale → login → email. */
export function pickUserName(user: IamUserRow): string | null {
const { name } = user;
if (typeof name === "string" && name.trim()) return name.trim();
if (name && typeof name === "object") {
const localized =
name.en ??
Object.values(name).find((v) => typeof v === "string" && v.trim());
if (localized?.trim()) return localized.trim();
}
return user.username?.trim() || user.email?.trim() || null;
}
/**
* Display names for a set of IAM user ids — one query for the whole set.
* `iam.users` is owned by the auth system and has no entity here, so it is read
* directly. A miss is not an error: the caller still holds the id and can fall
* back to it.
*/
export async function resolveIamUserNames(
dataSource: DataSource,
userIds: (string | null | undefined)[],
): Promise<Map<string, string>> {
const resolved = new Map<string, string>();
const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
if (ids.length === 0) return resolved;
const rows = (await dataSource.query(
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<IamUserRow & { id: string }>;
for (const row of rows) {
const name = pickUserName(row);
if (name) resolved.set(row.id, name);
}
return resolved;
}