From ab4ec91e020fbbc3d8f4746863730c4f3f2892bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 13:33:12 +0200 Subject: [PATCH] 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 --- .../src/app/api/cron/weekly-digest/route.ts | 40 ++++ packages/api/src/index.ts | 1 + .../api/src/lib/weekly-digest-template.ts | 128 ++++++++++++ packages/api/src/lib/weekly-digest.ts | 194 ++++++++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 apps/web/src/app/api/cron/weekly-digest/route.ts create mode 100644 packages/api/src/lib/weekly-digest-template.ts create mode 100644 packages/api/src/lib/weekly-digest.ts diff --git a/apps/web/src/app/api/cron/weekly-digest/route.ts b/apps/web/src/app/api/cron/weekly-digest/route.ts new file mode 100644 index 0000000..27cfe96 --- /dev/null +++ b/apps/web/src/app/api/cron/weekly-digest/route.ts @@ -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 `. + * 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 }, + ); + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 98c5c35..e345156 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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"; diff --git a/packages/api/src/lib/weekly-digest-template.ts b/packages/api/src/lib/weekly-digest-template.ts new file mode 100644 index 0000000..0425b5a --- /dev/null +++ b/packages/api/src/lib/weekly-digest-template.ts @@ -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) => + ` + ${r.name} + ${Math.round(r.utilizationPct)}% + `, + ) + .join(""); + + return ` + + + + + +
+ + + + + + + + + + + + + ${ + data.topResources.length > 0 + ? ` + + + ` + : "" + } + + + + + + + + + + + +
+

Weekly Digest

+

CapaKraken — ${data.weekLabel}

+
+ + + + + + + +
+

${Math.round(data.teamUtilizationPct)}%

+

Team Utilization

+
+

${data.overbookedCount}

+

Overbooked

+
+

${data.openDemandCount}

+

Open Demand

+
+

${data.upcomingVacationCount}

+

On Vacation

+
+
+

Top Utilization

+ + + + + + + + ${resourceRows} +
ResourceUtilization
+
+ + Open Timeline + +
+

+ CapaKraken · Automated weekly digest · Sent every Monday +

+
+
+ +`; +} + +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 +`; +} diff --git a/packages/api/src/lib/weekly-digest.ts b/packages/api/src/lib/weekly-digest.ts new file mode 100644 index 0000000..f580975 --- /dev/null +++ b/packages/api/src/lib/weekly-digest.ts @@ -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; select: Record }) => Promise>; + }; + resource: { + findMany: (args: { where: Record; select: Record; take?: number }) => Promise>; + }; + allocation: { + count: (args: { where: Record }) => Promise; + }; + vacation: { + count: (args: { where: Record }) => Promise; + }; +}; + +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 }, + 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(); + 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 }; +}