// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht, // wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet. import { prisma } from "@/lib/db"; import { planInclude, toPlanInput } from "@/lib/queries"; import { assessRestore, decideVersion, nextMajor, planRestore, restoreComment, type LatestVersion, type RestoreImpact, type VersionRef, } from "@/lib/versioning"; import type { PlanInput } from "@/lib/types"; import type { Prisma } from "@/generated/prisma/client"; const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue; async function loadPlanInput(scenarioId: string): Promise { const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude }); return scenario ? toPlanInput(scenario) : null; } async function loadLatest(scenarioId: string): Promise { const v = await prisma.scenarioVersion.findFirst({ where: { scenarioId }, orderBy: [{ major: "desc" }, { minor: "desc" }], }); if (!v) return null; return { id: v.id, major: v.major, minor: v.minor, isMajor: v.isMajor, createdById: v.createdById, updatedAt: v.updatedAt, snapshot: v.snapshot as unknown as PlanInput, }; } // Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung // eine neue Nebenversion an, aktualisiert die laufende oder tut nichts. // // Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche // Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht // der Zweck der Anfrage. export async function touchScenario(scenarioId: string, userId: string): Promise { try { const next = await loadPlanInput(scenarioId); if (!next) return; const latest = await loadLatest(scenarioId); const decision = decideVersion(latest, next, userId, new Date()); if (decision.action === "skip") return; if (decision.action === "update") { await prisma.scenarioVersion.update({ where: { id: decision.versionId }, data: { snapshot: asJson(next) }, }); return; } await prisma.scenarioVersion.create({ data: { scenarioId, major: decision.major, minor: decision.minor, createdById: userId, snapshot: asJson(next), }, }); } catch (err) { console.error("Versionierung fehlgeschlagen", { scenarioId, err }); } } // Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie // zusammengefasst und nie nachtraeglich veraendert. export async function createMajorVersion( scenarioId: string, userId: string, comment: string ): Promise { const snapshot = await loadPlanInput(scenarioId); if (!snapshot) return null; const latest = await loadLatest(scenarioId); const ref = nextMajor(latest); await prisma.$transaction([ prisma.scenarioVersion.create({ data: { scenarioId, major: ref.major, minor: ref.minor, comment: comment.trim(), isMajor: true, createdById: userId, snapshot: asJson(snapshot), }, }), prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }), ]); return ref; } // Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem // eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann. export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise { const children = await prisma.scenario.findMany({ where: { parentScenarioId: scenarioId }, select: { name: true, elements: { select: { sourceElementId: true } }, phases: { select: { sourcePhaseId: true } }, }, }); return assessRestore( snapshot, children.map((c) => ({ name: c.name, elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x), phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x), })) ); } // Setzt das Szenario auf den Stand einer Version zurueck. // // IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier // neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt // auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als // "geaendert" gelten. // // Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an. export async function restoreVersion( scenarioId: string, versionId: string, userId: string ): Promise { const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } }); if (!version) return null; const snap = version.snapshot as unknown as PlanInput; // Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird // der Plan nur noch ausgefuehrt. const [curPhases, curElements, curPersons] = await Promise.all([ prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }), prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }), prisma.person.findMany({ where: { scenarioId }, select: { role: true } }), ]); const plan = planRestore(snap, { phaseIds: curPhases.map((p) => p.id), elementIds: curElements.map((e) => e.id), personRoles: curPersons.map((p) => p.role), }); const phaseById = new Map(snap.phases.map((p) => [p.id, p])); const elementById = new Map(snap.elements.map((e) => [e.id, e])); await prisma.$transaction(async (tx) => { // Profil. await tx.scenario.update({ where: { id: scenarioId }, data: { householdType: snap.householdType, inflationRateDefault: snap.inflationRateDefault, initialCash: snap.initialCash, startYear: snap.startYear ?? null, }, }); // Personen: an der Rolle festgemacht (je Szenario eindeutig). for (const p of snap.persons) { await tx.person.upsert({ where: { scenarioId_role: { scenarioId, role: p.role } }, create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge }, update: { name: p.name, age: p.age, retirementAge: p.retirementAge }, }); } if (plan.deletePersonRoles.length > 0) { await tx.person.deleteMany({ where: { scenarioId, role: { in: plan.deletePersonRoles as never } }, }); } // Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade). if (plan.deletePhaseIds.length > 0) { await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } }); } if (plan.deleteElementIds.length > 0) { await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } }); } // Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und // beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden. for (const [i, id] of plan.parkPhaseIds.entries()) { await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } }); } const phaseData = (id: string) => { const ph = phaseById.get(id)!; return { sequenceNumber: ph.sequenceNumber, name: ph.name, durationYears: ph.durationYears, cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue, sourcePhaseId: ph.sourcePhaseId ?? null, }; }; for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) }); for (const id of plan.createPhaseIds) { await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } }); } const elementData = (id: string) => { const el = elementById.get(id)!; return { category: el.category, name: el.name, ownerRole: el.ownerRole ?? null, orderIndex: el.orderIndex, sourceElementId: el.sourceElementId ?? null, }; }; for (const id of plan.updateElementIds) { await tx.financialElement.update({ where: { id }, data: elementData(id) }); } for (const id of plan.createElementIds) { await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } }); } // Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit. const allElementIds = [...plan.updateElementIds, ...plan.createElementIds]; if (allElementIds.length > 0) { await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } }); await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } }); } for (const { elementId, phaseId } of plan.phaseValueKeys) { await tx.elementPhaseValue.create({ data: { elementId, phaseId, data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue, }, }); } for (const { elementId, fromPhaseId } of plan.transitionValueKeys) { await tx.elementTransitionValue.create({ data: { elementId, fromPhaseId, data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue, }, }); } }); // Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und // ohne dass irgendetwas aus der Historie verloren geht. const latest = await loadLatest(scenarioId); const current = await loadPlanInput(scenarioId); if (!current) return null; const created = await prisma.scenarioVersion.create({ data: { scenarioId, major: latest?.major ?? 1, minor: (latest?.minor ?? 0) + 1, comment: restoreComment({ major: version.major, minor: version.minor }), createdById: userId, snapshot: asJson(current), }, }); return { major: created.major, minor: created.minor }; }