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..e46c6b2 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,299 @@ 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) - await prisma.backlogSnapshot.create({ + // 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 + const preSnap = 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)) + + try { + 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 + + await prisma.$transaction(async tx => { + // 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 } }) + + // 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, + }, + }) + } + } + } + } + + // 4. 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, + }, + }) + } + + // 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, + }) + } + }) + } + } 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 a09b9e4..c67d487 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(), 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() }, + featureDependency: { deleteMany: vi.fn(), createMany: vi.fn() }, + projectOverhead: { deleteMany: vi.fn(), createMany: vi.fn() }, }) : Promise.resolve(fn)), }, }))