feat(FN-1686): merge fusion/fn-1686
This commit is contained in:
@@ -127,10 +127,11 @@ Concrete references:
|
||||
- SQLite (`node:sqlite`) with WAL mode + foreign keys
|
||||
- JSON helpers: `toJson`, `toJsonNullable`, `fromJson`
|
||||
- Tables: `tasks`, `config`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, `agentRatings`, `ai_sessions`, `workflow_steps`, `messages`, `plugins`, `routines`, `runAuditEvents`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), `__meta`
|
||||
- **Standalone roadmap contracts**: `packages/core/src/roadmap-types.ts`, `roadmap-ordering.ts`, `roadmap-store.ts`
|
||||
- **Standalone roadmap model**: `packages/core/src/roadmap-types.ts`, `roadmap-ordering.ts`, `roadmap-store.ts`
|
||||
- Roadmap-first entity types (`Roadmap`, `RoadmapMilestone`, `RoadmapFeature`)
|
||||
- Pure ordering helpers for contiguous 0-based milestone/feature order and deterministic cross-milestone feature moves
|
||||
- `RoadmapStore` for CRUD operations, deterministic ordering, and atomic reorder/move operations
|
||||
- Dashboard API routes in `packages/dashboard/src/roadmap-routes.ts`
|
||||
- Exported from `@fusion/core` for downstream persistence/API/UI work
|
||||
- **CentralCore**: `packages/core/src/central-core.ts`
|
||||
- Global project registry, health, central activity feed, global concurrency
|
||||
|
||||
279
packages/dashboard/src/roadmap-routes.routes.test.ts
Normal file
279
packages/dashboard/src/roadmap-routes.routes.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// @vitest-environment node
|
||||
|
||||
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 type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
|
||||
|
||||
vi.mock("./roadmap-suggestions.js", () => ({
|
||||
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
validateSuggestionInput: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
validateFeatureSuggestionInput: vi.fn(),
|
||||
ValidationError: class extends Error { name = "ValidationError"; constructor(m: string) { super(m); } },
|
||||
ParseError: class extends Error { name = "ParseError"; constructor(m: string) { super(m); } },
|
||||
ServiceUnavailableError: class extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } },
|
||||
}));
|
||||
|
||||
const mockGetOrCreateProjectStore = vi.fn();
|
||||
vi.mock("./project-store-resolver.js", () => ({
|
||||
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
|
||||
}));
|
||||
|
||||
function createMockRoadmapStore(): RoadmapStore {
|
||||
const roadmaps = new Map<string, Roadmap>();
|
||||
const milestones = new Map<string, RoadmapMilestone>();
|
||||
const features = new Map<string, RoadmapFeature>();
|
||||
return {
|
||||
createRoadmap: vi.fn((input: { title: string; description?: string }) => {
|
||||
const id = "RM-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
|
||||
const now = new Date().toISOString();
|
||||
const roadmap: Roadmap = { id, title: input.title, description: input.description, createdAt: now, updatedAt: now };
|
||||
roadmaps.set(id, roadmap);
|
||||
return roadmap;
|
||||
}),
|
||||
getRoadmap: vi.fn((id: string) => roadmaps.get(id)),
|
||||
listRoadmaps: vi.fn(() => Array.from(roadmaps.values())),
|
||||
updateRoadmap: vi.fn((id: string, updates: Partial<Roadmap>) => {
|
||||
const roadmap = roadmaps.get(id);
|
||||
if (!roadmap) throw new Error("Roadmap " + id + " not found");
|
||||
const updated = { ...roadmap, ...updates, updatedAt: new Date().toISOString() };
|
||||
roadmaps.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
deleteRoadmap: vi.fn((id: string) => { roadmaps.delete(id); }),
|
||||
createMilestone: vi.fn((roadmapId: string, input: { title: string; description?: string }) => {
|
||||
const roadmap = roadmaps.get(roadmapId);
|
||||
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
|
||||
const id = "RMS-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
|
||||
const now = new Date().toISOString();
|
||||
const existingMilestones = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId);
|
||||
const orderIndex = existingMilestones.length > 0 ? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1 : 0;
|
||||
const milestone: RoadmapMilestone = { id, roadmapId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
|
||||
milestones.set(id, milestone);
|
||||
return milestone;
|
||||
}),
|
||||
getMilestone: vi.fn((id: string) => milestones.get(id)),
|
||||
listMilestones: vi.fn((roadmapId: string) => Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex)),
|
||||
updateMilestone: vi.fn((id: string, updates: Partial<RoadmapMilestone>) => {
|
||||
const milestone = milestones.get(id);
|
||||
if (!milestone) throw new Error("Milestone " + id + " not found");
|
||||
const updated = { ...milestone, ...updates, updatedAt: new Date().toISOString() };
|
||||
milestones.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
deleteMilestone: vi.fn((id: string) => { milestones.delete(id); }),
|
||||
createFeature: vi.fn((milestoneId: string, input: { title: string; description?: string }) => {
|
||||
const milestone = milestones.get(milestoneId);
|
||||
if (!milestone) throw new Error("Milestone " + milestoneId + " not found");
|
||||
const id = "RF-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
|
||||
const now = new Date().toISOString();
|
||||
const existingFeatures = Array.from(features.values()).filter((f) => f.milestoneId === milestoneId);
|
||||
const orderIndex = existingFeatures.length > 0 ? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1 : 0;
|
||||
const feature: RoadmapFeature = { id, milestoneId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
|
||||
features.set(id, feature);
|
||||
return feature;
|
||||
}),
|
||||
getFeature: vi.fn((id: string) => features.get(id)),
|
||||
listFeatures: vi.fn((milestoneId: string) => Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex)),
|
||||
updateFeature: vi.fn((id: string, updates: Partial<RoadmapFeature>) => {
|
||||
const feature = features.get(id);
|
||||
if (!feature) throw new Error("Feature " + id + " not found");
|
||||
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
|
||||
features.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
deleteFeature: vi.fn((id: string) => { features.delete(id); }),
|
||||
reorderMilestones: vi.fn((input: { roadmapId: string; orderedMilestoneIds: string[] }) => {
|
||||
const { roadmapId, orderedMilestoneIds } = input;
|
||||
orderedMilestoneIds.forEach((id, index) => {
|
||||
const milestone = milestones.get(id);
|
||||
if (milestone) milestones.set(id, { ...milestone, orderIndex: index, updatedAt: new Date().toISOString() });
|
||||
});
|
||||
return Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
}),
|
||||
reorderFeatures: vi.fn((input: { roadmapId: string; milestoneId: string; orderedFeatureIds: string[] }) => {
|
||||
const { milestoneId, orderedFeatureIds } = input;
|
||||
orderedFeatureIds.forEach((id, index) => {
|
||||
const feature = features.get(id);
|
||||
if (feature) features.set(id, { ...feature, orderIndex: index, updatedAt: new Date().toISOString() });
|
||||
});
|
||||
return Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
}),
|
||||
moveFeature: vi.fn((input: { roadmapId: string; featureId: string; fromMilestoneId: string; toMilestoneId: string; targetOrderIndex: number }) => {
|
||||
const { featureId, toMilestoneId, targetOrderIndex } = input;
|
||||
const feature = features.get(featureId);
|
||||
if (!feature) throw new Error("Feature " + featureId + " not found");
|
||||
const updated: RoadmapFeature = { ...feature, milestoneId: toMilestoneId, orderIndex: targetOrderIndex, updatedAt: new Date().toISOString() };
|
||||
features.set(featureId, updated);
|
||||
return { movedFeature: updated, sourceMilestoneFeatures: [], targetMilestoneFeatures: [] };
|
||||
}),
|
||||
getMilestoneWithFeatures: vi.fn((id: string) => {
|
||||
const milestone = milestones.get(id);
|
||||
if (!milestone) return undefined;
|
||||
return { ...milestone, features: [] };
|
||||
}),
|
||||
getRoadmapWithHierarchy: vi.fn((id: string) => {
|
||||
const roadmap = roadmaps.get(id);
|
||||
if (!roadmap) return undefined;
|
||||
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: [] })) };
|
||||
}),
|
||||
} as unknown as RoadmapStore;
|
||||
}
|
||||
|
||||
describe("Roadmap Routes", () => {
|
||||
let app: express.Express;
|
||||
let mockStore: { getRoadmapStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
|
||||
let mockRoadmapStore: ReturnType<typeof createMockRoadmapStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoadmapStore = createMockRoadmapStore();
|
||||
mockStore = {
|
||||
getRoadmapStore: vi.fn(() => mockRoadmapStore),
|
||||
getRootDir: vi.fn(() => "/test/root"),
|
||||
};
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/roadmaps", () => {
|
||||
it("returns empty list when no roadmaps exist", async () => {
|
||||
const response = await performGet(app, "/api/roadmaps");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all roadmaps", async () => {
|
||||
mockRoadmapStore.createRoadmap({ title: "Roadmap 1" });
|
||||
mockRoadmapStore.createRoadmap({ title: "Roadmap 2" });
|
||||
const response = await performGet(app, "/api/roadmaps");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps", () => {
|
||||
it("creates a new roadmap", async () => {
|
||||
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "New Roadmap" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe("New Roadmap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/roadmaps/:roadmapId", () => {
|
||||
it("returns roadmap with hierarchy", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Milestone 1" });
|
||||
mockRoadmapStore.createFeature(milestone.id, { title: "Feature 1" });
|
||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe("Test Roadmap");
|
||||
expect(response.body.milestones).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/roadmaps/:roadmapId", () => {
|
||||
it("updates roadmap title", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Original Title" });
|
||||
const response = await performRequest(app, "PATCH", "/api/roadmaps/" + roadmap.id, JSON.stringify({ title: "Updated Title" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe("Updated Title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/roadmaps/:roadmapId", () => {
|
||||
it("deletes a roadmap", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "To Delete" });
|
||||
const response = await performRequest(app, "DELETE", "/api/roadmaps/" + roadmap.id);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/:roadmapId/milestones", () => {
|
||||
it("creates a milestone with auto orderIndex", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones", JSON.stringify({ title: "New Milestone" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.roadmapId).toBe(roadmap.id);
|
||||
expect(response.body.orderIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/:roadmapId/milestones/reorder", () => {
|
||||
it("reorders milestones", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const m1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "First" });
|
||||
const m2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Second" });
|
||||
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/roadmaps/milestones/:milestoneId", () => {
|
||||
it("updates a milestone", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Original" });
|
||||
const response = await performRequest(app, "PATCH", "/api/roadmaps/milestones/" + milestone.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/roadmaps/milestones/:milestoneId", () => {
|
||||
it("deletes a milestone", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "To Delete" });
|
||||
const response = await performRequest(app, "DELETE", "/api/roadmaps/milestones/" + milestone.id);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/milestones/:milestoneId/features", () => {
|
||||
it("creates a feature", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
||||
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({ title: "New Feature" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe("New Feature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/roadmaps/features/:featureId", () => {
|
||||
it("updates a feature", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Original" });
|
||||
const response = await performRequest(app, "PATCH", "/api/roadmaps/features/" + feature.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/roadmaps/features/:featureId", () => {
|
||||
it("deletes a feature", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "To Delete" });
|
||||
const response = await performRequest(app, "DELETE", "/api/roadmaps/features/" + feature.id);
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectId scoping", () => {
|
||||
it("uses projectId from query param", async () => {
|
||||
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
|
||||
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("test-project");
|
||||
});
|
||||
});
|
||||
});
|
||||
579
packages/dashboard/src/roadmap-routes.ts
Normal file
579
packages/dashboard/src/roadmap-routes.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Roadmap REST API Routes
|
||||
*
|
||||
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
|
||||
* Also includes AI-powered suggestion endpoints for milestone and feature creation.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
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,
|
||||
} 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) => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/roadmaps/milestones/:milestoneId/suggestions/features
|
||||
* Generate feature suggestions using AI.
|
||||
*/
|
||||
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
hasIssueBadgeFieldsChanged,
|
||||
} from "./github-webhooks.js";
|
||||
import { createMissionRouter } from "./mission-routes.js";
|
||||
import { createRoadmapRouter } from "./roadmap-routes.js";
|
||||
import { getOrCreateProjectStore, invalidateAllGlobalSettingsCaches } from "./project-store-resolver.js";
|
||||
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
||||
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
||||
@@ -42,15 +43,6 @@ import {
|
||||
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
||||
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
|
||||
import { writeSSEEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
generateMilestoneSuggestions,
|
||||
validateSuggestionInput,
|
||||
generateFeatureSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
ValidationError as SuggestionValidationError,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
} from "./roadmap-suggestions.js";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
@@ -2418,446 +2410,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Models
|
||||
registerModelsRoute(router, options?.modelRegistry, store);
|
||||
|
||||
// ── Roadmap Routes ─────────────────────────────────────────────────
|
||||
|
||||
// List all roadmaps
|
||||
router.get("/roadmaps", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const roadmaps = roadmapStore.listRoadmaps();
|
||||
res.json(roadmaps);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to list roadmaps");
|
||||
}
|
||||
});
|
||||
|
||||
// Create a roadmap
|
||||
router.post("/roadmaps", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { title, description } = req.body as { title: string; description?: string };
|
||||
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw badRequest("title is required");
|
||||
}
|
||||
|
||||
const roadmap = roadmapStore.createRoadmap({ title: title.trim(), description });
|
||||
res.status(201).json(roadmap);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
// Get a roadmap with full hierarchy
|
||||
router.get("/roadmaps/:roadmapId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const roadmap = roadmapStore.getRoadmapWithHierarchy(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw notFound(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
res.json(roadmap);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to get roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
// Update a roadmap
|
||||
router.patch("/roadmaps/:roadmapId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
const { title, description } = req.body as { title?: string; description?: string };
|
||||
|
||||
const roadmap = roadmapStore.updateRoadmap(roadmapId, { title, description });
|
||||
res.json(roadmap);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to update roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a roadmap
|
||||
router.delete("/roadmaps/:roadmapId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
roadmapStore.deleteRoadmap(roadmapId);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to delete roadmap");
|
||||
}
|
||||
});
|
||||
|
||||
// Create a milestone
|
||||
router.post("/roadmaps/:roadmapId/milestones", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
const { title, description } = req.body as { title: string; description?: string };
|
||||
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw badRequest("title is required");
|
||||
}
|
||||
|
||||
const milestone = roadmapStore.createMilestone(roadmapId, { title: title.trim(), description });
|
||||
res.status(201).json(milestone);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create milestone");
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder milestones
|
||||
router.post("/roadmaps/:roadmapId/milestones/reorder", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
const { orderedMilestoneIds } = req.body as { orderedMilestoneIds: string[] };
|
||||
|
||||
if (!Array.isArray(orderedMilestoneIds)) {
|
||||
throw badRequest("orderedMilestoneIds must be an array");
|
||||
}
|
||||
|
||||
roadmapStore.reorderMilestones({ roadmapId, orderedMilestoneIds });
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to reorder milestones");
|
||||
}
|
||||
});
|
||||
|
||||
// Update a milestone
|
||||
router.patch("/roadmaps/milestones/:milestoneId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description } = req.body as { title?: string; description?: string };
|
||||
|
||||
const milestone = roadmapStore.updateMilestone(milestoneId, { title, description });
|
||||
res.json(milestone);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to update milestone");
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a milestone
|
||||
router.delete("/roadmaps/milestones/:milestoneId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
roadmapStore.deleteMilestone(milestoneId);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to delete milestone");
|
||||
}
|
||||
});
|
||||
|
||||
// Create a feature
|
||||
router.post("/roadmaps/milestones/:milestoneId/features", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description } = req.body as { title: string; description?: string };
|
||||
|
||||
if (!title || typeof title !== "string" || !title.trim()) {
|
||||
throw badRequest("title is required");
|
||||
}
|
||||
|
||||
const feature = roadmapStore.createFeature(milestoneId, { title: title.trim(), description });
|
||||
res.status(201).json(feature);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create feature");
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder features within a milestone
|
||||
router.post("/roadmaps/milestones/:milestoneId/features/reorder", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
const { orderedFeatureIds } = req.body as { orderedFeatureIds: string[] };
|
||||
|
||||
if (!Array.isArray(orderedFeatureIds)) {
|
||||
throw badRequest("orderedFeatureIds must be an array");
|
||||
}
|
||||
|
||||
// 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: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to reorder features");
|
||||
}
|
||||
});
|
||||
|
||||
// Update a feature
|
||||
router.patch("/roadmaps/features/:featureId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { featureId } = req.params;
|
||||
const { title, description } = req.body as { title?: string; description?: string };
|
||||
|
||||
const feature = roadmapStore.updateFeature(featureId, { title, description });
|
||||
res.json(feature);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to update feature");
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a feature
|
||||
router.delete("/roadmaps/features/:featureId", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { featureId } = req.params;
|
||||
|
||||
roadmapStore.deleteFeature(featureId);
|
||||
res.status(204).send();
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to delete feature");
|
||||
}
|
||||
});
|
||||
|
||||
// Move a feature
|
||||
router.post("/roadmaps/features/:featureId/move", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.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: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to move feature");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Roadmap Milestone Suggestions ───────────────────────────────────────
|
||||
|
||||
// Generate milestone suggestions from a goal prompt
|
||||
router.post("/roadmaps/:roadmapId/suggestions/milestones", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
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: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to generate milestone suggestions");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Roadmap Feature Suggestions ──────────────────────────────────────────
|
||||
|
||||
// Generate feature suggestions for a milestone
|
||||
router.post("/roadmaps/milestones/:milestoneId/suggestions/features", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
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: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to generate feature suggestions");
|
||||
}
|
||||
});
|
||||
|
||||
// List all tasks
|
||||
router.get("/tasks", async (req, res) => {
|
||||
try {
|
||||
@@ -12591,6 +12143,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Mount mission routes at /api/missions
|
||||
router.use("/missions", createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager));
|
||||
|
||||
// ── Roadmap Routes ─────────────────────────────────────────────────────────
|
||||
// Mount roadmap routes at /api/roadmaps
|
||||
router.use("/roadmaps", createRoadmapRouter(store));
|
||||
|
||||
// ── Plugin Routes ─────────────────────────────────────────────────────────
|
||||
// Plugin management endpoints with projectId scoping support.
|
||||
// Uses getScopedStore(req) pattern for multi-project support.
|
||||
|
||||
Reference in New Issue
Block a user