feat(documents): Phase A schema + service skeletons

Adds Phase A data model deltas to documents/templates and the new
document_watchers table. Introduces createFromWizard/createFromUpload
stubs, getDocumentDetail aggregator, cancelDocument flow, signed-doc
email composer, reservation agreement context, and notifyDocumentEvent
fan-out. Validator update accepts new template formats with html-only
bodyHtml requirement. EOI cadence backfilled to 1 day to preserve
current effective behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:12:05 +02:00
parent d8ac62f6f4
commit 0eff6050ae
11 changed files with 9961 additions and 72 deletions

View File

@@ -0,0 +1,25 @@
CREATE TABLE "document_watchers" (
"document_id" text NOT NULL,
"user_id" text NOT NULL,
"added_by" text NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "document_watchers_document_id_user_id_pk" PRIMARY KEY("document_id","user_id")
);
--> statement-breakpoint
ALTER TABLE "document_templates" ALTER COLUMN "body_html" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "document_templates" ADD COLUMN "template_format" text DEFAULT 'html' NOT NULL;--> statement-breakpoint
ALTER TABLE "document_templates" ADD COLUMN "source_file_id" text;--> statement-breakpoint
ALTER TABLE "document_templates" ADD COLUMN "documenso_template_id" text;--> statement-breakpoint
ALTER TABLE "document_templates" ADD COLUMN "field_mapping" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "document_templates" ADD COLUMN "overlay_positions" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "document_templates" ADD COLUMN "reminder_cadence_days" integer;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN "reservation_id" text;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN "reminders_disabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN "reminder_cadence_override" integer;--> statement-breakpoint
ALTER TABLE "document_watchers" ADD CONSTRAINT "document_watchers_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_doc_watchers_doc" ON "document_watchers" USING btree ("document_id");--> statement-breakpoint
CREATE INDEX "idx_doc_watchers_user" ON "document_watchers" USING btree ("user_id");--> statement-breakpoint
ALTER TABLE "document_templates" ADD CONSTRAINT "document_templates_source_file_id_files_id_fk" FOREIGN KEY ("source_file_id") REFERENCES "public"."files"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_docs_reservation" ON "documents" USING btree ("reservation_id");--> statement-breakpoint
CREATE INDEX "idx_docs_status_port" ON "documents" USING btree ("port_id","status");--> statement-breakpoint
UPDATE "document_templates" SET "reminder_cadence_days" = 1 WHERE "template_type" = 'eoi';

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,13 @@
"when": 1777308900666, "when": 1777308900666,
"tag": "0012_large_zarda", "tag": "0012_large_zarda",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1777334766194,
"tag": "0013_abnormal_thundra",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,6 @@
import { import {
pgTable, pgTable,
primaryKey,
text, text,
boolean, boolean,
integer, integer,
@@ -55,6 +56,7 @@ export const documents = pgTable(
clientId: text('client_id').references(() => clients.id), clientId: text('client_id').references(() => clients.id),
yachtId: text('yacht_id'), // FK wired in relations.ts yachtId: text('yacht_id'), // FK wired in relations.ts
companyId: text('company_id'), // FK wired in relations.ts companyId: text('company_id'), // FK wired in relations.ts
reservationId: text('reservation_id'), // FK wired in relations.ts
documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other documentType: text('document_type').notNull(), // eoi, contract, nda, reservation_agreement, other
title: text('title').notNull(), title: text('title').notNull(),
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
@@ -63,6 +65,8 @@ export const documents = pgTable(
signedFileId: text('signed_file_id').references(() => files.id), signedFileId: text('signed_file_id').references(() => files.id),
isManualUpload: boolean('is_manual_upload').notNull().default(false), isManualUpload: boolean('is_manual_upload').notNull().default(false),
notes: text('notes'), notes: text('notes'),
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
reminderCadenceOverride: integer('reminder_cadence_override'),
createdBy: text('created_by').notNull(), createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
@@ -73,7 +77,9 @@ export const documents = pgTable(
index('idx_docs_client').on(table.clientId), index('idx_docs_client').on(table.clientId),
index('idx_documents_yacht').on(table.yachtId), index('idx_documents_yacht').on(table.yachtId),
index('idx_documents_company').on(table.companyId), 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_type').on(table.portId, table.documentType),
index('idx_docs_status_port').on(table.portId, table.status),
], ],
); );
@@ -134,8 +140,19 @@ export const documentTemplates = pgTable(
name: text('name').notNull(), name: text('name').notNull(),
description: text('description'), description: text('description'),
templateType: text('template_type').notNull(), // welcome_letter, handover_checklist, acknowledgment, correspondence, custom templateType: text('template_type').notNull(), // welcome_letter, handover_checklist, acknowledgment, correspondence, custom
bodyHtml: text('body_html').notNull(), // Nullable: only required when template_format='html'.
bodyHtml: text('body_html'),
mergeFields: jsonb('merge_fields').notNull().default([]), 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), isActive: boolean('is_active').notNull().default(true),
createdBy: text('created_by').notNull(), createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
@@ -147,6 +164,23 @@ export const documentTemplates = pgTable(
], ],
); );
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( export const formTemplates = pgTable(
'form_templates', 'form_templates',
{ {
@@ -200,6 +234,8 @@ export type DocumentEvent = typeof documentEvents.$inferSelect;
export type NewDocumentEvent = typeof documentEvents.$inferInsert; export type NewDocumentEvent = typeof documentEvents.$inferInsert;
export type DocumentTemplate = typeof documentTemplates.$inferSelect; export type DocumentTemplate = typeof documentTemplates.$inferSelect;
export type NewDocumentTemplate = typeof documentTemplates.$inferInsert; 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 FormTemplate = typeof formTemplates.$inferSelect;
export type NewFormTemplate = typeof formTemplates.$inferInsert; export type NewFormTemplate = typeof formTemplates.$inferInsert;
export type FormSubmission = typeof formSubmissions.$inferSelect; export type FormSubmission = typeof formSubmissions.$inferSelect;

View File

@@ -52,6 +52,7 @@ import {
documentSigners, documentSigners,
documentEvents, documentEvents,
documentTemplates, documentTemplates,
documentWatchers,
formTemplates, formTemplates,
formSubmissions, formSubmissions,
} from './documents'; } from './documents';
@@ -457,7 +458,7 @@ export const berthTagsRelations = relations(berthTags, ({ one }) => ({
// ─── Berth Reservations ─────────────────────────────────────────────────────── // ─── Berth Reservations ───────────────────────────────────────────────────────
export const berthReservationsRelations = relations(berthReservations, ({ one }) => ({ export const berthReservationsRelations = relations(berthReservations, ({ one, many }) => ({
berth: one(berths, { berth: one(berths, {
fields: [berthReservations.berthId], fields: [berthReservations.berthId],
references: [berths.id], references: [berths.id],
@@ -482,6 +483,7 @@ export const berthReservationsRelations = relations(berthReservations, ({ one })
fields: [berthReservations.contractFileId], fields: [berthReservations.contractFileId],
references: [files.id], references: [files.id],
}), }),
documents: many(documents),
})); }));
// ─── Documents ──────────────────────────────────────────────────────────────── // ─── Documents ────────────────────────────────────────────────────────────────
@@ -538,8 +540,13 @@ export const documentsRelations = relations(documents, ({ one, many }) => ({
fields: [documents.companyId], fields: [documents.companyId],
references: [companies.id], references: [companies.id],
}), }),
reservation: one(berthReservations, {
fields: [documents.reservationId],
references: [berthReservations.id],
}),
signers: many(documentSigners), signers: many(documentSigners),
events: many(documentEvents), events: many(documentEvents),
watchers: many(documentWatchers),
})); }));
export const documentSignersRelations = relations(documentSigners, ({ one, many }) => ({ export const documentSignersRelations = relations(documentSigners, ({ one, many }) => ({
@@ -566,6 +573,17 @@ export const documentTemplatesRelations = relations(documentTemplates, ({ one })
fields: [documentTemplates.portId], fields: [documentTemplates.portId],
references: [ports.id], references: [ports.id],
}), }),
sourceFile: one(files, {
fields: [documentTemplates.sourceFileId],
references: [files.id],
}),
}));
export const documentWatchersRelations = relations(documentWatchers, ({ one }) => ({
document: one(documents, {
fields: [documentWatchers.documentId],
references: [documents.id],
}),
})); }));
export const formTemplatesRelations = relations(formTemplates, ({ one, many }) => ({ export const formTemplatesRelations = relations(formTemplates, ({ one, many }) => ({

View File

@@ -49,22 +49,16 @@ export interface TemplateVersion {
* We use a convention: version is stored in the `mergeFields` jsonb array * We use a convention: version is stored in the `mergeFields` jsonb array
* as `["__version__:N"]` to avoid adding a new column. * as `["__version__:N"]` to avoid adding a new column.
*/ */
function getVersionFromRecord( function getVersionFromRecord(record: typeof documentTemplates.$inferSelect): number {
record: typeof documentTemplates.$inferSelect,
): number {
const mf = record.mergeFields as unknown; const mf = record.mergeFields as unknown;
if (!Array.isArray(mf)) return 1; if (!Array.isArray(mf)) return 1;
const versionEntry = (mf as string[]).find((e) => const versionEntry = (mf as string[]).find((e) => e.startsWith('__version__:'));
e.startsWith('__version__:'),
);
if (!versionEntry) return 1; if (!versionEntry) return 1;
const n = parseInt(versionEntry.split(':')[1] ?? '1', 10); const n = parseInt(versionEntry.split(':')[1] ?? '1', 10);
return isNaN(n) ? 1 : n; return isNaN(n) ? 1 : n;
} }
function buildMergeFieldsWithVersion( function buildMergeFieldsWithVersion(version: number): string[] {
version: number,
): string[] {
return [`__version__:${version}`]; return [`__version__:${version}`];
} }
@@ -72,9 +66,7 @@ function buildMergeFieldsWithVersion(
* Parse TipTap JSON from bodyHtml field. Returns the parsed object, or null * Parse TipTap JSON from bodyHtml field. Returns the parsed object, or null
* if bodyHtml is plain HTML (legacy records). * if bodyHtml is plain HTML (legacy records).
*/ */
function parseTipTapContent( function parseTipTapContent(bodyHtml: string): Record<string, unknown> | null {
bodyHtml: string,
): Record<string, unknown> | null {
try { try {
const parsed = JSON.parse(bodyHtml) as unknown; const parsed = JSON.parse(bodyHtml) as unknown;
if ( if (
@@ -92,10 +84,7 @@ function parseTipTapContent(
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────
export async function listAdminTemplates( export async function listAdminTemplates(portId: string, query: ListAdminTemplatesInput) {
portId: string,
query: ListAdminTemplatesInput,
) {
const { type, isActive } = query; const { type, isActive } = query;
const conditions = [eq(documentTemplates.portId, portId)]; const conditions = [eq(documentTemplates.portId, portId)];
@@ -116,21 +105,15 @@ export async function listAdminTemplates(
return rows.map((row) => ({ return rows.map((row) => ({
...row, ...row,
version: getVersionFromRecord(row), version: getVersionFromRecord(row),
content: parseTipTapContent(row.bodyHtml), content: parseTipTapContent(row.bodyHtml ?? ''),
})); }));
} }
// ─── Get by ID ──────────────────────────────────────────────────────────────── // ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getAdminTemplate( export async function getAdminTemplate(portId: string, templateId: string) {
portId: string,
templateId: string,
) {
const row = await db.query.documentTemplates.findFirst({ const row = await db.query.documentTemplates.findFirst({
where: and( where: and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)),
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
}); });
if (!row) { if (!row) {
@@ -140,15 +123,13 @@ export async function getAdminTemplate(
return { return {
...row, ...row,
version: getVersionFromRecord(row), version: getVersionFromRecord(row),
content: parseTipTapContent(row.bodyHtml), content: parseTipTapContent(row.bodyHtml ?? ''),
}; };
} }
// ─── Validate TipTap Content ───────────────────────────────────────────────── // ─── Validate TipTap Content ─────────────────────────────────────────────────
function assertValidContent( function assertValidContent(content: Record<string, unknown>): void {
content: Record<string, unknown>,
): void {
const unsupported = validateTipTapDocument( const unsupported = validateTipTapDocument(
content as unknown as Parameters<typeof validateTipTapDocument>[0], content as unknown as Parameters<typeof validateTipTapDocument>[0],
); );
@@ -257,21 +238,13 @@ export async function updateAdminTemplate(
const [updated] = await db const [updated] = await db
.update(documentTemplates) .update(documentTemplates)
.set(updateValues) .set(updateValues)
.where( .where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)))
and(
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
)
.returning(); .returning();
return { return {
...updated!, ...updated!,
version: newVersion, version: newVersion,
content: content: data.content !== undefined ? data.content : existing.content,
data.content !== undefined
? data.content
: existing.content,
}; };
} }
@@ -287,12 +260,7 @@ export async function deleteAdminTemplate(
await db await db
.delete(documentTemplates) .delete(documentTemplates)
.where( .where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)));
and(
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
);
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
@@ -337,10 +305,7 @@ export async function getAdminTemplateVersions(
.filter((log) => { .filter((log) => {
const meta = log.metadata as Record<string, unknown> | null; const meta = log.metadata as Record<string, unknown> | null;
return ( return (
meta !== null && meta !== null && typeof meta === 'object' && 'versionSnapshot' in meta && 'content' in meta
typeof meta === 'object' &&
'versionSnapshot' in meta &&
'content' in meta
); );
}) })
.map((log) => { .map((log) => {
@@ -403,12 +368,7 @@ export async function rollbackAdminTemplate(
mergeFields: buildMergeFieldsWithVersion(newVersion), mergeFields: buildMergeFieldsWithVersion(newVersion),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where( .where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)))
and(
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
)
.returning(); .returning();
return { return {

View File

@@ -432,8 +432,14 @@ export async function resolveTemplate(
throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`); throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
} }
// HTML body is required for the html template format; non-html formats
// resolve elsewhere (see template_format dispatch in PR6).
if (template.bodyHtml === null) {
throw new ValidationError('Template has no HTML body to render');
}
// Interpolate all tokens // Interpolate all tokens
let resolved = template.bodyHtml; let resolved: string = template.bodyHtml;
for (const [token, value] of Object.entries(tokenMap)) { for (const [token, value] of Object.entries(tokenMap)) {
// Escape token for use in regex // Escape token for use in regex
const escaped = token.replace(/[{}]/g, '\\$&'); const escaped = token.replace(/[{}]/g, '\\$&');

View File

@@ -1,7 +1,13 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { documents, documentSigners, documentEvents, files } from '@/lib/db/schema/documents'; import {
documents,
documentSigners,
documentEvents,
documentWatchers,
files,
} from '@/lib/db/schema/documents';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients'; import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
@@ -760,3 +766,184 @@ export async function handleDocumentCancelled(eventData: {
emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id }); emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id });
} }
// ─── Phase A: hub + wizard surface (PR1 skeletons; bodies land in PRs 4-6) ────
export interface DocumentDetailWatcher {
userId: string;
addedBy: string;
addedAt: Date;
}
export interface DocumentDetail {
document: typeof documents.$inferSelect;
signers: (typeof documentSigners.$inferSelect)[];
events: (typeof documentEvents.$inferSelect)[];
watchers: DocumentDetailWatcher[];
}
/**
* Single-roundtrip aggregator for the document detail page (PR5).
* Returns the document plus all signers, events (newest first), and watchers.
* Throws NotFoundError if the document is not in `portId`.
*/
export async function getDocumentDetail(id: string, portId: string): Promise<DocumentDetail> {
const document = await getDocumentById(id, portId);
const [signers, events, watchers] = await Promise.all([
db.query.documentSigners.findMany({
where: eq(documentSigners.documentId, id),
orderBy: (ds, { asc }) => [asc(ds.signingOrder)],
}),
db.query.documentEvents.findMany({
where: eq(documentEvents.documentId, id),
orderBy: (de, { desc }) => [desc(de.createdAt)],
}),
db
.select({
userId: documentWatchers.userId,
addedBy: documentWatchers.addedBy,
addedAt: documentWatchers.addedAt,
})
.from(documentWatchers)
.where(eq(documentWatchers.documentId, id)),
]);
return { document, signers, events, watchers };
}
/**
* User-initiated cancel of an in-flight document. Voids the doc in Documenso
* (when present), updates DB status, logs an event, emits socket. Webhook
* receiver also handles documenso-initiated cancellations via
* `handleDocumentCancelled`.
*
* The actual Documenso void call lands in PR2 (`documenso-client.voidDocument`);
* this skeleton updates DB state only.
*/
export async function cancelDocument(
documentId: string,
portId: string,
meta: AuditMeta,
): Promise<typeof documents.$inferSelect> {
const existing = await getDocumentById(documentId, portId);
if (['completed', 'cancelled', 'rejected'].includes(existing.status)) {
throw new ConflictError(`Document is already ${existing.status}`);
}
// PR2 will wire the Documenso void here.
const [updated] = await db
.update(documents)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(and(eq(documents.id, documentId), eq(documents.portId, portId)))
.returning();
await db.insert(documentEvents).values({
documentId,
eventType: 'cancelled',
eventData: { initiatedBy: meta.userId },
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'document',
entityId: documentId,
oldValue: { status: existing.status },
newValue: { status: 'cancelled' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:cancelled', { documentId });
return updated!;
}
/**
* Returns prefilled email composer payload for the "Email signed PDF to all
* signatories" action on the document detail page.
*
* Available for `status='completed' && signedFileId !== null`.
*
* Body content (from per-port `signed_doc_completion` template), full
* sender-resolution, and watcher-cc helpers land alongside PR8 (email
* composer with attachments). For PR1 this returns the minimal correct
* recipients + auto-attachment shape so detail-page integration tests can
* assert against it.
*/
export interface ComposeSignedDocEmailResult {
to: string[];
cc: string[];
subject: string;
body: string;
attachments: Array<{ fileId: string; filename?: string }>;
defaultSenderType: 'system' | 'user';
}
export async function composeSignedDocEmail(
documentId: string,
portId: string,
): Promise<ComposeSignedDocEmailResult> {
const doc = await getDocumentById(documentId, portId);
if (doc.status !== 'completed') {
throw new ConflictError('Document is not completed');
}
if (!doc.signedFileId) {
throw new ValidationError('Document has no signed PDF');
}
const signers = await db
.select({ email: documentSigners.signerEmail })
.from(documentSigners)
.where(eq(documentSigners.documentId, documentId));
const dedupedRecipients = Array.from(new Set(signers.map((s) => s.email)));
return {
to: dedupedRecipients,
cc: [],
subject: `Signed ${doc.documentType.replace(/_/g, ' ')}${doc.title}`,
body: '',
attachments: [{ fileId: doc.signedFileId }],
defaultSenderType: 'system',
};
}
/**
* Skeleton for the create-document wizard entry point (PR6).
*
* Dispatches across the three pathways:
* - 'documenso-template' — render + sign in Documenso
* - 'inapp' — render PDF locally (html / pdf_form / pdf_overlay), upload to Documenso
* - 'upload' — admin-supplied PDF, upload to Documenso, auto-place signature fields
*
* The full implementation lands in PR6 once the wizard validator + new
* template formats ship; PR1 only fixes the public surface.
*/
export async function createFromWizard(
_portId: string,
_data: unknown,
_meta: AuditMeta,
): Promise<typeof documents.$inferSelect> {
throw new Error('createFromWizard not yet implemented (Phase A PR6)');
}
/**
* Skeleton for the upload-driven creation path (PR6).
*
* Stores a port-uploaded PDF in MinIO via the files service, mirrors a row
* into `documents` + `documentSigners`, calls Documenso `createDocument`
* with the buffer, optionally calls `sendDocument` when `sendImmediately`.
*/
export async function createFromUpload(
_portId: string,
_data: unknown,
_meta: AuditMeta,
): Promise<typeof documents.$inferSelect> {
throw new Error('createFromUpload not yet implemented (Phase A PR6)');
}

View File

@@ -1,12 +1,17 @@
import { and, count, eq, gt, sql } from 'drizzle-orm'; import { and, count, eq, gt, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { documents, documentWatchers } from '@/lib/db/schema/documents';
import { notifications } from '@/lib/db/schema/operations'; import { notifications } from '@/lib/db/schema/operations';
import { userNotificationPreferences } from '@/lib/db/schema/system'; import { userNotificationPreferences } from '@/lib/db/schema/system';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { getQueue } from '@/lib/queue'; import { getQueue } from '@/lib/queue';
import { NotFoundError } from '@/lib/errors'; import { NotFoundError } from '@/lib/errors';
import type { ListNotificationsInput, UpdatePreferencesInput } from '@/lib/validators/notifications'; import { logger } from '@/lib/logger';
import type {
ListNotificationsInput,
UpdatePreferencesInput,
} from '@/lib/validators/notifications';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -81,7 +86,10 @@ export async function createNotification(
// 2. Preference check (skip for system_alert type — always delivered) // 2. Preference check (skip for system_alert type — always delivered)
if (type !== 'system_alert') { if (type !== 'system_alert') {
const [pref] = await db const [pref] = await db
.select({ inApp: userNotificationPreferences.inApp, email: userNotificationPreferences.email }) .select({
inApp: userNotificationPreferences.inApp,
email: userNotificationPreferences.email,
})
.from(userNotificationPreferences) .from(userNotificationPreferences)
.where( .where(
and( and(
@@ -170,10 +178,7 @@ export async function listNotifications(
const { page, limit, unreadOnly } = query; const { page, limit, unreadOnly } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const conditions = [ const conditions = [eq(notifications.userId, userId), eq(notifications.portId, portId)];
eq(notifications.userId, userId),
eq(notifications.portId, portId),
];
if (unreadOnly) { if (unreadOnly) {
conditions.push(eq(notifications.isRead, false)); conditions.push(eq(notifications.isRead, false));
@@ -239,10 +244,7 @@ export async function markAllRead(userId: string, portId: string): Promise<void>
// ─── getUnreadCount ─────────────────────────────────────────────────────────── // ─── getUnreadCount ───────────────────────────────────────────────────────────
export async function getUnreadCount( export async function getUnreadCount(userId: string, portId: string): Promise<{ count: number }> {
userId: string,
portId: string,
): Promise<{ count: number }> {
const c = await getUnreadCountValue(userId, portId); const c = await getUnreadCountValue(userId, portId);
return { count: c }; return { count: c };
} }
@@ -261,6 +263,91 @@ export async function getPreferences(userId: string, portId: string) {
); );
} }
// ─── notifyDocumentEvent ──────────────────────────────────────────────────────
export type DocumentEventType =
| 'sent'
| 'signed'
| 'completed'
| 'expired'
| 'cancelled'
| 'rejected';
const DOCUMENT_EVENT_TITLES: Record<DocumentEventType, string> = {
sent: 'Document sent for signing',
signed: 'Document signed',
completed: 'Document fully signed',
expired: 'Document expired',
cancelled: 'Document cancelled',
rejected: 'Document rejected',
};
const DOCUMENT_EVENT_NOTIF_TYPES: Record<DocumentEventType, string> = {
sent: 'document_sent',
signed: 'document_signed',
completed: 'document_completed',
expired: 'document_expired',
cancelled: 'document_cancelled',
rejected: 'document_rejected',
};
/**
* Fan out an in-app notification for a document lifecycle event to:
* - the document creator
* - all rows in `document_watchers` for the document
*
* Existing socket events (`document:created`, `document:sent`, etc.) keep
* firing from `documents.service.ts`; this helper only adds in-app
* notifications. Used by PR4/PR5 detail page + watcher feature.
*
* Future: also notify the entity assignee once that concept exists on
* interests/reservations.
*/
export async function notifyDocumentEvent(
documentId: string,
eventType: DocumentEventType,
): Promise<void> {
const doc = await db.query.documents.findFirst({
where: eq(documents.id, documentId),
});
if (!doc) {
logger.warn({ documentId }, 'notifyDocumentEvent: document not found');
return;
}
const watcherRows = await db
.select({ userId: documentWatchers.userId })
.from(documentWatchers)
.where(eq(documentWatchers.documentId, documentId));
const recipientIds = new Set<string>();
if (doc.createdBy && doc.createdBy !== 'system') {
recipientIds.add(doc.createdBy);
}
for (const row of watcherRows) {
recipientIds.add(row.userId);
}
const title = DOCUMENT_EVENT_TITLES[eventType];
const notifType = DOCUMENT_EVENT_NOTIF_TYPES[eventType];
await Promise.all(
Array.from(recipientIds).map((userId) =>
createNotification({
portId: doc.portId,
userId,
type: notifType,
title,
description: `"${doc.title}"`,
link: `/documents/${doc.id}`,
entityType: 'document',
entityId: doc.id,
dedupeKey: `document:${doc.id}:${eventType}`,
}),
),
);
}
// ─── updatePreferences ──────────────────────────────────────────────────────── // ─── updatePreferences ────────────────────────────────────────────────────────
export async function updatePreferences( export async function updatePreferences(

View File

@@ -0,0 +1,136 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { berthReservations } from '@/lib/db/schema/reservations';
import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { NotFoundError } from '@/lib/errors';
export type ReservationAgreementContext = {
client: {
id: string;
fullName: string;
nationality: string | null;
};
yacht: {
id: string;
name: string;
lengthFt: string | null;
flag: string | null;
};
berth: {
id: string;
mooringNumber: string;
area: string | null;
lengthFt: string | null;
priceCurrency: string;
};
reservation: {
id: string;
status: string;
startDate: Date;
endDate: Date | null;
tenureType: string;
termSummary: string;
signedDate: string | null;
};
port: {
name: string;
defaultCurrency: string;
};
date: {
today: string;
year: string;
};
};
/**
* Build the merge-context shape used when generating a reservation agreement
* document. Mirrors `buildEoiContext` for consistency: pure read-only,
* tenant-scoped via `portId`, throws on missing rows.
*
* `termSummary` is a human-readable rendering of `tenureType` + dates that
* templates can use as `{{reservation.termSummary}}` without needing date
* formatting helpers in the template language.
*/
export async function buildReservationAgreementContext(
reservationId: string,
portId: string,
): Promise<ReservationAgreementContext> {
const reservation = await db.query.berthReservations.findFirst({
where: and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId)),
});
if (!reservation) throw new NotFoundError('Reservation');
const [client, yacht, berth, port] = await Promise.all([
db.query.clients.findFirst({
where: and(eq(clients.id, reservation.clientId), eq(clients.portId, portId)),
}),
db.query.yachts.findFirst({
where: and(eq(yachts.id, reservation.yachtId), eq(yachts.portId, portId)),
}),
db.query.berths.findFirst({
where: and(eq(berths.id, reservation.berthId), eq(berths.portId, portId)),
}),
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
]);
if (!client) throw new NotFoundError('Client');
if (!yacht) throw new NotFoundError('Yacht');
if (!berth) throw new NotFoundError('Berth');
if (!port) throw new NotFoundError('Port');
const start = reservation.startDate.toISOString().slice(0, 10);
const end = reservation.endDate ? reservation.endDate.toISOString().slice(0, 10) : null;
let termSummary: string;
if (reservation.tenureType === 'permanent') {
termSummary = `Permanent berth, commencing ${start}`;
} else if (reservation.tenureType === 'fixed_term' && end) {
termSummary = `Fixed term: ${start} to ${end}`;
} else if (reservation.tenureType === 'seasonal' && end) {
termSummary = `Seasonal: ${start} to ${end}`;
} else {
termSummary = `${reservation.tenureType} from ${start}`;
}
const now = new Date();
return {
client: {
id: client.id,
fullName: client.fullName,
nationality: client.nationality,
},
yacht: {
id: yacht.id,
name: yacht.name,
lengthFt: yacht.lengthFt,
flag: yacht.flag,
},
berth: {
id: berth.id,
mooringNumber: berth.mooringNumber,
area: berth.area,
lengthFt: berth.lengthFt,
priceCurrency: berth.priceCurrency,
},
reservation: {
id: reservation.id,
status: reservation.status,
startDate: reservation.startDate,
endDate: reservation.endDate,
tenureType: reservation.tenureType,
termSummary,
signedDate: null,
},
port: {
name: port.name,
defaultCurrency: port.defaultCurrency,
},
date: {
today: now.toISOString().slice(0, 10),
year: String(now.getFullYear()),
},
};
}

View File

@@ -15,7 +15,9 @@ const mergeFieldsSchema = z
}, },
); );
export const createTemplateSchema = z.object({ export const templateFormats = ['html', 'pdf_form', 'pdf_overlay', 'documenso_render'] as const;
const createTemplateBaseSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
templateType: z.enum([ templateType: z.enum([
@@ -26,12 +28,18 @@ export const createTemplateSchema = z.object({
'correspondence', 'correspondence',
'custom', 'custom',
]), ]),
bodyHtml: z.string().min(1), templateFormat: z.enum(templateFormats).default('html'),
bodyHtml: z.string().min(1).optional(),
mergeFields: mergeFieldsSchema, mergeFields: mergeFieldsSchema,
isActive: z.boolean().default(true), isActive: z.boolean().default(true),
}); });
export const updateTemplateSchema = createTemplateSchema.partial(); export const createTemplateSchema = createTemplateBaseSchema.refine(
(data) => data.templateFormat !== 'html' || (data.bodyHtml && data.bodyHtml.length > 0),
{ path: ['bodyHtml'], message: 'bodyHtml is required when templateFormat is html' },
);
export const updateTemplateSchema = createTemplateBaseSchema.partial();
export const listTemplatesSchema = baseListQuerySchema.extend({ export const listTemplatesSchema = baseListQuerySchema.extend({
templateType: z.string().optional(), templateType: z.string().optional(),