import { pgTable, primaryKey, text, boolean, integer, numeric, timestamp, jsonb, index, uniqueIndex, type AnyPgColumn, } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { ports } from './ports'; import { clients } from './clients'; import { yachts } from './yachts'; import { companies } from './companies'; import { interests } from './interests'; import { berthReservations } from './reservations'; export const files = pgTable( 'files', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id), clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }), yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }), companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }), folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, { onDelete: 'set null', }), filename: text('filename').notNull(), originalName: text('original_name').notNull(), mimeType: text('mime_type'), sizeBytes: text('size_bytes'), // stored as text to avoid bigint issues; parse as number in app storagePath: text('storage_path').notNull(), storageBucket: text('storage_bucket').notNull().default('crm-files'), category: text('category'), // eoi, contract, image, receipt, correspondence, misc uploadedBy: text('uploaded_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_files_port').on(table.portId), index('idx_files_client').on(table.clientId), index('idx_files_yacht').on(table.yachtId), index('idx_files_company').on(table.companyId), index('idx_files_folder').on(table.folderId), index('idx_files_port_folder').on(table.portId, table.folderId), // Composite indexes for the aggregated-projection queries // (`listFilesAggregatedByEntity`) — every join carries a defense-in- // depth `port_id` filter so the leading column matters at scale. index('idx_files_port_client').on(table.portId, table.clientId), index('idx_files_port_company').on(table.portId, table.companyId), index('idx_files_port_yacht').on(table.portId, table.yachtId), ], ); export const documents = pgTable( 'documents', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id), interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }), // H-01: nullable; tolerate the owning client being hard-deleted (rare — // archive is the normal path — but if it happens the document row // should outlive it so the audit trail stays intact). clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }), yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }), companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }), reservationId: text('reservation_id').references(() => berthReservations.id, { onDelete: 'set null', }), folderId: text('folder_id').references((): AnyPgColumn => documentFolders.id, { onDelete: 'set null', }), documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other title: text('title').notNull(), status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled documensoId: text('documenso_id'), /** Documenso v2 webhooks send only the numeric internal id (e.g. "19"), * while every other v2 API path expects the public envelope_xxx string * stored in `documensoId`. Captured at create-time so the webhook * resolver can match incoming events by either id. Null for v1 * documents (where `documensoId` already holds the only id). */ documensoNumericId: text('documenso_numeric_id'), // H-01: nullable file references; both pre- and post-signing blobs // can be soft-archived independently of the document row. fileId: text('file_id').references(() => files.id, { onDelete: 'set null' }), signedFileId: text('signed_file_id').references(() => files.id, { onDelete: 'set null' }), isManualUpload: boolean('is_manual_upload').notNull().default(false), /** Email addresses CC'd on the completion notification (the * passive Documenso CC concept — see plan Q4). Per-document set * by the rep; doesn't gate signing. */ completionCcEmails: text('completion_cc_emails').array().default([]), /** Optional auto-reminder cadence — when set, a daily worker * fires `sendSigningReminder()` for unsigned signers every * N days until they complete. Null = manual reminders only. */ autoReminderIntervalDays: integer('auto_reminder_interval_days'), notes: text('notes'), /** Phase 6 polish — rep-authored note inserted above the CTA in * every signing-invitation email for THIS document. Falls back to * the empty string when null. Plain-text (XSS-escaped by the * email renderer); not Markdown. */ invitationMessage: text('invitation_message'), remindersDisabled: boolean('reminders_disabled').notNull().default(false), reminderCadenceOverride: integer('reminder_cadence_override'), // Phase 3 — per-document field overrides. When NULL, the canonical // client/yacht record value flows through; when set, this document // uses the override without touching the underlying record. Mirrors // the AcroForm field set per docs/eoi-documenso-field-mapping.md. // These are NOT promoted to client_contacts/addresses/yachts unless // the dialog's "Save as new" toggle is ticked at generate time. overrideClientEmail: text('override_client_email'), overrideClientPhone: text('override_client_phone'), overrideClientAddressLine1: text('override_client_address_line_1'), overrideClientAddressLine2: text('override_client_address_line_2'), overrideClientCity: text('override_client_city'), overrideClientState: text('override_client_state'), overrideClientPostalCode: text('override_client_postal_code'), overrideClientCountry: text('override_client_country'), overrideYachtName: text('override_yacht_name'), overrideYachtLengthFt: numeric('override_yacht_length_ft'), overrideYachtWidthFt: numeric('override_yacht_width_ft'), overrideYachtDraftFt: numeric('override_yacht_draft_ft'), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_docs_port').on(table.portId), index('idx_docs_interest').on(table.interestId), index('idx_docs_client').on(table.clientId), index('idx_documents_yacht').on(table.yachtId), index('idx_documents_company').on(table.companyId), index('idx_docs_reservation').on(table.reservationId), index('idx_docs_type').on(table.portId, table.documentType), index('idx_docs_status_port').on(table.portId, table.status), // Cover the file FKs Postgres doesn't auto-index. Without these, // deleting (or RESTRICT-checking) a referenced files row scans // the documents table fully. index('idx_docs_file_id').on(table.fileId), index('idx_docs_signed_file_id').on(table.signedFileId), index('idx_docs_documenso_numeric_id').on(table.documensoNumericId), index('idx_docs_folder').on(table.folderId), // Composite indexes for the aggregated-projection queries // (`listInflightWorkflowsAggregatedByEntity`) — every join carries a // defense-in-depth `port_id` filter so the leading column matters at scale. index('idx_docs_port_client').on(table.portId, table.clientId), index('idx_docs_port_company').on(table.portId, table.companyId), index('idx_docs_port_yacht').on(table.portId, table.yachtId), ], ); export const documentSigners = pgTable( 'document_signers', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), documentId: text('document_id') .notNull() .references(() => documents.id, { onDelete: 'cascade' }), signerName: text('signer_name').notNull(), signerEmail: text('signer_email').notNull(), signerRole: text('signer_role').notNull(), // client, developer, sales, approver, other signingOrder: integer('signing_order').notNull(), status: text('status').notNull().default('pending'), // pending, signed, declined signedAt: timestamp('signed_at', { withTimezone: true }), signingUrl: text('signing_url'), embeddedUrl: text('embedded_url'), /** Phase 1+2 lifecycle tracking — set by the send-invitation endpoint * and the Documenso webhook handler respectively. */ invitedAt: timestamp('invited_at', { withTimezone: true }), openedAt: timestamp('opened_at', { withTimezone: true }), lastReminderSentAt: timestamp('last_reminder_sent_at', { withTimezone: true }), /** Documenso recipient token — used for token-based lookup when the * webhook fires (more robust than email match when one address * serves multiple roles). */ signingToken: text('signing_token'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_ds_doc').on(table.documentId), index('idx_ds_signing_token').on(table.signingToken), ], ); export const documentEvents = pgTable( 'document_events', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), documentId: text('document_id') .notNull() .references(() => documents.id, { onDelete: 'cascade' }), eventType: text('event_type').notNull(), // created, sent, viewed, signed, completed, expired, reminder_sent // H-01: events outlive their signer row so the audit trail stays // intact if a recipient is removed. signerId: text('signer_id').references(() => documentSigners.id, { onDelete: 'set null' }), // C.2: recipient_email captured at event time. Enables per-recipient // dedup (`(documenso_document_id, recipient_email, event_type)`) so // a RECIPIENT_SIGNED webhook for signer-A doesn't dedup against an // earlier RECIPIENT_SIGNED for signer-B on the same envelope. recipientEmail: text('recipient_email'), eventData: jsonb('event_data').default({}), signatureHash: text('signature_hash'), // deduplication (legacy: per-payload-hash) createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_de_doc').on(table.documentId), // Reverse-lookup signer→events without scanning the events table. index('idx_de_signer').on(table.signerId), uniqueIndex('idx_de_dedup') .on(table.documentId, table.signatureHash) .where(sql`${table.signatureHash} IS NOT NULL`), // C.2: per-recipient event dedup. Distinct event_type per (document, // recipient) so re-delivery of the same SIGNED webhook for the same // recipient is a no-op, while a different recipient's SIGNED still // lands. uniqueIndex('idx_de_per_recipient_dedup') .on(table.documentId, table.recipientEmail, table.eventType) .where(sql`${table.recipientEmail} IS NOT NULL`), ], ); export const documentTemplates = pgTable( 'document_templates', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id), name: text('name').notNull(), description: text('description'), templateType: text('template_type').notNull(), // welcome_letter, handover_checklist, acknowledgment, correspondence, custom // Nullable: only required when template_format='html'. bodyHtml: text('body_html'), mergeFields: jsonb('merge_fields').notNull().default([]), // 'html' | 'pdf_form' | 'pdf_overlay' | 'documenso_render' templateFormat: text('template_format').notNull().default('html'), sourceFileId: text('source_file_id').references(() => files.id), documensoTemplateId: text('documenso_template_id'), // pdf_form: { acroFieldName: mergeToken } fieldMapping: jsonb('field_mapping').notNull().default({}), // pdf_overlay: [{ token, page, x, y, fontSize }] overlayPositions: jsonb('overlay_positions').notNull().default([]), // null = no auto-reminders reminderCadenceDays: integer('reminder_cadence_days'), isActive: boolean('is_active').notNull().default(true), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_dt_port').on(table.portId), index('idx_dt_type').on(table.portId, table.templateType), index('idx_dt_source_file').on(table.sourceFileId), ], ); export const documentWatchers = pgTable( 'document_watchers', { documentId: text('document_id') .notNull() .references(() => documents.id, { onDelete: 'cascade' }), userId: text('user_id').notNull(), addedBy: text('added_by').notNull(), addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ primaryKey({ columns: [table.documentId, table.userId] }), index('idx_doc_watchers_doc').on(table.documentId), index('idx_doc_watchers_user').on(table.userId), ], ); export const formTemplates = pgTable( 'form_templates', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id), name: text('name').notNull(), description: text('description'), fields: jsonb('fields').notNull(), branding: jsonb('branding').default({}), isActive: boolean('is_active').notNull().default(true), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [index('idx_ft_port').on(table.portId)], ); export const formSubmissions = pgTable( 'form_submissions', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), formTemplateId: text('form_template_id') .notNull() .references(() => formTemplates.id), clientId: text('client_id').references(() => clients.id), interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }), token: text('token').notNull().unique(), prefilledData: jsonb('prefilled_data').default({}), submittedData: jsonb('submitted_data'), status: text('status').notNull().default('pending'), // pending, submitted, expired expiresAt: timestamp('expires_at', { withTimezone: true }), submittedAt: timestamp('submitted_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('idx_fs_token').on(table.token), index('idx_fs_template').on(table.formTemplateId), index('idx_fs_client').on(table.clientId), ], ); /** * Per-port folder tree for organising documents. Self-referencing * via parent_id; null parent = root. Unlimited depth — the UI is the * gate (collapsed sidebar tree + breadcrumb header). Cycle prevention * happens in the service layer (parent_id chain walk on insert/move). * * On folder delete: children (both subfolders and documents) bubble * up to the deleted folder's parent. Never CASCADE. */ export const documentFolders = pgTable( 'document_folders', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), portId: text('port_id') .notNull() .references(() => ports.id), // Null = root. ON DELETE NO ACTION on the FK (added by migration // 0050) — the service bubbles children up to the deleted folder's // parent in a transaction instead of cascading. parentId: text('parent_id'), name: text('name').notNull(), /** True = folder is managed by the system (one of the three roots * Clients/Companies/Yachts, or an auto-created entity subfolder). * System folders reject rename/move/delete at the API layer. Demoted * to false when the owning entity is hard-deleted. */ systemManaged: boolean('system_managed').notNull().default(false), /** null | 'root' | 'client' | 'company' | 'yacht'. 'root' is the * three system roots; the entity values mark per-entity subfolders. */ entityType: text('entity_type'), /** Null when entityType is null or 'root'; the entity's id otherwise. * Combined with entityType to dedupe entity folders per port. */ entityId: text('entity_id'), /** Mirrors the entity's archive state. Non-null = folder muted in UI * and auto-deposit halted. Cleared on entity restore. */ archivedAt: timestamp('archived_at', { withTimezone: true }), createdBy: text('created_by').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index('idx_document_folders_port').on(table.portId), index('idx_document_folders_parent').on(table.parentId), uniqueIndex('uniq_document_folders_sibling_name').on( table.portId, sql`COALESCE(${table.parentId}, '__root__')`, sql`LOWER(${table.name})`, ), // One subfolder per entity per port. Excludes 'root' folders (the // three system roots are deduped by sibling-name uniqueness). uniqueIndex('uniq_document_folders_entity') .on(table.portId, table.entityType, table.entityId) .where(sql`${table.entityId} IS NOT NULL`), ], ); export type DocumentFolder = typeof documentFolders.$inferSelect; export type NewDocumentFolder = typeof documentFolders.$inferInsert; export type File = typeof files.$inferSelect; export type NewFile = typeof files.$inferInsert; export type Document = typeof documents.$inferSelect; export type NewDocument = typeof documents.$inferInsert; export type DocumentSigner = typeof documentSigners.$inferSelect; export type NewDocumentSigner = typeof documentSigners.$inferInsert; export type DocumentEvent = typeof documentEvents.$inferSelect; export type NewDocumentEvent = typeof documentEvents.$inferInsert; export type DocumentTemplate = typeof documentTemplates.$inferSelect; export type NewDocumentTemplate = typeof documentTemplates.$inferInsert; export type DocumentWatcher = typeof documentWatchers.$inferSelect; export type NewDocumentWatcher = typeof documentWatchers.$inferInsert; export type FormTemplate = typeof formTemplates.$inferSelect; export type NewFormTemplate = typeof formTemplates.$inferInsert; export type FormSubmission = typeof formSubmissions.$inferSelect; export type NewFormSubmission = typeof formSubmissions.$inferInsert;