feat(digest): add weekly capacity digest email cron

Sends a Monday digest to all ADMIN + MANAGER users with:
- Team utilization % for the next 4 weeks
- Overbooked resource count
- Open demand count
- Upcoming vacation count
- Top 5 most utilized resources

Route: GET /api/cron/weekly-digest (secured by CRON_SECRET).
HTML template and plain-text fallback included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:33:12 +02:00
parent 607af1a857
commit ab4ec91e02
4 changed files with 363 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ export { createNotification, createNotificationsForUsers } from "./lib/create-no
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
export { sendWeeklyDigest } from "./lib/weekly-digest.js";
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js";
@@ -0,0 +1,128 @@
export interface WeeklyDigestData {
weekLabel: string;
teamUtilizationPct: number;
overbookedCount: number;
openDemandCount: number;
upcomingVacationCount: number;
topResources: Array<{ name: string; utilizationPct: number }>;
appBaseUrl: string;
}
export function buildWeeklyDigestHtml(data: WeeklyDigestData): string {
const utilizationColor =
data.teamUtilizationPct >= 90
? "#d97706"
: data.teamUtilizationPct >= 70
? "#059669"
: "#6b7280";
const resourceRows = data.topResources
.map(
(r) =>
`<tr>
<td style="padding:6px 12px;font-size:13px;color:#374151">${r.name}</td>
<td style="padding:6px 12px;font-size:13px;color:#374151;text-align:right">${Math.round(r.utilizationPct)}%</td>
</tr>`,
)
.join("");
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:system-ui,-apple-system,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:24px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden">
<!-- Header -->
<tr>
<td style="background:#1e3a5f;padding:24px 32px">
<p style="margin:0;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.15em;color:#93c5fd">Weekly Digest</p>
<h1 style="margin:8px 0 0;font-size:20px;font-weight:700;color:#ffffff">CapaKraken — ${data.weekLabel}</h1>
</td>
</tr>
<!-- Stats row -->
<tr>
<td style="padding:24px 32px">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align:center;padding:0 8px">
<p style="margin:0;font-size:32px;font-weight:700;color:${utilizationColor}">${Math.round(data.teamUtilizationPct)}%</p>
<p style="margin:4px 0 0;font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.1em">Team Utilization</p>
</td>
<td style="text-align:center;padding:0 8px">
<p style="margin:0;font-size:32px;font-weight:700;color:${data.overbookedCount > 0 ? "#dc2626" : "#059669"}">${data.overbookedCount}</p>
<p style="margin:4px 0 0;font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.1em">Overbooked</p>
</td>
<td style="text-align:center;padding:0 8px">
<p style="margin:0;font-size:32px;font-weight:700;color:#d97706">${data.openDemandCount}</p>
<p style="margin:4px 0 0;font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.1em">Open Demand</p>
</td>
<td style="text-align:center;padding:0 8px">
<p style="margin:0;font-size:32px;font-weight:700;color:#6b7280">${data.upcomingVacationCount}</p>
<p style="margin:4px 0 0;font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.1em">On Vacation</p>
</td>
</tr>
</table>
</td>
</tr>
${
data.topResources.length > 0
? `<!-- Top utilization table -->
<tr>
<td style="padding:0 32px 24px">
<p style="margin:0 0 12px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.12em;color:#6b7280">Top Utilization</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden">
<thead>
<tr style="background:#f9fafb">
<th style="padding:8px 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:#6b7280;text-align:left">Resource</th>
<th style="padding:8px 12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:#6b7280;text-align:right">Utilization</th>
</tr>
</thead>
<tbody>${resourceRows}</tbody>
</table>
</td>
</tr>`
: ""
}
<!-- CTA -->
<tr>
<td style="padding:0 32px 32px;text-align:center">
<a href="${data.appBaseUrl}/timeline"
style="display:inline-block;background:#2563eb;color:#ffffff;font-weight:600;font-size:14px;padding:12px 28px;border-radius:8px;text-decoration:none">
Open Timeline
</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f9fafb;border-top:1px solid #e5e7eb;padding:16px 32px">
<p style="margin:0;font-size:11px;color:#9ca3af;text-align:center">
CapaKraken · Automated weekly digest · Sent every Monday
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
export function buildWeeklyDigestText(data: WeeklyDigestData): string {
return `CapaKraken Weekly Digest — ${data.weekLabel}
Team Utilization: ${Math.round(data.teamUtilizationPct)}%
Overbooked: ${data.overbookedCount}
Open Demand: ${data.openDemandCount}
Upcoming Vacation: ${data.upcomingVacationCount}
${data.topResources.length > 0 ? `Top Utilization:\n${data.topResources.map((r) => ` ${r.name}: ${Math.round(r.utilizationPct)}%`).join("\n")}\n` : ""}
Open Timeline: ${data.appBaseUrl}/timeline
`;
}
+194
View File
@@ -0,0 +1,194 @@
import type { WeeklyDigestData } from "./weekly-digest-template.js";
import { sendEmail } from "./email.js";
import { buildWeeklyDigestHtml, buildWeeklyDigestText } from "./weekly-digest-template.js";
import { getAppBaseUrl } from "./app-base-url.js";
import { listAssignmentBookings } from "@capakraken/application";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
import type { WeekdayAvailability } from "@capakraken/shared";
/** Structural DB client type — pass `prisma as any` from cron routes. */
type DbClient = {
user: {
findMany: (args: { where: Record<string, unknown>; select: Record<string, unknown> }) => Promise<Array<{ id: string; email: string; name: string | null }>>;
};
resource: {
findMany: (args: { where: Record<string, unknown>; select: Record<string, unknown>; take?: number }) => Promise<Array<{
id: string;
displayName: string;
availability: unknown;
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country: { code: string } | null;
metroCity: { name: string } | null;
}>>;
};
allocation: {
count: (args: { where: Record<string, unknown> }) => Promise<number>;
};
vacation: {
count: (args: { where: Record<string, unknown> }) => Promise<number>;
};
};
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function weekLabel(start: Date, end: Date): string {
const fmt = (d: Date) =>
d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
export async function sendWeeklyDigest(db: DbClient): Promise<{ sent: number; skipped: number }> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const periodStart = today;
const periodEnd = addDays(today, 27); // next 4 weeks
// Collect recipients: ADMIN + MANAGER users
const recipients = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } as Record<string, unknown> },
select: { id: true, email: true, name: true },
});
if (recipients.length === 0) {
return { sent: 0, skipped: 0 };
}
// Load active resources
const resources = await db.resource.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
take: 200,
});
// Compute utilization for each resource
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db as any,
resources.map((r) => ({
id: r.id,
availability: r.availability as unknown as WeekdayAvailability,
countryId: r.countryId,
countryCode: r.country?.code,
federalState: r.federalState,
metroCityId: r.metroCityId,
metroCityName: r.metroCity?.name,
})),
periodStart,
periodEnd,
);
const bookings = await listAssignmentBookings(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db as any,
{ startDate: periodStart, endDate: periodEnd, resourceIds: resources.map((r) => r.id) },
);
const bookingsByResource = new Map<string, typeof bookings>();
for (const b of bookings) {
if (!b.resourceId) continue;
const arr = bookingsByResource.get(b.resourceId) ?? [];
arr.push(b);
bookingsByResource.set(b.resourceId, arr);
}
let totalAvailable = 0;
let totalBooked = 0;
let overbookedCount = 0;
const resourceUtils: { name: string; utilizationPct: number }[] = [];
for (const r of resources) {
const availability = r.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(r.id);
const available = calculateEffectiveAvailableHours({ availability, periodStart, periodEnd, context });
const booked = (bookingsByResource.get(r.id) ?? []).reduce(
(sum, b) =>
sum +
calculateEffectiveBookedHours({
availability,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
periodStart,
periodEnd,
context,
}),
0,
);
totalAvailable += available;
totalBooked += booked;
if (booked > available + 0.5) overbookedCount++;
if (available > 0) {
resourceUtils.push({ name: r.displayName, utilizationPct: Math.min(100, (booked / available) * 100) });
}
}
const teamUtilizationPct = totalAvailable > 0 ? Math.min(100, (totalBooked / totalAvailable) * 100) : 0;
const topResources = resourceUtils
.sort((a, b) => b.utilizationPct - a.utilizationPct)
.slice(0, 5);
// Count open demands (placeholder allocations)
const openDemandCount = await db.allocation.count({
where: {
isPlaceholder: true,
status: { in: ["PROPOSED", "CONFIRMED"] as unknown as string },
endDate: { gte: today },
},
});
// Count upcoming vacations (next 14 days)
const vacationEnd = addDays(today, 14);
const upcomingVacationCount = await db.vacation.count({
where: {
startDate: { lte: vacationEnd },
endDate: { gte: today },
},
});
const appBaseUrl = getAppBaseUrl();
const digestData: WeeklyDigestData = {
weekLabel: weekLabel(periodStart, periodEnd),
teamUtilizationPct,
overbookedCount,
openDemandCount,
upcomingVacationCount,
topResources,
appBaseUrl,
};
const html = buildWeeklyDigestHtml(digestData);
const text = buildWeeklyDigestText(digestData);
const subject = `CapaKraken Weekly Digest — ${isoDate(today)}`;
let sent = 0;
let skipped = 0;
for (const user of recipients) {
const ok = await sendEmail({ to: user.email, subject, html, text });
if (ok) sent++;
else skipped++;
}
return { sent, skipped };
}