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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user