From c54870ca84157433a0c186f3fd6134133aaaf1b1 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Wed, 29 Apr 2026 15:40:01 +1000 Subject: [PATCH 1/2] feat(#233): expand snapshot JSON to capture timeline state (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Snapshot JSON now versioned (schemaVersion: 2) - Captures: project settings, resource types, named resources, timeline entries, story timeline entries, epic deps, feature deps, overhead items - Rollback restores all captured state in a transaction - Auto-creates pre_rollback snapshot before applying any rollback - CSV import snapshot now uses shared buildSnapshot() to ensure full state capture - Backwards compatible with v1 (array-shape) snapshots — legacy ones treated as epics-only Prerequisite for #233 Phase 2-4 (Resource Optimiser). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/src/routes/csv.ts | 11 +- server/src/routes/snapshots.ts | 467 +++++++++++++++++++++++++++++---- server/src/test/setup.ts | 22 +- 3 files changed, 438 insertions(+), 62 deletions(-) diff --git a/server/src/routes/csv.ts b/server/src/routes/csv.ts index c0f7ad2..90e4c69 100644 --- a/server/src/routes/csv.ts +++ b/server/src/routes/csv.ts @@ -6,6 +6,7 @@ import { parse } from 'csv-parse/sync' import { stringify } from 'csv-stringify/sync' import { calcDurationDays } from '../utils/round.js' import { pruneSnapshots } from '../lib/snapshotUtils.js' +import { buildSnapshot } from './snapshots.js' const router = Router({ mergeParams: true }) router.use(authenticate) @@ -518,18 +519,16 @@ router.post('/import-csv', asyncHandler(async (req: AuthRequest, res: Response) rtByName.set(rtName.toLowerCase(), newRt) } - // Auto-snapshot before import - const existingEpics = await prisma.epic.findMany({ - where: { projectId }, - include: { features: { include: { userStories: { include: { tasks: { include: { resourceType: true } } } } } } }, - }) + // Auto-snapshot before import using shared buildSnapshot() for full state capture + const existingEpics = await prisma.epic.findMany({ where: { projectId }, select: { id: true } }) if (existingEpics.length > 0) { + const snapshotData = await buildSnapshot(projectId) await prisma.backlogSnapshot.create({ data: { projectId, label: 'Auto-snapshot before CSV import', trigger: 'csv_import', - snapshot: existingEpics as unknown as object, + snapshot: snapshotData as unknown as object, createdById: req.userId!, }, }) diff --git a/server/src/routes/snapshots.ts b/server/src/routes/snapshots.ts index 82e2947..f68fda3 100644 --- a/server/src/routes/snapshots.ts +++ b/server/src/routes/snapshots.ts @@ -8,8 +8,20 @@ import { pruneSnapshots } from '../lib/snapshotUtils.js' const router = Router({ mergeParams: true }) router.use(authenticate) -// Build the full backlog JSON for a project -async function buildSnapshot(projectId: string) { +// --------------------------------------------------------------------------- +// Snapshot shape (schemaVersion 2) +// Documented trigger values: +// 'manual' — user-initiated from the UI +// 'csv_import' — auto-saved before a CSV import +// 'template_apply' — auto-saved before applying a template +// 'optimiser_apply' — auto-saved before the optimiser applies a scenario (Phase 2+) +// 'pre_rollback' — auto-saved before rolling back to a prior snapshot (reversible rollback) +// --------------------------------------------------------------------------- + +/** Epic tree shape returned by the backlog query */ +type EpicTree = Awaited> + +async function fetchEpics(projectId: string) { return prisma.epic.findMany({ where: { projectId }, orderBy: { order: 'asc' }, @@ -32,6 +44,109 @@ async function buildSnapshot(projectId: string) { }) } +/** Build the full project snapshot (schemaVersion 2). */ +async function buildSnapshot(projectId: string) { + const [ + epics, + project, + resourceTypes, + namedResources, + timelineEntries, + storyTimelineEntries, + epicDependencies, + featureDependencies, + overheadItems, + ] = await Promise.all([ + fetchEpics(projectId), + prisma.project.findUnique({ + where: { id: projectId }, + select: { startDate: true, onboardingWeeks: true, bufferWeeks: true, hoursPerDay: true }, + }), + prisma.resourceType.findMany({ + where: { projectId }, + select: { + id: true, + name: true, + category: true, + count: true, + hoursPerDay: true, + dayRate: true, + globalTypeId: true, + allocationMode: true, + allocationPercent: true, + allocationStartWeek: true, + allocationEndWeek: true, + }, + }), + prisma.namedResource.findMany({ + where: { resourceType: { projectId } }, + select: { + id: true, + resourceTypeId: true, + name: true, + startWeek: true, + endWeek: true, + allocationPct: true, + allocationMode: true, + allocationPercent: true, + allocationStartWeek: true, + allocationEndWeek: true, + pricingModel: true, + }, + }), + prisma.timelineEntry.findMany({ + where: { projectId }, + select: { featureId: true, startWeek: true, durationWeeks: true, isManual: true }, + }), + prisma.storyTimelineEntry.findMany({ + where: { projectId }, + select: { storyId: true, startWeek: true, durationWeeks: true, isManual: true }, + }), + prisma.epicDependency.findMany({ + where: { epic: { projectId } }, + select: { epicId: true, dependsOnId: true }, + }), + prisma.featureDependency.findMany({ + where: { feature: { epic: { projectId } } }, + select: { featureId: true, dependsOnId: true }, + }), + prisma.projectOverhead.findMany({ + where: { projectId }, + select: { name: true, type: true, value: true, resourceTypeId: true, order: true }, + }), + ]) + + return { + schemaVersion: 2 as const, + epics, + project: project + ? { + startDate: project.startDate, + onboardingWeeks: project.onboardingWeeks, + bufferWeeks: project.bufferWeeks, + hoursPerDay: project.hoursPerDay, + } + : null, + resourceTypes, + namedResources, + timelineEntries, + storyTimelineEntries, + epicDependencies, + featureDependencies, + overheadItems, + } +} + +/** Normalise snapshot data to extract the epics array regardless of schema version. */ +function extractEpics(snapshotData: unknown): EpicTree { + if (Array.isArray(snapshotData)) { + // Legacy v1 — snapshot is just the epics array + return snapshotData as EpicTree + } + const obj = snapshotData as { schemaVersion?: number; epics?: EpicTree } + return (obj.epics ?? []) as EpicTree +} + // GET /api/projects/:projectId/snapshots router.get('/', asyncHandler(async (req: AuthRequest, res: Response) => { const projectId = req.params.projectId as string @@ -89,10 +204,10 @@ router.get('/:snapshotId/diff', asyncHandler(async (req: AuthRequest, res: Respo const snap = await prisma.backlogSnapshot.findFirst({ where: { id: snapshotId, projectId } }) if (!snap) { res.status(404).json({ error: 'Snapshot not found' }); return } - const current = await buildSnapshot(projectId) + const currentSnapshot = await buildSnapshot(projectId) // Produce a simple flat diff of epic/feature/story/task names - const flatten = (epics: typeof current) => { + const flatten = (epics: EpicTree) => { const items: string[] = [] for (const e of epics) { items.push(`Epic: ${e.name}`) @@ -109,8 +224,10 @@ router.get('/:snapshotId/diff', asyncHandler(async (req: AuthRequest, res: Respo return items } - const snapItems = flatten(snap.snapshot as unknown as typeof current) - const currentItems = flatten(current) + const snapEpics = extractEpics(snap.snapshot) + const currentEpics = extractEpics(currentSnapshot) + const snapItems = flatten(snapEpics) + const currentItems = flatten(currentEpics) const snapSet = new Set(snapItems) const currentSet = new Set(currentItems) @@ -130,65 +247,313 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: const snap = await prisma.backlogSnapshot.findFirst({ where: { id: snapshotId, projectId } }) if (!snap) { res.status(404).json({ error: 'Snapshot not found' }); return } - // Auto-snapshot current state before rollback - const currentData = await buildSnapshot(projectId) + // Auto-snapshot current state BEFORE rollback so it can itself be rolled back ('pre_rollback'). + const preRollbackData = await buildSnapshot(projectId) + const dateStr = new Date().toISOString().slice(0, 10) + const originalLabel = snap.label ?? snapshotId await prisma.backlogSnapshot.create({ data: { projectId, - label: 'Pre-rollback auto-snapshot', - trigger: 'rollback', - snapshot: currentData as any, + label: `Auto-saved before rollback to '${originalLabel}' — ${dateStr}`, + trigger: 'pre_rollback', + snapshot: preRollbackData as unknown as object, createdById: req.userId!, }, }) - // #177: enforce retention policy after auto-snapshot await pruneSnapshots(prisma, projectId) - const resourceTypes = await prisma.resourceType.findMany({ - where: { projectId }, - select: { id: true, name: true }, - }) - const rtMap = new Map( - resourceTypes.map(rt => [rt.name.toLowerCase(), rt.id]), - ) - - const epics = snap.snapshot as unknown as typeof currentData - await prisma.$transaction(async tx => { - await tx.epic.deleteMany({ where: { projectId } }) - - for (const epic of epics) { - const newEpic = await tx.epic.create({ - data: { name: epic.name, description: epic.description, order: epic.order, projectId }, - }) - for (const feature of epic.features) { - const newFeature = await tx.feature.create({ - data: { name: feature.name, description: feature.description, assumptions: feature.assumptions, order: feature.order, epicId: newEpic.id }, + // Determine snapshot version + const snapshotData = snap.snapshot as unknown + const isLegacy = + Array.isArray(snapshotData) || + (typeof snapshotData === 'object' && + snapshotData !== null && + !('schemaVersion' in snapshotData)) + + if (isLegacy) { + // --- Legacy v1: restore epics only (original behaviour) --- + const epics = extractEpics(snapshotData) + const resourceTypes = await prisma.resourceType.findMany({ + where: { projectId }, + select: { id: true, name: true }, + }) + const rtMap = new Map(resourceTypes.map(rt => [rt.name.toLowerCase(), rt.id])) + + await prisma.$transaction(async tx => { + await tx.epic.deleteMany({ where: { projectId } }) + for (const epic of epics) { + const newEpic = await tx.epic.create({ + data: { name: epic.name, description: epic.description, order: epic.order, projectId }, }) - for (const story of feature.userStories) { - const newStory = await tx.userStory.create({ - data: { name: story.name, description: story.description, assumptions: story.assumptions, order: story.order, featureId: newFeature.id, appliedTemplateId: story.appliedTemplateId }, + for (const feature of epic.features) { + const newFeature = await tx.feature.create({ + data: { name: feature.name, description: feature.description, assumptions: feature.assumptions, order: feature.order, epicId: newEpic.id }, }) - for (const task of story.tasks) { - const resourceTypeId = task.resourceType?.name - ? (rtMap.get(task.resourceType.name.toLowerCase()) ?? null) - : null - await tx.task.create({ - data: { - name: task.name, - description: task.description, - assumptions: task.assumptions, - hoursEffort: task.hoursEffort, - durationDays: task.durationDays, - order: task.order, - userStoryId: newStory.id, - resourceTypeId, - }, + for (const story of feature.userStories) { + const newStory = await tx.userStory.create({ + data: { name: story.name, description: story.description, assumptions: story.assumptions, order: story.order, featureId: newFeature.id, appliedTemplateId: story.appliedTemplateId }, }) + for (const task of story.tasks) { + const resourceTypeId = task.resourceType?.name + ? (rtMap.get(task.resourceType.name.toLowerCase()) ?? null) + : null + await tx.task.create({ + data: { + name: task.name, + description: task.description, + assumptions: task.assumptions, + hoursEffort: task.hoursEffort, + durationDays: task.durationDays, + order: task.order, + userStoryId: newStory.id, + resourceTypeId, + }, + }) + } } } } - } - }) + }) + } else { + // --- v2: full-state restore in a single transaction --- + type V2Snapshot = Awaited> + const v2 = snapshotData as V2Snapshot + + // Build an ID map of current resource types (by snapshot id) for task FK resolution + const currentRTs = await prisma.resourceType.findMany({ + where: { projectId }, + select: { id: true, name: true }, + }) + const rtNameMap = new Map(currentRTs.map(rt => [rt.name.toLowerCase(), rt.id])) + + await prisma.$transaction(async tx => { + // 1. Restore epics (delete all, recreate from snapshot — IDs will change) + // We track old→new ID mapping so downstream FK restores use new IDs. + await tx.epic.deleteMany({ where: { projectId } }) + + // Build old→new ID maps as we recreate the tree + const epicIdMap = new Map() + const featureIdMap = new Map() + const storyIdMap = new Map() + + for (const epic of v2.epics) { + const newEpic = await tx.epic.create({ + data: { name: epic.name, description: epic.description, order: epic.order, projectId }, + }) + epicIdMap.set(epic.id, newEpic.id) + for (const feature of epic.features) { + const newFeature = await tx.feature.create({ + data: { name: feature.name, description: feature.description, assumptions: feature.assumptions, order: feature.order, epicId: newEpic.id }, + }) + featureIdMap.set(feature.id, newFeature.id) + for (const story of feature.userStories) { + const newStory = await tx.userStory.create({ + data: { name: story.name, description: story.description, assumptions: story.assumptions, order: story.order, featureId: newFeature.id, appliedTemplateId: story.appliedTemplateId }, + }) + storyIdMap.set(story.id, newStory.id) + for (const task of story.tasks) { + const resourceTypeId = task.resourceType?.name + ? (rtNameMap.get(task.resourceType.name.toLowerCase()) ?? null) + : null + await tx.task.create({ + data: { + name: task.name, + description: task.description, + assumptions: task.assumptions, + hoursEffort: task.hoursEffort, + durationDays: task.durationDays, + order: task.order, + userStoryId: newStory.id, + resourceTypeId, + }, + }) + } + } + } + } + + // 2. Restore project fields + if (v2.project) { + await tx.project.update({ + where: { id: projectId }, + data: { + startDate: v2.project.startDate, + onboardingWeeks: v2.project.onboardingWeeks, + bufferWeeks: v2.project.bufferWeeks, + hoursPerDay: v2.project.hoursPerDay, + }, + }) + } + + // 3. Restore ResourceTypes — update if exists, create if not + for (const rt of v2.resourceTypes) { + const exists = await tx.resourceType.findUnique({ where: { id: rt.id } }) + if (exists) { + await tx.resourceType.update({ + where: { id: rt.id }, + data: { + name: rt.name, + category: rt.category, + count: rt.count, + hoursPerDay: rt.hoursPerDay, + dayRate: rt.dayRate, + globalTypeId: rt.globalTypeId, + allocationMode: rt.allocationMode, + allocationPercent: rt.allocationPercent, + allocationStartWeek: rt.allocationStartWeek, + allocationEndWeek: rt.allocationEndWeek, + }, + }) + } else { + await tx.resourceType.create({ + data: { + id: rt.id, + name: rt.name, + category: rt.category, + count: rt.count, + hoursPerDay: rt.hoursPerDay, + dayRate: rt.dayRate, + globalTypeId: rt.globalTypeId, + allocationMode: rt.allocationMode, + allocationPercent: rt.allocationPercent, + allocationStartWeek: rt.allocationStartWeek, + allocationEndWeek: rt.allocationEndWeek, + projectId, + }, + }) + } + // Keep rtNameMap in sync so task FKs resolve correctly + rtNameMap.set(rt.name.toLowerCase(), rt.id) + } + + // 4. Restore NamedResources — update if exists, create if not + for (const nr of v2.namedResources) { + const exists = await tx.namedResource.findUnique({ where: { id: nr.id } }) + if (exists) { + await tx.namedResource.update({ + where: { id: nr.id }, + data: { + name: nr.name, + startWeek: nr.startWeek, + endWeek: nr.endWeek, + allocationPct: nr.allocationPct, + allocationMode: nr.allocationMode, + allocationPercent: nr.allocationPercent, + allocationStartWeek: nr.allocationStartWeek, + allocationEndWeek: nr.allocationEndWeek, + pricingModel: nr.pricingModel, + }, + }) + } else { + await tx.namedResource.create({ + data: { + id: nr.id, + resourceTypeId: nr.resourceTypeId, + name: nr.name, + startWeek: nr.startWeek, + endWeek: nr.endWeek, + allocationPct: nr.allocationPct, + allocationMode: nr.allocationMode, + allocationPercent: nr.allocationPercent, + allocationStartWeek: nr.allocationStartWeek, + allocationEndWeek: nr.allocationEndWeek, + pricingModel: nr.pricingModel, + }, + }) + } + } + + // 5. Restore TimelineEntries — delete then recreate using new feature IDs + await tx.timelineEntry.deleteMany({ where: { projectId } }) + if (v2.timelineEntries.length > 0) { + const mappedTLEs = v2.timelineEntries + .map(e => { + const newFeatureId = featureIdMap.get(e.featureId) + if (!newFeatureId) return null + return { + projectId, + featureId: newFeatureId, + startWeek: e.startWeek, + durationWeeks: e.durationWeeks, + isManual: e.isManual, + } + }) + .filter((e): e is NonNullable => e !== null) + if (mappedTLEs.length > 0) { + await tx.timelineEntry.createMany({ data: mappedTLEs, skipDuplicates: true }) + } + } + + // 6. Restore StoryTimelineEntries — delete then recreate using new story IDs + await tx.storyTimelineEntry.deleteMany({ where: { projectId } }) + if (v2.storyTimelineEntries.length > 0) { + const mappedSTLEs = v2.storyTimelineEntries + .map(e => { + const newStoryId = storyIdMap.get(e.storyId) + if (!newStoryId) return null + return { + projectId, + storyId: newStoryId, + startWeek: e.startWeek, + durationWeeks: e.durationWeeks, + isManual: e.isManual, + } + }) + .filter((e): e is NonNullable => e !== null) + if (mappedSTLEs.length > 0) { + await tx.storyTimelineEntry.createMany({ data: mappedSTLEs, skipDuplicates: true }) + } + } + + // 7. Restore EpicDependencies using new epic IDs + await tx.epicDependency.deleteMany({ where: { epic: { projectId } } }) + if (v2.epicDependencies.length > 0) { + const mappedEDs = v2.epicDependencies + .map(d => { + const newEpicId = epicIdMap.get(d.epicId) + const newDependsOnId = epicIdMap.get(d.dependsOnId) + if (!newEpicId || !newDependsOnId) return null + return { epicId: newEpicId, dependsOnId: newDependsOnId } + }) + .filter((d): d is NonNullable => d !== null) + if (mappedEDs.length > 0) { + await tx.epicDependency.createMany({ data: mappedEDs, skipDuplicates: true }) + } + } + + // 8. Restore FeatureDependencies using new feature IDs + await tx.featureDependency.deleteMany({ where: { feature: { epic: { projectId } } } }) + if (v2.featureDependencies.length > 0) { + const mappedFDs = v2.featureDependencies + .map(d => { + const newFeatureId = featureIdMap.get(d.featureId) + const newDependsOnId = featureIdMap.get(d.dependsOnId) + if (!newFeatureId || !newDependsOnId) return null + return { featureId: newFeatureId, dependsOnId: newDependsOnId } + }) + .filter((d): d is NonNullable => d !== null) + if (mappedFDs.length > 0) { + await tx.featureDependency.createMany({ data: mappedFDs, skipDuplicates: true }) + } + } + + // 9. Restore OverheadItems — delete all then recreate + await tx.projectOverhead.deleteMany({ where: { projectId } }) + if (v2.overheadItems.length > 0) { + await tx.projectOverhead.createMany({ + data: v2.overheadItems.map(o => ({ + projectId, + name: o.name, + type: o.type, + value: o.value, + resourceTypeId: o.resourceTypeId, + order: o.order, + })), + skipDuplicates: true, + }) + } + }) + } res.json({ message: 'Rollback complete' }) })) diff --git a/server/src/test/setup.ts b/server/src/test/setup.ts index a09b9e4..d61e34c 100644 --- a/server/src/test/setup.ts +++ b/server/src/test/setup.ts @@ -24,12 +24,12 @@ vi.mock('../lib/prisma.js', () => ({ featureTemplate: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, templateTask: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), count: vi.fn() }, templateSnapshot: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn() }, - projectOverhead: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn() }, - timelineEntry: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), upsert: vi.fn(), delete: vi.fn(), deleteMany: vi.fn() }, - featureDependency: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn() }, - epicDependency: { findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue(null), create: vi.fn(), delete: vi.fn() }, + projectOverhead: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + timelineEntry: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), upsert: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + epicDependency: { findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue(null), create: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + featureDependency: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, storyDependency: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), upsert: vi.fn().mockResolvedValue({}), delete: vi.fn(), deleteMany: vi.fn() }, - storyTimelineEntry: { findMany: vi.fn().mockResolvedValue([]), findFirst: vi.fn(), upsert: vi.fn().mockResolvedValue({}), deleteMany: vi.fn() }, + storyTimelineEntry: { findMany: vi.fn().mockResolvedValue([]), findFirst: vi.fn(), upsert: vi.fn().mockResolvedValue({}), deleteMany: vi.fn(), createMany: vi.fn() }, backlogSnapshot: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, namedResource: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), count: vi.fn() }, rateCard: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), deleteMany: vi.fn() }, @@ -50,6 +50,18 @@ vi.mock('../lib/prisma.js', () => ({ $transaction: vi.fn((fn: unknown) => typeof fn === 'function' ? (fn as (tx: unknown) => unknown)({ rateCard: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), updateMany: vi.fn() }, rateCardEntry: { deleteMany: vi.fn() }, + epic: { deleteMany: vi.fn(), create: vi.fn().mockResolvedValue({ id: 'epic-id' }) }, + feature: { create: vi.fn().mockResolvedValue({ id: 'feature-id' }) }, + userStory: { create: vi.fn().mockResolvedValue({ id: 'story-id' }) }, + task: { create: vi.fn() }, + project: { update: vi.fn() }, + resourceType: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn() }, + namedResource: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn() }, + timelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, + storyTimelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, + epicDependency: { deleteMany: vi.fn(), createMany: vi.fn() }, + featureDependency: { deleteMany: vi.fn(), createMany: vi.fn() }, + projectOverhead: { deleteMany: vi.fn(), createMany: vi.fn() }, }) : Promise.resolve(fn)), }, })) From f2c4a432e394db3b13c934e4e859bf6f0fec9d15 Mon Sep 17 00:00:00 2001 From: NickMonrad Date: Wed, 29 Apr 2026 15:50:22 +1000 Subject: [PATCH 2/2] =?UTF-8?q?fix(#233):=20address=20Phase=201=20review?= =?UTF-8?q?=20=E2=80=94=20transaction=20ordering,=20upsert,=20error=20hand?= =?UTF-8?q?ling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/src/routes/snapshots.ts | 162 +++++++++++++++------------------ server/src/test/setup.ts | 4 +- 2 files changed, 76 insertions(+), 90 deletions(-) diff --git a/server/src/routes/snapshots.ts b/server/src/routes/snapshots.ts index f68fda3..e46c6b2 100644 --- a/server/src/routes/snapshots.ts +++ b/server/src/routes/snapshots.ts @@ -251,7 +251,7 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: const preRollbackData = await buildSnapshot(projectId) const dateStr = new Date().toISOString().slice(0, 10) const originalLabel = snap.label ?? snapshotId - await prisma.backlogSnapshot.create({ + const preSnap = await prisma.backlogSnapshot.create({ data: { projectId, label: `Auto-saved before rollback to '${originalLabel}' — ${dateStr}`, @@ -270,6 +270,7 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: snapshotData !== null && !('schemaVersion' in snapshotData)) + try { if (isLegacy) { // --- Legacy v1: restore epics only (original behaviour) --- const epics = extractEpics(snapshotData) @@ -319,15 +320,74 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: type V2Snapshot = Awaited> const v2 = snapshotData as V2Snapshot - // Build an ID map of current resource types (by snapshot id) for task FK resolution - const currentRTs = await prisma.resourceType.findMany({ - where: { projectId }, - select: { id: true, name: true }, - }) - const rtNameMap = new Map(currentRTs.map(rt => [rt.name.toLowerCase(), rt.id])) - await prisma.$transaction(async tx => { - // 1. Restore epics (delete all, recreate from snapshot — IDs will change) + // 1. Restore ResourceTypes FIRST so task FKs resolve correctly when recreating epics + const rtNameMap = new Map() + for (const rt of v2.resourceTypes) { + await tx.resourceType.upsert({ + where: { id: rt.id }, + update: { + name: rt.name, + category: rt.category, + count: rt.count, + hoursPerDay: rt.hoursPerDay, + dayRate: rt.dayRate, + globalTypeId: rt.globalTypeId, + allocationMode: rt.allocationMode, + allocationPercent: rt.allocationPercent, + allocationStartWeek: rt.allocationStartWeek, + allocationEndWeek: rt.allocationEndWeek, + }, + create: { + id: rt.id, + name: rt.name, + category: rt.category, + count: rt.count, + hoursPerDay: rt.hoursPerDay, + dayRate: rt.dayRate, + globalTypeId: rt.globalTypeId, + allocationMode: rt.allocationMode, + allocationPercent: rt.allocationPercent, + allocationStartWeek: rt.allocationStartWeek, + allocationEndWeek: rt.allocationEndWeek, + projectId, + }, + }) + rtNameMap.set(rt.name.toLowerCase(), rt.id) + } + + // 2. Restore NamedResources (depends on RTs existing) + for (const nr of v2.namedResources) { + await tx.namedResource.upsert({ + where: { id: nr.id }, + update: { + name: nr.name, + startWeek: nr.startWeek, + endWeek: nr.endWeek, + allocationPct: nr.allocationPct, + allocationMode: nr.allocationMode, + allocationPercent: nr.allocationPercent, + allocationStartWeek: nr.allocationStartWeek, + allocationEndWeek: nr.allocationEndWeek, + pricingModel: nr.pricingModel, + }, + create: { + id: nr.id, + resourceTypeId: nr.resourceTypeId, + name: nr.name, + startWeek: nr.startWeek, + endWeek: nr.endWeek, + allocationPct: nr.allocationPct, + allocationMode: nr.allocationMode, + allocationPercent: nr.allocationPercent, + allocationStartWeek: nr.allocationStartWeek, + allocationEndWeek: nr.allocationEndWeek, + pricingModel: nr.pricingModel, + }, + }) + } + + // 3. Restore epics (delete all, recreate from snapshot — IDs will change) // We track old→new ID mapping so downstream FK restores use new IDs. await tx.epic.deleteMany({ where: { projectId } }) @@ -372,7 +432,7 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: } } - // 2. Restore project fields + // 4. Restore project fields if (v2.project) { await tx.project.update({ where: { id: projectId }, @@ -385,84 +445,6 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: }) } - // 3. Restore ResourceTypes — update if exists, create if not - for (const rt of v2.resourceTypes) { - const exists = await tx.resourceType.findUnique({ where: { id: rt.id } }) - if (exists) { - await tx.resourceType.update({ - where: { id: rt.id }, - data: { - name: rt.name, - category: rt.category, - count: rt.count, - hoursPerDay: rt.hoursPerDay, - dayRate: rt.dayRate, - globalTypeId: rt.globalTypeId, - allocationMode: rt.allocationMode, - allocationPercent: rt.allocationPercent, - allocationStartWeek: rt.allocationStartWeek, - allocationEndWeek: rt.allocationEndWeek, - }, - }) - } else { - await tx.resourceType.create({ - data: { - id: rt.id, - name: rt.name, - category: rt.category, - count: rt.count, - hoursPerDay: rt.hoursPerDay, - dayRate: rt.dayRate, - globalTypeId: rt.globalTypeId, - allocationMode: rt.allocationMode, - allocationPercent: rt.allocationPercent, - allocationStartWeek: rt.allocationStartWeek, - allocationEndWeek: rt.allocationEndWeek, - projectId, - }, - }) - } - // Keep rtNameMap in sync so task FKs resolve correctly - rtNameMap.set(rt.name.toLowerCase(), rt.id) - } - - // 4. Restore NamedResources — update if exists, create if not - for (const nr of v2.namedResources) { - const exists = await tx.namedResource.findUnique({ where: { id: nr.id } }) - if (exists) { - await tx.namedResource.update({ - where: { id: nr.id }, - data: { - name: nr.name, - startWeek: nr.startWeek, - endWeek: nr.endWeek, - allocationPct: nr.allocationPct, - allocationMode: nr.allocationMode, - allocationPercent: nr.allocationPercent, - allocationStartWeek: nr.allocationStartWeek, - allocationEndWeek: nr.allocationEndWeek, - pricingModel: nr.pricingModel, - }, - }) - } else { - await tx.namedResource.create({ - data: { - id: nr.id, - resourceTypeId: nr.resourceTypeId, - name: nr.name, - startWeek: nr.startWeek, - endWeek: nr.endWeek, - allocationPct: nr.allocationPct, - allocationMode: nr.allocationMode, - allocationPercent: nr.allocationPercent, - allocationStartWeek: nr.allocationStartWeek, - allocationEndWeek: nr.allocationEndWeek, - pricingModel: nr.pricingModel, - }, - }) - } - } - // 5. Restore TimelineEntries — delete then recreate using new feature IDs await tx.timelineEntry.deleteMany({ where: { projectId } }) if (v2.timelineEntries.length > 0) { @@ -554,6 +536,10 @@ router.post('/:snapshotId/rollback', asyncHandler(async (req: AuthRequest, res: } }) } + } catch (err) { + await prisma.backlogSnapshot.delete({ where: { id: preSnap.id } }).catch(() => {}) + throw err + } res.json({ message: 'Rollback complete' }) })) diff --git a/server/src/test/setup.ts b/server/src/test/setup.ts index d61e34c..c67d487 100644 --- a/server/src/test/setup.ts +++ b/server/src/test/setup.ts @@ -55,8 +55,8 @@ vi.mock('../lib/prisma.js', () => ({ userStory: { create: vi.fn().mockResolvedValue({ id: 'story-id' }) }, task: { create: vi.fn() }, project: { update: vi.fn() }, - resourceType: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn() }, - namedResource: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn() }, + resourceType: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn(), upsert: vi.fn() }, + namedResource: { findUnique: vi.fn().mockResolvedValue(null), update: vi.fn(), create: vi.fn(), upsert: vi.fn() }, timelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, storyTimelineEntry: { deleteMany: vi.fn(), createMany: vi.fn() }, epicDependency: { deleteMany: vi.fn(), createMany: vi.fn() },