Bericht: vollstaendige Fehlerabsicherung + robustes Laden von pdfkit
Deploy App / deploy (push) Successful in 1m2s

Der gemeldete 500 kam OHNE JSON-Koerper -- der Client konnte deshalb nur
"Fehler 500" zeigen. Ursache: Der Absturz lag ausserhalb der bisherigen
try/catch-Bloecke. Jetzt liegt der GESAMTE Handler in einem try/catch,
jede Ursache wird protokolliert und im Klartext zurueckgegeben.

Zusaetzlich zwei Risiken entfernt:
- Die verschachtelte Abfrage (Plan -> Szenarien -> Plan -> Personen) war
  ein Ringbezug; Szenarien werden jetzt einzeln nachgeladen.
- pdfkit wird ueber einen Resolver geholt, der statischen Import,
  .default und createRequire durchprobiert -- unabhaengig davon, wie der
  Bundler CJS-Interop aufloest.

Neuer Test mit realistischem Plan (Immobilie, Schuld, AHV, PK, 3a,
Kopie-Szenario) -- laeuft lokal durch, schliesst die Datenform als
Ursache aus. 223 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:09:21 +02:00
parent e22f2cfa2b
commit 8b50f8d4ae
3 changed files with 195 additions and 90 deletions
+84 -83
View File
@@ -67,86 +67,88 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const cfg = parsed.data;
const plan = await prisma.plan.findFirst({
where: { id: planId, userId },
include: {
persons: { orderBy: { role: "asc" } },
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], include: planInclude },
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
},
});
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
// Nur Szenarien dieses Plans, in der Reihenfolge der Auswahl -- Basis zuerst.
const chosen = cfg.scenarioIds
.map((id) => plan.scenarios.find((s) => s.id === id))
.filter((s): s is (typeof plan.scenarios)[number] => !!s)
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
if (chosen.length === 0) {
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
}
const analyses =
cfg.analysisIds.length === 0
? []
: await prisma.savedAnalysis.findMany({
where: { id: { in: cfg.analysisIds }, planId },
orderBy: { createdAt: "asc" },
select: { name: true, type: true, result: true },
});
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
const config: ReportConfig = {
title: cfg.title,
metric: cfg.metric,
source: cfg.source,
scenarioIds: chosen.map((s) => s.id),
analysisIds: cfg.analysisIds,
comment: cfg.comment ?? null,
};
const model = buildReport({
planName: plan.name,
author: user?.username ?? "unbekannt",
createdAt: new Date(),
config,
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
household: {
householdType: plan.householdType,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
},
actuals: plan.actuals.map(
(a): ActualsSetInput => ({
id: a.id,
recordedOn: a.recordedOn.toISOString().slice(0, 10),
year: a.year,
comment: a.comment,
cash: a.cash,
values: a.values as Record<string, { value?: number; mortgage?: number }>,
})
),
origins: plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId }))),
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
});
// Rendern und Ablegen sind die beiden Schritte, die zur Laufzeit scheitern können
// (Schriftdaten, Grösse des Datensatzes). Ein nacktes 500 wäre hier nicht diagnostizierbar,
// deshalb wird die Ursache protokolliert UND zurückgegeben -- es ist ein Ein-Personen-
// Werkzeug, und der Nutzer ist der Einzige, der den Fehler melden kann.
let pdf: Buffer;
// Der GESAMTE weitere Ablauf liegt in einem try/catch. Ein unbehandelter Fehler ergäbe ein
// nacktes 500 ohne JSON-Körper -- der Client könnte dann nur «Fehler 500» melden, und die
// Ursache bliebe im Dunkeln.
try {
pdf = await renderReportPdf(model);
} catch (err) {
console.error("[reports] PDF-Erzeugung fehlgeschlagen", err);
return NextResponse.json(
{ error: `PDF-Erzeugung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` },
{ status: 500 }
);
}
const plan = await prisma.plan.findFirst({
where: { id: planId, userId },
include: {
persons: { orderBy: { role: "asc" } },
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], select: { id: true, name: true, isBase: true } },
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
},
});
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
// Szenarien einzeln nachladen statt über eine verschachtelte Abfrage: Plan -> Szenarien ->
// Plan -> Personen wäre ein Ringbezug, und ein paar Abfragen mehr sind hier belanglos.
const wanted = plan.scenarios
.filter((s) => cfg.scenarioIds.includes(s.id))
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
if (wanted.length === 0) {
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
}
const chosen = (
await Promise.all(wanted.map((s) => prisma.scenario.findUnique({ where: { id: s.id }, include: planInclude })))
).filter((s): s is NonNullable<typeof s> => !!s);
if (chosen.length === 0) {
return NextResponse.json({ error: "Szenarien konnten nicht geladen werden." }, { status: 400 });
}
// Element-Herkunft aller Szenarien -- Grundlage der Ist-Zuordnung.
const allElements = await prisma.financialElement.findMany({
where: { scenario: { planId } },
select: { id: true, sourceElementId: true },
});
const analyses =
cfg.analysisIds.length === 0
? []
: await prisma.savedAnalysis.findMany({
where: { id: { in: cfg.analysisIds }, planId },
orderBy: { createdAt: "asc" },
select: { name: true, type: true, result: true },
});
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
const config: ReportConfig = {
title: cfg.title,
metric: cfg.metric,
source: cfg.source,
scenarioIds: chosen.map((s) => s.id),
analysisIds: cfg.analysisIds,
comment: cfg.comment ?? null,
};
const model = buildReport({
planName: plan.name,
author: user?.username ?? "unbekannt",
createdAt: new Date(),
config,
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
household: {
householdType: plan.householdType,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
},
actuals: plan.actuals.map(
(a): ActualsSetInput => ({
id: a.id,
recordedOn: a.recordedOn.toISOString().slice(0, 10),
year: a.year,
comment: a.comment,
cash: a.cash,
values: a.values as Record<string, { value?: number; mortgage?: number }>,
})
),
origins: allElements,
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
});
const pdf = await renderReportPdf(model);
try {
const created = await prisma.report.create({
data: {
planId,
@@ -161,10 +163,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
});
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
} catch (err) {
console.error("[reports] Speichern fehlgeschlagen", err);
return NextResponse.json(
{ error: `Bericht konnte nicht gespeichert werden: ${err instanceof Error ? err.message : String(err)}` },
{ status: 500 }
);
// Jede Ursache landet hier -- Datenbank, Modellaufbau, Schriftdaten, Speichern.
console.error("[reports] Bericht fehlgeschlagen", err);
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
return NextResponse.json({ error: `Bericht fehlgeschlagen -- ${message}` }, { status: 500 });
}
}