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:
@@ -107,7 +107,6 @@ src/
|
|||||||
### Data model
|
### Data model
|
||||||
|
|
||||||
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` pairs (`'client' | 'company'`). Resolve via `src/lib/services/yachts.service.ts` / `eoi-context.ts` — never read the columns ad hoc.
|
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` pairs (`'client' | 'company'`). Resolve via `src/lib/services/yachts.service.ts` / `eoi-context.ts` — never read the columns ad hoc.
|
||||||
- **Deal-pulse risk signals:** `dateDocumentDeclined`, `dateReservationCancelled`, `dateBerthSoldToOther` are NOT columns on `interests` — they're **derived at read time** inside `getInterestById` from `document_events` (eventType in `('rejected','declined')`), `berth_reservations` (status='cancelled'), and other won interests sharing the same berth via `interest_berths`. The Phase 2 design call: derive vs. denormalize — derivation kept the master plan's "no new tables" promise. Cost: 3 extra SELECTs on the detail endpoint (run in parallel); list views don't render the chip so they're unaffected.
|
|
||||||
- **Multi-berth interest model:** `interest_berths` is the source of truth — `interests.berth_id` does not exist (dropped in 0029). Three flags: `is_primary` (≤1 per interest, partial unique index — "the berth for this deal"), `is_specific_interest` (true → public map shows "Under Offer"), `is_in_eoi_bundle` (covered by EOI signature). Read/write only via `src/lib/services/interest-berths.service.ts` helpers.
|
- **Multi-berth interest model:** `interest_berths` is the source of truth — `interests.berth_id` does not exist (dropped in 0029). Three flags: `is_primary` (≤1 per interest, partial unique index — "the berth for this deal"), `is_specific_interest` (true → public map shows "Under Offer"), `is_in_eoi_bundle` (covered by EOI signature). Read/write only via `src/lib/services/interest-berths.service.ts` helpers.
|
||||||
- **Notes (polymorphic):** `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes` via an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks `updatedAt` — service substitutes `createdAt` for shape uniformity.
|
- **Notes (polymorphic):** `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes` via an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks `updatedAt` — service substitutes `createdAt` for shape uniformity.
|
||||||
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, EOI-rendered in this exact form. Regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
|
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, EOI-rendered in this exact form. Regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
|
||||||
|
|||||||
@@ -868,19 +868,36 @@ Deferred:
|
|||||||
Returns the 3 dates on the API response; `interest-detail-header` threads them
|
Returns the 3 dates on the API response; `interest-detail-header` threads them
|
||||||
through to `<DealPulseChip>`. Chosen over new schema columns to keep the master
|
through to `<DealPulseChip>`. Chosen over new schema columns to keep the master
|
||||||
plan's "no new tables" promise. Documented in CLAUDE.md.
|
plan's "no new tables" promise. Documented in CLAUDE.md.
|
||||||
- ◐ Phase 3 — EOI field overrides (schema only; 9f57868)
|
- ◐ Phase 3 — EOI field overrides (9f57868 + session 2026-05-18 PM)
|
||||||
- ☑ 3a — Schema migration 0073, Drizzle additions, audit_actions free-text verbs
|
- ☑ 3a — Schema migration 0073, Drizzle additions, audit_actions free-text verbs
|
||||||
- ☐ 3b — EOI dialog UI (combobox + 2 checkboxes per field)
|
- ☑ 3b — EOI dialog UI overrides for email/phone/yacht-name; service-level
|
||||||
- ☐ 3c — Yacht spawn from EOI (inline Sheet + YachtForm)
|
side-effects (create non-primary contact, promote-to-primary, write
|
||||||
- ☐ 3d — Audit surfacing + client/yacht detail badges + set-primary endpoint
|
documents.override\_\*) inside a single transaction via
|
||||||
- ◐ Phase 4 — Reminders (schema + service + form; fb4a09e + session 2026-05-18 PM)
|
`src/lib/services/eoi-overrides.service.ts`. Both pathways (inapp +
|
||||||
|
Documenso template) layer overrides onto the in-memory EoiContext
|
||||||
|
before render. Audit verbs `eoi_field_override` + `promote_to_primary`
|
||||||
|
- `eoi_spawn_yacht` formalised in `src/lib/audit.ts`. Address
|
||||||
|
overrides + per-yacht detail badge deferred.
|
||||||
|
- ☑ 3c — "+ New yacht" button next to yacht-name field opens nested
|
||||||
|
`<YachtForm>` Sheet (pre-fills owner = current client, stamps
|
||||||
|
`source='eoi-generated'`); on save, the interest's yachtId is patched
|
||||||
|
so the EOI's yacht block populates without a manual re-link.
|
||||||
|
- ☑ 3d — `POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary`
|
||||||
|
(transactional demote+promote via `promoteContactToPrimary`); `[EOI]`
|
||||||
|
badge on non-primary contact rows in `<ContactsEditor>` with title-attr
|
||||||
|
explainer. Yacht detail-page badge deferred.
|
||||||
|
- ◐ Phase 4 — Reminders (fb4a09e + session 2026-05-18 PM)
|
||||||
- ☑ Schema migration 0072: reminders.yacht_id + fired_at + interests.reminder_note
|
- ☑ Schema migration 0072: reminders.yacht_id + fired_at + interests.reminder_note
|
||||||
- ☑ Service + validators accept yachtId with port-scoping check
|
- ☑ Service + validators accept yachtId with port-scoping check
|
||||||
- ☑ Dialog UI extended with YachtPicker (free-text search, no clientId scope)
|
- ☑ Dialog UI extended with YachtPicker (free-text search, no clientId scope)
|
||||||
- ☑ `<ReminderCard>` shows yacht subtitle (Ship icon + yacht name)
|
- ☑ `<ReminderCard>` shows yacht subtitle (Ship icon + yacht name)
|
||||||
- ☑ `listReminders` now filters by query.yachtId; `getReminder` joins yacht relation
|
- ☑ `listReminders` filters by query.yachtId; `getReminder` joins yacht relation
|
||||||
- ☐ Worker scheduler refactor (fired_at gate; cron tick)
|
- ☑ Worker `processOverdueReminders` claims due rows via `UPDATE...RETURNING`
|
||||||
- ☐ user_profiles.preferences.digest_time_of_day picker in /settings
|
with `fired_at IS NULL` race-safe gate, so parallel workers can't
|
||||||
|
double-fire the same reminder.
|
||||||
|
- ☑ `user_profiles.preferences.digestTimeOfDay` picker on `/settings`
|
||||||
|
(time input + help text). `<ReminderForm>` honours the preference via
|
||||||
|
a React-Query me-prefs fetch keyed `['me', 'preferences']`.
|
||||||
- ☐ Per-entity-page `[+ Task]` buttons threading `defaultYachtId` (etc.)
|
- ☐ Per-entity-page `[+ Task]` buttons threading `defaultYachtId` (etc.)
|
||||||
- ◐ Phase 5 — Email-copy refactor (branding chain only; df1594d)
|
- ◐ Phase 5 — Email-copy refactor (branding chain only; df1594d)
|
||||||
- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
|
- ☑ Per-port background URL — closes the last hard-coded portnimara.com asset
|
||||||
@@ -898,6 +915,18 @@ Deferred:
|
|||||||
- ☐ Interest-detail "Emails" tab — surface tab doesn't exist yet; bounce banner
|
- ☐ Interest-detail "Emails" tab — surface tab doesn't exist yet; bounce banner
|
||||||
would live there when the tab lands (deferred to a wider emails-surface session)
|
would live there when the tab lands (deferred to a wider emails-surface session)
|
||||||
- ☐ Manual round-trip test against real bounced delivery
|
- ☐ Manual round-trip test against real bounced delivery
|
||||||
|
- **Workspace activation:** set `IMAP_HOST=imap.gmail.com`,
|
||||||
|
`IMAP_PORT=993`, `IMAP_USER=<workspace-account>`, `IMAP_PASS=<app-password>`
|
||||||
|
in the worker env. App Passwords are generated at Account → Security
|
||||||
|
→ 2-Step Verification → App passwords. Google displays the password
|
||||||
|
as **16 characters in 4 groups of 4 separated by spaces** (e.g.
|
||||||
|
`abcd efgh ijkl mnop`). Per Google's own docs the spaces are visual
|
||||||
|
only — paste the 16-char unbroken string into `.env`. The poller
|
||||||
|
strips whitespace defensively (`src/jobs/processors/imap-bounce-poller.ts`)
|
||||||
|
so a copy-paste with spaces still works. Bounces land in the
|
||||||
|
envelope sender's mailbox (the SMTP user account), so pointing the
|
||||||
|
poller at that single mailbox catches every automated-email bounce
|
||||||
|
in one place.
|
||||||
- ◐ Phase 7 — PDF template editor (field-map types only; 9f57868)
|
- ◐ Phase 7 — PDF template editor (field-map types only; 9f57868)
|
||||||
- ☑ FieldMap type definitions + Zod validators + page-count cross-validator
|
- ☑ FieldMap type definitions + Zod validators + page-count cross-validator
|
||||||
- ☐ 7.1 Read + place (~2 weeks): editor shell, page picker, marker drop
|
- ☐ 7.1 Read + place (~2 weeks): editor shell, page picker, marker drop
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { promoteContactToPrimary } from '@/lib/services/clients.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3d — promote a non-primary `client_contacts` row to primary,
|
||||||
|
* demoting the prior primary for the same channel inside a single
|
||||||
|
* transaction. Surfaces from the "[EOI] Set as primary" action on the
|
||||||
|
* client detail panel, and from the EOI dialog's "Set as default for
|
||||||
|
* future docs" toggle (though that path calls the service directly via
|
||||||
|
* `eoi-overrides.service`).
|
||||||
|
*/
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('clients', 'edit', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const contact = await promoteContactToPrimary(params.contactId!, params.id!, ctx.portId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: contact });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -26,7 +26,7 @@ export const POST = withAuth(
|
|||||||
ipAddress: ctx.ipAddress,
|
ipAddress: ctx.ipAddress,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
{ dimensionUnit: body.dimensionUnit },
|
{ dimensionUnit: body.dimensionUnit, overrides: body.overrides },
|
||||||
);
|
);
|
||||||
return NextResponse.json({ data: result }, { status: 201 });
|
return NextResponse.json({ data: result }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,24 +1,66 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { db } from '@/lib/db';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { clientContacts } from '@/lib/db/schema/clients';
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
import { buildEoiContext } from '@/lib/services/eoi-context';
|
import { buildEoiContext } from '@/lib/services/eoi-context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the resolved `EoiContext` — the actual data that would be
|
* Returns the resolved `EoiContext` for the given interest. Drives the
|
||||||
* auto-filled into the EOI document — for the given interest. Drives
|
* EOI generate dialog's pre-flight preview so a sales rep can see (and
|
||||||
* the EOI generate dialog's pre-flight preview so a sales rep can see
|
* correct) every value before sending the document for signing.
|
||||||
* (and correct) every value before sending the document for signing.
|
|
||||||
*
|
*
|
||||||
* No mutation; pure read of denormalized data the EOI builder already
|
* Augments the core context with `available.emails` / `available.phones`
|
||||||
* computes server-side. Returns 404 if the interest is missing or in
|
* — every non-deleted client_contacts row for the linked client. The
|
||||||
* another port (the buildEoiContext function throws NotFoundError).
|
* dialog renders these as combobox options so the rep can pick a
|
||||||
|
* secondary contact for this EOI (Phase 3b).
|
||||||
|
*
|
||||||
|
* No mutation; pure read.
|
||||||
*/
|
*/
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('interests', 'view', async (_req, ctx, params) => {
|
withPermission('interests', 'view', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const context = await buildEoiContext(params.id!, ctx.portId);
|
const context = await buildEoiContext(params.id!, ctx.portId);
|
||||||
return NextResponse.json({ data: context });
|
|
||||||
|
// Resolve the linked client to fetch every contact row. The core
|
||||||
|
// context only carries the single "primary" value; the dialog needs
|
||||||
|
// the full set to render the combobox.
|
||||||
|
const interest = await db.query.interests.findFirst({
|
||||||
|
where: and(eq(interests.id, params.id!), eq(interests.portId, ctx.portId)),
|
||||||
|
});
|
||||||
|
if (!interest) throw new NotFoundError('Interest');
|
||||||
|
|
||||||
|
const contactRows = await db
|
||||||
|
.select({
|
||||||
|
id: clientContacts.id,
|
||||||
|
channel: clientContacts.channel,
|
||||||
|
value: clientContacts.value,
|
||||||
|
isPrimary: clientContacts.isPrimary,
|
||||||
|
source: clientContacts.source,
|
||||||
|
})
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(eq(clientContacts.clientId, interest.clientId))
|
||||||
|
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt));
|
||||||
|
|
||||||
|
const available = {
|
||||||
|
emails: contactRows
|
||||||
|
.filter((c) => c.channel === 'email')
|
||||||
|
.map((c) => ({ id: c.id, value: c.value, isPrimary: c.isPrimary, source: c.source })),
|
||||||
|
phones: contactRows
|
||||||
|
.filter((c) => c.channel === 'phone' || c.channel === 'whatsapp')
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
value: c.value,
|
||||||
|
isPrimary: c.isPrimary,
|
||||||
|
channel: c.channel,
|
||||||
|
source: c.source,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ data: { ...context, available } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ const updateProfileSchema = z.object({
|
|||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
// Phase 4 — per-user default reminder firing time-of-day. HH:MM in
|
||||||
|
// 24h local clock (validated below). Per-reminder dueAt overrides
|
||||||
|
// this; this is only the dialog's default when the rep doesn't
|
||||||
|
// pick an explicit time. Server clamps to '00:00'–'23:59'.
|
||||||
|
digestTimeOfDay: z
|
||||||
|
.string()
|
||||||
|
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'use HH:MM 24-hour format')
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -161,6 +169,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
|||||||
'timezone',
|
'timezone',
|
||||||
'tablePreferences',
|
'tablePreferences',
|
||||||
'defaultPortId',
|
'defaultPortId',
|
||||||
|
// Phase 4 — reminder default firing time.
|
||||||
|
'digestTimeOfDay',
|
||||||
]);
|
]);
|
||||||
const existing = (profile.preferences as Record<string, unknown>) ?? {};
|
const existing = (profile.preferences as Record<string, unknown>) ?? {};
|
||||||
const merged = Object.fromEntries(
|
const merged = Object.fromEntries(
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ interface Contact {
|
|||||||
valueCountry?: string | null;
|
valueCountry?: string | null;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
|
/** Phase 3d — origin tag surfaced as an [EOI] badge when an EOI
|
||||||
|
* spawned this contact. */
|
||||||
|
source?: string | null;
|
||||||
|
sourceDocumentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHANNEL_OPTIONS = [
|
const CHANNEL_OPTIONS = [
|
||||||
@@ -254,6 +258,19 @@ function ContactRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contact.source === 'eoi-custom-input' && !contact.isPrimary ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800"
|
||||||
|
title={
|
||||||
|
contact.sourceDocumentId
|
||||||
|
? 'Spawned from an EOI — open the source document for details.'
|
||||||
|
: 'Spawned from an EOI override.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
EOI
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={togglePrimary}
|
onClick={togglePrimary}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||||
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||||
@@ -82,9 +83,37 @@ interface EoiContextResponse {
|
|||||||
} | null;
|
} | null;
|
||||||
eoiBerthRange: string;
|
eoiBerthRange: string;
|
||||||
port: { name: string };
|
port: { name: string };
|
||||||
|
/** Phase 3b — every contact row the dialog renders in its
|
||||||
|
* override comboboxes. Populated by the eoi-context route. */
|
||||||
|
available: {
|
||||||
|
emails: Array<{ id: string; value: string; isPrimary: boolean; source: string }>;
|
||||||
|
phones: Array<{
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
channel: 'phone' | 'whatsapp';
|
||||||
|
source: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3b — per-field override state captured by the dialog. Sent
|
||||||
|
* verbatim on the generate-and-sign POST and translated server-side
|
||||||
|
* into the documents.override_* columns + (optionally) client_contacts
|
||||||
|
* mutations.
|
||||||
|
*/
|
||||||
|
interface FieldOverrideState {
|
||||||
|
/** When null, no override is active for this field. */
|
||||||
|
value: string | null;
|
||||||
|
/** The client_contacts.id the value came from (when picked from the
|
||||||
|
* combobox). Null = fresh typed value. */
|
||||||
|
contactId: string | null;
|
||||||
|
useOnlyForThisEoi: boolean;
|
||||||
|
setAsDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface EoiGenerateDialogProps {
|
interface EoiGenerateDialogProps {
|
||||||
interestId: string;
|
interestId: string;
|
||||||
/** Used to wire the "Edit on client" deep-link inside the dialog. */
|
/** Used to wire the "Edit on client" deep-link inside the dialog. */
|
||||||
@@ -122,6 +151,12 @@ export function EoiGenerateDialog({
|
|||||||
// (drives off the yacht's `lengthUnit` column). Stored as state so the
|
// (drives off the yacht's `lengthUnit` column). Stored as state so the
|
||||||
// rep can flip ft↔m before generating without losing the underlying data.
|
// rep can flip ft↔m before generating without losing the underlying data.
|
||||||
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
|
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
|
||||||
|
// Phase 3b — per-field override state. null entries = no override.
|
||||||
|
const [emailOverride, setEmailOverride] = useState<FieldOverrideState | null>(null);
|
||||||
|
const [phoneOverride, setPhoneOverride] = useState<FieldOverrideState | null>(null);
|
||||||
|
const [yachtNameOverride, setYachtNameOverride] = useState<FieldOverrideState | null>(null);
|
||||||
|
// Phase 3c — yacht spawn flow.
|
||||||
|
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
|
||||||
|
|
||||||
// Resolved EOI context — the actual values the document will be
|
// Resolved EOI context — the actual values the document will be
|
||||||
// auto-filled with. Loaded only while the dialog is open so we don't
|
// auto-filled with. Loaded only while the dialog is open so we don't
|
||||||
@@ -254,14 +289,14 @@ export function EoiGenerateDialog({
|
|||||||
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
|
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
|
||||||
}
|
}
|
||||||
async function patchYacht(body: Record<string, unknown>) {
|
|
||||||
if (!ctx?.yacht) return;
|
|
||||||
await apiFetch(`/api/v1/yachts/${ctx.yacht.id}`, { method: 'PATCH', body });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Required for the EOI's top paragraph (Section 2). Without these
|
// Required for the EOI's top paragraph (Section 2). Without these
|
||||||
// the document is unsignable, so generation is blocked.
|
// the document is unsignable, so generation is blocked.
|
||||||
|
//
|
||||||
|
// Email is rendered separately below with the Phase 3b override
|
||||||
|
// controls (combobox + 2 checkboxes), so it's omitted from the row
|
||||||
|
// array here — but its required-met status still gates `requiredMet`
|
||||||
|
// via `emailPresent` below.
|
||||||
const required = ctx
|
const required = ctx
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -274,12 +309,6 @@ export function EoiGenerateDialog({
|
|||||||
placeholder: 'Full legal name',
|
placeholder: 'Full legal name',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'email',
|
|
||||||
label: 'Email address',
|
|
||||||
value: ctx.client.primaryEmail ?? null,
|
|
||||||
present: !!ctx.client.primaryEmail,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'address',
|
key: 'address',
|
||||||
// Mirrors the rendered EOI Address field exactly so the rep sees
|
// Mirrors the rendered EOI Address field exactly so the rep sees
|
||||||
@@ -313,19 +342,10 @@ export function EoiGenerateDialog({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Optional — Section 3 of the EOI. Generation proceeds without them.
|
// Optional — Section 3 of the EOI. Generation proceeds without them.
|
||||||
|
// Yacht-name + phone are rendered separately below with Phase 3b
|
||||||
|
// override controls; the remainder show as straight previews.
|
||||||
const optional = ctx
|
const optional = ctx
|
||||||
? [
|
? [
|
||||||
{
|
|
||||||
key: 'yacht',
|
|
||||||
label: 'Yacht name',
|
|
||||||
value: ctx.yacht?.name ?? null,
|
|
||||||
edit: ctx.yacht
|
|
||||||
? {
|
|
||||||
onSave: async (next: string | null) => await patchYacht({ name: next ?? '' }),
|
|
||||||
placeholder: 'Yacht name',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'dimensions',
|
key: 'dimensions',
|
||||||
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
|
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
|
||||||
@@ -341,15 +361,12 @@ export function EoiGenerateDialog({
|
|||||||
label: 'Berth bundle range',
|
label: 'Berth bundle range',
|
||||||
value: ctx.eoiBerthRange || null,
|
value: ctx.eoiBerthRange || null,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'phone',
|
|
||||||
label: 'Phone',
|
|
||||||
value: ctx.client.primaryPhone ?? null,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const requiredMet = required.length > 0 && required.every((r) => r.present);
|
const emailPresent = ctx ? !!(emailOverride?.value ?? ctx.client.primaryEmail) : false;
|
||||||
|
const requiredMet =
|
||||||
|
!!ctx && required.length > 0 && required.every((r) => r.present) && emailPresent;
|
||||||
|
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
if (!requiredMet) return;
|
if (!requiredMet) return;
|
||||||
@@ -358,6 +375,25 @@ export function EoiGenerateDialog({
|
|||||||
try {
|
try {
|
||||||
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
|
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
|
||||||
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
|
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
|
||||||
|
// Phase 3b — pack the per-field overrides the rep selected. Each
|
||||||
|
// is null when untouched; the server validator accepts an absent
|
||||||
|
// entry and falls back to the canonical record.
|
||||||
|
const overridePayload = (s: FieldOverrideState | null) =>
|
||||||
|
s && s.value !== null
|
||||||
|
? {
|
||||||
|
value: s.value,
|
||||||
|
useOnlyForThisEoi: s.useOnlyForThisEoi,
|
||||||
|
setAsDefault: s.setAsDefault,
|
||||||
|
...(s.contactId ? { contactId: s.contactId } : {}),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const overrides = {
|
||||||
|
clientEmail: overridePayload(emailOverride),
|
||||||
|
clientPhone: overridePayload(phoneOverride),
|
||||||
|
yachtName: overridePayload(yachtNameOverride),
|
||||||
|
};
|
||||||
|
const hasAnyOverride = overrides.clientEmail || overrides.clientPhone || overrides.yachtName;
|
||||||
|
|
||||||
await apiFetch(url, {
|
await apiFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -370,6 +406,7 @@ export function EoiGenerateDialog({
|
|||||||
// EOI's Length/Width/Draft formValues. Defaults server-side to
|
// EOI's Length/Width/Draft formValues. Defaults server-side to
|
||||||
// the yacht's own `lengthUnit` column when unspecified.
|
// the yacht's own `lengthUnit` column when unspecified.
|
||||||
dimensionUnit: effectiveDimensionUnit,
|
dimensionUnit: effectiveDimensionUnit,
|
||||||
|
...(hasAnyOverride ? { overrides } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Bounce every cache that surfaces the interest's EOI state so the
|
// Bounce every cache that surfaces the interest's EOI state so the
|
||||||
@@ -451,6 +488,15 @@ export function EoiGenerateDialog({
|
|||||||
edit={row.edit}
|
edit={row.edit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<OverridableContactField
|
||||||
|
label="Email address"
|
||||||
|
canonicalValue={ctx.client.primaryEmail ?? null}
|
||||||
|
canonicalContactId={ctx.available.emails.find((e) => e.isPrimary)?.id ?? null}
|
||||||
|
options={ctx.available.emails}
|
||||||
|
override={emailOverride}
|
||||||
|
onChange={setEmailOverride}
|
||||||
|
missing={!emailPresent}
|
||||||
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 border-t pt-2">
|
<div className="space-y-1 border-t pt-2">
|
||||||
@@ -490,9 +536,39 @@ export function EoiGenerateDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<dl className="space-y-1.5">
|
<dl className="space-y-1.5">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<OverridableContactField
|
||||||
|
label="Yacht name"
|
||||||
|
canonicalValue={ctx.yacht?.name ?? null}
|
||||||
|
canonicalContactId={null}
|
||||||
|
options={[]}
|
||||||
|
override={yachtNameOverride}
|
||||||
|
onChange={setYachtNameOverride}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{clientId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setYachtSpawnOpen(true)}
|
||||||
|
className="mt-0.5 shrink-0 text-[11px] text-primary hover:underline"
|
||||||
|
title="Create a new yacht linked to this client"
|
||||||
|
>
|
||||||
|
+ New yacht
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{optional.map((row) => (
|
{optional.map((row) => (
|
||||||
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
|
<PreviewRow key={row.key} label={row.label} value={row.value} />
|
||||||
))}
|
))}
|
||||||
|
<OverridableContactField
|
||||||
|
label="Phone"
|
||||||
|
canonicalValue={ctx.client.primaryPhone ?? null}
|
||||||
|
canonicalContactId={ctx.available.phones.find((p) => p.isPrimary)?.id ?? null}
|
||||||
|
options={ctx.available.phones}
|
||||||
|
override={phoneOverride}
|
||||||
|
onChange={setPhoneOverride}
|
||||||
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
{portSlug && clientId && (
|
{portSlug && clientId && (
|
||||||
@@ -674,6 +750,33 @@ export function EoiGenerateDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
||||||
|
{/* Phase 3c — nested yacht-spawn Sheet. Pre-selects the linked
|
||||||
|
client as owner so the rep only types the yacht-specific
|
||||||
|
fields. After save, PATCH the interest with the new yachtId so
|
||||||
|
the EOI's yacht block populates without a manual re-link. */}
|
||||||
|
{clientId ? (
|
||||||
|
<YachtForm
|
||||||
|
open={yachtSpawnOpen}
|
||||||
|
onOpenChange={setYachtSpawnOpen}
|
||||||
|
initialOwner={{ type: 'client', id: clientId }}
|
||||||
|
createExtras={{ source: 'eoi-generated' }}
|
||||||
|
onCreated={async (created) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/v1/interests/${interestId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { yachtId: created.id },
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['interests', interestId, 'eoi-context'],
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -781,3 +884,217 @@ function PreviewRow({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3b — overridable row for a contact channel (email/phone) or a
|
||||||
|
* single-value field (yacht name). Renders as a plain text row showing
|
||||||
|
* the canonical value, with a small "Override" affordance that expands
|
||||||
|
* into a Select (over `options`) + Input (for fresh values) + the two
|
||||||
|
* intent checkboxes.
|
||||||
|
*
|
||||||
|
* State is owned by the parent dialog so cancellation collapses cleanly
|
||||||
|
* back to canonical without round-tripping through the row.
|
||||||
|
*/
|
||||||
|
function OverridableContactField({
|
||||||
|
label,
|
||||||
|
canonicalValue,
|
||||||
|
canonicalContactId,
|
||||||
|
options,
|
||||||
|
override,
|
||||||
|
onChange,
|
||||||
|
missing,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
canonicalValue: string | null;
|
||||||
|
/** id of the row that holds `canonicalValue` in `options`. Used to
|
||||||
|
* pre-select the matching Select item when the user opens override
|
||||||
|
* mode without changing anything. */
|
||||||
|
canonicalContactId: string | null;
|
||||||
|
/** Picker options. For yacht-name pass [] — only the manual text path
|
||||||
|
* is available. */
|
||||||
|
options: Array<{ id: string; value: string; isPrimary: boolean }>;
|
||||||
|
override: FieldOverrideState | null;
|
||||||
|
onChange: (next: FieldOverrideState | null) => void;
|
||||||
|
missing?: boolean;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [manualValue, setManualValue] = useState('');
|
||||||
|
|
||||||
|
// Synthetic combobox value: either an existing contact id, "__manual__"
|
||||||
|
// for free-text entry, or "__canonical__" for "use the canonical primary".
|
||||||
|
const selectValue =
|
||||||
|
override?.contactId ??
|
||||||
|
(override?.value != null && override?.contactId == null ? '__manual__' : '__canonical__');
|
||||||
|
|
||||||
|
// Resolve the displayed effective value for the read-only header (when
|
||||||
|
// collapsed): show the override if active, otherwise canonical.
|
||||||
|
const effective = override?.value ?? canonicalValue ?? null;
|
||||||
|
|
||||||
|
const collapseAndClear = () => {
|
||||||
|
setExpanded(false);
|
||||||
|
setManualValue('');
|
||||||
|
onChange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline gap-2 text-sm">
|
||||||
|
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
|
||||||
|
<dd
|
||||||
|
className={cn(
|
||||||
|
'flex-1 wrap-break-word inline-flex items-center gap-2',
|
||||||
|
missing
|
||||||
|
? 'text-rose-700 font-medium'
|
||||||
|
: effective
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-muted-foreground italic',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1">
|
||||||
|
{effective ?? (missing ? 'Missing — required' : 'Not set')}
|
||||||
|
{override?.value != null ? (
|
||||||
|
<span className="ml-1 inline-flex items-center rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-800">
|
||||||
|
[EOI]
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{!expanded ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="text-[11px] text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{override?.value != null ? 'Edit override' : 'Override'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={collapseAndClear}
|
||||||
|
className="text-[11px] text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Clear & close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<div className="ml-32 space-y-2 rounded-md border bg-background/60 p-2">
|
||||||
|
{options.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={selectValue}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === '__canonical__') {
|
||||||
|
onChange(null);
|
||||||
|
setManualValue('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v === '__manual__') {
|
||||||
|
onChange({
|
||||||
|
value: manualValue.trim() || null,
|
||||||
|
contactId: null,
|
||||||
|
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
|
||||||
|
setAsDefault: override?.setAsDefault ?? false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = options.find((o) => o.id === v);
|
||||||
|
if (!picked) return;
|
||||||
|
onChange({
|
||||||
|
value: picked.value,
|
||||||
|
contactId: picked.id,
|
||||||
|
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
|
||||||
|
setAsDefault: override?.setAsDefault ?? false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__canonical__">
|
||||||
|
Use canonical {label.toLowerCase()}
|
||||||
|
{canonicalContactId && options.find((o) => o.id === canonicalContactId)
|
||||||
|
? ` (${canonicalValue ?? ''})`
|
||||||
|
: ''}
|
||||||
|
</SelectItem>
|
||||||
|
{options
|
||||||
|
.filter((o) => !o.isPrimary)
|
||||||
|
.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
{o.value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__manual__">+ Type a new value…</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
{selectValue === '__manual__' || options.length === 0 ? (
|
||||||
|
<Input
|
||||||
|
value={manualValue || override?.value || ''}
|
||||||
|
placeholder={`New ${label.toLowerCase()}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
setManualValue(e.target.value);
|
||||||
|
onChange({
|
||||||
|
value: e.target.value.trim() || null,
|
||||||
|
contactId: null,
|
||||||
|
useOnlyForThisEoi: override?.useOnlyForThisEoi ?? false,
|
||||||
|
setAsDefault: override?.setAsDefault ?? false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-start gap-2 text-[11px] text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={override?.useOnlyForThisEoi ?? false}
|
||||||
|
disabled={!override || !override.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
override &&
|
||||||
|
onChange({
|
||||||
|
...override,
|
||||||
|
useOnlyForThisEoi: e.target.checked,
|
||||||
|
// Mutually exclusive intent — both true at once doesn't
|
||||||
|
// make sense (per-doc vs. promote-to-canonical).
|
||||||
|
setAsDefault: e.target.checked ? false : override.setAsDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Use only for this EOI
|
||||||
|
<span className="block text-[10px]">
|
||||||
|
Records the deviation on this document; canonical record untouched.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-[11px] text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={override?.setAsDefault ?? false}
|
||||||
|
disabled={!override || !override.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
override &&
|
||||||
|
onChange({
|
||||||
|
...override,
|
||||||
|
setAsDefault: e.target.checked,
|
||||||
|
useOnlyForThisEoi: e.target.checked ? false : override.useOnlyForThisEoi,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Set as default for future docs
|
||||||
|
<span className="block text-[10px]">
|
||||||
|
Promotes this value to the canonical primary on save.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ function ReminderFormBody({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
}: ReminderFormProps) {
|
}: ReminderFormProps) {
|
||||||
const isEdit = !!reminder;
|
const isEdit = !!reminder;
|
||||||
|
// Phase 4 — load the rep's preferred default-reminder time (HH:MM)
|
||||||
|
// BEFORE seeding the dueAt state. React Query's cache keeps this
|
||||||
|
// available synchronously on subsequent dialog opens (staleTime 60s)
|
||||||
|
// so the initial value is the rep's preference, not the historical
|
||||||
|
// 09:00 fallback. Enabled only on create-mode opens — edit mode
|
||||||
|
// already has the existing dueAt to seed from.
|
||||||
|
const meQuery = useQuery<{ data: { preferences?: { digestTimeOfDay?: string } } }>({
|
||||||
|
queryKey: ['me', 'preferences'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/me'),
|
||||||
|
enabled: open && !reminder,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const userTodPref = meQuery.data?.data.preferences?.digestTimeOfDay ?? null;
|
||||||
|
|
||||||
// Tomorrow 9am default for new-reminder dueAt.
|
// Tomorrow 9am default for new-reminder dueAt.
|
||||||
//
|
//
|
||||||
// <input type="datetime-local"> takes/produces LOCAL wall-clock time
|
// <input type="datetime-local"> takes/produces LOCAL wall-clock time
|
||||||
@@ -97,9 +111,20 @@ function ReminderFormBody({
|
|||||||
const defaultDueAt = useMemo(() => {
|
const defaultDueAt = useMemo(() => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
t.setDate(t.getDate() + 1);
|
t.setDate(t.getDate() + 1);
|
||||||
t.setHours(9, 0, 0, 0);
|
// Honour the rep's user_profiles.preferences.digestTimeOfDay when
|
||||||
|
// set ("HH:MM"). Falls back to 09:00 — historical default.
|
||||||
|
let h = 9;
|
||||||
|
let m = 0;
|
||||||
|
if (userTodPref && /^\d{2}:\d{2}$/.test(userTodPref)) {
|
||||||
|
const [hh = '09', mm = '00'] = userTodPref.split(':');
|
||||||
|
const parsedH = Number.parseInt(hh, 10);
|
||||||
|
const parsedM = Number.parseInt(mm, 10);
|
||||||
|
if (Number.isFinite(parsedH) && parsedH >= 0 && parsedH <= 23) h = parsedH;
|
||||||
|
if (Number.isFinite(parsedM) && parsedM >= 0 && parsedM <= 59) m = parsedM;
|
||||||
|
}
|
||||||
|
t.setHours(h, m, 0, 0);
|
||||||
return toLocalDatetimeLocal(t);
|
return toLocalDatetimeLocal(t);
|
||||||
}, []);
|
}, [userTodPref]);
|
||||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||||
const [note, setNote] = useState(reminder?.note ?? '');
|
const [note, setNote] = useState(reminder?.note ?? '');
|
||||||
const [dueAt, setDueAt] = useState(
|
const [dueAt, setDueAt] = useState(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type { CountryCode } from '@/lib/i18n/countries';
|
|||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
user?: { name: string; email: string };
|
user?: { name: string; email: string };
|
||||||
preferences?: { country?: string; timezone?: string };
|
preferences?: { country?: string; timezone?: string; digestTimeOfDay?: string };
|
||||||
profile?: {
|
profile?: {
|
||||||
avatarFileId?: string | null;
|
avatarFileId?: string | null;
|
||||||
firstName?: string | null;
|
firstName?: string | null;
|
||||||
@@ -45,6 +45,10 @@ export function UserSettings() {
|
|||||||
const [originalEmail, setOriginalEmail] = useState('');
|
const [originalEmail, setOriginalEmail] = useState('');
|
||||||
const [country, setCountry] = useState<string | null>(null);
|
const [country, setCountry] = useState<string | null>(null);
|
||||||
const [timezone, setTimezone] = useState<string | null>(null);
|
const [timezone, setTimezone] = useState<string | null>(null);
|
||||||
|
/** Phase 4 — default reminder firing time-of-day (HH:MM). Drives the
|
||||||
|
* `<ReminderForm>` "Due Date & Time" default when the rep creates a
|
||||||
|
* reminder without explicitly picking a time. */
|
||||||
|
const [digestTimeOfDay, setDigestTimeOfDay] = useState<string>('09:00');
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
||||||
@@ -84,6 +88,7 @@ export function UserSettings() {
|
|||||||
// saved yet — first-time users land on a sensible default rather
|
// saved yet — first-time users land on a sensible default rather
|
||||||
// than an empty picker. Doesn't overwrite an explicit choice.
|
// than an empty picker. Doesn't overwrite an explicit choice.
|
||||||
setTimezone(res.data.preferences?.timezone ?? detectedTz ?? null);
|
setTimezone(res.data.preferences?.timezone ?? detectedTz ?? null);
|
||||||
|
setDigestTimeOfDay(res.data.preferences?.digestTimeOfDay ?? '09:00');
|
||||||
const fid = res.data.profile?.avatarFileId ?? null;
|
const fid = res.data.profile?.avatarFileId ?? null;
|
||||||
setAvatarFileId(fid);
|
setAvatarFileId(fid);
|
||||||
setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null);
|
setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null);
|
||||||
@@ -145,6 +150,7 @@ export function UserSettings() {
|
|||||||
preferences: {
|
preferences: {
|
||||||
country: country ?? undefined,
|
country: country ?? undefined,
|
||||||
timezone: timezone ?? undefined,
|
timezone: timezone ?? undefined,
|
||||||
|
digestTimeOfDay: digestTimeOfDay || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -339,6 +345,20 @@ export function UserSettings() {
|
|||||||
</WarningCallout>
|
</WarningCallout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="settings-digest-tod">Default reminder time</Label>
|
||||||
|
<Input
|
||||||
|
id="settings-digest-tod"
|
||||||
|
type="time"
|
||||||
|
value={digestTimeOfDay}
|
||||||
|
onChange={(e) => setDigestTimeOfDay(e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When you create a reminder without picking a time, this is the time-of-day it
|
||||||
|
defaults to. Per-reminder overrides still win.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={saveProfile} disabled={saving === 'profile'}>
|
<Button onClick={saveProfile} disabled={saving === 'profile'}>
|
||||||
<Save className="mr-1.5 h-4 w-4" />
|
<Save className="mr-1.5 h-4 w-4" />
|
||||||
|
|||||||
@@ -58,11 +58,35 @@ interface YachtFormProps {
|
|||||||
* owner-history workflow is the right surface for ownership changes).
|
* owner-history workflow is the right surface for ownership changes).
|
||||||
*/
|
*/
|
||||||
initialOwner?: { type: 'client' | 'company'; id: string };
|
initialOwner?: { type: 'client' | 'company'; id: string };
|
||||||
|
/**
|
||||||
|
* Phase 3c — extra fields baked into the create POST. Lets the EOI
|
||||||
|
* spawn flow stamp the new yacht with `source='eoi-generated'` (and
|
||||||
|
* optionally `sourceDocumentId` when the calling document is already
|
||||||
|
* persisted) without exposing those fields in the form UI.
|
||||||
|
*/
|
||||||
|
createExtras?: Partial<{
|
||||||
|
source: 'manual' | 'imported' | 'eoi-generated';
|
||||||
|
sourceDocumentId: string;
|
||||||
|
}>;
|
||||||
|
/**
|
||||||
|
* Phase 3c — called with the new yacht's id/name after a successful
|
||||||
|
* create POST, BEFORE the dialog closes. The EOI spawn flow uses this
|
||||||
|
* to PATCH the interest's yachtId so the EOI re-fetches with the
|
||||||
|
* just-spawned yacht in scope.
|
||||||
|
*/
|
||||||
|
onCreated?: (yacht: { id: string; name: string }) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
||||||
|
|
||||||
export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtFormProps) {
|
export function YachtForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
yacht,
|
||||||
|
initialOwner,
|
||||||
|
createExtras,
|
||||||
|
onCreated,
|
||||||
|
}: YachtFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isEdit = !!yacht;
|
const isEdit = !!yacht;
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -135,12 +159,19 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: rest,
|
body: rest,
|
||||||
});
|
});
|
||||||
} else {
|
return null;
|
||||||
await apiFetch('/api/v1/yachts', { method: 'POST', body: data });
|
|
||||||
}
|
}
|
||||||
|
const created = await apiFetch<{ data: { id: string; name: string } }>('/api/v1/yachts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...data, ...(createExtras ?? {}) },
|
||||||
|
});
|
||||||
|
return created.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: async (created) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
||||||
|
if (created && onCreated) {
|
||||||
|
await onCreated(created);
|
||||||
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
|
|||||||
@@ -72,7 +72,14 @@ export async function processImapBouncePoll(): Promise<void> {
|
|||||||
const host = process.env.IMAP_HOST;
|
const host = process.env.IMAP_HOST;
|
||||||
const portStr = process.env.IMAP_PORT;
|
const portStr = process.env.IMAP_PORT;
|
||||||
const user = process.env.IMAP_USER;
|
const user = process.env.IMAP_USER;
|
||||||
const pass = process.env.IMAP_PASS;
|
// Strip whitespace from the password defensively. Google Workspace App
|
||||||
|
// Passwords are 16 characters formatted as 4 groups of 4 separated by
|
||||||
|
// spaces (`abcd efgh ijkl mnop`); pasted as-is, Gmail's IMAP server
|
||||||
|
// rejects them with an auth error in some configurations. Per
|
||||||
|
// https://support.google.com/mail/answer/185833, the password must be
|
||||||
|
// entered as the unbroken 16-character string. Stripping here means a
|
||||||
|
// copy-paste with the visual spaces preserved still works.
|
||||||
|
const pass = process.env.IMAP_PASS?.replace(/\s+/g, '');
|
||||||
if (!host || !portStr || !user || !pass) {
|
if (!host || !portStr || !user || !pass) {
|
||||||
logger.debug('IMAP bounce poll skipped — IMAP_* env not configured');
|
logger.debug('IMAP bounce poll skipped — IMAP_* env not configured');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -57,7 +57,14 @@ export type AuditAction =
|
|||||||
// which the audit filter dropdown couldn't surface as its own bucket
|
// which the audit filter dropdown couldn't surface as its own bucket
|
||||||
// and the FTS GENERATED index missed entirely.
|
// and the FTS GENERATED index missed entirely.
|
||||||
| 'outcome_set'
|
| '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
|
* Common shape passed to service functions so they can stamp audit logs and
|
||||||
|
|||||||
@@ -762,6 +762,71 @@ export async function updateContact(
|
|||||||
return updated;
|
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(
|
export async function removeContact(
|
||||||
contactId: string,
|
contactId: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
|||||||
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
|
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
|
||||||
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
|
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
|
||||||
import { buildEoiContext } from '@/lib/services/eoi-context';
|
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 { getPrimaryBerth } from '@/lib/services/interest-berths.service';
|
||||||
import type {
|
import type {
|
||||||
CreateTemplateInput,
|
CreateTemplateInput,
|
||||||
@@ -484,12 +491,16 @@ async function generateEoiFromSourcePdf(
|
|||||||
context: GenerateInput,
|
context: GenerateInput,
|
||||||
meta: AuditMeta,
|
meta: AuditMeta,
|
||||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||||
|
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
|
||||||
): Promise<{ document: DbDocument; file: DbFile }> {
|
): Promise<{ document: DbDocument; file: DbFile }> {
|
||||||
if (!context.interestId) {
|
if (!context.interestId) {
|
||||||
throw new ValidationError('interestId is required for EOI template generation');
|
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, {
|
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
|
||||||
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
|
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
|
||||||
});
|
});
|
||||||
@@ -586,15 +597,24 @@ export async function generateAndSign(
|
|||||||
signers: GenerateAndSignInput['signers'],
|
signers: GenerateAndSignInput['signers'],
|
||||||
pathway: 'inapp' | 'documenso-template',
|
pathway: 'inapp' | 'documenso-template',
|
||||||
meta: AuditMeta,
|
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') {
|
if (pathway === 'documenso-template') {
|
||||||
return generateAndSignViaDocumensoTemplate(portId, context, meta, options);
|
return generateAndSignViaDocumensoTemplate(portId, context, meta, options, applied);
|
||||||
}
|
}
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
throw new ValidationError('templateId is required for inapp pathway');
|
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(
|
async function generateAndSignViaInApp(
|
||||||
@@ -604,6 +624,7 @@ async function generateAndSignViaInApp(
|
|||||||
signers: GenerateAndSignInput['signers'],
|
signers: GenerateAndSignInput['signers'],
|
||||||
meta: AuditMeta,
|
meta: AuditMeta,
|
||||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||||
|
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
|
||||||
) {
|
) {
|
||||||
const template = await getTemplateById(templateId, portId);
|
const template = await getTemplateById(templateId, portId);
|
||||||
|
|
||||||
@@ -662,8 +683,14 @@ async function generateAndSignViaInApp(
|
|||||||
context,
|
context,
|
||||||
meta,
|
meta,
|
||||||
options,
|
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.
|
// Fetch PDF bytes from the active storage backend to send to Documenso.
|
||||||
const pdfStream = await (await getStorageBackend()).get(file.storagePath);
|
const pdfStream = await (await getStorageBackend()).get(file.storagePath);
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -729,12 +756,16 @@ async function generateAndSignViaDocumensoTemplate(
|
|||||||
context: GenerateInput,
|
context: GenerateInput,
|
||||||
meta: AuditMeta,
|
meta: AuditMeta,
|
||||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||||
|
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
|
||||||
) {
|
) {
|
||||||
if (!context.interestId) {
|
if (!context.interestId) {
|
||||||
throw new ValidationError('interestId is required for documenso-template pathway');
|
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);
|
const signers = await getPortEoiSigners(portId);
|
||||||
// Per-port Documenso template + recipient IDs (with env fallback). Each
|
// Per-port Documenso template + recipient IDs (with env fallback). Each
|
||||||
// tenant pointing at its own Documenso instance has different numeric
|
// tenant pointing at its own Documenso instance has different numeric
|
||||||
@@ -800,6 +831,10 @@ async function generateAndSignViaDocumensoTemplate(
|
|||||||
})
|
})
|
||||||
.returning();
|
.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.
|
// Persist the per-recipient signer rows from Documenso's create response.
|
||||||
// Without these the EOI tab's "Signing progress" panel shows
|
// Without these the EOI tab's "Signing progress" panel shows
|
||||||
// "No signers loaded" forever (the webhook handler only updates existing
|
// "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 { db } from '@/lib/db';
|
||||||
import { reminders, interests, clients } from '@/lib/db/schema';
|
import { reminders, interests, clients } from '@/lib/db/schema';
|
||||||
@@ -523,38 +523,55 @@ export async function processFollowUpReminders() {
|
|||||||
export async function processOverdueReminders() {
|
export async function processOverdueReminders() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Find pending reminders past their due date
|
// Un-snooze reminders whose snooze window has elapsed first, so a
|
||||||
const overdueReminders = await db
|
// reminder that just transitioned snoozed → pending is eligible in
|
||||||
.select()
|
// the same tick (rather than waiting a full 15 minutes for the next
|
||||||
.from(reminders)
|
// scan).
|
||||||
.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
|
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
|
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
|
||||||
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, 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,
|
currentOwnerId: data.owner.id,
|
||||||
status: data.status ?? 'active',
|
status: data.status ?? 'active',
|
||||||
notes: data.notes ?? null,
|
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();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ export const generateSchema = z.object({
|
|||||||
berthId: z.string().optional(),
|
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({
|
export const generateAndSignSchema = generateSchema.extend({
|
||||||
pathway: z.enum(['inapp', 'documenso-template']).default('inapp'),
|
pathway: z.enum(['inapp', 'documenso-template']).default('inapp'),
|
||||||
signers: z
|
signers: z
|
||||||
@@ -80,6 +103,14 @@ export const generateAndSignSchema = generateSchema.extend({
|
|||||||
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
|
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
|
||||||
* server defaults to the yacht's `lengthUnit` column when omitted. */
|
* server defaults to the yacht's `lengthUnit` column when omitted. */
|
||||||
dimensionUnit: z.enum(['ft', 'm']).optional(),
|
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>;
|
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'),
|
status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
tagIds: z.array(z.string()).optional().default([]),
|
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 });
|
export const updateYachtSchema = createYachtSchema.partial().omit({ owner: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user