Files
fusion/packages/dashboard/src/roadmap-routes.ts
2026-04-16 08:53:14 -07:00

706 lines
23 KiB
TypeScript

/**
* Roadmap REST API Routes
*
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
* 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
* - Milestones: GET /:roadmapId/milestones, POST /:roadmapId/milestones,
* PATCH /milestones/:id, DELETE /milestones/:id,
* POST /:roadmapId/milestones/reorder
* - Features: GET /milestones/:milestoneId/features,
* POST /milestones/:milestoneId/features,
* PATCH /features/:id, DELETE /features/:id,
* POST /milestones/:milestoneId/features/reorder,
* 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";
import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore } from "@fusion/core";
import {
ApiError,
badRequest,
notFound,
internalError,
} from "./api-error.js";
/**
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
* This is used in route handlers to ensure all errors are properly typed.
*/
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
if (error instanceof ApiError) throw error;
if (error instanceof Error) throw new ApiError(500, error.message);
throw new ApiError(500, fallbackMessage);
}
import {
generateMilestoneSuggestions,
validateSuggestionInput,
generateFeatureSuggestions,
validateFeatureSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
SUGGESTION_TIMEOUT_MS,
} from "./roadmap-suggestions.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
// ── Validation Utilities ──────────────────────────────────────────────────────
function validateTitle(title: unknown): string {
if (!title || typeof title !== "string" || !title.trim()) {
throw badRequest("title is required");
}
if (title.length > 200) {
throw badRequest("title must not exceed 200 characters");
}
return title.trim();
}
function validateDescription(desc: unknown): string | undefined {
if (desc === undefined || desc === null) return undefined;
if (typeof desc !== "string") {
throw badRequest("description must be a string");
}
if (desc.length > 5000) {
throw badRequest("description must not exceed 5000 characters");
}
return desc.trim() || undefined;
}
function validateStringArray(arr: unknown, fieldName: string): string[] {
if (!Array.isArray(arr)) {
throw badRequest(`${fieldName} must be an array`);
}
if (!arr.every((item) => typeof item === "string")) {
throw badRequest(`${fieldName} must be an array of strings`);
}
return arr;
}
// ── Router Factory ────────────────────────────────────────────────────────────
export function createRoadmapRouter(store: TaskStore): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
function getProjectIdFromRequest(req: Request): string | undefined {
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
return req.query.projectId;
}
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
return req.body.projectId;
}
return undefined;
}
function getScopedStore(): TaskStore {
const scoped = requestContext.getStore();
return scoped ?? store;
}
router.use(async (req: Request, _res: Response, next) => {
try {
const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
requestContext.run(scopedStore, next);
} catch (error) {
next(error);
}
});
// ── Roadmap Endpoints ─────────────────────────────────────────────────────
/**
* GET /api/roadmaps
* List all roadmaps.
*/
router.get("/", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const roadmaps = roadmapStore.listRoadmaps();
res.json(roadmaps);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to list roadmaps");
}
});
/**
* POST /api/roadmaps
* Create a new roadmap.
*/
router.post("/", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { title, description } = req.body as { title: string; description?: string };
const validatedTitle = validateTitle(title);
const validatedDesc = validateDescription(description);
const roadmap = roadmapStore.createRoadmap({
title: validatedTitle,
description: validatedDesc,
});
res.status(201).json(roadmap);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to create roadmap");
}
});
/**
* GET /api/roadmaps/:roadmapId
* Get a roadmap with full hierarchy (milestones and features).
*/
router.get("/:roadmapId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const roadmap = roadmapStore.getRoadmapWithHierarchy(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
res.json(roadmap);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to get roadmap");
}
});
/**
* PATCH /api/roadmaps/:roadmapId
* Update roadmap metadata.
*/
router.patch("/:roadmapId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const roadmap = roadmapStore.updateRoadmap(roadmapId, {
title: title !== undefined ? validateTitle(title) : undefined,
description: description !== undefined ? validateDescription(description) : undefined,
});
res.json(roadmap);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update roadmap");
}
});
/**
* DELETE /api/roadmaps/:roadmapId
* Delete a roadmap and all its milestones/features.
*/
router.delete("/:roadmapId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
roadmapStore.deleteRoadmap(roadmapId);
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete roadmap");
}
});
// ── Milestone Endpoints ───────────────────────────────────────────────────
/**
* POST /api/roadmaps/:roadmapId/milestones
* Create a new milestone in a roadmap.
*/
router.post("/:roadmapId/milestones", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const { title, description } = req.body as { title: string; description?: string };
const validatedTitle = validateTitle(title);
const validatedDesc = validateDescription(description);
const milestone = roadmapStore.createMilestone(roadmapId, {
title: validatedTitle,
description: validatedDesc,
});
res.status(201).json(milestone);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to create milestone");
}
});
/**
* POST /api/roadmaps/:roadmapId/milestones/reorder
* Reorder milestones within a roadmap.
*/
router.post("/:roadmapId/milestones/reorder", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
const { orderedMilestoneIds } = req.body as { orderedMilestoneIds: string[] };
validateStringArray(orderedMilestoneIds, "orderedMilestoneIds");
roadmapStore.reorderMilestones({ roadmapId, orderedMilestoneIds });
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to reorder milestones");
}
});
/**
* PATCH /api/roadmaps/milestones/:milestoneId
* Update a milestone.
*/
router.patch("/milestones/:milestoneId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const milestone = roadmapStore.updateMilestone(milestoneId, {
title: title !== undefined ? validateTitle(title) : undefined,
description: description !== undefined ? validateDescription(description) : undefined,
});
res.json(milestone);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update milestone");
}
});
/**
* DELETE /api/roadmaps/milestones/:milestoneId
* Delete a milestone and all its features.
*/
router.delete("/milestones/:milestoneId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
roadmapStore.deleteMilestone(milestoneId);
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete milestone");
}
});
// ── Feature Endpoints ─────────────────────────────────────────────────────
/**
* POST /api/roadmaps/milestones/:milestoneId/features
* Create a new feature in a milestone.
*/
router.post("/milestones/:milestoneId/features", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
const { title, description } = req.body as { title: string; description?: string };
const validatedTitle = validateTitle(title);
const validatedDesc = validateDescription(description);
const feature = roadmapStore.createFeature(milestoneId, {
title: validatedTitle,
description: validatedDesc,
});
res.status(201).json(feature);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to create feature");
}
});
/**
* POST /api/roadmaps/milestones/:milestoneId/features/reorder
* Reorder features within a milestone.
*/
router.post("/milestones/:milestoneId/features/reorder", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { milestoneId } = req.params;
const { orderedFeatureIds } = req.body as { orderedFeatureIds: string[] };
validateStringArray(orderedFeatureIds, "orderedFeatureIds");
// Get the milestone to find the roadmapId
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
roadmapStore.reorderFeatures({
roadmapId: milestone.roadmapId,
milestoneId,
orderedFeatureIds,
});
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to reorder features");
}
});
/**
* PATCH /api/roadmaps/features/:featureId
* Update a feature.
*/
router.patch("/features/:featureId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { featureId } = req.params;
const { title, description } = req.body as { title?: string; description?: string };
const feature = roadmapStore.updateFeature(featureId, {
title: title !== undefined ? validateTitle(title) : undefined,
description: description !== undefined ? validateDescription(description) : undefined,
});
res.json(feature);
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to update feature");
}
});
/**
* DELETE /api/roadmaps/features/:featureId
* Delete a feature.
*/
router.delete("/features/:featureId", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { featureId } = req.params;
roadmapStore.deleteFeature(featureId);
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to delete feature");
}
});
/**
* POST /api/roadmaps/features/:featureId/move
* Move a feature to a different milestone or position.
*/
router.post("/features/:featureId/move", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { featureId } = req.params;
const { targetMilestoneId, targetIndex } = req.body as {
targetMilestoneId: string;
targetIndex: number;
};
if (!targetMilestoneId) {
throw badRequest("targetMilestoneId is required");
}
if (typeof targetIndex !== "number") {
throw badRequest("targetIndex must be a number");
}
// Get the feature and source milestone
const feature = roadmapStore.getFeature(featureId);
if (!feature) {
throw notFound(`Feature ${featureId} not found`);
}
const fromMilestone = roadmapStore.getMilestone(feature.milestoneId);
if (!fromMilestone) {
throw notFound(`Source milestone ${feature.milestoneId} not found`);
}
const toMilestone = roadmapStore.getMilestone(targetMilestoneId);
if (!toMilestone) {
throw notFound(`Target milestone ${targetMilestoneId} not found`);
}
roadmapStore.moveFeature({
roadmapId: fromMilestone.roadmapId,
featureId,
fromMilestoneId: feature.milestoneId,
toMilestoneId: targetMilestoneId,
targetOrderIndex: targetIndex,
});
res.status(204).send();
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to move feature");
}
});
// ── Suggestion Endpoints ───────────────────────────────────────────────────
/**
* POST /api/roadmaps/:roadmapId/suggestions/milestones
* Generate milestone suggestions using AI.
*/
router.post("/:roadmapId/suggestions/milestones", async (req, res) => {
// Route-level timeout as safety net (slightly longer than internal timeout)
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
let routeTimedOut = false;
const routeTimeoutId = setTimeout(() => {
routeTimedOut = true;
if (!res.headersSent) {
res.status(503).json({ error: "Request timed out" });
}
}, ROUTE_TIMEOUT_MS);
// Clean up timeout on connection close
res.on("close", () => {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
});
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const scopedStore = getScopedStore();
const { roadmapId } = req.params;
// Check if roadmap exists
const roadmap = roadmapStore.getRoadmap(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
// Validate input
let input: { goalPrompt: string; count?: number };
try {
validateSuggestionInput(req.body);
input = req.body as { goalPrompt: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateMilestoneSuggestions(
input.goalPrompt,
input.count,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate milestone suggestions");
} finally {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
}
});
/**
* POST /api/roadmaps/milestones/:milestoneId/suggestions/features
* Generate feature suggestions using AI.
*/
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
// Route-level timeout as safety net (slightly longer than internal timeout)
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
let routeTimedOut = false;
const routeTimeoutId = setTimeout(() => {
routeTimedOut = true;
if (!res.headersSent) {
res.status(503).json({ error: "Request timed out" });
}
}, ROUTE_TIMEOUT_MS);
// Clean up timeout on connection close
res.on("close", () => {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
});
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const scopedStore = getScopedStore();
const { milestoneId } = req.params;
// Get the milestone to find the roadmap
const milestone = roadmapStore.getMilestone(milestoneId);
if (!milestone) {
throw notFound(`Milestone ${milestoneId} not found`);
}
// Get the roadmap for context
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${milestone.roadmapId} not found`);
}
// Get existing features for this milestone
const existingFeatures = roadmapStore.listFeatures(milestoneId);
const existingFeatureTitles = existingFeatures.map((f) => f.title);
// Validate input
let input: { prompt?: string; count?: number };
try {
validateFeatureSuggestionInput(req.body);
input = req.body as { prompt?: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Build the context for feature suggestion
const context = {
roadmapTitle: roadmap.title,
roadmapDescription: roadmap.description,
milestoneTitle: milestone.title,
milestoneDescription: milestone.description,
existingFeatureTitles,
};
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateFeatureSuggestions(
context,
input.count,
input.prompt,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to generate feature suggestions");
} finally {
if (routeTimeoutId) clearTimeout(routeTimeoutId);
}
});
// ── 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
* 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.
*/
router.get("/:roadmapId/handoff/mission", async (req, res) => {
try {
const roadmapStore = getScopedStore().getRoadmapStore();
const { roadmapId } = req.params;
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");
}
});
/**
* 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;
}