Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
src/lib/services/document-reminders.ts
Normal file
123
src/lib/services/document-reminders.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { and, eq, inArray } 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 { 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
|
||||
|
||||
function getCurrentHourInTimezone(timezone: string): number {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
hour12: false,
|
||||
});
|
||||
return parseInt(formatter.format(now), 10);
|
||||
}
|
||||
|
||||
export async function sendReminderIfAllowed(
|
||||
documentId: string,
|
||||
portId: string,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Record event
|
||||
await db.insert(documentEvents).values({
|
||||
documentId,
|
||||
eventType: 'reminder_sent',
|
||||
signerId: pendingSigner.id,
|
||||
eventData: { signerEmail: pendingSigner.signerEmail, signerRole: pendingSigner.signerRole },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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(
|
||||
eq(documents.portId, portId),
|
||||
inArray(documents.status, ['sent', 'partially_signed']),
|
||||
inArray(documents.interestId, interestIds),
|
||||
),
|
||||
});
|
||||
|
||||
for (const doc of activeDocs) {
|
||||
try {
|
||||
await sendReminderIfAllowed(doc.id, portId);
|
||||
} catch (err) {
|
||||
logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed for document');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user