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:
@@ -57,7 +57,14 @@ export type AuditAction =
|
||||
// which the audit filter dropdown couldn't surface as its own bucket
|
||||
// and the FTS GENERATED index missed entirely.
|
||||
| 'outcome_set'
|
||||
| 'outcome_cleared';
|
||||
| 'outcome_cleared'
|
||||
// Phase 3 — EOI override / contact promote / yacht spawn from EOI.
|
||||
// The DB column is free-text per migration 0073; these strings just
|
||||
// formalise the catalogue so the audit-log filter dropdown can surface
|
||||
// them as their own buckets.
|
||||
| 'eoi_field_override'
|
||||
| 'promote_to_primary'
|
||||
| 'eoi_spawn_yacht';
|
||||
|
||||
/**
|
||||
* Common shape passed to service functions so they can stamp audit logs and
|
||||
|
||||
@@ -762,6 +762,71 @@ export async function updateContact(
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3d — promote a non-primary client_contacts row to primary,
|
||||
* demoting the prior primary for the same channel inside one
|
||||
* transaction. Throws when the contact is already primary or the row
|
||||
* does not exist on the targeted client.
|
||||
*
|
||||
* Used by the EOI dialog's "Set as default for future docs" toggle
|
||||
* (via the eoi-overrides service) and by the client-detail "[EOI] Set
|
||||
* as primary" action.
|
||||
*/
|
||||
export async function promoteContactToPrimary(
|
||||
contactId: string,
|
||||
clientId: string,
|
||||
portId: string,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
|
||||
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
||||
|
||||
const contact = await db.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
||||
});
|
||||
if (!contact) throw new NotFoundError('Contact');
|
||||
if (contact.isPrimary) {
|
||||
// No-op — return the row as-is so callers can be idempotent.
|
||||
return contact;
|
||||
}
|
||||
|
||||
const updated = await withTransaction(async (tx) => {
|
||||
// Demote the prior primary for the same channel 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, clientId),
|
||||
eq(clientContacts.channel, contact.channel),
|
||||
eq(clientContacts.isPrimary, true),
|
||||
),
|
||||
);
|
||||
const [row] = await tx
|
||||
.update(clientContacts)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
|
||||
.returning();
|
||||
return row!;
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'promote_to_primary',
|
||||
entityType: 'client_contact',
|
||||
entityId: contactId,
|
||||
newValue: { clientId, channel: contact.channel, value: contact.value },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function removeContact(
|
||||
contactId: string,
|
||||
clientId: string,
|
||||
|
||||
@@ -28,6 +28,13 @@ import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
|
||||
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
|
||||
import { buildEoiContext } from '@/lib/services/eoi-context';
|
||||
import {
|
||||
applyEoiOverridesBeforeRender,
|
||||
applyOverridesToContext,
|
||||
persistDocumentOverrides,
|
||||
type EoiOverridesInput,
|
||||
type AppliedOverrides,
|
||||
} from '@/lib/services/eoi-overrides.service';
|
||||
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
|
||||
import type {
|
||||
CreateTemplateInput,
|
||||
@@ -484,12 +491,16 @@ async function generateEoiFromSourcePdf(
|
||||
context: GenerateInput,
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
|
||||
): Promise<{ document: DbDocument; file: DbFile }> {
|
||||
if (!context.interestId) {
|
||||
throw new ValidationError('interestId is required for EOI template generation');
|
||||
}
|
||||
|
||||
const eoiContext = await buildEoiContext(context.interestId, portId);
|
||||
const eoiContext = applyOverridesToContext(
|
||||
await buildEoiContext(context.interestId, portId),
|
||||
applied,
|
||||
);
|
||||
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
|
||||
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
|
||||
});
|
||||
@@ -586,15 +597,24 @@ export async function generateAndSign(
|
||||
signers: GenerateAndSignInput['signers'],
|
||||
pathway: 'inapp' | 'documenso-template',
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
options?: { dimensionUnit?: 'ft' | 'm'; overrides?: EoiOverridesInput },
|
||||
) {
|
||||
// Phase 3b — apply per-field overrides BEFORE either pathway resolves the
|
||||
// EOI context, so any setAsDefault contact promotion is visible to the
|
||||
// buildEoiContext read. The returned `applied.resolved` is layered onto
|
||||
// the in-memory context for useOnlyForThisEoi / fresh-value cases where
|
||||
// the canonical record isn't being touched.
|
||||
const applied = context.interestId
|
||||
? await applyEoiOverridesBeforeRender(portId, context.interestId, options?.overrides, meta)
|
||||
: { resolved: {}, documentOverrideColumns: {} };
|
||||
|
||||
if (pathway === 'documenso-template') {
|
||||
return generateAndSignViaDocumensoTemplate(portId, context, meta, options);
|
||||
return generateAndSignViaDocumensoTemplate(portId, context, meta, options, applied);
|
||||
}
|
||||
if (!templateId) {
|
||||
throw new ValidationError('templateId is required for inapp pathway');
|
||||
}
|
||||
return generateAndSignViaInApp(templateId, portId, context, signers, meta, options);
|
||||
return generateAndSignViaInApp(templateId, portId, context, signers, meta, options, applied);
|
||||
}
|
||||
|
||||
async function generateAndSignViaInApp(
|
||||
@@ -604,6 +624,7 @@ async function generateAndSignViaInApp(
|
||||
signers: GenerateAndSignInput['signers'],
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
|
||||
) {
|
||||
const template = await getTemplateById(templateId, portId);
|
||||
|
||||
@@ -662,8 +683,14 @@ async function generateAndSignViaInApp(
|
||||
context,
|
||||
meta,
|
||||
options,
|
||||
applied,
|
||||
);
|
||||
|
||||
// Phase 3b — record per-document override columns + backfill the
|
||||
// source_document_id on any client_contacts rows inserted during the
|
||||
// override side-effects.
|
||||
await persistDocumentOverrides(documentRecord.id, applied, meta);
|
||||
|
||||
// Fetch PDF bytes from the active storage backend to send to Documenso.
|
||||
const pdfStream = await (await getStorageBackend()).get(file.storagePath);
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -729,12 +756,16 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
context: GenerateInput,
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
|
||||
) {
|
||||
if (!context.interestId) {
|
||||
throw new ValidationError('interestId is required for documenso-template pathway');
|
||||
}
|
||||
|
||||
const eoiContext = await buildEoiContext(context.interestId, portId);
|
||||
const eoiContext = applyOverridesToContext(
|
||||
await buildEoiContext(context.interestId, portId),
|
||||
applied,
|
||||
);
|
||||
const signers = await getPortEoiSigners(portId);
|
||||
// Per-port Documenso template + recipient IDs (with env fallback). Each
|
||||
// tenant pointing at its own Documenso instance has different numeric
|
||||
@@ -800,6 +831,10 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Phase 3b — record any per-document override columns + backfill
|
||||
// source_document_id on freshly inserted contact rows.
|
||||
await persistDocumentOverrides(documentRecord!.id, applied, meta);
|
||||
|
||||
// Persist the per-recipient signer rows from Documenso's create response.
|
||||
// Without these the EOI tab's "Signing progress" panel shows
|
||||
// "No signers loaded" forever (the webhook handler only updates existing
|
||||
|
||||
336
src/lib/services/eoi-overrides.service.ts
Normal file
336
src/lib/services/eoi-overrides.service.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, lte, gte, desc, asc, inArray, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNull, lte, gte, desc, asc, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { reminders, interests, clients } from '@/lib/db/schema';
|
||||
@@ -523,38 +523,55 @@ export async function processFollowUpReminders() {
|
||||
export async function processOverdueReminders() {
|
||||
const now = new Date();
|
||||
|
||||
// Find pending reminders past their due date
|
||||
const overdueReminders = await db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now)));
|
||||
|
||||
for (const reminder of overdueReminders) {
|
||||
if (reminder.assignedTo) {
|
||||
void createNotification({
|
||||
portId: reminder.portId,
|
||||
userId: reminder.assignedTo,
|
||||
type: 'reminder_overdue',
|
||||
title: 'Reminder overdue',
|
||||
description: reminder.title,
|
||||
entityType: 'reminder',
|
||||
entityId: reminder.id,
|
||||
link: '/reminders',
|
||||
});
|
||||
|
||||
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
|
||||
reminderId: reminder.id,
|
||||
title: reminder.title,
|
||||
dueAt: reminder.dueAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also un-snooze reminders whose snooze period has passed
|
||||
// Un-snooze reminders whose snooze window has elapsed first, so a
|
||||
// reminder that just transitioned snoozed → pending is eligible in
|
||||
// the same tick (rather than waiting a full 15 minutes for the next
|
||||
// scan).
|
||||
await db
|
||||
.update(reminders)
|
||||
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
|
||||
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, now)));
|
||||
|
||||
logger.info({ overdueCount: overdueReminders.length }, 'Processed overdue reminders');
|
||||
// Phase 4 — claim due reminders by stamping fired_at in a single
|
||||
// UPDATE...RETURNING. Postgres's row locks guarantee only one worker
|
||||
// wins per row, so parallel maintenance workers can't double-fire the
|
||||
// same reminder. Limited to status='pending' (the un-snooze pass
|
||||
// above already promoted anything that was snoozed-expired).
|
||||
//
|
||||
// Partial index `idx_reminders_due_unfired` from migration 0072
|
||||
// covers (port_id, due_at) WHERE fired_at IS NULL AND status IN
|
||||
// ('pending', 'snoozed') so the scan stays cheap even on a large
|
||||
// backlog of long-fired reminders.
|
||||
const claimed = await db
|
||||
.update(reminders)
|
||||
.set({ firedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(eq(reminders.status, 'pending'), lte(reminders.dueAt, now), isNull(reminders.firedAt)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
for (const reminder of claimed) {
|
||||
if (!reminder.assignedTo) continue;
|
||||
void createNotification({
|
||||
portId: reminder.portId,
|
||||
userId: reminder.assignedTo,
|
||||
type: 'reminder_overdue',
|
||||
title: 'Reminder overdue',
|
||||
description: reminder.title,
|
||||
entityType: 'reminder',
|
||||
entityId: reminder.id,
|
||||
link: '/reminders',
|
||||
// Per-reminder dedup is now redundant given fired_at, but keep
|
||||
// the key so a manual re-fire (e.g. ops clears fired_at) still
|
||||
// respects the cooldown.
|
||||
dedupeKey: `reminder:${reminder.id}`,
|
||||
});
|
||||
emitToRoom(`user:${reminder.assignedTo}`, 'reminder:overdue', {
|
||||
reminderId: reminder.id,
|
||||
title: reminder.title,
|
||||
dueAt: reminder.dueAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ firedCount: claimed.length }, 'Reminder cron: claimed + notified');
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ export async function createYacht(portId: string, data: CreateYachtInput, meta:
|
||||
currentOwnerId: data.owner.id,
|
||||
status: data.status ?? 'active',
|
||||
notes: data.notes ?? null,
|
||||
// Phase 3c — origin tracking. Defaults to 'manual' at the DB
|
||||
// level; pass-through allows the EOI spawn flow to mark the row
|
||||
// as 'eoi-generated' with the generating document_id.
|
||||
source: data.source ?? 'manual',
|
||||
sourceDocumentId: data.sourceDocumentId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -63,6 +63,29 @@ export const generateSchema = z.object({
|
||||
berthId: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 3b — per-field override descriptor used by the EOI dialog.
|
||||
*
|
||||
* Three modes:
|
||||
* - `useOnlyForThisEoi=true` → write the value to documents.override_*
|
||||
* and stop; client_contacts is untouched.
|
||||
* - `setAsDefault=true` + new value → insert a client_contacts row
|
||||
* (source='eoi-custom-input', source_document_id=this EOI), promote
|
||||
* to primary inside a transaction (demote the prior primary first).
|
||||
* - `setAsDefault=true` + existing `contactId` → promote that row.
|
||||
* - both flags false + new value → insert a non-primary client_contacts
|
||||
* row only (rep typed a fresh value but doesn't want it as default).
|
||||
*/
|
||||
const fieldOverrideSchema = z.object({
|
||||
value: z.string().min(1).max(500),
|
||||
useOnlyForThisEoi: z.boolean().default(false),
|
||||
setAsDefault: z.boolean().default(false),
|
||||
/** When the value comes from an existing client_contacts row, the rep
|
||||
* picked it from the combobox — pass the id so the service can skip
|
||||
* re-inserting and just promote it (when setAsDefault is set). */
|
||||
contactId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const generateAndSignSchema = generateSchema.extend({
|
||||
pathway: z.enum(['inapp', 'documenso-template']).default('inapp'),
|
||||
signers: z
|
||||
@@ -80,6 +103,14 @@ export const generateAndSignSchema = generateSchema.extend({
|
||||
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
|
||||
* server defaults to the yacht's `lengthUnit` column when omitted. */
|
||||
dimensionUnit: z.enum(['ft', 'm']).optional(),
|
||||
/** Phase 3b — optional per-field overrides applied at generation. */
|
||||
overrides: z
|
||||
.object({
|
||||
clientEmail: fieldOverrideSchema.optional(),
|
||||
clientPhone: fieldOverrideSchema.optional(),
|
||||
yachtName: fieldOverrideSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;
|
||||
|
||||
@@ -34,6 +34,11 @@ export const createYachtSchema = z.object({
|
||||
status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
|
||||
notes: z.string().optional(),
|
||||
tagIds: z.array(z.string()).optional().default([]),
|
||||
// Phase 3c — origin tracking. Defaults to 'manual'; the EOI spawn flow
|
||||
// sends 'eoi-generated' and the migration-0073 CHECK enforces the
|
||||
// values at the DB level.
|
||||
source: z.enum(['manual', 'imported', 'eoi-generated']).optional(),
|
||||
sourceDocumentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export const updateYachtSchema = createYachtSchema.partial().omit({ owner: true });
|
||||
|
||||
Reference in New Issue
Block a user