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:
@@ -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');
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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's wrong, and add what's missing.
|
||||
</p>
|
||||
<p style="text-align:center;margin:24px 0;">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user