Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
9.9 KiB
TypeScript
213 lines
9.9 KiB
TypeScript
import {
|
|
pgTable,
|
|
text,
|
|
boolean,
|
|
integer,
|
|
numeric,
|
|
timestamp,
|
|
primaryKey,
|
|
index,
|
|
uniqueIndex,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { ports } from './ports';
|
|
import { clients } from './clients';
|
|
import { berths } from './berths';
|
|
import { yachts } from './yachts';
|
|
|
|
// Pipeline stages: enquiry, qualified, nurturing, eoi, reservation, deposit_paid, contract
|
|
// (doc sub-status carried on eoi_doc_status / reservation_doc_status / contract_doc_status)
|
|
|
|
export const interests = pgTable(
|
|
'interests',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
// H-01: deleting a port is a manual super-admin operation; interests
|
|
// shouldn't outlive their port. RESTRICT forces the operator to
|
|
// explicitly archive/transfer interests first.
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id, { onDelete: 'restrict' }),
|
|
// H-01: client is required and design intent is archive-first — the
|
|
// service-layer hard-delete path nullifies FKs explicitly. RESTRICT
|
|
// is a defensive backstop against an ad-hoc DB hard-delete that
|
|
// would otherwise leave the interest pointing at a missing client.
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'restrict' }),
|
|
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
|
/** Who owns this deal. Auto-assigned on create from system_settings
|
|
* `default_new_interest_owner`; reassignable via the interest header. */
|
|
assignedTo: text('assigned_to'),
|
|
pipelineStage: text('pipeline_stage').notNull().default('enquiry'),
|
|
/** Sub-status for the doc-signing stages. NULL while the deal hasn't
|
|
* reached the stage yet; 'pending' | 'sent' | 'signed' | 'declined' | 'voided'. */
|
|
eoiDocStatus: text('eoi_doc_status'),
|
|
reservationDocStatus: text('reservation_doc_status'),
|
|
contractDocStatus: text('contract_doc_status'),
|
|
/** Documenso IDs per document type. EOI uses the existing `documensoId`
|
|
* for backward compat with the template-generate path. */
|
|
reservationDocumensoId: text('reservation_documenso_id'),
|
|
contractDocumensoId: text('contract_documenso_id'),
|
|
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
|
|
source: text('source'), // website, manual, referral, broker
|
|
eoiStatus: text('eoi_status'), // null, waiting_for_signatures, signed, expired
|
|
documensoId: text('documenso_id'),
|
|
contractStatus: text('contract_status'),
|
|
depositStatus: text('deposit_status'),
|
|
reservationStatus: text('reservation_status'),
|
|
/** Agreed deposit amount captured at reservation-agreement time. Lets
|
|
* the payments running-total decide when the deposit is "fully paid"
|
|
* and the stage advances automatically. */
|
|
depositExpectedAmount: numeric('deposit_expected_amount'),
|
|
depositExpectedCurrency: text('deposit_expected_currency').default('EUR'),
|
|
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
|
|
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
|
|
dateEoiSent: timestamp('date_eoi_sent', { withTimezone: true }),
|
|
dateEoiSigned: timestamp('date_eoi_signed', { withTimezone: true }),
|
|
dateReservationSigned: timestamp('date_reservation_signed', { withTimezone: true }),
|
|
dateContractSent: timestamp('date_contract_sent', { withTimezone: true }),
|
|
dateContractSigned: timestamp('date_contract_signed', { withTimezone: true }),
|
|
dateDepositReceived: timestamp('date_deposit_received', { withTimezone: true }),
|
|
reminderEnabled: boolean('reminder_enabled').notNull().default(false),
|
|
reminderDays: integer('reminder_days'),
|
|
reminderLastFired: timestamp('reminder_last_fired', { withTimezone: true }),
|
|
/** Terminal outcome. Independent of pipelineStage - `outcome` is set
|
|
* alongside the stage transition to `completed` to distinguish won
|
|
* deals from the various lost variants. NULL while the interest is
|
|
* still active. */
|
|
outcome: text('outcome'), // 'won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled'
|
|
/** Free-text reason captured at the time the outcome is set. Surfaces
|
|
* in the timeline + reports. */
|
|
outcomeReason: text('outcome_reason'),
|
|
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
|
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
|
/** Recommender inputs - dual-stored. ft is the canonical unit the
|
|
* recommender SQL queries on; m is the human-friendly entry the rep
|
|
* may have actually typed. The matching `*_unit` column says which
|
|
* side is source-of-truth — display prefers that side and recomputes
|
|
* the other so the rep's literal entry doesn't drift through repeated
|
|
* conversions. Resolver treats nulls as "no constraint" on that axis. */
|
|
desiredLengthFt: numeric('desired_length_ft'),
|
|
desiredWidthFt: numeric('desired_width_ft'),
|
|
desiredDraftFt: numeric('desired_draft_ft'),
|
|
desiredLengthM: numeric('desired_length_m'),
|
|
desiredWidthM: numeric('desired_width_m'),
|
|
desiredDraftM: numeric('desired_draft_m'),
|
|
desiredLengthUnit: text('desired_length_unit').notNull().default('ft'),
|
|
desiredWidthUnit: text('desired_width_unit').notNull().default('ft'),
|
|
desiredDraftUnit: text('desired_draft_unit').notNull().default('ft'),
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_interests_port').on(table.portId),
|
|
index('idx_interests_client').on(table.clientId),
|
|
index('idx_interests_yacht').on(table.yachtId),
|
|
index('idx_interests_stage').on(table.portId, table.pipelineStage),
|
|
index('idx_interests_archived')
|
|
.on(table.portId)
|
|
.where(sql`${table.archivedAt} IS NULL`),
|
|
index('idx_interests_outcome').on(table.portId, table.outcome),
|
|
index('idx_interests_assigned_to').on(table.assignedTo),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Many-to-many junction between interests and berths.
|
|
*
|
|
* Replaces the old single-berth `interests.berth_id` column. Each row
|
|
* carries three role flags so a rep can model "actively pitching this
|
|
* berth" vs "covered by the EOI bundle but not pitched" vs "primary
|
|
* berth for the deal" independently:
|
|
*
|
|
* - is_primary : at most one row per interest is the primary;
|
|
* templates / forms / "the berth for this deal"
|
|
* semantics resolve through this row.
|
|
* - is_specific_interest : true = berth shows as "Under Offer" on the
|
|
* public map. false = legal/EOI-only link.
|
|
* - is_in_eoi_bundle : covered by the interest's EOI signature.
|
|
*
|
|
* EOI bypass: when the interest has a signed primary EOI but a specific
|
|
* berth in the bundle still needs its own EOI, a rep records the bypass
|
|
* reason here.
|
|
*/
|
|
export const interestBerths = pgTable(
|
|
'interest_berths',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
interestId: text('interest_id')
|
|
.notNull()
|
|
.references(() => interests.id, { onDelete: 'cascade' }),
|
|
berthId: text('berth_id')
|
|
.notNull()
|
|
.references(() => berths.id, { onDelete: 'restrict' }),
|
|
isPrimary: boolean('is_primary').notNull().default(false),
|
|
isSpecificInterest: boolean('is_specific_interest').notNull().default(true),
|
|
isInEoiBundle: boolean('is_in_eoi_bundle').notNull().default(false),
|
|
eoiBypassReason: text('eoi_bypass_reason'),
|
|
eoiBypassedBy: text('eoi_bypassed_by'),
|
|
eoiBypassedAt: timestamp('eoi_bypassed_at', { withTimezone: true }),
|
|
addedBy: text('added_by'),
|
|
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
|
notes: text('notes'),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
|
uniqueIndex('idx_ib_one_primary')
|
|
.on(table.interestId)
|
|
.where(sql`${table.isPrimary} = true`),
|
|
index('idx_ib_berth').on(table.berthId),
|
|
index('idx_ib_specific')
|
|
.on(table.berthId)
|
|
.where(sql`${table.isSpecificInterest} = true`),
|
|
],
|
|
);
|
|
|
|
export const interestNotes = pgTable(
|
|
'interest_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
interestId: text('interest_id')
|
|
.notNull()
|
|
.references(() => interests.id, { onDelete: 'cascade' }),
|
|
authorId: text('author_id').notNull(), // user ID
|
|
content: text('content').notNull(),
|
|
mentions: text('mentions').array(), // array of mentioned user IDs
|
|
isLocked: boolean('is_locked').notNull().default(false),
|
|
/** Snapshot of the linked interest's pipeline_stage at note creation.
|
|
* Lets a rep see how the deal's notes evolved across the lifecycle
|
|
* (e.g. concerns raised at qualified vs after reservation). Backfill
|
|
* not attempted for pre-2026-05-15 rows — they stay null. */
|
|
pipelineStageAtCreation: text('pipeline_stage_at_creation'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_in_interest').on(table.interestId)],
|
|
);
|
|
|
|
export const interestTags = pgTable(
|
|
'interest_tags',
|
|
{
|
|
interestId: text('interest_id')
|
|
.notNull()
|
|
.references(() => interests.id, { onDelete: 'cascade' }),
|
|
tagId: text('tag_id').notNull(), // references tags.id
|
|
},
|
|
(table) => [primaryKey({ columns: [table.interestId, table.tagId] })],
|
|
);
|
|
|
|
export type Interest = typeof interests.$inferSelect;
|
|
export type NewInterest = typeof interests.$inferInsert;
|
|
export type InterestNote = typeof interestNotes.$inferSelect;
|
|
export type NewInterestNote = typeof interestNotes.$inferInsert;
|
|
export type InterestBerth = typeof interestBerths.$inferSelect;
|
|
export type NewInterestBerth = typeof interestBerths.$inferInsert;
|