feat(FN-1674): add roadmap export and handoff system

- Add RoadmapStore with read APIs for project-scoped roadmap data
- Add roadmap-handoff mapper to transform roadmap data for export
- Add project-scoped handoff API route (/api/projects/:id/roadmap/handoff)
- Add useRoadmaps hook for fetching and exposing roadmap data to components
- Update RoadmapsView with export/handoff UX path and roadmap detail view
- Add roadmap routes with project-scoped handoff endpoint
- Add comprehensive tests for handoff mapper and roadmap routes
- Update architecture.md and add dashboard-guide.md documentation
This commit is contained in:
Fusion
2026-04-15 17:06:36 -07:00
committed by gsxdsm
parent 9d42dbe93c
commit 07b406d412
12 changed files with 1151 additions and 6 deletions

View File

@@ -171,6 +171,51 @@ function createMockRoadmapStore(): RoadmapStore {
description: feature.description,
};
}),
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("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 })),
};
}),
};
}),
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
const roadmap = roadmaps.get(roadmapId);
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
const handoffs = [];
for (const m of ms) {
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
for (const f of fs) {
handoffs.push({
source: {
roadmapId: roadmap.id,
milestoneId: m.id,
featureId: f.id,
roadmapTitle: roadmap.title,
milestoneTitle: m.title,
milestoneOrderIndex: m.orderIndex,
featureOrderIndex: f.orderIndex,
},
title: f.title,
description: f.description,
});
}
}
return handoffs;
}),
} as unknown as RoadmapStore;
}
@@ -190,6 +235,19 @@ describe("Roadmap Routes", () => {
app = express();
app.use(express.json());
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
// Add error handler for tests that check 404 responses
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message });
return;
}
res.status(500).json({ error: "Internal server error" });
});
});
afterEach(() => {
@@ -344,6 +402,68 @@ describe("Roadmap Routes", () => {
});
});
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
it("returns both mission and feature handoffs", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
// Verify mission handoff structure
expect(response.body.mission).toBeDefined();
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
expect(response.body.mission.title).toBe("Combined Handoff");
expect(response.body.mission.milestones).toHaveLength(2);
// Verify feature handoffs structure
expect(response.body.features).toBeDefined();
expect(response.body.features).toHaveLength(2);
expect(response.body.features[0].title).toBe("Feature A");
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
expect(response.body.features[1].title).toBe("Feature B");
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
});
it("returns empty features array when roadmap has no features", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
expect(response.status).toBe(200);
expect(response.body.features).toHaveLength(0);
});
it("returns 404 when roadmap not found", async () => {
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
expect(response.status).toBe(404);
});
it("returns 404 for cross-project isolation", async () => {
// Create roadmap in default store
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
// Mock a different project store that returns no roadmap
mockGetOrCreateProjectStore.mockResolvedValueOnce({
getRoadmapStore: vi.fn(() => ({
getMissionPlanningHandoff: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
listFeatureTaskPlanningHandoffs: vi.fn(() => {
throw new Error("Roadmap nonexistent not found");
}),
})),
getRootDir: vi.fn(() => "/test/root"),
});
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
expect(response.status).toBe(404);
});
});
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
it("returns mission handoff payload", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });

View File

@@ -2,7 +2,8 @@
* Roadmap REST API Routes
*
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
* Also includes AI-powered suggestion endpoints for milestone and feature creation.
* Also includes AI-powered suggestion endpoints for milestone and feature creation,
* and read-only handoff endpoints for exporting roadmap data to mission/task planning.
*
* Endpoints:
* - Roadmaps: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id
@@ -16,6 +17,9 @@
* POST /features/:id/move
* - Suggestions: POST /:roadmapId/suggestions/milestones,
* POST /milestones/:milestoneId/suggestions/features
* - Export/Handoff: GET /:roadmapId/export, GET /:roadmapId/handoff,
* GET /:roadmapId/handoff/mission,
* GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
*/
import { Router, type Request, type Response } from "express";
@@ -594,6 +598,36 @@ export function createRoadmapRouter(store: TaskStore): Router {
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff
* Get both mission-oriented and task-oriented handoff payloads for the roadmap.
*
* This is a convenience endpoint that combines both handoff types in a single response.
* Returns 404 if the roadmap is not found.
*/
router.get("/:roadmapId/handoff", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
// Get both handoff types
const missionHandoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
const featureHandoffs = roadmapStore.listFeatureTaskPlanningHandoffs(roadmapId);
res.json({
mission: missionHandoff,
features: featureHandoffs,
});
} catch (err) {
if (err instanceof ApiError) throw err;
// Handle not-found case from store methods
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err, "Failed to generate handoff");
}
});
/**
* GET /api/roadmaps/:roadmapId/handoff/mission
* Get a mission planning handoff payload for the roadmap.
@@ -603,10 +637,14 @@ export function createRoadmapRouter(store: TaskStore): Router {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const handoff = roadmapStore.getRoadmapMissionHandoff(roadmapId);
const handoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
res.json(handoff);
} catch (err) {
if (err instanceof ApiError) throw err;
// Handle not-found case from store methods
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(err.message);
}
rethrowAsApiError(err, "Failed to generate mission handoff");
}
});