diff --git a/src/app/api/v1/documents/[id]/remind/route.ts b/src/app/api/v1/documents/[id]/remind/route.ts index 35a3d4f..81549eb 100644 --- a/src/app/api/v1/documents/[id]/remind/route.ts +++ b/src/app/api/v1/documents/[id]/remind/route.ts @@ -1,14 +1,30 @@ import { NextResponse } from 'next/server'; +import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { sendReminderIfAllowed } from '@/lib/services/document-reminders'; +const remindBodySchema = z + .object({ + signerId: z.string().optional(), + }) + .optional(); + export const POST = withAuth( withPermission('documents', 'edit', async (req, ctx, params) => { try { - const sent = await sendReminderIfAllowed(params.id!, ctx.portId); - return NextResponse.json({ data: { sent } }); + let signerId: string | undefined; + const text = await req.text(); + if (text) { + const parsed = remindBodySchema.safeParse(JSON.parse(text)); + if (parsed.success && parsed.data) signerId = parsed.data.signerId; + } + const result = await sendReminderIfAllowed(params.id!, ctx.portId, { + auto: false, + signerId, + }); + return NextResponse.json({ data: result }); } catch (error) { return errorResponse(error); } diff --git a/src/lib/services/document-reminders.ts b/src/lib/services/document-reminders.ts index f563752..328d1b5 100644 --- a/src/lib/services/document-reminders.ts +++ b/src/lib/services/document-reminders.ts @@ -1,13 +1,17 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { documents, documentSigners, documentEvents } from '@/lib/db/schema/documents'; -import { interests } from '@/lib/db/schema/interests'; +import { + documents, + documentSigners, + documentEvents, + documentTemplates, +} from '@/lib/db/schema/documents'; import { ports } from '@/lib/db/schema/ports'; import { sendReminder as documensoRemind } from '@/lib/services/documenso-client'; import { logger } from '@/lib/logger'; -// BR-023: Reminders only during 9-16 in port timezone, with 24h cooldown +// ─── Helpers ───────────────────────────────────────────────────────────────── function getCurrentHourInTimezone(timezone: string): number { const now = new Date(); @@ -19,105 +23,189 @@ function getCurrentHourInTimezone(timezone: string): number { return parseInt(formatter.format(now), 10); } +interface ReminderEligibilityArgs { + status: string; + documensoId: string | null; + remindersDisabled: boolean; + reminderCadenceOverride: number | null; + templateCadenceDays: number | null; + lastReminderAt: Date | null; + now?: Date; +} + +/** + * Pure cadence/disable check used by the cron path. Auto-mode reminders + * additionally enforce the 9–16 port-timezone window, which lives in the + * caller below. + */ +export function isReminderDue(args: ReminderEligibilityArgs): boolean { + const now = args.now ?? new Date(); + if (!['sent', 'partially_signed'].includes(args.status)) return false; + if (args.documensoId == null) return false; + if (args.remindersDisabled) return false; + + const cadence = args.reminderCadenceOverride ?? args.templateCadenceDays; + if (cadence === null) return false; + + if (args.lastReminderAt == null) return true; + const elapsedMs = now.getTime() - args.lastReminderAt.getTime(); + return elapsedMs >= cadence * 24 * 60 * 60 * 1000; +} + +// ─── sendReminderIfAllowed ─────────────────────────────────────────────────── + +export interface SendReminderOptions { + /** true = cron auto-fire, enforces 9-16 window + cadence cooldown. + * false (default) = manual UI action, bypasses both. */ + auto?: boolean; + /** Optional — target a specific pending signer (parallel mode), or + * bypass the lowest-pending default in sequential mode (must still be the + * next pending signer in that case). */ + signerId?: string; +} + +export interface SendReminderResult { + sent: boolean; + reason?: string; + signerId?: string; +} + export async function sendReminderIfAllowed( documentId: string, portId: string, -): Promise { + options: SendReminderOptions = {}, +): Promise { + const { auto = false, signerId } = options; + const doc = await db.query.documents.findFirst({ where: and(eq(documents.id, documentId), eq(documents.portId, portId)), }); - if (!doc || !doc.interestId || !doc.documensoId) return false; - if (!['sent', 'partially_signed'].includes(doc.status)) return false; - - // Check interest.reminderEnabled - const interest = await db.query.interests.findFirst({ - where: eq(interests.id, doc.interestId), - }); - - if (!interest?.reminderEnabled) return false; - - // Check port timezone - const port = await db.query.ports.findFirst({ - where: eq(ports.id, portId), - }); - - const timezone = port?.timezone ?? 'UTC'; - const currentHour = getCurrentHourInTimezone(timezone); - - if (currentHour < 9 || currentHour >= 16) return false; - - // Check 24h cooldown — last reminder_sent event for this document - const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - - const lastReminder = await db.query.documentEvents.findFirst({ - where: and( - eq(documentEvents.documentId, documentId), - eq(documentEvents.eventType, 'reminder_sent'), - ), - orderBy: (de, { desc }) => [desc(de.createdAt)], - }); - - if (lastReminder && lastReminder.createdAt > twentyFourHoursAgo) { - return false; + if (!doc) return { sent: false, reason: 'Document not found' }; + if (!doc.documensoId) return { sent: false, reason: 'Document has no Documenso id' }; + if (!['sent', 'partially_signed'].includes(doc.status)) { + return { sent: false, reason: `Document is ${doc.status}` }; + } + if (doc.remindersDisabled) { + return { sent: false, reason: 'Reminders disabled for this document' }; } - // Find current pending signer (lowest signingOrder with status='pending') - const pendingSigner = await db.query.documentSigners.findFirst({ - where: and( - eq(documentSigners.documentId, documentId), - eq(documentSigners.status, 'pending'), - ), - orderBy: (ds, { asc }) => [asc(ds.signingOrder)], - }); + // Resolve effective cadence (override → template → null/disabled) + let templateCadenceDays: number | null = null; + if (auto) { + // Auto path needs a cadence to fire at all; manual sends bypass cadence. + // We still load the template cadence so `isReminderDue` has the input. + const templateRow = doc.fileId + ? await db.query.documentTemplates.findFirst({ + // Fallback: look up by document type if no explicit template_id. + where: and( + eq(documentTemplates.portId, portId), + eq(documentTemplates.templateType, doc.documentType), + ), + }) + : null; + templateCadenceDays = templateRow?.reminderCadenceDays ?? null; - if (!pendingSigner) return false; + const lastReminder = await db.query.documentEvents.findFirst({ + where: and( + eq(documentEvents.documentId, documentId), + eq(documentEvents.eventType, 'reminder_sent'), + ), + orderBy: (de, { desc }) => [desc(de.createdAt)], + }); + + const due = isReminderDue({ + status: doc.status, + documensoId: doc.documensoId, + remindersDisabled: doc.remindersDisabled, + reminderCadenceOverride: doc.reminderCadenceOverride, + templateCadenceDays, + lastReminderAt: lastReminder?.createdAt ?? null, + }); + if (!due) return { sent: false, reason: 'Cadence cooldown' }; + + // Auto only: 9-16 window in port timezone + const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); + const timezone = port?.timezone ?? 'UTC'; + const currentHour = getCurrentHourInTimezone(timezone); + if (currentHour < 9 || currentHour >= 16) { + return { sent: false, reason: 'Outside 9-16 port-timezone window' }; + } + } + + // Pick the signer to nudge. + const pendingSigners = await db + .select() + .from(documentSigners) + .where(and(eq(documentSigners.documentId, documentId), eq(documentSigners.status, 'pending'))) + .orderBy(sql`${documentSigners.signingOrder} ASC`); + if (pendingSigners.length === 0) { + return { sent: false, reason: 'No pending signers' }; + } + + let target = pendingSigners[0]!; + + if (signerId) { + const requested = pendingSigners.find((s) => s.id === signerId); + if (!requested) { + return { sent: false, reason: 'Signer is not pending' }; + } + // Sequential mode: require the lowest pending order. + if (requested.signingOrder !== pendingSigners[0]!.signingOrder) { + return { sent: false, reason: 'Signer is not next in sequence' }; + } + target = requested; + } - // Send reminder via Documenso try { - await documensoRemind(doc.documensoId, pendingSigner.id); + await documensoRemind(doc.documensoId, target.id, portId); } catch (err) { - logger.error({ err, documentId, signerId: pendingSigner.id }, 'Failed to send Documenso reminder'); - return false; + logger.error({ err, documentId, signerId: target.id }, 'Documenso reminder failed'); + return { sent: false, reason: 'Documenso reminder failed' }; } - // Record event await db.insert(documentEvents).values({ documentId, eventType: 'reminder_sent', - signerId: pendingSigner.id, - eventData: { signerEmail: pendingSigner.signerEmail, signerRole: pendingSigner.signerRole }, + signerId: target.id, + eventData: { + signerEmail: target.signerEmail, + signerRole: target.signerRole, + auto, + }, }); - return true; + return { sent: true, signerId: target.id }; } +// ─── processReminderQueue ──────────────────────────────────────────────────── + +/** + * Cron entry point. Selects in-flight documents whose effective cadence + * (override or template) is set, then attempts auto-fire on each. + * `interests.reminderEnabled` is no longer part of the gating — per-doc + * `remindersDisabled` is the kill switch instead. + */ export async function processReminderQueue(portId: string): Promise { - // Find all documents with status 'sent' or 'partially_signed' linked to interests with reminderEnabled=true - const activeInterests = await db.query.interests.findMany({ - where: and( - eq(interests.portId, portId), - eq(interests.reminderEnabled, true), - ), - }); - - if (activeInterests.length === 0) return; - - const interestIds = activeInterests.map((i) => i.id); - - const activeDocs = await db.query.documents.findMany({ - where: and( - eq(documents.portId, portId), - inArray(documents.status, ['sent', 'partially_signed']), - inArray(documents.interestId, interestIds), - ), - }); + const activeDocs = await db + .select({ id: documents.id }) + .from(documents) + .leftJoin(documentTemplates, eq(documentTemplates.templateType, documents.documentType)) + .where( + and( + eq(documents.portId, portId), + inArray(documents.status, ['sent', 'partially_signed']), + isNotNull(documents.documensoId), + eq(documents.remindersDisabled, false), + sql`COALESCE(${documents.reminderCadenceOverride}, ${documentTemplates.reminderCadenceDays}) IS NOT NULL`, + ), + ); for (const doc of activeDocs) { try { - await sendReminderIfAllowed(doc.id, portId); + await sendReminderIfAllowed(doc.id, portId, { auto: true }); } catch (err) { - logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed for document'); + logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed'); } } } diff --git a/tests/unit/services/document-reminders-cadence.test.ts b/tests/unit/services/document-reminders-cadence.test.ts new file mode 100644 index 0000000..3bdcfeb --- /dev/null +++ b/tests/unit/services/document-reminders-cadence.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { isReminderDue } from '@/lib/services/document-reminders'; + +const now = new Date('2026-04-28T12:00:00Z'); + +function args(overrides: Partial[0]> = {}) { + return { + status: 'sent' as const, + documensoId: 'doc-1', + remindersDisabled: false, + reminderCadenceOverride: null, + templateCadenceDays: 7, + lastReminderAt: null, + now, + ...overrides, + }; +} + +describe('isReminderDue', () => { + it('returns true when no prior reminder exists and cadence is set', () => { + expect(isReminderDue(args())).toBe(true); + }); + + it('returns false when document is completed', () => { + expect(isReminderDue(args({ status: 'completed' }))).toBe(false); + }); + + it('returns false when document has no Documenso id', () => { + expect(isReminderDue(args({ documensoId: null }))).toBe(false); + }); + + it('returns false when reminders are disabled per-doc', () => { + expect(isReminderDue(args({ remindersDisabled: true }))).toBe(false); + }); + + it('returns false when neither override nor template cadence is set', () => { + expect(isReminderDue(args({ templateCadenceDays: null }))).toBe(false); + }); + + it('respects per-doc override over template default', () => { + // 1-day override, last fired 12h ago → not due + const lastReminderAt = new Date(now.getTime() - 12 * 60 * 60 * 1000); + expect( + isReminderDue(args({ templateCadenceDays: 7, reminderCadenceOverride: 1, lastReminderAt })), + ).toBe(false); + + // 1-day override, last fired 25h ago → due + const earlier = new Date(now.getTime() - 25 * 60 * 60 * 1000); + expect( + isReminderDue( + args({ + templateCadenceDays: 7, + reminderCadenceOverride: 1, + lastReminderAt: earlier, + }), + ), + ).toBe(true); + }); + + it('treats template cadence as the fallback when no override', () => { + // 7-day template, last fired 6 days ago → not due + const lastReminderAt = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000); + expect(isReminderDue(args({ lastReminderAt }))).toBe(false); + + // 7 days exactly → due + const sevenDays = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + expect(isReminderDue(args({ lastReminderAt: sevenDays }))).toBe(true); + }); +});