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,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(
|
||||
|
||||
Reference in New Issue
Block a user