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:
Fusion
2026-04-15 15:31:05 -07:00
committed by gsxdsm
parent 561ca32040
commit e63bbf4a85
7 changed files with 676 additions and 1 deletions

View File

@@ -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);
});
});
});
});

View File

@@ -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,
};
}
}

View File

@@ -4372,4 +4372,146 @@ describe("Settings API wrappers", () => {
expect(url).toContain("projectId=proj_abc");
});
});
describe("roadmap export/handoff APIs", () => {
it("exportRoadmap sends GET to export endpoint", async () => {
const { exportRoadmap } = await import("./api");
const exportData = {
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
milestones: [],
features: [],
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(exportData),
text: () => Promise.resolve(JSON.stringify(exportData)),
} as unknown as Response);
const result = await exportRoadmap("RM-001");
expect(result.roadmap.id).toBe("RM-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/RM-001/export");
});
it("exportRoadmap includes projectId when provided", async () => {
const { exportRoadmap } = await import("./api");
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(exportData),
text: () => Promise.resolve(JSON.stringify(exportData)),
} as unknown as Response);
await exportRoadmap("RM-001", "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/RM-001/export");
expect(url).toContain("projectId=proj_abc");
});
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
const { getRoadmapMissionHandoff } = await import("./api");
const handoffData = {
sourceRoadmapId: "RM-001",
title: "Test Roadmap",
milestones: [],
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
const result = await getRoadmapMissionHandoff("RM-001");
expect(result.sourceRoadmapId).toBe("RM-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/RM-001/handoff/mission");
});
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
const { getRoadmapFeatureHandoff } = await import("./api");
const handoffData = {
source: {
roadmapId: "RM-001",
milestoneId: "RMS-001",
featureId: "RF-001",
roadmapTitle: "Test",
milestoneTitle: "Phase 1",
milestoneOrderIndex: 0,
featureOrderIndex: 0,
},
title: "Feature 1",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
const result = await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001");
expect(result.source.featureId).toBe("RF-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
});
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
const { getRoadmapFeatureHandoff } = await import("./api");
const handoffData = {
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
title: "F",
};
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "application/json" : null,
},
json: () => Promise.resolve(handoffData),
text: () => Promise.resolve(JSON.stringify(handoffData)),
} as unknown as Response);
await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001", "proj_xyz");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("/api/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
expect(url).toContain("projectId=proj_xyz");
});
});
});

View File

@@ -47,6 +47,9 @@ import type {
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -4565,6 +4568,31 @@ export function moveRoadmapFeature(
});
}
/** Export a roadmap as a flat bundle for persistence/import/export */
export function exportRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapExportBundle> {
return api<RoadmapExportBundle>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/export`, projectId));
}
/** Get mission planning handoff payload for a roadmap */
export function getRoadmapMissionHandoff(roadmapId: string, projectId?: string): Promise<RoadmapMissionPlanningHandoff> {
return api<RoadmapMissionPlanningHandoff>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff/mission`, projectId));
}
/** Get task planning handoff payload for a single roadmap feature */
export function getRoadmapFeatureHandoff(
roadmapId: string,
milestoneId: string,
featureId: string,
projectId?: string
): Promise<RoadmapFeatureTaskPlanningHandoff> {
return api<RoadmapFeatureTaskPlanningHandoff>(
withProjectId(
`/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`,
projectId
)
);
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{

View File

@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import { get as performGet, request as performRequest } from "./test-request.js";
import { createRoadmapRouter } from "./roadmap-routes.js";
import { ApiError } from "./api-error.js";
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
vi.mock("./roadmap-suggestions.js", () => ({
@@ -120,6 +121,56 @@ function createMockRoadmapStore(): RoadmapStore {
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === id).sort((a, b) => a.orderIndex - b.orderIndex);
return { ...roadmap, milestones: ms.map((m) => ({ ...m, features: [] })) };
}),
getRoadmapExport: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const allFeatures = ms.flatMap((m) => Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex));
return { roadmap, milestones: ms, features: allFeatures };
}),
getRoadmapMissionHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceRoadmapId: roadmap.id,
title: roadmap.title,
description: roadmap.description,
milestones: ms.map((m) => {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
return {
sourceMilestoneId: m.id,
title: m.title,
description: m.description,
orderIndex: m.orderIndex,
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
};
}),
};
}),
getRoadmapFeatureHandoff: vi.fn((roadmapId: string, milestoneId: string, featureId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
const milestone = milestones.get(milestoneId);
if (!milestone) throw new ApiError(500, "Milestone " + milestoneId + " not found");
if (milestone.roadmapId !== roadmapId) throw new ApiError(500, "Milestone " + milestoneId + " does not belong to roadmap " + roadmapId);
const feature = features.get(featureId);
if (!feature) throw new ApiError(500, "Feature " + featureId + " not found");
if (feature.milestoneId !== milestoneId) throw new ApiError(500, "Feature " + featureId + " does not belong to milestone " + milestoneId);
return {
source: {
roadmapId: roadmap.id,
milestoneId: milestone.id,
featureId: feature.id,
roadmapTitle: roadmap.title,
milestoneTitle: milestone.title,
milestoneOrderIndex: milestone.orderIndex,
featureOrderIndex: feature.orderIndex,
},
title: feature.title,
description: feature.description,
};
}),
} as unknown as RoadmapStore;
}
@@ -276,4 +327,56 @@ describe("Roadmap Routes", () => {
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("test-project");
});
});
describe("GET /api/roadmaps/:roadmapId/export", () => {
it("returns export bundle with all entities", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Export Test", description: "Test desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "F1" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/export");
expect(response.status).toBe(200);
expect(response.body.roadmap.id).toBe(roadmap.id);
expect(response.body.roadmap.title).toBe("Export Test");
expect(response.body.milestones.length).toBe(1);
expect(response.body.features.length).toBe(1);
expect(response.body.features[0].id).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
it("returns mission handoff payload", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff/mission");
expect(response.status).toBe(200);
expect(response.body.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.title).toBe("Mission Handoff");
expect(response.body.description).toBe("Mission desc");
expect(response.body.milestones.length).toBe(1);
expect(response.body.milestones[0].sourceMilestoneId).toBe(milestone.id);
expect(response.body.milestones[0].features.length).toBe(1);
expect(response.body.milestones[0].features[0].sourceFeatureId).toBe(feature.id);
});
});
describe("GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", () => {
it("returns task handoff payload for feature", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Feature Handoff" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A", description: "Feature desc" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/milestones/" + milestone.id + "/features/" + feature.id + "/handoff/task");
expect(response.status).toBe(200);
expect(response.body.source.roadmapId).toBe(roadmap.id);
expect(response.body.source.milestoneId).toBe(milestone.id);
expect(response.body.source.featureId).toBe(feature.id);
expect(response.body.source.roadmapTitle).toBe("Feature Handoff");
expect(response.body.source.milestoneTitle).toBe("Phase 1");
expect(response.body.title).toBe("Feature A");
expect(response.body.description).toBe("Feature desc");
});
});
});

View File

@@ -575,5 +575,58 @@ export function createRoadmapRouter(store: TaskStore): Router {
}
});
// ── Export / Handoff Endpoints ──────────────────────────────────────────
/**
* GET /api/roadmaps/:roadmapId/export
* Get a flat export bundle for the roadmap.
*/
router.get("/:roadmapId/export", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const export_ = roadmapStore.getRoadmapExport(roadmapId);
res.json(export_);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to export roadmap");
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff/mission
* Get a mission planning handoff payload for the roadmap.
*/
router.get("/:roadmapId/handoff/mission", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const handoff = roadmapStore.getRoadmapMissionHandoff(roadmapId);
res.json(handoff);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate mission handoff");
}
});
/**
* GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
* Get a task planning handoff payload for a single feature.
*/
router.get("/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId, milestoneId, featureId } = req.params;
const handoff = roadmapStore.getRoadmapFeatureHandoff(roadmapId, milestoneId, featureId);
res.json(handoff);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate task handoff");
}
});
return router;
}