Files
Nexus/apps/web/src/app/api/reports/allocations/route.ts
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

145 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { NextResponse } from "next/server";
import { z } from "zod";
import { buildSplitAllocationReadModel } from "@nexus/application";
import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
import { prisma } from "@nexus/db";
import type { AllocationLike } from "@nexus/shared";
import { auth } from "~/server/auth.js";
import { AllocationReport } from "~/components/reports/AllocationReport.js";
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
// certainly malformed input and would generate nonsensical SQL range scans.
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
const queryParamsSchema = z.object({
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
format: z.enum(["pdf", "xlsx"]).default("pdf"),
});
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const userRole = (session.user as { role?: string }).role;
if (!userRole || !ALLOWED_ROLES.has(userRole)) {
return new NextResponse("Forbidden", { status: 403 });
}
const { searchParams } = new URL(request.url);
const parsed = queryParamsSchema.safeParse({
startDate: searchParams.get("startDate") ?? undefined,
endDate: searchParams.get("endDate") ?? undefined,
format: searchParams.get("format") ?? undefined,
});
if (!parsed.success) {
return new NextResponse("Invalid query parameters", { status: 400 });
}
const startDate = parsed.data.startDate ?? new Date();
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
if (endDate < startDate) {
return new NextResponse("endDate must be >= startDate", { status: 400 });
}
const format = parsed.data.format;
const [demandRequirements, assignments] = await Promise.all([
prisma.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
include: {
project: { select: { id: true, name: true, shortCode: true } },
},
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
take: 1000,
}),
prisma.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
include: {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true } },
},
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
take: 1000,
}),
]);
const allocationView = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const assignmentRows = allocationView.assignments.slice(0, 500);
const directory = await getAnonymizationDirectory(prisma);
const rows = assignmentRows.map(
(
a: AllocationLike & {
resource?: { id: string; displayName?: string | null } | null;
project?: { shortCode: string; name: string } | null;
},
) => {
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
return {
resourceName: resource?.displayName ?? "Unknown",
projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project",
role: a.role ?? "",
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
hoursPerDay: a.hoursPerDay,
dailyCostCents: a.dailyCostCents,
};
},
);
const ts = Date.now();
if (format === "xlsx") {
const workbookRows = [
["Resource", "Project", "Role", "Start Date", "End Date", "Hours/Day", "Daily Cost (ct)"],
...rows.map((row) => [
row.resourceName,
row.projectName,
row.role,
row.startDate,
row.endDate,
row.hoursPerDay,
row.dailyCostCents,
]),
];
const buffer = Buffer.from(await createWorkbookArrayBuffer("Allocations", workbookRows));
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="allocations-${ts}.xlsx"`,
},
});
}
const title = `Allocation Report ${startDate.toLocaleDateString("en-GB")} ${endDate.toLocaleDateString("en-GB")}`;
const generatedAt = new Date().toLocaleString("en-GB");
const doc = createElement(AllocationReport, { title, generatedAt, rows });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buffer = await renderToBuffer(doc as any);
return new NextResponse(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="allocations-${ts}.pdf"`,
},
});
}