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:
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { sendWeeklyDigest } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/cron/weekly-digest
|
||||
*
|
||||
* Sends a weekly capacity digest email to all ADMIN and MANAGER users.
|
||||
* Contains team utilization, overbooking count, open demand count, and
|
||||
* upcoming vacations for the next 4 weeks.
|
||||
*
|
||||
* Secure with CRON_SECRET: requests must include `Authorization: Bearer <secret>`.
|
||||
* Schedule: run every Monday morning (e.g. `0 7 * * 1` in cron syntax).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const deny = verifyCronSecret(request);
|
||||
if (deny) return deny;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await sendWeeklyDigest(prisma as any);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...result,
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, route: "/api/cron/weekly-digest" }, "Weekly digest cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user