diff --git a/CLAUDE.md b/CLAUDE.md index 758a929f..6186844e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,6 @@ src/ ### Data model - **Polymorphic ownership:** Yachts and invoice billing-entities use `_type` + `_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. - **Notes (polymorphic):** `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes` via an `entityType` discriminator. `` 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. diff --git a/docs/MASTER-PLAN-2026-05-18.md b/docs/MASTER-PLAN-2026-05-18.md index c12d8536..edd39db6 100644 --- a/docs/MASTER-PLAN-2026-05-18.md +++ b/docs/MASTER-PLAN-2026-05-18.md @@ -868,19 +868,36 @@ Deferred: Returns the 3 dates on the API response; `interest-detail-header` threads them through to ``. Chosen over new schema columns to keep the master 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 - - ☐ 3b — EOI dialog UI (combobox + 2 checkboxes per field) - - ☐ 3c — Yacht spawn from EOI (inline Sheet + YachtForm) - - ☐ 3d — Audit surfacing + client/yacht detail badges + set-primary endpoint -- ◐ Phase 4 — Reminders (schema + service + form; fb4a09e + session 2026-05-18 PM) + - ☑ 3b — EOI dialog UI overrides for email/phone/yacht-name; service-level + side-effects (create non-primary contact, promote-to-primary, write + documents.override\_\*) inside a single transaction via + `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 + `` 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 `` 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 - ☑ Service + validators accept yachtId with port-scoping check - ☑ Dialog UI extended with YachtPicker (free-text search, no clientId scope) - ☑ `` shows yacht subtitle (Ship icon + yacht name) - - ☑ `listReminders` now filters by query.yachtId; `getReminder` joins yacht relation - - ☐ Worker scheduler refactor (fired_at gate; cron tick) - - ☐ user_profiles.preferences.digest_time_of_day picker in /settings + - ☑ `listReminders` filters by query.yachtId; `getReminder` joins yacht relation + - ☑ Worker `processOverdueReminders` claims due rows via `UPDATE...RETURNING` + 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). `` honours the preference via + a React-Query me-prefs fetch keyed `['me', 'preferences']`. - ☐ Per-entity-page `[+ Task]` buttons threading `defaultYachtId` (etc.) - ◐ Phase 5 — Email-copy refactor (branding chain only; df1594d) - ☑ 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 would live there when the tab lands (deferred to a wider emails-surface session) - ☐ Manual round-trip test against real bounced delivery + - **Workspace activation:** set `IMAP_HOST=imap.gmail.com`, + `IMAP_PORT=993`, `IMAP_USER=`, `IMAP_PASS=` + 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) - ☑ FieldMap type definitions + Zod validators + page-count cross-validator - ☐ 7.1 Read + place (~2 weeks): editor shell, page picker, marker drop diff --git a/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts b/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts new file mode 100644 index 00000000..1583ec74 --- /dev/null +++ b/src/app/api/v1/clients/[id]/contacts/[contactId]/promote-to-primary/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts b/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts index 2acf21e9..af34db21 100644 --- a/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts +++ b/src/app/api/v1/document-templates/[id]/generate-and-sign/route.ts @@ -26,7 +26,7 @@ export const POST = withAuth( ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }, - { dimensionUnit: body.dimensionUnit }, + { dimensionUnit: body.dimensionUnit, overrides: body.overrides }, ); return NextResponse.json({ data: result }, { status: 201 }); } catch (error) { diff --git a/src/app/api/v1/interests/[id]/eoi-context/route.ts b/src/app/api/v1/interests/[id]/eoi-context/route.ts index 22746147..2088e45b 100644 --- a/src/app/api/v1/interests/[id]/eoi-context/route.ts +++ b/src/app/api/v1/interests/[id]/eoi-context/route.ts @@ -1,24 +1,66 @@ import { NextResponse } from 'next/server'; +import { and, desc, eq } from 'drizzle-orm'; 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'; /** - * Returns the resolved `EoiContext` — the actual data that would be - * auto-filled into the EOI document — for the given interest. Drives - * the EOI generate dialog's pre-flight preview so a sales rep can see - * (and correct) every value before sending the document for signing. + * Returns the resolved `EoiContext` for the given interest. Drives the + * EOI generate dialog's pre-flight preview so a sales rep can see (and + * correct) every value before sending the document for signing. * - * No mutation; pure read of denormalized data the EOI builder already - * computes server-side. Returns 404 if the interest is missing or in - * another port (the buildEoiContext function throws NotFoundError). + * Augments the core context with `available.emails` / `available.phones` + * — every non-deleted client_contacts row for the linked client. The + * 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( withPermission('interests', 'view', async (_req, ctx, params) => { try { 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) { return errorResponse(error); } diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts index ea753ec3..be902201 100644 --- a/src/app/api/v1/me/route.ts +++ b/src/app/api/v1/me/route.ts @@ -65,6 +65,14 @@ const updateProfileSchema = z.object({ .strict(), ) .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() .optional(), @@ -161,6 +169,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { 'timezone', 'tablePreferences', 'defaultPortId', + // Phase 4 — reminder default firing time. + 'digestTimeOfDay', ]); const existing = (profile.preferences as Record) ?? {}; const merged = Object.fromEntries( diff --git a/src/components/clients/contacts-editor.tsx b/src/components/clients/contacts-editor.tsx index eb6f14be..f35fdfb8 100644 --- a/src/components/clients/contacts-editor.tsx +++ b/src/components/clients/contacts-editor.tsx @@ -31,6 +31,10 @@ interface Contact { valueCountry?: string | null; label?: string | null; 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 = [ @@ -254,6 +258,19 @@ function ContactRow({ /> + {contact.source === 'eoi-custom-input' && !contact.isPrimary ? ( + + EOI + + ) : null} + + ) : null} + {optional.map((row) => ( - + ))} + p.isPrimary)?.id ?? null} + options={ctx.available.phones} + override={phoneOverride} + onChange={setPhoneOverride} + /> {portSlug && clientId && ( @@ -674,6 +750,33 @@ export function EoiGenerateDialog({ + + {/* 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 ? ( + { + 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} ); } @@ -781,3 +884,217 @@ function PreviewRow({ ); } + +/** + * 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 ( +
+
+
{label}
+
+ + {effective ?? (missing ? 'Missing — required' : 'Not set')} + {override?.value != null ? ( + + [EOI] + + ) : null} + + {!expanded ? ( + + ) : ( + + )} +
+
+ + {expanded ? ( +
+ {options.length > 0 ? ( + + ) : null} + {selectValue === '__manual__' || options.length === 0 ? ( + { + 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} +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/reminders/reminder-form.tsx b/src/components/reminders/reminder-form.tsx index 296bae7c..ff3cd464 100644 --- a/src/components/reminders/reminder-form.tsx +++ b/src/components/reminders/reminder-form.tsx @@ -85,6 +85,20 @@ function ReminderFormBody({ onSuccess, }: ReminderFormProps) { 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. // // takes/produces LOCAL wall-clock time @@ -97,9 +111,20 @@ function ReminderFormBody({ const defaultDueAt = useMemo(() => { const t = new Date(); 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); - }, []); + }, [userTodPref]); const [title, setTitle] = useState(reminder?.title ?? ''); const [note, setNote] = useState(reminder?.note ?? ''); const [dueAt, setDueAt] = useState( diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx index 8e775a9f..371f25ef 100644 --- a/src/components/settings/user-settings.tsx +++ b/src/components/settings/user-settings.tsx @@ -23,7 +23,7 @@ import type { CountryCode } from '@/lib/i18n/countries'; interface MeResponse { user?: { name: string; email: string }; - preferences?: { country?: string; timezone?: string }; + preferences?: { country?: string; timezone?: string; digestTimeOfDay?: string }; profile?: { avatarFileId?: string | null; firstName?: string | null; @@ -45,6 +45,10 @@ export function UserSettings() { const [originalEmail, setOriginalEmail] = useState(''); const [country, setCountry] = useState(null); const [timezone, setTimezone] = useState(null); + /** Phase 4 — default reminder firing time-of-day (HH:MM). Drives the + * `` "Due Date & Time" default when the rep creates a + * reminder without explicitly picking a time. */ + const [digestTimeOfDay, setDigestTimeOfDay] = useState('09:00'); const [saving, setSaving] = useState(null); const [message, setMessage] = useState(null); const [resetMsg, setResetMsg] = useState(null); @@ -84,6 +88,7 @@ export function UserSettings() { // saved yet — first-time users land on a sensible default rather // than an empty picker. Doesn't overwrite an explicit choice. setTimezone(res.data.preferences?.timezone ?? detectedTz ?? null); + setDigestTimeOfDay(res.data.preferences?.digestTimeOfDay ?? '09:00'); const fid = res.data.profile?.avatarFileId ?? null; setAvatarFileId(fid); setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null); @@ -145,6 +150,7 @@ export function UserSettings() { preferences: { country: country ?? undefined, timezone: timezone ?? undefined, + digestTimeOfDay: digestTimeOfDay || undefined, }, }, }); @@ -339,6 +345,20 @@ export function UserSettings() { )} +
+ + setDigestTimeOfDay(e.target.value)} + className="w-32" + /> +

+ When you create a reminder without picking a time, this is the time-of-day it + defaults to. Per-reminder overrides still win. +

+