feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker

Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
  threaded through generate-and-sign validator + both pathways
  (in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
  transaction: useOnlyForThisEoi writes documents.override_* and stops;
  setAsDefault demotes the prior primary + promotes (existing contactId)
  or inserts + promotes (fresh value); neither flag inserts a non-primary
  client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
  source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
  can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
  combobox + manual input + 2 checkboxes per field with mutually
  exclusive intent semantics.

Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
  opens it as a nested Sheet pre-filled with the linked client as owner.
  On save the new yacht is stamped source='eoi-generated' and the
  interest is PATCHed with the new yachtId so the EOI context reflows.

Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
  (transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
  promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
  source='eoi-custom-input'.

Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
  fired_at IS NULL gate so parallel workers can't double-fire. Uses
  the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
  value lands in user_profiles.preferences.digestTimeOfDay (validated
  HH:MM at the route). <ReminderForm> seeds its dueAt from this
  preference via a React-Query me-prefs fetch.

Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
  of Google Workspace's 16-char App Password formatted as
  "abcd efgh ijkl mnop" still authenticates. Workspace activation
  procedure documented in MASTER-PLAN §Phase 6 (was previously written
  to CLAUDE.md, which was bloat — moved to the plan).

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 16:18:03 +02:00
parent 503207ef68
commit eaab14943b
20 changed files with 1119 additions and 92 deletions

View File

@@ -0,0 +1,336 @@
/**
* 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;
}