Sets up the schema + service primitives the rest of the nested-
document-subfolders feature will build on (master UAT line 728+).
This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio,
lifecycle hooks for outcome rename, aggregated-projection list
query, and backfill script are deferred to follow-up commits.
Schema (migration 0078_files_interest_id.sql):
- `files.interest_id` text REFERENCES interests(id) ON DELETE SET
NULL. Mirrors the existing documents.interest_id; lets file
uploads be scoped to a deal while still rolling up to the parent
client folder.
- idx_files_interest + idx_files_port_interest for the aggregated-
projection queries that will surface "This deal" vs "From
client" file lists.
Service:
- EntityType extended to include 'interest'. Interest folders parent
under the owning client's entity folder (not at a system root), so
the tree reads Clients/Acme/Deal A1-A3/ — nested.
- ensureEntityFolder recursively ensures the parent client folder
first when given an interest, guaranteeing the deal folder lands
inside the right client subfolder even when the first artifact on
the deal predates any client-level upload.
- resolveEntityDisplayName for interest: "Deal — <mooringNumber>"
(when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the
stable fallback. Dynamic-import on getPrimaryBerth dodges the
circular dep between document-folders.service and
interest-berths.service.
Aggregated projection (files.ts):
- listFilesAggregatedByEntity SELECT now includes the new
interest_id column so AggregatedFileRow's structural type matches.
Downstream consumers gain access to the deal scope; the actual
"From this deal" subheading in InterestDocumentsTab is wired in
the follow-up.
Remaining work (tracked in master UAT line 728+, parked for next
session):
- UploadZone `scopeOptions` radio (single-option pickers hide the
radio entirely for client/yacht/company surfaces).
- Lifecycle hooks for interest outcome → folder rename ("Deal
A1-A3 (Won)") via soft-rescue per CLAUDE.md.
- listFilesAggregatedByEntity rewrite to surface "This deal" vs
"From client" subheadings on InterestDocumentsTab.
- Documents Hub tree rendering for nested interest folders.
- backfill script: existing files with entity_type='interest' +
entity_id but missing interest_id column → populate.
Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
427 lines
20 KiB
TypeScript
427 lines
20 KiB
TypeScript
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' }),
|
|
/**
|
|
* Optional deal scope. When a file is uploaded inside an interest
|
|
* (the EOI tab, the documents tab on the interest detail page), this
|
|
* FK captures which deal it belongs to so the file can be filed
|
|
* under the interest subfolder while still rolling up to the parent
|
|
* client folder. NULL for client/yacht/company-level uploads.
|
|
*
|
|
* Added by migration 0078; not yet wired into ensureEntityFolder
|
|
* (interest subfolder nesting) — see master UAT line 728+ for the
|
|
* remaining work plan.
|
|
*/
|
|
interestId: text('interest_id').references(() => interests.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),
|
|
index('idx_files_interest').on(table.interestId),
|
|
index('idx_files_port_interest').on(table.portId, table.interestId),
|
|
],
|
|
);
|
|
|
|
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;
|