All files / lib/communication/cron reminder-notifications.ts

100% Statements 39/39
95% Branches 19/20
100% Functions 7/7
100% Lines 36/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                11x 11x 11x 11x   11x 11x       10x 10x       10x     4x 4x       4x   1x     4x           13x 10x   12x 12x 11x   1x           1x                 10x   10x   11x 13x   12x 13x 13x     13x   13x 12x                                                       12x 12x           12x     1x       12x      
import { createDal } from "../../../dal";
import { getDb } from "../../../db";
import { NotificationService } from "../../../services/notification/notification.service";
import { CommunicationGateway } from "../gateway";
 
export async function checkDueReminders(
	env: CloudflareBindings,
): Promise<void> {
	const db = getDb(env.DB);
	const dal = createDal(db);
	const gateway = new CommunicationGateway(dal, env);
	const notificationService = new NotificationService(dal, env);
 
	const dueReminders = await dal.leadReminders.findDueReminders();
	if (dueReminders.length === 0) return;
 
	// Batch 1: single query to find all reminder_due logs from the last hour.
	// Replaces the per-reminder findRecentByEventAndMetadata call (was N queries).
	const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
	const recentLogs = await dal.notificationDeliveryLog.findAllRecentByEvent(
		"reminder_due",
		oneHourAgo,
	);
	const alreadyNotified = new Set<number>(
		recentLogs
			.map((log) => {
				try {
					const meta = JSON.parse(log.metadataJson ?? "{}") as Record<
						string,
						unknown
					>;
					return typeof meta.reminderId === "number" ? meta.reminderId : null;
				} catch {
					return null;
				}
			})
			.filter((id): id is number => id !== null),
	);
 
	// Batch 2: fetch team members once per unique proId (parallel, not per reminder).
	// Replaces the per-reminder getProUsersWithContact call (was N queries; now U queries
	// where U = unique pros in this cron batch, which is typically much smaller than N).
	const uniqueProIds = [...new Set(dueReminders.map((r) => r.proId))];
	const teamMemberEntries = await Promise.all(
		uniqueProIds.map(async (proId) => {
			try {
				const members = await dal.userTenantRoles.getProUsersWithContact(proId);
				return [proId, members] as const;
			} catch (err) {
				console.error(
					"[REMINDER] Failed to fetch team members for pro",
					proId,
					":",
					err,
				);
				return [
					proId,
					[] as Awaited<
						ReturnType<typeof dal.userTenantRoles.getProUsersWithContact>
					>,
				] as const;
			}
		}),
	);
	const teamMembersByProId = new Map(teamMemberEntries);
 
	const portalUrl = env.PORTAL_URL || "https://portal.interioring.com";
 
	for (const { reminder, leadId, proId, customerName } of dueReminders) {
		if (alreadyNotified.has(reminder.id)) continue;
 
		const teamMembers = teamMembersByProId.get(proId) ?? [];
		const ownersAndManagers = teamMembers.filter(
			(m) => m.role === "owner" || m.role === "manager",
		);
 
		const leadUrl = `${portalUrl}/crm/leads/${leadId}`;
 
		for (const member of ownersAndManagers) {
			await gateway.send({
				channel: "email",
				recipient: member.email,
				eventType: "reminder_due",
				proId,
				userId: member.userId,
				content: {
					template: "reminder-due",
					subject: `Reminder due: ${reminder.title}`,
					props: {
						proName: member.name,
						reminderTitle: reminder.title,
						customerName,
						dueAt: reminder.dueAt.toLocaleString("en-IN", {
							timeZone: "Asia/Kolkata",
							dateStyle: "medium",
							timeStyle: "short",
						}),
						notes: reminder.notes ?? undefined,
						leadUrl,
					},
					recipientName: member.name,
				},
				metadata: { reminderId: reminder.id, leadId },
			});
		}
 
		// Send push notifications to owners/managers
		try {
			await notificationService.notify({
				proId,
				eventType: "reminder_due",
				title: `Reminder: ${reminder.title || "Follow up"}`,
				body: reminder.notes?.substring(0, 200) || "You have a reminder due",
				data: { url: `/crm/leads/${leadId}`, leadId },
				targetUserIds: ownersAndManagers.map((m) => m.userId),
			});
		} catch (err) {
			console.error("[REMINDER] Failed to send push notification:", err);
		}
 
		// Update status from "upcoming" to "overdue"
		await dal.leadReminders.update(reminder.id, { status: "overdue" });
	}
}