merge: PR1 — data model + service skeletons (Phase A)
This commit is contained in:
25
src/lib/db/migrations/0013_abnormal_thundra.sql
Normal file
25
src/lib/db/migrations/0013_abnormal_thundra.sql
Normal 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';
|
||||
9419
src/lib/db/migrations/meta/0013_snapshot.json
Normal file
9419
src/lib/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1777308900666,
|
||||
"tag": "0012_large_zarda",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1777334766194,
|
||||
"tag": "0013_abnormal_thundra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
@@ -55,6 +56,7 @@ export const documents = pgTable(
|
||||
clientId: text('client_id').references(() => clients.id),
|
||||
yachtId: text('yacht_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
|
||||
title: text('title').notNull(),
|
||||
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),
|
||||
isManualUpload: boolean('is_manual_upload').notNull().default(false),
|
||||
notes: text('notes'),
|
||||
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||
createdBy: text('created_by').notNull(),
|
||||
createdAt: timestamp('created_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_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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -134,8 +140,19 @@ export const documentTemplates = pgTable(
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
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([]),
|
||||
// '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(),
|
||||
@@ -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(
|
||||
'form_templates',
|
||||
{
|
||||
@@ -200,6 +234,8 @@ 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;
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
documentSigners,
|
||||
documentEvents,
|
||||
documentTemplates,
|
||||
documentWatchers,
|
||||
formTemplates,
|
||||
formSubmissions,
|
||||
} from './documents';
|
||||
@@ -457,7 +458,7 @@ export const berthTagsRelations = relations(berthTags, ({ one }) => ({
|
||||
|
||||
// ─── Berth Reservations ───────────────────────────────────────────────────────
|
||||
|
||||
export const berthReservationsRelations = relations(berthReservations, ({ one }) => ({
|
||||
export const berthReservationsRelations = relations(berthReservations, ({ one, many }) => ({
|
||||
berth: one(berths, {
|
||||
fields: [berthReservations.berthId],
|
||||
references: [berths.id],
|
||||
@@ -482,6 +483,7 @@ export const berthReservationsRelations = relations(berthReservations, ({ one })
|
||||
fields: [berthReservations.contractFileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
documents: many(documents),
|
||||
}));
|
||||
|
||||
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||
@@ -538,8 +540,13 @@ export const documentsRelations = relations(documents, ({ one, many }) => ({
|
||||
fields: [documents.companyId],
|
||||
references: [companies.id],
|
||||
}),
|
||||
reservation: one(berthReservations, {
|
||||
fields: [documents.reservationId],
|
||||
references: [berthReservations.id],
|
||||
}),
|
||||
signers: many(documentSigners),
|
||||
events: many(documentEvents),
|
||||
watchers: many(documentWatchers),
|
||||
}));
|
||||
|
||||
export const documentSignersRelations = relations(documentSigners, ({ one, many }) => ({
|
||||
@@ -566,6 +573,17 @@ export const documentTemplatesRelations = relations(documentTemplates, ({ one })
|
||||
fields: [documentTemplates.portId],
|
||||
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 }) => ({
|
||||
|
||||
@@ -49,22 +49,16 @@ export interface TemplateVersion {
|
||||
* We use a convention: version is stored in the `mergeFields` jsonb array
|
||||
* as `["__version__:N"]` to avoid adding a new column.
|
||||
*/
|
||||
function getVersionFromRecord(
|
||||
record: typeof documentTemplates.$inferSelect,
|
||||
): number {
|
||||
function getVersionFromRecord(record: typeof documentTemplates.$inferSelect): number {
|
||||
const mf = record.mergeFields as unknown;
|
||||
if (!Array.isArray(mf)) return 1;
|
||||
const versionEntry = (mf as string[]).find((e) =>
|
||||
e.startsWith('__version__:'),
|
||||
);
|
||||
const versionEntry = (mf as string[]).find((e) => e.startsWith('__version__:'));
|
||||
if (!versionEntry) return 1;
|
||||
const n = parseInt(versionEntry.split(':')[1] ?? '1', 10);
|
||||
return isNaN(n) ? 1 : n;
|
||||
}
|
||||
|
||||
function buildMergeFieldsWithVersion(
|
||||
version: number,
|
||||
): string[] {
|
||||
function buildMergeFieldsWithVersion(version: number): string[] {
|
||||
return [`__version__:${version}`];
|
||||
}
|
||||
|
||||
@@ -72,9 +66,7 @@ function buildMergeFieldsWithVersion(
|
||||
* Parse TipTap JSON from bodyHtml field. Returns the parsed object, or null
|
||||
* if bodyHtml is plain HTML (legacy records).
|
||||
*/
|
||||
function parseTipTapContent(
|
||||
bodyHtml: string,
|
||||
): Record<string, unknown> | null {
|
||||
function parseTipTapContent(bodyHtml: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(bodyHtml) as unknown;
|
||||
if (
|
||||
@@ -92,10 +84,7 @@ function parseTipTapContent(
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listAdminTemplates(
|
||||
portId: string,
|
||||
query: ListAdminTemplatesInput,
|
||||
) {
|
||||
export async function listAdminTemplates(portId: string, query: ListAdminTemplatesInput) {
|
||||
const { type, isActive } = query;
|
||||
|
||||
const conditions = [eq(documentTemplates.portId, portId)];
|
||||
@@ -116,21 +105,15 @@ export async function listAdminTemplates(
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
version: getVersionFromRecord(row),
|
||||
content: parseTipTapContent(row.bodyHtml),
|
||||
content: parseTipTapContent(row.bodyHtml ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getAdminTemplate(
|
||||
portId: string,
|
||||
templateId: string,
|
||||
) {
|
||||
export async function getAdminTemplate(portId: string, templateId: string) {
|
||||
const row = await db.query.documentTemplates.findFirst({
|
||||
where: and(
|
||||
eq(documentTemplates.id, templateId),
|
||||
eq(documentTemplates.portId, portId),
|
||||
),
|
||||
where: and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)),
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
@@ -140,15 +123,13 @@ export async function getAdminTemplate(
|
||||
return {
|
||||
...row,
|
||||
version: getVersionFromRecord(row),
|
||||
content: parseTipTapContent(row.bodyHtml),
|
||||
content: parseTipTapContent(row.bodyHtml ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Validate TipTap Content ─────────────────────────────────────────────────
|
||||
|
||||
function assertValidContent(
|
||||
content: Record<string, unknown>,
|
||||
): void {
|
||||
function assertValidContent(content: Record<string, unknown>): void {
|
||||
const unsupported = validateTipTapDocument(
|
||||
content as unknown as Parameters<typeof validateTipTapDocument>[0],
|
||||
);
|
||||
@@ -257,21 +238,13 @@ export async function updateAdminTemplate(
|
||||
const [updated] = await db
|
||||
.update(documentTemplates)
|
||||
.set(updateValues)
|
||||
.where(
|
||||
and(
|
||||
eq(documentTemplates.id, templateId),
|
||||
eq(documentTemplates.portId, portId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
...updated!,
|
||||
version: newVersion,
|
||||
content:
|
||||
data.content !== undefined
|
||||
? data.content
|
||||
: existing.content,
|
||||
content: data.content !== undefined ? data.content : existing.content,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -287,12 +260,7 @@ export async function deleteAdminTemplate(
|
||||
|
||||
await db
|
||||
.delete(documentTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(documentTemplates.id, templateId),
|
||||
eq(documentTemplates.portId, portId),
|
||||
),
|
||||
);
|
||||
.where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
@@ -337,10 +305,7 @@ export async function getAdminTemplateVersions(
|
||||
.filter((log) => {
|
||||
const meta = log.metadata as Record<string, unknown> | null;
|
||||
return (
|
||||
meta !== null &&
|
||||
typeof meta === 'object' &&
|
||||
'versionSnapshot' in meta &&
|
||||
'content' in meta
|
||||
meta !== null && typeof meta === 'object' && 'versionSnapshot' in meta && 'content' in meta
|
||||
);
|
||||
})
|
||||
.map((log) => {
|
||||
@@ -403,12 +368,7 @@ export async function rollbackAdminTemplate(
|
||||
mergeFields: buildMergeFieldsWithVersion(newVersion),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(documentTemplates.id, templateId),
|
||||
eq(documentTemplates.portId, portId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
|
||||
@@ -432,8 +432,14 @@ export async function resolveTemplate(
|
||||
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
|
||||
let resolved = template.bodyHtml;
|
||||
let resolved: string = template.bodyHtml;
|
||||
for (const [token, value] of Object.entries(tokenMap)) {
|
||||
// Escape token for use in regex
|
||||
const escaped = token.replace(/[{}]/g, '\\$&');
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
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 { clients } from '@/lib/db/schema/clients';
|
||||
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 });
|
||||
}
|
||||
|
||||
// ─── 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)');
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { and, count, eq, gt, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documents, documentWatchers } from '@/lib/db/schema/documents';
|
||||
import { notifications } from '@/lib/db/schema/operations';
|
||||
import { userNotificationPreferences } from '@/lib/db/schema/system';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { getQueue } from '@/lib/queue';
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -81,7 +86,10 @@ export async function createNotification(
|
||||
// 2. Preference check (skip for system_alert type — always delivered)
|
||||
if (type !== 'system_alert') {
|
||||
const [pref] = await db
|
||||
.select({ inApp: userNotificationPreferences.inApp, email: userNotificationPreferences.email })
|
||||
.select({
|
||||
inApp: userNotificationPreferences.inApp,
|
||||
email: userNotificationPreferences.email,
|
||||
})
|
||||
.from(userNotificationPreferences)
|
||||
.where(
|
||||
and(
|
||||
@@ -170,10 +178,7 @@ export async function listNotifications(
|
||||
const { page, limit, unreadOnly } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.portId, portId),
|
||||
];
|
||||
const conditions = [eq(notifications.userId, userId), eq(notifications.portId, portId)];
|
||||
|
||||
if (unreadOnly) {
|
||||
conditions.push(eq(notifications.isRead, false));
|
||||
@@ -239,10 +244,7 @@ export async function markAllRead(userId: string, portId: string): Promise<void>
|
||||
|
||||
// ─── getUnreadCount ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function getUnreadCount(
|
||||
userId: string,
|
||||
portId: string,
|
||||
): Promise<{ count: number }> {
|
||||
export async function getUnreadCount(userId: string, portId: string): Promise<{ count: number }> {
|
||||
const c = await getUnreadCountValue(userId, portId);
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
export async function updatePreferences(
|
||||
|
||||
136
src/lib/services/reservation-agreement-context.ts
Normal file
136
src/lib/services/reservation-agreement-context.ts
Normal 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()),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
description: z.string().max(500).optional(),
|
||||
templateType: z.enum([
|
||||
@@ -26,12 +28,18 @@ export const createTemplateSchema = z.object({
|
||||
'correspondence',
|
||||
'custom',
|
||||
]),
|
||||
bodyHtml: z.string().min(1),
|
||||
templateFormat: z.enum(templateFormats).default('html'),
|
||||
bodyHtml: z.string().min(1).optional(),
|
||||
mergeFields: mergeFieldsSchema,
|
||||
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({
|
||||
templateType: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user