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 { checkBudgetThresholds } from "./lib/budget-alerts.js";
|
||||||
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
|
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
|
||||||
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
|
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
|
||||||
|
export { sendWeeklyDigest } from "./lib/weekly-digest.js";
|
||||||
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
|
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
|
||||||
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
|
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
|
||||||
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.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