Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s

Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

Modell:
- Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase).
- Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit
  abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt.
- Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen
  als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein.
- Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId,
  sourceElementId). Ueber den Namen zu matchen waere fragil gewesen.

Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live):
- geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile.
- Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert,
  Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix.
- Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im
  Dunkelschema unbrauchbar.

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

Datenmodell/Migration:
- Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle
  Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/
  FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben
  Behaelter zugeordnet, auch mehrfach verschachtelte.
- Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process),
  inkl. verschachtelter Szenarien und Cascade. Der Test ist als
  migrations.test.ts committet und sichert kuenftige Migrationen ab.

API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
@@ -16,7 +16,7 @@ export async function PUT(
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } });
const phase = await prisma.phase.findFirst({ where: { id: phaseId, scenarioId: element.scenarioId } });
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json();
@@ -16,7 +16,7 @@ export async function PUT(
const element = await getOwnedElement(elementId, userId);
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } });
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, scenarioId: element.scenarioId } });
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const body = await request.json();
+5 -5
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { maxPhaseDuration } from "@/lib/calculations";
@@ -28,9 +28,9 @@ export async function PUT(
let duration = parsed.data.durationYears;
if (duration != null) {
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
const plan = await getOwnedPlan(existing.planId, userId);
if (plan) {
const planInput = toPlanInput(plan);
const scenario = await getOwnedScenario(existing.scenarioId, userId);
if (scenario) {
const planInput = toPlanInput(scenario);
const yearsBefore = planInput.phases
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
.reduce((s, p) => s + p.durationYears, 0);
@@ -63,7 +63,7 @@ export async function DELETE(
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
const later = await prisma.phase.findFirst({
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } },
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
});
if (later) {
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
+9 -66
View File
@@ -1,41 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
// Der Plan ist nur noch der Behaelter: er traegt den Namen; die Finanzdaten liegen im Szenario.
const patchSchema = z.object({ name: z.string().min(1).max(120) });
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const planInput = toPlanInput(plan);
const computed = computePlan(planInput);
return NextResponse.json({ plan: planInput, computed });
}
// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
const patchSchema = z.union([
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
z.object({
name: z.string().min(1).max(120).optional(),
initialCash: z.number().min(0).max(1_000_000_000).optional(),
}),
]);
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
@@ -43,50 +14,22 @@ export async function PATCH(
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = patchSchema.safeParse(body);
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const data = parsed.data;
const hasProfile = "householdType" in data;
if (hasProfile) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
const updated = await prisma.$transaction(async (tx) => {
await tx.person.deleteMany({ where: { planId: plan.id } });
return tx.plan.update({
where: { id: plan.id },
data: {
name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault,
persons: { create: data.persons },
},
});
});
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
const updated = await prisma.plan.update({
where: { id: plan.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
// Loescht den Plan inkl. aller Szenarien (Cascade).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
await prisma.plan.delete({ where: { id: plan.id } });
return NextResponse.json({ ok: true });
}
@@ -1,102 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
const scenarioSchema = z.object({
name: z.string().min(1).max(120),
branchFromPhaseId: z.string().min(1),
});
// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.).
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const body = await request.json();
const parsed = scenarioSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const source = await getOwnedPlan(planId, userId);
if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
const copiedPhases = source.phases
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id));
const newPlanId = await prisma.$transaction(async (tx) => {
const newPlan = await tx.plan.create({
data: {
userId,
name: parsed.data.name,
householdType: source.householdType,
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
parentPlanId: source.id,
persons: {
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
},
},
});
// Phasen kopieren (alte -> neue Id).
const phaseIdMap = new Map<string, string>();
let lastNewPhaseId = "";
for (const phase of copiedPhases) {
const created = await tx.phase.create({
data: {
planId: newPlan.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
// Cash-Entscheid (einmalige Sonderein-/ausgaben) mitkopieren.
cashTransition: phase.cashTransition ?? undefined,
},
});
phaseIdMap.set(phase.id, created.id);
lastNewPhaseId = created.id;
}
// Elemente + deren Phasen-/Uebergangswerte kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
planId: newPlan.id,
category: el.category,
name: el.name,
ownerRole: el.ownerRole,
orderIndex: el.orderIndex,
},
});
for (const pv of el.phaseValues) {
const newPhaseId = phaseIdMap.get(pv.phaseId);
if (!newPhaseId) continue;
await tx.elementPhaseValue.create({
data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object },
});
}
for (const tv of el.transitionValues) {
if (!copiedPhaseIds.has(tv.fromPhaseId)) continue;
const newFromId = phaseIdMap.get(tv.fromPhaseId);
if (!newFromId) continue;
await tx.elementTransitionValue.create({
data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object },
});
}
}
await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } });
return newPlan.id;
});
return NextResponse.json({ planId: newPlanId }, { status: 201 });
}
+28 -24
View File
@@ -10,18 +10,17 @@ const personSchema = z.object({
retirementAge: z.number().int().min(30).max(100),
});
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation).
export const planProfileSchema = z.object({
// Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein
// anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario).
export const scenarioProfileSchema = z.object({
householdType: z.enum(["SINGLE", "COUPLE"]),
inflationRateDefault: z.number().min(-20).max(50),
persons: z.array(personSchema).min(1).max(2),
});
const createPlanSchema = z
.object({ name: z.string().min(1).max(120) })
.and(planProfileSchema);
const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema);
export function validatePersonsForType(data: z.infer<typeof planProfileSchema>): string | null {
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
return "Einzelperson-Plan benoetigt genau eine Person.";
}
@@ -31,40 +30,35 @@ export function validatePersonsForType(data: z.infer<typeof planProfileSchema>):
return null;
}
// Liste der Plaene mit ihrem Szenario-Baum (nur Kopfdaten).
export async function GET() {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const plans = await prisma.plan.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
parentPlanId: true,
branchFromPhaseId: true,
createdAt: true,
phases: {
select: { id: true, name: true, sequenceNumber: true },
orderBy: { sequenceNumber: "asc" },
scenarios: {
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
},
},
});
return NextResponse.json({ plans });
}
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil traegt.
export async function POST(request: NextRequest) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const body = await request.json();
const parsed = createPlanSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 });
@@ -72,11 +66,21 @@ export async function POST(request: NextRequest) {
data: {
userId,
name: parsed.data.name,
householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault,
persons: { create: parsed.data.persons },
scenarios: {
create: {
name: "Basisszenario",
isBase: true,
householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault,
persons: { create: parsed.data.persons },
},
},
},
include: { scenarios: true },
});
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 });
return NextResponse.json(
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
{ status: 201 }
);
}
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
const copySchema = z.object({ name: z.string().min(1).max(120) });
// Erstellt ein neues Szenario als vollstaendige Kopie eines bestehenden. Das Original wird
// zum Elternteil -- damit haengt der Baum in der Seitenleiste und der Diff hat seine Basis.
// Jede kopierte Phase/jedes kopierte Element traegt einen Herkunfts-Verweis auf sein
// Gegenstueck im Original; darauf beruht die Abweichungs-Markierung im UI.
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const parsed = copySchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const source = await getOwnedScenario(scenarioId, userId);
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
const newId = await prisma.$transaction(async (tx) => {
const created = await tx.scenario.create({
data: {
planId: source.planId,
name: parsed.data.name,
isBase: false,
parentScenarioId: source.id,
householdType: source.householdType,
inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash,
persons: {
create: source.persons.map((p) => ({
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
})),
},
},
});
// Phasen kopieren (alte -> neue Id merken, fuer die Werte-Zuordnung).
const phaseIdMap = new Map<string, string>();
for (const phase of source.phases) {
const p = await tx.phase.create({
data: {
scenarioId: created.id,
sequenceNumber: phase.sequenceNumber,
name: phase.name,
durationYears: phase.durationYears,
cashTransition: phase.cashTransition ?? undefined,
sourcePhaseId: phase.id,
},
});
phaseIdMap.set(phase.id, p.id);
}
// Elemente inkl. Phasen- und Uebergangswerten kopieren.
for (const el of source.elements) {
const newEl = await tx.financialElement.create({
data: {
scenarioId: created.id,
category: el.category,
name: el.name,
ownerRole: el.ownerRole,
orderIndex: el.orderIndex,
sourceElementId: el.id,
},
});
for (const pv of el.phaseValues) {
const phaseId = phaseIdMap.get(pv.phaseId);
if (!phaseId) continue;
await tx.elementPhaseValue.create({
data: { elementId: newEl.id, phaseId, data: pv.data as object },
});
}
for (const tv of el.transitionValues) {
const fromPhaseId = phaseIdMap.get(tv.fromPhaseId);
if (!fromPhaseId) continue;
await tx.elementTransitionValue.create({
data: { elementId: newEl.id, fromPhaseId, data: tv.data as object },
});
}
}
return created.id;
});
return NextResponse.json({ scenarioId: newId }, { status: 201 });
}
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan } from "@/lib/queries";
import { getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
@@ -20,17 +20,17 @@ const createSchema = z.object({
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(),
});
// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie.
// Legt ein neues finanzielles Element (szenario-weit) an. Personen-Pflicht je Kategorie.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
{ params }: { params: Promise<{ scenarioId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const body = await request.json();
const parsed = createSchema.safeParse(body);
@@ -51,13 +51,13 @@ export async function POST(
}
const maxOrder = await prisma.financialElement.aggregate({
where: { planId: plan.id },
where: { scenarioId: scenario.id },
_max: { orderIndex: true },
});
const element = await prisma.financialElement.create({
data: {
planId: plan.id,
scenarioId: scenario.id,
category,
name,
ownerRole,
@@ -1,31 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan, planToCsv } from "@/lib/calculations";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
{ params }: { params: Promise<{ scenarioId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) {
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
}
const { planId } = await params;
const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) {
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) {
return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
}
const planInput = toPlanInput(plan);
const planInput = toPlanInput(scenario);
const computed = computePlan(planInput);
const csv = planToCsv(planInput, computed);
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
"Content-Disposition": `attachment; filename="${scenario.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
},
});
}
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getOwnedPlan, toPlanInput } from "@/lib/queries";
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { Prisma } from "@/generated/prisma/client";
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
@@ -17,20 +17,20 @@ const createPhaseSchema = z.object({
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ planId: string }> }
{ params }: { params: Promise<{ scenarioId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const { scenarioId } = await params;
const plan = await getOwnedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const scenario = await getOwnedScenario(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const body = await request.json().catch(() => ({}));
const parsed = createPhaseSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const planInput = toPlanInput(plan);
const planInput = toPlanInput(scenario);
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
@@ -54,7 +54,7 @@ export async function POST(
const phase = await prisma.$transaction(async (tx) => {
const created = await tx.phase.create({
data: {
planId: plan.id,
scenarioId: scenario.id,
sequenceNumber: nextSequence,
name: defaultName,
durationYears: duration,
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
import { getCurrentUserId } from "@/lib/session";
import { computePlan } from "@/lib/calculations";
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
// Liefert das Szenario samt Berechnung -- und zusaetzlich das ELTERN-Szenario als
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await getOwnedScenarioWithMeta(scenarioId, userId);
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const planInput = toPlanInput(scenario);
const computed = computePlan(planInput);
let base: ReturnType<typeof toPlanInput> | null = null;
if (scenario.parentScenarioId) {
const parent = await getOwnedScenario(scenario.parentScenarioId, userId);
if (parent) base = toPlanInput(parent);
}
return NextResponse.json({
plan: planInput,
computed,
base,
meta: {
id: scenario.id,
planId: scenario.planId,
planName: scenario.plan.name,
name: scenario.name,
isBase: scenario.isBase,
parentScenarioId: scenario.parentScenarioId,
},
});
}
// Name/Cash-Anfangswert ODER das ganze Grundprofil (Haushaltsform/Personen/Inflation).
const patchSchema = z.union([
scenarioProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
z.object({
name: z.string().min(1).max(120).optional(),
initialCash: z.number().min(0).max(1_000_000_000).optional(),
}),
]);
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
const data = parsed.data;
if ("householdType" in data) {
const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 });
const updated = await prisma.$transaction(async (tx) => {
await tx.person.deleteMany({ where: { scenarioId: scenario.id } });
return tx.scenario.update({
where: { id: scenario.id },
data: {
name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault,
persons: { create: data.persons },
},
});
});
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
const updated = await prisma.scenario.update({
where: { id: scenario.id },
data: {
name: "name" in data ? data.name : undefined,
initialCash:
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
},
});
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
}
// Loescht ein Szenario. Das Basisszenario kann nicht geloescht werden (dafuer den Plan loeschen).
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { scenarioId } = await params;
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
if (scenario.isBase) {
return NextResponse.json(
{ error: "Das Basisszenario kann nicht geloescht werden. Loeschen Sie stattdessen den Plan." },
{ status: 400 }
);
}
await prisma.scenario.delete({ where: { id: scenario.id } });
return NextResponse.json({ ok: true });
}