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:
@@ -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)');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user