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
- @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>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
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"`,
|
||
},
|
||
});
|
||
}
|