2026-04-28 02:50:00 +02:00
|
|
|
|
import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
2026-04-28 02:50:00 +02:00
|
|
|
|
import {
|
|
|
|
|
|
documents,
|
|
|
|
|
|
documentSigners,
|
|
|
|
|
|
documentEvents,
|
|
|
|
|
|
documentTemplates,
|
|
|
|
|
|
} from '@/lib/db/schema/documents';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
|
|
|
|
|
import { sendReminder as documensoRemind } from '@/lib/services/documenso-client';
|
|
|
|
|
|
import { logger } from '@/lib/logger';
|
|
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
interface ReminderEligibilityArgs {
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
documensoId: string | null;
|
|
|
|
|
|
remindersDisabled: boolean;
|
|
|
|
|
|
reminderCadenceOverride: number | null;
|
|
|
|
|
|
templateCadenceDays: number | null;
|
|
|
|
|
|
lastReminderAt: Date | null;
|
|
|
|
|
|
now?: Date;
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
// ─── sendReminderIfAllowed ───────────────────────────────────────────────────
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
export interface SendReminderOptions {
|
|
|
|
|
|
/** true = cron auto-fire, enforces 9-16 window + cadence cooldown.
|
|
|
|
|
|
* false (default) = manual UI action, bypasses both. */
|
|
|
|
|
|
auto?: boolean;
|
2026-05-04 22:57:01 +02:00
|
|
|
|
/** Optional - target a specific pending signer (parallel mode), or
|
2026-04-28 02:50:00 +02:00
|
|
|
|
* bypass the lowest-pending default in sequential mode (must still be the
|
|
|
|
|
|
* next pending signer in that case). */
|
|
|
|
|
|
signerId?: string;
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
export interface SendReminderResult {
|
|
|
|
|
|
sent: boolean;
|
|
|
|
|
|
reason?: string;
|
|
|
|
|
|
signerId?: string;
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
export async function sendReminderIfAllowed(
|
|
|
|
|
|
documentId: string,
|
|
|
|
|
|
portId: string,
|
|
|
|
|
|
options: SendReminderOptions = {},
|
|
|
|
|
|
): Promise<SendReminderResult> {
|
|
|
|
|
|
const { auto = false, signerId } = options;
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
const doc = await db.query.documents.findFirst({
|
|
|
|
|
|
where: and(eq(documents.id, documentId), eq(documents.portId, portId)),
|
|
|
|
|
|
});
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
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' };
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
// 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(
|
|
|
|
|
|
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' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
// 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' };
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
let target = pendingSigners[0]!;
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-28 02:50:00 +02:00
|
|
|
|
await documensoRemind(doc.documensoId, target.id, portId);
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
} catch (err) {
|
2026-04-28 02:50:00 +02:00
|
|
|
|
logger.error({ err, documentId, signerId: target.id }, 'Documenso reminder failed');
|
|
|
|
|
|
return { sent: false, reason: 'Documenso reminder failed' };
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
|
documentId,
|
|
|
|
|
|
eventType: 'reminder_sent',
|
2026-04-28 02:50:00 +02:00
|
|
|
|
signerId: target.id,
|
|
|
|
|
|
eventData: {
|
|
|
|
|
|
signerEmail: target.signerEmail,
|
|
|
|
|
|
signerRole: target.signerRole,
|
|
|
|
|
|
auto,
|
|
|
|
|
|
},
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
return { sent: true, signerId: target.id };
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
// ─── processReminderQueue ────────────────────────────────────────────────────
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-04-28 02:50:00 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Cron entry point. Selects in-flight documents whose effective cadence
|
|
|
|
|
|
* (override or template) is set, then attempts auto-fire on each.
|
2026-05-04 22:57:01 +02:00
|
|
|
|
* `interests.reminderEnabled` is no longer part of the gating - per-doc
|
2026-04-28 02:50:00 +02:00
|
|
|
|
* `remindersDisabled` is the kill switch instead.
|
2026-05-05 20:41:23 +02:00
|
|
|
|
*
|
|
|
|
|
|
* Performance: the pre-bulk version called `sendReminderIfAllowed` per
|
|
|
|
|
|
* doc, which re-fetched the port row (invariant), the template-by-type
|
|
|
|
|
|
* map (repeats heavily), the last reminder event, and the pending
|
|
|
|
|
|
* signers — 5×N round trips per cron tick. This implementation hoists
|
|
|
|
|
|
* the invariants out of the loop and turns the per-row queries into
|
|
|
|
|
|
* grouped scans (one per dimension), so a port with 500 in-flight docs
|
|
|
|
|
|
* is now ~7 round trips total instead of ~2500.
|
2026-04-28 02:50:00 +02:00
|
|
|
|
*/
|
|
|
|
|
|
export async function processReminderQueue(portId: string): Promise<void> {
|
|
|
|
|
|
const activeDocs = await db
|
2026-05-05 20:41:23 +02:00
|
|
|
|
.select({
|
|
|
|
|
|
id: documents.id,
|
|
|
|
|
|
documentType: documents.documentType,
|
|
|
|
|
|
documensoId: documents.documensoId,
|
|
|
|
|
|
status: documents.status,
|
|
|
|
|
|
remindersDisabled: documents.remindersDisabled,
|
|
|
|
|
|
reminderCadenceOverride: documents.reminderCadenceOverride,
|
|
|
|
|
|
fileId: documents.fileId,
|
|
|
|
|
|
})
|
2026-04-28 02:50:00 +02:00
|
|
|
|
.from(documents)
|
2026-05-05 21:19:39 +02:00
|
|
|
|
// CRITICAL: scope the join to the same port — `documentTemplates.templateType`
|
|
|
|
|
|
// is not unique across ports, so a leftJoin without `portId` produces a
|
|
|
|
|
|
// cartesian explosion (one output row per template-type match across
|
|
|
|
|
|
// every port). The downstream loop fires `documensoRemind` per row,
|
|
|
|
|
|
// which means the same signer in port A would receive N reminders on
|
|
|
|
|
|
// a single cron tick (once per port that defined a template of the
|
|
|
|
|
|
// same type). Audit follow-up after Tier 3 ship.
|
|
|
|
|
|
.leftJoin(
|
|
|
|
|
|
documentTemplates,
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(documentTemplates.templateType, documents.documentType),
|
|
|
|
|
|
eq(documentTemplates.portId, portId),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-04-28 02:50:00 +02:00
|
|
|
|
.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`,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
2026-05-05 20:41:23 +02:00
|
|
|
|
if (activeDocs.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Hoist invariants out of the per-doc loop ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// (1) Port row (timezone) — invariant across the whole batch.
|
|
|
|
|
|
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) {
|
|
|
|
|
|
// Outside the 9-16 window — nothing to do this tick.
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// (2) Per-type template cadence map — repeats per documentType.
|
|
|
|
|
|
const distinctTypes = Array.from(new Set(activeDocs.map((d) => d.documentType)));
|
|
|
|
|
|
const templateRows = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
templateType: documentTemplates.templateType,
|
|
|
|
|
|
reminderCadenceDays: documentTemplates.reminderCadenceDays,
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(documentTemplates)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
eq(documentTemplates.portId, portId),
|
|
|
|
|
|
inArray(documentTemplates.templateType, distinctTypes),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
const templateCadenceByType = new Map(
|
|
|
|
|
|
templateRows.map((r) => [r.templateType, r.reminderCadenceDays ?? null]),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// (3) Latest reminder_sent event per doc — one grouped query.
|
|
|
|
|
|
const docIds = activeDocs.map((d) => d.id);
|
|
|
|
|
|
const lastReminderRows = await db
|
|
|
|
|
|
.select({
|
|
|
|
|
|
documentId: documentEvents.documentId,
|
|
|
|
|
|
lastAt: sql<Date>`max(${documentEvents.createdAt})`,
|
|
|
|
|
|
})
|
|
|
|
|
|
.from(documentEvents)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
and(
|
|
|
|
|
|
inArray(documentEvents.documentId, docIds),
|
|
|
|
|
|
eq(documentEvents.eventType, 'reminder_sent'),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
.groupBy(documentEvents.documentId);
|
|
|
|
|
|
const lastReminderByDoc = new Map(lastReminderRows.map((r) => [r.documentId, r.lastAt]));
|
|
|
|
|
|
|
|
|
|
|
|
// (4) Pending signers per doc — one inArray scan.
|
|
|
|
|
|
const pendingSignerRows = await db
|
|
|
|
|
|
.select()
|
|
|
|
|
|
.from(documentSigners)
|
|
|
|
|
|
.where(and(inArray(documentSigners.documentId, docIds), eq(documentSigners.status, 'pending')))
|
|
|
|
|
|
.orderBy(sql`${documentSigners.signingOrder} ASC`);
|
|
|
|
|
|
const pendingByDoc = new Map<string, typeof pendingSignerRows>();
|
|
|
|
|
|
for (const row of pendingSignerRows) {
|
|
|
|
|
|
const arr = pendingByDoc.get(row.documentId) ?? [];
|
|
|
|
|
|
arr.push(row);
|
|
|
|
|
|
pendingByDoc.set(row.documentId, arr);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Per-doc fire — at this point every per-row query is a Map.get.
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
for (const doc of activeDocs) {
|
|
|
|
|
|
try {
|
2026-05-05 20:41:23 +02:00
|
|
|
|
const due = isReminderDue({
|
|
|
|
|
|
status: doc.status,
|
|
|
|
|
|
documensoId: doc.documensoId,
|
|
|
|
|
|
remindersDisabled: doc.remindersDisabled,
|
|
|
|
|
|
reminderCadenceOverride: doc.reminderCadenceOverride,
|
|
|
|
|
|
templateCadenceDays: templateCadenceByType.get(doc.documentType) ?? null,
|
|
|
|
|
|
lastReminderAt: lastReminderByDoc.get(doc.id) ?? null,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!due) continue;
|
|
|
|
|
|
|
|
|
|
|
|
const pending = pendingByDoc.get(doc.id) ?? [];
|
|
|
|
|
|
const target = pending[0];
|
|
|
|
|
|
if (!target || !doc.documensoId) continue;
|
|
|
|
|
|
|
|
|
|
|
|
await documensoRemind(doc.documensoId, target.id, portId);
|
|
|
|
|
|
await db.insert(documentEvents).values({
|
|
|
|
|
|
documentId: doc.id,
|
|
|
|
|
|
eventType: 'reminder_sent',
|
|
|
|
|
|
signerId: target.id,
|
|
|
|
|
|
eventData: {
|
|
|
|
|
|
signerEmail: target.signerEmail,
|
|
|
|
|
|
signerRole: target.signerRole,
|
|
|
|
|
|
auto: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
} catch (err) {
|
2026-04-28 02:50:00 +02:00
|
|
|
|
logger.error({ err, documentId: doc.id, portId }, 'Reminder processing failed');
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|