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

@@ -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(