Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster, unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar. Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos, bei Monte-Carlo je Szenario einzeln. Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug trotzdem bricht, warnt der Dialog vorher namentlich. Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus. Migration gegen echtes Postgres verifiziert. Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { phaseDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
@@ -22,6 +23,7 @@ export async function PATCH(
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -37,5 +39,6 @@ export async function DELETE(
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.financialElement.delete({ where: { id: element.id } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { transitionDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { cashTransitionSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
|
||||
@@ -27,5 +28,6 @@ export async function PUT(
|
||||
data: { cashTransition: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
|
||||
const updatePhaseSchema = z.object({
|
||||
@@ -47,6 +48,7 @@ export async function PUT(
|
||||
durationYears: duration ?? undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(existing.scenarioId, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } });
|
||||
}
|
||||
|
||||
@@ -70,5 +72,6 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
await prisma.phase.delete({ where: { id: phaseId } });
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const personSchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
@@ -81,6 +82,8 @@ export async function POST(request: NextRequest) {
|
||||
include: { scenarios: true },
|
||||
});
|
||||
|
||||
// Das frisch angelegte Basisszenario startet mit Version 1.0.
|
||||
await touchScenario(plan.scenarios[0].id, userId);
|
||||
return NextResponse.json(
|
||||
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
|
||||
{ status: 201 }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
@@ -90,5 +91,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return created.id;
|
||||
});
|
||||
|
||||
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
|
||||
await touchScenario(newId, userId);
|
||||
return NextResponse.json({ scenarioId: newId }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -65,5 +66,6 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||
import { num, type PhaseData } from "@/lib/elements";
|
||||
@@ -74,6 +75,7 @@ export async function POST(
|
||||
return created;
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
@@ -77,6 +78,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
},
|
||||
});
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
|
||||
return prisma.scenarioVersion.findFirst({
|
||||
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
|
||||
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const snapshot = version.snapshot as unknown as PlanInput;
|
||||
|
||||
return NextResponse.json({
|
||||
version: {
|
||||
id: version.id,
|
||||
major: version.major,
|
||||
minor: version.minor,
|
||||
comment: version.comment,
|
||||
isMajor: version.isMajor,
|
||||
createdAt: version.createdAt,
|
||||
},
|
||||
plan: snapshot,
|
||||
computed: computePlan(snapshot),
|
||||
impact: await restoreImpact(scenarioId, snapshot),
|
||||
});
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
|
||||
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const ref = await restoreVersion(scenarioId, versionId, userId);
|
||||
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { createMajorVersion } from "@/lib/versioning-db";
|
||||
import { isValidMajorComment } from "@/lib/versioning";
|
||||
|
||||
async function ownedScenario(scenarioId: string, userId: string) {
|
||||
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
}
|
||||
|
||||
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
|
||||
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
|
||||
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 ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.scenarioVersion.findMany({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
major: true,
|
||||
minor: true,
|
||||
comment: true,
|
||||
isMajor: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
currentMajor: scenario.currentMajor,
|
||||
versions: rows.map((r) => ({
|
||||
id: r.id,
|
||||
major: r.major,
|
||||
minor: r.minor,
|
||||
comment: r.comment,
|
||||
isMajor: r.isMajor,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
|
||||
// Hauptversion ohne Begründung wäre nur eine Zahl.
|
||||
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 scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = majorSchema.safeParse(await request.json());
|
||||
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
|
||||
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
|
||||
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref }, { status: 201 });
|
||||
}
|
||||
Reference in New Issue
Block a user