337 lines
12 KiB
TypeScript
337 lines
12 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, 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 EoiOverridesInput {
|
||
|
|
clientEmail?: FieldOverrideInput;
|
||
|
|
clientPhone?: FieldOverrideInput;
|
||
|
|
yachtName?: FieldOverrideInput;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface AppliedOverrides {
|
||
|
|
/** Values to layer onto the in-memory EoiContext before rendering. */
|
||
|
|
resolved: {
|
||
|
|
clientEmail?: string;
|
||
|
|
clientPhone?: string;
|
||
|
|
yachtName?: 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;
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// One audit row per touched field summarising the override intent.
|
||
|
|
const auditFields: Array<{ field: string; override: FieldOverrideInput }> = [];
|
||
|
|
if (overrides.clientEmail)
|
||
|
|
auditFields.push({ field: 'clientEmail', override: overrides.clientEmail });
|
||
|
|
if (overrides.clientPhone)
|
||
|
|
auditFields.push({ field: 'clientPhone', override: overrides.clientPhone });
|
||
|
|
if (overrides.yachtName)
|
||
|
|
auditFields.push({ field: 'yachtName', override: overrides.yachtName });
|
||
|
|
|
||
|
|
for (const { field, override } of auditFields) {
|
||
|
|
void createAuditLog({
|
||
|
|
userId: meta.userId,
|
||
|
|
portId,
|
||
|
|
action: 'eoi_field_override',
|
||
|
|
entityType: 'interest',
|
||
|
|
entityId: interestId,
|
||
|
|
newValue: {
|
||
|
|
field,
|
||
|
|
// Truncate to avoid bloating audit rows with long free-text.
|
||
|
|
value: override.value.slice(0, 200),
|
||
|
|
useOnlyForThisEoi: override.useOnlyForThisEoi,
|
||
|
|
setAsDefault: override.setAsDefault,
|
||
|
|
fromContactId: override.contactId ?? null,
|
||
|
|
},
|
||
|
|
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;
|
||
|
|
if (Object.keys(cols).length === 0) return;
|
||
|
|
|
||
|
|
await db.update(documents).set(cols).where(eq(documents.id, documentId));
|
||
|
|
|
||
|
|
// Backfill source_document_id on any client_contacts rows this run
|
||
|
|
// inserted. 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'),
|
||
|
|
// Backfill only the recently-inserted rows that haven't been
|
||
|
|
// attributed yet. Bounded by createdAt so re-runs don't sweep up
|
||
|
|
// older orphans.
|
||
|
|
sql`${clientContacts.createdAt} > NOW() - INTERVAL '1 minute'`,
|
||
|
|
sql`${clientContacts.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 };
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
return context;
|
||
|
|
}
|