Files
pn-new-crm/src/lib/services/eoi-overrides.service.ts
Matt 221ae5784e 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
2026-05-23 00:52:59 +02:00

550 lines
20 KiB
TypeScript

/**
* Phase 3b - EOI field-override side-effects + persistence.
*
* The EOI dialog lets reps override pre-filled fields (email, phone,
* yacht name) with one of three intents:
*
* 1. **Use only for this EOI** (`useOnlyForThisEoi=true`)
* → write to `documents.override_*` columns only; never mutate
* client_contacts or yachts. Future EOIs revert to the canonical
* primary.
*
* 2. **Set as default for future docs** (`setAsDefault=true`)
* → promote an existing `client_contacts` row to primary, or insert
* + promote if the rep typed a fresh value. Demote the prior
* primary inside the same transaction. `documents.override_*`
* stays NULL because the canonical record now matches.
*
* 3. **Neither flag** (default - rep picked a secondary from the
* combobox OR typed something fresh)
* → if the value is fresh (no `contactId`), insert a non-primary
* `client_contacts` row (`source='eoi-custom-input'`,
* `source_document_id=<this EOI>`). Either way write
* `documents.override_*` so the rendered doc records the
* deviation from the canonical primary.
*
* Yacht name overrides have no contact-row analog. `useOnlyForThisEoi`
* writes to `documents.override_yacht_name`; `setAsDefault` patches the
* canonical `yachts.name` column.
*
* The applied override values are returned so the caller can layer them
* onto the in-memory EOI context before rendering - without a separate
* round-trip to re-read the freshly-mutated contact rows.
*/
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { documents } from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ValidationError } from '@/lib/errors';
import { withTransaction } from '@/lib/db/utils';
export interface FieldOverrideInput {
value: string;
useOnlyForThisEoi: boolean;
setAsDefault: boolean;
contactId?: string | null;
}
export interface AddressOverrideInput {
line1?: string;
line2?: string;
city?: string;
subdivisionIso?: string;
postalCode?: string;
countryIso?: string;
useOnlyForThisEoi: boolean;
setAsDefault: boolean;
/** Existing client_addresses.id when the rep picked one from a list;
* null = fresh values typed in the dialog. */
addressId?: string | null;
}
export interface EoiOverridesInput {
clientEmail?: FieldOverrideInput;
clientPhone?: FieldOverrideInput;
yachtName?: FieldOverrideInput;
clientAddress?: AddressOverrideInput;
}
export interface AppliedOverrides {
/** Values to layer onto the in-memory EoiContext before rendering. */
resolved: {
clientEmail?: string;
clientPhone?: string;
yachtName?: string;
clientAddress?: {
line1: string;
line2: string;
city: string;
subdivisionIso: string;
postalCode: string;
countryIso: string;
};
};
/** Columns to write to `documents.override_*` after the doc row exists.
* Empty when every override either ran `setAsDefault` (canonical
* updated) or no overrides were supplied. */
documentOverrideColumns: Partial<{
overrideClientEmail: string;
overrideClientPhone: string;
overrideYachtName: string;
overrideClientAddressLine1: string;
overrideClientAddressLine2: string;
overrideClientCity: string;
overrideClientState: string;
overrideClientPostalCode: string;
overrideClientCountry: string;
}>;
}
/**
* Apply override side-effects (insert contacts, promote primaries,
* patch yacht name) and return the values to be used at render time.
*
* Runs all mutations in a single transaction so a partial failure
* (e.g. setAsDefault promotion succeeds for email but fails for
* phone) doesn't leave the contact table in a split-brain state.
*
* Audit log entries: `eoi_field_override` per field touched.
*/
export async function applyEoiOverridesBeforeRender(
portId: string,
interestId: string,
overrides: EoiOverridesInput | undefined,
meta: AuditMeta,
): Promise<AppliedOverrides> {
const empty: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} };
if (!overrides) return empty;
// Resolve the interest's client (for contact mutations) and yacht (for
// yacht-name mutations) up-front so the transaction body has everything
// it needs without re-fetching.
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new ValidationError('interest not found for overrides');
const client = await db.query.clients.findFirst({
where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)),
});
if (!client) throw new ValidationError('client not found for overrides');
const yacht = interest.yachtId
? await db.query.yachts.findFirst({
where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)),
})
: null;
// ─── Single transaction wrapping every side-effect ────────────────────────
return withTransaction(async (tx) => {
const resolved: AppliedOverrides['resolved'] = {};
const documentOverrideColumns: AppliedOverrides['documentOverrideColumns'] = {};
// Helper for contact-channel overrides (email + phone share logic).
const applyContactOverride = async (
override: FieldOverrideInput,
channel: 'email' | 'phone',
docColumn: 'overrideClientEmail' | 'overrideClientPhone',
): Promise<string> => {
const value = override.value.trim();
if (!value) throw new ValidationError(`${channel} override value cannot be empty`);
if (override.useOnlyForThisEoi) {
// No contact mutation. Override applies only to this document.
documentOverrideColumns[docColumn] = value;
return value;
}
if (override.setAsDefault) {
// Promote: either an existing contactId or a fresh insert. Demote
// the prior primary for the same channel first so the partial
// unique index doesn't reject the promotion.
await tx
.update(clientContacts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(clientContacts.clientId, client.id),
eq(clientContacts.channel, channel),
eq(clientContacts.isPrimary, true),
),
);
if (override.contactId) {
// Promote existing row.
await tx
.update(clientContacts)
.set({ isPrimary: true, value, updatedAt: new Date() })
.where(
and(
eq(clientContacts.id, override.contactId),
eq(clientContacts.clientId, client.id),
),
);
} else {
// Fresh insert + primary.
await tx.insert(clientContacts).values({
clientId: client.id,
channel,
value,
isPrimary: true,
source: 'eoi-custom-input',
});
}
// Canonical now matches → documents.override_* stays NULL.
return value;
}
// Neither flag set. If the rep picked an existing contact row
// (contactId set) we don't mutate; if they typed a fresh value
// we insert a non-primary contact so it shows up in future
// dropdowns. Either way we record the deviation on the document.
if (!override.contactId) {
await tx.insert(clientContacts).values({
clientId: client.id,
channel,
value,
isPrimary: false,
source: 'eoi-custom-input',
});
}
documentOverrideColumns[docColumn] = value;
return value;
};
if (overrides.clientEmail) {
resolved.clientEmail = await applyContactOverride(
overrides.clientEmail,
'email',
'overrideClientEmail',
);
}
if (overrides.clientPhone) {
resolved.clientPhone = await applyContactOverride(
overrides.clientPhone,
'phone',
'overrideClientPhone',
);
}
if (overrides.yachtName) {
const value = overrides.yachtName.value.trim();
if (!value) throw new ValidationError('yacht name override cannot be empty');
if (!yacht) {
// Yacht-name override without a linked yacht only makes sense
// for the per-document path - otherwise there's no canonical
// record to update.
if (overrides.yachtName.setAsDefault) {
throw new ValidationError('cannot setAsDefault for yacht name when no yacht is linked');
}
documentOverrideColumns.overrideYachtName = value;
} else if (overrides.yachtName.useOnlyForThisEoi) {
documentOverrideColumns.overrideYachtName = value;
} else if (overrides.yachtName.setAsDefault) {
await tx
.update(yachts)
.set({ name: value, updatedAt: new Date() })
.where(eq(yachts.id, yacht.id));
} else {
// Default behaviour: per-document override.
documentOverrideColumns.overrideYachtName = value;
}
resolved.yachtName = value;
}
if (overrides.clientAddress) {
const a = overrides.clientAddress;
const resolvedAddr = {
line1: (a.line1 ?? '').trim(),
line2: (a.line2 ?? '').trim(),
city: (a.city ?? '').trim(),
subdivisionIso: (a.subdivisionIso ?? '').trim(),
postalCode: (a.postalCode ?? '').trim(),
countryIso: (a.countryIso ?? '').trim().toUpperCase(),
};
// Treat the address as one logical field - at least line1 + countryIso
// must be present for an EOI to render legally.
if (!resolvedAddr.line1 || !resolvedAddr.countryIso) {
throw new ValidationError('address override requires line1 and countryIso');
}
if (a.useOnlyForThisEoi) {
documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1;
if (resolvedAddr.line2)
documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2;
if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city;
if (resolvedAddr.subdivisionIso)
documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso;
if (resolvedAddr.postalCode)
documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode;
documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso;
} else if (a.setAsDefault) {
// Promote: demote the prior primary, then either update an existing
// address row (when addressId was provided) or insert a fresh one.
await tx
.update(clientAddresses)
.set({ isPrimary: false, updatedAt: new Date() })
.where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)));
if (a.addressId) {
await tx
.update(clientAddresses)
.set({
// client_addresses has no addressLine2 column - concat line1+line2.
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: true,
updatedAt: new Date(),
})
.where(
and(eq(clientAddresses.id, a.addressId), eq(clientAddresses.clientId, client.id)),
);
} else {
await tx.insert(clientAddresses).values({
clientId: client.id,
portId: client.portId,
// client_addresses has no addressLine2 column - concat line1+line2.
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: true,
source: 'eoi-custom-input',
});
}
// Canonical now matches → documents.override_* stays NULL.
} else {
// Neither flag: persist per-doc + (if no addressId) insert a
// non-primary address row for future reuse.
if (!a.addressId) {
await tx.insert(clientAddresses).values({
clientId: client.id,
portId: client.portId,
streetAddress: resolvedAddr.line2
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
: resolvedAddr.line1,
city: resolvedAddr.city || null,
subdivisionIso: resolvedAddr.subdivisionIso || null,
postalCode: resolvedAddr.postalCode || null,
countryIso: resolvedAddr.countryIso,
isPrimary: false,
source: 'eoi-custom-input',
});
}
documentOverrideColumns.overrideClientAddressLine1 = resolvedAddr.line1;
if (resolvedAddr.line2)
documentOverrideColumns.overrideClientAddressLine2 = resolvedAddr.line2;
if (resolvedAddr.city) documentOverrideColumns.overrideClientCity = resolvedAddr.city;
if (resolvedAddr.subdivisionIso)
documentOverrideColumns.overrideClientState = resolvedAddr.subdivisionIso;
if (resolvedAddr.postalCode)
documentOverrideColumns.overrideClientPostalCode = resolvedAddr.postalCode;
documentOverrideColumns.overrideClientCountry = resolvedAddr.countryIso;
}
resolved.clientAddress = resolvedAddr;
}
// One audit row per touched field summarising the override intent.
const auditFields: Array<{ field: string; intent: Record<string, unknown> }> = [];
if (overrides.clientEmail)
auditFields.push({
field: 'clientEmail',
intent: {
value: overrides.clientEmail.value.slice(0, 200),
useOnlyForThisEoi: overrides.clientEmail.useOnlyForThisEoi,
setAsDefault: overrides.clientEmail.setAsDefault,
fromContactId: overrides.clientEmail.contactId ?? null,
},
});
if (overrides.clientPhone)
auditFields.push({
field: 'clientPhone',
intent: {
value: overrides.clientPhone.value.slice(0, 200),
useOnlyForThisEoi: overrides.clientPhone.useOnlyForThisEoi,
setAsDefault: overrides.clientPhone.setAsDefault,
fromContactId: overrides.clientPhone.contactId ?? null,
},
});
if (overrides.yachtName)
auditFields.push({
field: 'yachtName',
intent: {
value: overrides.yachtName.value.slice(0, 200),
useOnlyForThisEoi: overrides.yachtName.useOnlyForThisEoi,
setAsDefault: overrides.yachtName.setAsDefault,
},
});
if (overrides.clientAddress)
auditFields.push({
field: 'clientAddress',
intent: {
useOnlyForThisEoi: overrides.clientAddress.useOnlyForThisEoi,
setAsDefault: overrides.clientAddress.setAsDefault,
fromAddressId: overrides.clientAddress.addressId ?? null,
countryIso: overrides.clientAddress.countryIso,
},
});
for (const { field, intent } of auditFields) {
void createAuditLog({
userId: meta.userId,
portId,
action: 'eoi_field_override',
entityType: 'interest',
entityId: interestId,
newValue: { field, ...intent },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
return { resolved, documentOverrideColumns };
});
}
/**
* Persist `documents.override_*` columns after the document row has
* been inserted. No-op when no columns are set.
*
* `source_document_id` on any client_contacts rows inserted by the
* preceding `applyEoiOverridesBeforeRender` call is left NULL until
* this point - the document id doesn't exist yet during the contact
* insert. This function backfills it.
*/
export async function persistDocumentOverrides(
documentId: string,
applied: AppliedOverrides,
meta: AuditMeta,
): Promise<void> {
const cols = applied.documentOverrideColumns;
// Even when cols is empty (every override ran setAsDefault), we still
// need to backfill source_document_id on freshly-inserted contact /
// address / yacht rows whose insertion preceded the document row's
// existence. Skip only when applied is the empty default.
const hasResolved = Object.keys(applied.resolved).length > 0;
if (Object.keys(cols).length > 0) {
await db.update(documents).set(cols).where(eq(documents.id, documentId));
} else if (!hasResolved) {
return;
}
// Backfill source_document_id on freshly-inserted contact + address +
// yacht rows from this generation pass. Bounded by createdAt < 1 min
// so re-runs don't sweep older orphans. Done outside the override
// transaction because the document id wasn't known yet at that point.
await db
.update(clientContacts)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(clientContacts.source, 'eoi-custom-input'),
sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${clientContacts.sourceDocumentId} IS NULL`,
),
);
await db
.update(clientAddresses)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(clientAddresses.source, 'eoi-custom-input'),
sql`${clientAddresses.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${clientAddresses.sourceDocumentId} IS NULL`,
),
);
// Phase 3 follow-up - yacht spawn from EOI runs BEFORE generateAndSign
// so the yacht row's source_document_id is NULL at insert time. Same
// bounded backfill pattern as contacts.
await db
.update(yachts)
.set({ sourceDocumentId: documentId })
.where(
and(
eq(yachts.source, 'eoi-generated'),
sql`${yachts.createdAt} > NOW() - INTERVAL '1 minute'`,
sql`${yachts.sourceDocumentId} IS NULL`,
),
);
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'update',
entityType: 'document',
entityId: documentId,
metadata: { action: 'persist_eoi_overrides', columns: Object.keys(cols) },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
/**
* Layer applied override values onto an EOI context object so the
* renderer (in-app pdf-lib OR Documenso payload) sees the override
* values instead of the canonical record. Mutates the supplied object
* (cheap; the caller built it).
*/
export function applyOverridesToContext<
T extends {
client: {
primaryEmail: string | null;
primaryPhone: string | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
country: string;
countryIso: string;
} | null;
};
yacht: { name: string } | null;
},
>(context: T, applied: AppliedOverrides): T {
if (applied.resolved.clientEmail !== undefined) {
context.client.primaryEmail = applied.resolved.clientEmail;
}
if (applied.resolved.clientPhone !== undefined) {
context.client.primaryPhone = applied.resolved.clientPhone;
}
if (applied.resolved.yachtName !== undefined && context.yacht) {
context.yacht.name = applied.resolved.yachtName;
}
if (applied.resolved.clientAddress) {
const a = applied.resolved.clientAddress;
const combinedStreet = a.line2 ? `${a.line1}\n${a.line2}` : a.line1;
// Strip the country-code prefix from the subdivision ISO so the EOI
// renders the subdivision suffix exactly the way the canonical
// address pipeline does (e.g. 'US-CA' → 'CA').
const subdivisionSuffix = a.subdivisionIso.includes('-')
? a.subdivisionIso.split('-').slice(1).join('-')
: a.subdivisionIso;
context.client.address = {
street: combinedStreet,
city: a.city,
subdivision: subdivisionSuffix,
postalCode: a.postalCode,
// `country` (long name) is only used by the deprecated UI preview
// line; the EOI's Address field renders countryIso, so we set them
// consistently and leave the long-name lookup to the renderer.
country: a.countryIso,
countryIso: a.countryIso,
};
}
return context;
}