feat(FN-1691): add roadmap export and handoff API
- Add RoadmapStore.exportRoadmap() and RoadmapStore.handoffRoadmap() DTO methods with full test coverage - Add REST endpoints POST /api/roadmaps/:id/export and POST /api/roadmaps/:id/handoff in roadmap-routes.ts - Add corresponding api.ts wrappers with request/response type definitions and test coverage - Update architecture docs with roadmap export/handoff endpoint reference
This commit is contained in:
@@ -742,4 +742,205 @@ describe("RoadmapStore", () => {
|
||||
expect(db.getSchemaVersion()).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("export / handoff", () => {
|
||||
describe("getRoadmapExport", () => {
|
||||
it("returns flat export bundle with all entities", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Export Test", description: "Test description" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "Milestone 1" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Milestone 2" });
|
||||
const f1 = store.createFeature(m1.id, { title: "Feature 1" });
|
||||
const f2 = store.createFeature(m1.id, { title: "Feature 2" });
|
||||
const f3 = store.createFeature(m2.id, { title: "Feature 3" });
|
||||
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.roadmap.id).toBe(roadmap.id);
|
||||
expect(export_.roadmap.title).toBe("Export Test");
|
||||
expect(export_.milestones.length).toBe(2);
|
||||
expect(export_.features.length).toBe(3);
|
||||
expect(export_.features.map((f) => f.id)).toEqual([f1.id, f2.id, f3.id]);
|
||||
});
|
||||
|
||||
it("returns milestones in deterministic order", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Order Test" });
|
||||
// Create in reverse order to test deterministic sorting
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Second" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "First" });
|
||||
|
||||
// m2 was created first (orderIndex 0), m1 was created second (orderIndex 1)
|
||||
// Deterministic order: orderIndex ASC → m2 comes first
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.milestones[0].id).toBe(m2.id);
|
||||
expect(export_.milestones[1].id).toBe(m1.id);
|
||||
});
|
||||
|
||||
it("returns features grouped by milestone in deterministic order", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Features Order" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
// Create in reverse order to test deterministic sorting
|
||||
const f2 = store.createFeature(m1.id, { title: "F2" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
|
||||
// f2 was created first (orderIndex 0), f1 was created second (orderIndex 1)
|
||||
// Deterministic order: orderIndex ASC → f2 comes first
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.features.length).toBe(2);
|
||||
expect(export_.features[0].id).toBe(f2.id);
|
||||
expect(export_.features[1].id).toBe(f1.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent roadmap", () => {
|
||||
expect(() => store.getRoadmapExport("RM-nonexistent")).toThrow("Roadmap RM-nonexistent not found");
|
||||
});
|
||||
|
||||
it("returns empty arrays when roadmap has no milestones", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Empty" });
|
||||
const export_ = store.getRoadmapExport(roadmap.id);
|
||||
|
||||
expect(export_.milestones).toEqual([]);
|
||||
expect(export_.features).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoadmapMissionHandoff", () => {
|
||||
it("returns mission planning handoff with source IDs preserved", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "Phase 1", description: "Phase 1 desc" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Phase 2" });
|
||||
const f1 = store.createFeature(m1.id, { title: "Task A", description: "Task A desc" });
|
||||
const f2 = store.createFeature(m2.id, { title: "Task B" });
|
||||
|
||||
const handoff = store.getRoadmapMissionHandoff(roadmap.id);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe(roadmap.id);
|
||||
expect(handoff.title).toBe("Mission Handoff");
|
||||
expect(handoff.description).toBe("Mission desc");
|
||||
expect(handoff.milestones.length).toBe(2);
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe(m1.id);
|
||||
expect(handoff.milestones[0].title).toBe("Phase 1");
|
||||
expect(handoff.milestones[0].description).toBe("Phase 1 desc");
|
||||
expect(handoff.milestones[0].features.length).toBe(1);
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe(f1.id);
|
||||
expect(handoff.milestones[0].features[0].title).toBe("Task A");
|
||||
expect(handoff.milestones[1].features.length).toBe(1);
|
||||
expect(handoff.milestones[1].features[0].sourceFeatureId).toBe(f2.id);
|
||||
});
|
||||
|
||||
it("preserves deterministic ordering in handoff", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Order Check" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
const f1 = store.createFeature(m1.id, { title: "First" });
|
||||
const f2 = store.createFeature(m1.id, { title: "Second" });
|
||||
const f3 = store.createFeature(m1.id, { title: "Third" });
|
||||
|
||||
const handoff = store.getRoadmapMissionHandoff(roadmap.id);
|
||||
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe(f1.id);
|
||||
expect(handoff.milestones[0].features[1].sourceFeatureId).toBe(f2.id);
|
||||
expect(handoff.milestones[0].features[2].sourceFeatureId).toBe(f3.id);
|
||||
});
|
||||
|
||||
it("throws for non-existent roadmap", () => {
|
||||
expect(() => store.getRoadmapMissionHandoff("RM-nonexistent")).toThrow("Roadmap RM-nonexistent not found");
|
||||
});
|
||||
|
||||
it("includes orderIndex for milestones and features", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Index Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "First" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Second" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
const handoff = store.getRoadmapMissionHandoff(roadmap.id);
|
||||
|
||||
// Milestones should be in deterministic order (m1 created first, so orderIndex 0)
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe(m1.id);
|
||||
expect(handoff.milestones[0].orderIndex).toBeDefined();
|
||||
expect(handoff.milestones[1].sourceMilestoneId).toBe(m2.id);
|
||||
expect(handoff.milestones[1].orderIndex).toBeDefined();
|
||||
// Features should be in deterministic order
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe(f1.id);
|
||||
expect(handoff.milestones[0].features[0].orderIndex).toBeDefined();
|
||||
expect(handoff.milestones[1].features[0].sourceFeatureId).toBe(f2.id);
|
||||
expect(handoff.milestones[1].features[0].orderIndex).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRoadmapFeatureHandoff", () => {
|
||||
it("returns task planning handoff for a feature", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Feature Handoff" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "Phase 1" });
|
||||
const f1 = store.createFeature(m1.id, { title: "Feature A", description: "Feature A desc" });
|
||||
|
||||
const handoff = store.getRoadmapFeatureHandoff(roadmap.id, m1.id, f1.id);
|
||||
|
||||
expect(handoff.source.roadmapId).toBe(roadmap.id);
|
||||
expect(handoff.source.milestoneId).toBe(m1.id);
|
||||
expect(handoff.source.featureId).toBe(f1.id);
|
||||
expect(handoff.source.roadmapTitle).toBe("Feature Handoff");
|
||||
expect(handoff.source.milestoneTitle).toBe("Phase 1");
|
||||
expect(handoff.source.milestoneOrderIndex).toBeDefined();
|
||||
expect(handoff.source.featureOrderIndex).toBeDefined();
|
||||
expect(handoff.title).toBe("Feature A");
|
||||
expect(handoff.description).toBe("Feature A desc");
|
||||
});
|
||||
|
||||
it("throws for non-existent roadmap", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff("RM-nonexistent", m1.id, f1.id)).toThrow("Roadmap RM-nonexistent not found");
|
||||
});
|
||||
|
||||
it("throws for non-existent milestone", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap.id, "RMS-nonexistent", "RF-nonexistent")).toThrow("Milestone RMS-nonexistent not found");
|
||||
});
|
||||
|
||||
it("throws when milestone does not belong to roadmap", () => {
|
||||
const roadmap1 = store.createRoadmap({ title: "Roadmap 1" });
|
||||
const roadmap2 = store.createRoadmap({ title: "Roadmap 2" });
|
||||
const m2 = store.createMilestone(roadmap2.id, { title: "M2" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap1.id, m2.id, f2.id)).toThrow(`Milestone ${m2.id} does not belong to roadmap ${roadmap1.id}`);
|
||||
});
|
||||
|
||||
it("throws for non-existent feature", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap.id, m1.id, "RF-nonexistent")).toThrow("Feature RF-nonexistent not found");
|
||||
});
|
||||
|
||||
it("throws when feature does not belong to milestone", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "M2" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
expect(() => store.getRoadmapFeatureHandoff(roadmap.id, m1.id, f2.id)).toThrow(`Feature ${f2.id} does not belong to milestone ${m1.id}`);
|
||||
});
|
||||
|
||||
it("includes order indices in source reference", () => {
|
||||
const roadmap = store.createRoadmap({ title: "Order Test" });
|
||||
const m1 = store.createMilestone(roadmap.id, { title: "First" });
|
||||
const m2 = store.createMilestone(roadmap.id, { title: "Second" });
|
||||
const f1 = store.createFeature(m1.id, { title: "F1" });
|
||||
const f2 = store.createFeature(m2.id, { title: "F2" });
|
||||
|
||||
// M1 is created first so has orderIndex 0, M2 has orderIndex 1
|
||||
const handoff1 = store.getRoadmapFeatureHandoff(roadmap.id, m1.id, f1.id);
|
||||
const handoff2 = store.getRoadmapFeatureHandoff(roadmap.id, m2.id, f2.id);
|
||||
|
||||
// m1 was created first so it has lower orderIndex
|
||||
expect(handoff1.source.milestoneOrderIndex).toBeLessThan(handoff2.source.milestoneOrderIndex);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,10 @@ import type {
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapMilestoneWithFeatures,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapExportBundle,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapFeatureSourceRef,
|
||||
} from "./roadmap-types.js";
|
||||
import {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
@@ -731,4 +735,138 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Export / Handoff Operations ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a flat export bundle for a roadmap.
|
||||
*
|
||||
* Returns all roadmap data in a flat structure suitable for persistence,
|
||||
* APIs, import/export, and sync jobs. Entities are separated so downstream
|
||||
* persistence layers can upsert by table/collection.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The export bundle with ordered entities
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapExport(roadmapId: string): RoadmapExportBundle {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const allFeatures: RoadmapFeature[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
allFeatures.push(...features);
|
||||
}
|
||||
|
||||
return {
|
||||
roadmap,
|
||||
milestones,
|
||||
features: allFeatures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order. Does not couple to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getRoadmapMissionHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestones.map((milestone) => {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: features.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a task planning handoff payload for a single roadmap feature.
|
||||
*
|
||||
* Returns a self-contained handoff payload for converting a roadmap feature
|
||||
* into task planning flows without coupling to MissionStore internals.
|
||||
*
|
||||
* @param roadmapId - Parent roadmap ID (for validation)
|
||||
* @param milestoneId - Parent milestone ID (for validation)
|
||||
* @param featureId - Feature ID to generate handoff for
|
||||
* @returns The task planning handoff payload
|
||||
* @throws Error if any entity is not found or if ownership validation fails
|
||||
*/
|
||||
getRoadmapFeatureHandoff(
|
||||
roadmapId: string,
|
||||
milestoneId: string,
|
||||
featureId: string,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Validate milestone exists and belongs to roadmap
|
||||
const milestone = this.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw new Error(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(`Milestone ${milestoneId} does not belong to roadmap ${roadmapId}`);
|
||||
}
|
||||
|
||||
// Validate feature exists and belongs to milestone
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(`Feature ${featureId} does not belong to milestone ${milestoneId}`);
|
||||
}
|
||||
|
||||
// Build the source reference with ordering context
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
return {
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user