merge: PR9 — reminder framework polish (Phase A)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,36 +23,88 @@ 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<boolean> {
|
||||
options: SendReminderOptions = {},
|
||||
): Promise<SendReminderResult> {
|
||||
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;
|
||||
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' };
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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;
|
||||
|
||||
const lastReminder = await db.query.documentEvents.findFirst({
|
||||
where: and(
|
||||
@@ -58,66 +114,98 @@ export async function sendReminderIfAllowed(
|
||||
orderBy: (de, { desc }) => [desc(de.createdAt)],
|
||||
});
|
||||
|
||||
if (lastReminder && lastReminder.createdAt > twentyFourHoursAgo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)],
|
||||
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' };
|
||||
|
||||
if (!pendingSigner) return false;
|
||||
|
||||
// Send reminder via Documenso
|
||||
try {
|
||||
await documensoRemind(doc.documensoId, pendingSigner.id);
|
||||
} catch (err) {
|
||||
logger.error({ err, documentId, signerId: pendingSigner.id }, 'Failed to send Documenso reminder');
|
||||
return false;
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
await documensoRemind(doc.documensoId, target.id, portId);
|
||||
} catch (err) {
|
||||
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<void> {
|
||||
// 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(
|
||||
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']),
|
||||
inArray(documents.interestId, interestIds),
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
70
tests/unit/services/document-reminders-cadence.test.ts
Normal file
70
tests/unit/services/document-reminders-cadence.test.ts
Normal file
@@ -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<Parameters<typeof isReminderDue>[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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user