chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -39,7 +39,7 @@ const patchBerthSchema = z
async function loadScopedRow(interestId: string, berthId: string, portId: string) {
// Verify interest port-scope first so unrelated 404s look identical to a
// truly-missing row (enumeration prevention plan §14.10).
// truly-missing row (enumeration prevention - plan §14.10).
const interest = await db.query.interests.findFirst({
where: eq(interests.id, interestId),
});
@@ -73,7 +73,7 @@ export const patchHandler: RouteHandler = async (req, ctx, params) => {
const { interest } = await loadScopedRow(interestId, berthId, ctx.portId);
// Plan §5.5: the bypass control is only available once the interest's
// primary EOI is signed. Defend the API too never trust the UI to
// primary EOI is signed. Defend the API too - never trust the UI to
// gate this.
if (body.eoiBypassReason !== undefined && interest.eoiStatus !== 'signed') {
throw new ValidationError('EOI bypass requires a signed primary EOI on the interest');

View File

@@ -63,7 +63,7 @@ export const addHandler: RouteHandler = async (req, ctx, params) => {
}
// Tenant scope: berth must belong to this port (never trust a client-
// supplied id to cross port boundaries plan §14.10).
// supplied id to cross port boundaries - plan §14.10).
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)),
});

View File

@@ -14,7 +14,7 @@ import { buildEoiContext } from '@/lib/services/eoi-context';
* correct) every value before sending the document for signing.
*
* Augments the core context with `available.emails` / `available.phones`
* every non-deleted client_contacts row for the linked client. The
* - every non-deleted client_contacts row for the linked client. The
* dialog renders these as combobox options so the rep can pick a
* secondary contact for this EOI (Phase 3b).
*

View File

@@ -28,7 +28,7 @@ export const GET = withAuth(
export const POST = withAuth(
withPermission('payments', 'record', async (req, ctx, params) => {
try {
// Body's interestId must match the URL param defense-in-depth against
// Body's interestId must match the URL param - defense-in-depth against
// a client that sends one ID in the URL but another in the body.
const body = await parseBody(req, createPaymentSchema);
if (body.interestId !== params.id) {

View File

@@ -7,8 +7,8 @@ import { errorResponse } from '@/lib/errors';
import { recommendBerths } from '@/lib/services/berth-recommender.service';
/**
* POST body mirrors `RecommendBerthsArgs` minus the `interestId` (route
* param) and `portId` (resolved from the auth context never trust a
* POST body - mirrors `RecommendBerthsArgs` minus the `interestId` (route
* param) and `portId` (resolved from the auth context - never trust a
* client-supplied port, plan §14.10).
*/
const recommendBerthsSchema = z

View File

@@ -26,7 +26,7 @@ export const PATCH = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
// A19 / F27: same-stage write returns the sentinel emit 204.
// A19 / F27: same-stage write returns the sentinel - emit 204.
if (interest === STAGE_NOOP) {
return new NextResponse(null, { status: 204 });
}

View File

@@ -19,7 +19,7 @@ import { brandingPrimaryColor, renderShell } from '@/lib/email/shell';
* Generates a one-shot token + emails the client the public form URL.
*/
/**
* GET list past issuances for the interest. Lets the rep see when each
* GET - list past issuances for the interest. Lets the rep see when each
* token was generated + which one is currently active, so they can choose
* Resend (re-email the existing token) over Regenerate (mint a fresh one)
* when the same client is still working through the existing form.
@@ -58,7 +58,7 @@ export const POST = withAuth(
resendTokenId = body.tokenId;
}
} catch {
// No JSON body keep the default.
// No JSON body - keep the default.
}
const result = resendTokenId
@@ -72,13 +72,13 @@ export const POST = withAuth(
// §1.4: prefer the per-port supplemental_form_url (typically the
// marketing site's hosted form) when configured; otherwise fall
// back to the built-in CRM route. Both modes use the same token
// the marketing site forwards the token to the same backend.
// - the marketing site forwards the token to the same backend.
const emailCfg = await getPortEmailConfig(ctx.portId);
const link = emailCfg.supplementalFormUrl
? `${emailCfg.supplementalFormUrl}?token=${encodeURIComponent(result.token)}`
: `${env.NEXT_PUBLIC_APP_URL}/public/supplemental-info/${result.token}`;
// Resend implies "email me again" the rep clicked the action with
// Resend implies "email me again" - the rep clicked the action with
// intent. Force the email path on for resends regardless of the
// `sendEmail` body flag.
const willSendEmail = resendTokenId ? true : shouldSendEmail;
@@ -106,7 +106,7 @@ export const POST = withAuth(
</p>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 16px;">
Before we draft your Expression of Interest, we need to confirm a few details.
The form below is pre-filled with what we have on file please review, correct
The form below is pre-filled with what we have on file - please review, correct
anything that&apos;s wrong, and add what&apos;s missing.
</p>
<p style="text-align:center;margin:24px 0;">

View File

@@ -7,7 +7,10 @@ import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { auditLogs } from '@/lib/db/schema/system';
import { documents, documentEvents } from '@/lib/db/schema/documents';
import { user } from '@/lib/db/schema/users';
import { user, userProfiles } from '@/lib/db/schema/users';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { clients } from '@/lib/db/schema/clients';
import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = {
@@ -107,6 +110,77 @@ export const GET = withAuth(
return userNameById.get(userId) ?? null;
};
// Collect every UUID that appears in an audit row's newValue under
// a known FK field, then batch-resolve to human labels - berth
// mooring numbers, yacht names, client names, user display names.
// Without this, `Updated primary berth → <uuid>` leaks raw IDs
// into the timeline. Order: scan rows, fetch labels, build maps.
const berthIds = new Set<string>();
const yachtIds = new Set<string>();
const clientFkIds = new Set<string>();
const userFkIds = new Set<string>();
const USER_FK_FIELDS = new Set(['assignedTo', 'ownerId', 'createdBy', 'reassignedTo']);
for (const row of auditRows) {
const nv = row.newValue as Record<string, unknown> | null;
if (!nv) continue;
for (const [key, val] of Object.entries(nv)) {
if (typeof val !== 'string' || val.length < 32) continue;
if (key === 'berthId') berthIds.add(val);
else if (key === 'yachtId') yachtIds.add(val);
else if (key === 'clientId') clientFkIds.add(val);
else if (USER_FK_FIELDS.has(key)) userFkIds.add(val);
}
}
const [berthRows, yachtRows, clientRows, profileRows] = await Promise.all([
berthIds.size > 0
? db
.select({ id: berths.id, mooring: berths.mooringNumber })
.from(berths)
.where(inArray(berths.id, Array.from(berthIds)))
: Promise.resolve([] as Array<{ id: string; mooring: string }>),
yachtIds.size > 0
? db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(inArray(yachts.id, Array.from(yachtIds)))
: Promise.resolve([] as Array<{ id: string; name: string }>),
clientFkIds.size > 0
? db
.select({ id: clients.id, name: clients.fullName })
.from(clients)
.where(inArray(clients.id, Array.from(clientFkIds)))
: Promise.resolve([] as Array<{ id: string; name: string }>),
userFkIds.size > 0
? db
.select({
userId: userProfiles.userId,
displayName: userProfiles.displayName,
firstName: userProfiles.firstName,
lastName: userProfiles.lastName,
})
.from(userProfiles)
.where(inArray(userProfiles.userId, Array.from(userFkIds)))
: Promise.resolve(
[] as Array<{
userId: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
}>,
),
]);
const fkLabels: FkLabelMaps = {
berths: new Map(berthRows.map((b) => [b.id, `Berth ${b.mooring}`])),
yachts: new Map(yachtRows.map((y) => [y.id, y.name])),
clients: new Map(clientRows.map((c) => [c.id, c.name])),
users: new Map(
profileRows.map((p) => [
p.userId,
[p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || 'User',
]),
),
};
// Union and sort
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
id: row.id,
@@ -117,6 +191,7 @@ export const GET = withAuth(
row.newValue as Record<string, unknown> | null,
(row.metadata as Record<string, unknown>) ?? {},
row.userId,
fkLabels,
),
userId: row.userId,
userName: resolveUserName(row.userId),
@@ -171,11 +246,19 @@ export const GET = withAuth(
}),
);
interface FkLabelMaps {
berths: Map<string, string>;
yachts: Map<string, string>;
clients: Map<string, string>;
users: Map<string, string>;
}
function buildAuditDescription(
action: string,
newValue: Record<string, unknown> | null,
metadata: Record<string, unknown>,
userId: string | null,
fkLabels: FkLabelMaps,
): string {
if (action === 'create') return 'Interest created';
if (action === 'archive') return 'Interest archived';
@@ -209,21 +292,63 @@ function buildAuditDescription(
return `Stage changed to ${stageLabel(newValue.pipelineStage as string)}`;
}
if (action === 'update') {
// Interest-berth link mutations get a sentence per flag transition
// ("Berth A1 added to EOI bundle") instead of a literal key/value
// dump. The audit row is logged against the parent interest, but
// `newValue` carries the interest_berths row's flags + the keying
// berthId - so the rep reads it as "what just happened on this
// berth link", not "field X changed to Y".
if (newValue && 'berthId' in newValue && typeof newValue.berthId === 'string') {
const berthLabel = fkLabels.berths.get(newValue.berthId) ?? '(removed berth)';
const phrases = describeInterestBerthFlags(newValue);
if (phrases.length > 0) {
return phrases.map((p) => p.replace('{berth}', berthLabel)).join(' · ');
}
}
// §1.1: surface which field(s) changed instead of a generic
// "Interest updated". We have the new-value bag in audit_logs;
// human-friendly labels for the most common fields.
return describeUpdateDiff(newValue);
return describeUpdateDiff(newValue, fkLabels);
}
return action;
}
/**
* Convert an `interest_berths` audit-log diff bag into one or more
* plain-English sentences. The audit row's `newValue` carries the
* post-state for one or more flag columns (`isPrimary`,
* `isInEoiBundle`, `isSpecificInterest`) plus the keying `berthId`.
* We narrate each flag transition individually so reps don't see a
* literal "X → on / Y → off" dump.
*
* `{berth}` is a placeholder the caller substitutes with the resolved
* mooring label (e.g. "Berth A1") - keeping the substitution out of
* here lets us return the same string shape for the "removed berth"
* fallback case.
*/
function describeInterestBerthFlags(newValue: Record<string, unknown>): string[] {
const phrases: string[] = [];
if (newValue.isPrimary === true) phrases.push('{berth} set as primary berth');
else if (newValue.isPrimary === false) phrases.push('{berth} no longer primary berth');
if (newValue.isInEoiBundle === true) phrases.push('{berth} added to EOI bundle');
else if (newValue.isInEoiBundle === false) phrases.push('{berth} removed from EOI bundle');
if (newValue.isSpecificInterest === true)
phrases.push('{berth} marked as specific interest (public Under Offer)');
else if (newValue.isSpecificInterest === false)
phrases.push('{berth} no longer marked as specific interest');
return phrases;
}
/**
* Render a "leadCategory: hot_lead, source: website" style description from
* an audit log's newValue payload. Filters out audit-internal fields,
* passes through human-friendly labels for known fields, falls back to
* the raw key name when the field isn't in the catalog.
*/
function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
function describeUpdateDiff(
newValue: Record<string, unknown> | null,
fkLabels: FkLabelMaps,
): string {
if (!newValue) return 'Interest updated';
// Audit-internal / housekeeping fields skipped from the timeline copy.
@@ -235,6 +360,7 @@ function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
assignedTo: 'owner',
yachtId: 'yacht',
berthId: 'primary berth',
clientId: 'client',
eoiDocStatus: 'EOI status',
reservationDocStatus: 'reservation status',
contractDocStatus: 'contract status',
@@ -255,14 +381,31 @@ function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
reminderDays: 'reminder cadence',
reminderNote: 'reminder note',
outcome: 'outcome',
isPrimary: 'primary-berth flag',
isInEoiBundle: 'in EOI bundle',
isSpecificInterest: 'specific-interest flag',
};
// FK-field → label-map lookup. When the audit row carries a UUID for
// one of these fields, we substitute the human label (mooring number,
// yacht/client name, user display name) instead of leaking the id.
const FK_FIELD_MAP: Record<string, keyof FkLabelMaps> = {
berthId: 'berths',
yachtId: 'yachts',
clientId: 'clients',
assignedTo: 'users',
ownerId: 'users',
createdBy: 'users',
reassignedTo: 'users',
};
const changed: string[] = [];
for (const [key, value] of Object.entries(newValue)) {
if (SKIP.has(key)) continue;
if (key === 'pipelineStage') continue; // handled by the earlier branch
const label = FIELD_LABELS[key] ?? key;
const formatted = formatDiffValue(value);
const label = FIELD_LABELS[key] ?? humanizeKey(key);
const fkMapKey = FK_FIELD_MAP[key];
const formatted = formatDiffValue(value, fkMapKey ? fkLabels[fkMapKey] : null);
changed.push(formatted ? `${label}${formatted}` : label);
}
@@ -272,11 +415,24 @@ function describeUpdateDiff(newValue: Record<string, unknown> | null): string {
return `Updated ${changed.slice(0, 3).join(', ')} and ${changed.length - 3} more`;
}
function formatDiffValue(v: unknown): string {
function humanizeKey(key: string): string {
return key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase();
}
function formatDiffValue(v: unknown, fkLabelMap: Map<string, string> | null): string {
if (v === null || v === undefined) return 'cleared';
if (typeof v === 'boolean') return v ? 'on' : 'off';
if (typeof v === 'number') return String(v);
if (typeof v === 'string') {
// Resolve UUIDs through the supplied FK label map when present, so
// `berthId → a53e3b1d-...` renders as `primary berth → Berth A1`.
// Falls back to "(removed)" if the entity is gone, never the raw id.
if (fkLabelMap) {
return fkLabelMap.get(v) ?? '(removed)';
}
// Truncate verbose strings so the timeline line stays one row.
return v.length > 40 ? `${v.slice(0, 37)}` : v;
}

View File

@@ -11,7 +11,7 @@ import {
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
/**
* Phase 3 Custom document upload-to-Documenso endpoint.
* Phase 3 - Custom document upload-to-Documenso endpoint.
*
* POST `/api/v1/interests/[id]/upload-for-signing`
*
@@ -25,7 +25,7 @@ import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
* The Contract + Reservation tabs (Phase 4) post here from their
* drag-drop UI. Tests can invoke the service directly.
*
* Permission: documents.send_for_signing sending a document for
* Permission: documents.send_for_signing - sending a document for
* signing is destructive (queues an outbound email + an admin-visible
* Documenso doc). Plus interests.edit because the pipeline-stage
* auto-advance side-effect is interest-mutating (matches the
@@ -62,7 +62,7 @@ const fieldSchema = z.object({
fieldMeta: z.record(z.string(), z.unknown()).optional(),
});
const documentTypeSchema = z.enum(['contract', 'reservation_agreement']);
const documentTypeSchema = z.enum(['eoi', 'contract', 'reservation_agreement', 'generic']);
const MAX_PDF_BYTES = 50 * 1024 * 1024;
@@ -104,7 +104,7 @@ export const POST = withAuth(
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
}
const buffer = Buffer.from(await file.arrayBuffer());
// Magic-byte check at the route boundary too service repeats it
// Magic-byte check at the route boundary too - service repeats it
// as defense in depth but a bad upload should error before we hit
// any side-effecting code.
if (!isPdfMagic(buffer)) {

View File

@@ -7,13 +7,13 @@ import { listInterestsForBoard } from '@/lib/services/interests.service';
import { boardFiltersSchema } from '@/lib/validators/interests';
/**
* Board (kanban) endpoint returns every active interest for the port
* Board (kanban) endpoint - returns every active interest for the port
* with a minimal projection (id, clientName, mooring, leadCategory,
* stage, updatedAt). No pagination: the kanban renders the whole
* pipeline at once. The service hard-caps at 5000 rows to keep payload
* size bounded; if `truncated: true` the UI surfaces a banner.
*
* Filter params are a strict subset of the list endpoint see
* Filter params are a strict subset of the list endpoint - see
* boardFiltersSchema. `pipelineStage` and `includeArchived` are
* intentionally rejected at validation time.
*/

View File

@@ -19,7 +19,7 @@ import { errorResponse } from '@/lib/errors';
* Synchronous bulk endpoint for the interests list.
*
* Per-row loop is fine for the page-size cap (100 rows max). Larger jobs
* (CSV imports, port-wide migrations) belong on the BullMQ `bulk` queue
* (CSV imports, port-wide migrations) belong on the BullMQ `bulk` queue -
* see src/lib/queue/workers/bulk.ts. The synchronous path gives the user
* instant feedback and a per-row failure list, which the queue can't.
*/