feat(FN-2576): merge fusion/fn-2576 (auto-resolved)
- test(FN-2576): complete Step 4 — add todo route coverage - feat(FN-2576): complete Step 3 — add todo client API functions - feat(FN-2576): complete Step 2 — register todo router - feat(FN-2576): complete Step 1 — add todo routes module
This commit is contained in:
@@ -54,6 +54,13 @@ import type {
|
||||
RoadmapExportBundle,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
TodoList,
|
||||
TodoItem,
|
||||
TodoListWithItems,
|
||||
TodoListCreateInput,
|
||||
TodoListUpdateInput,
|
||||
TodoItemCreateInput,
|
||||
TodoItemUpdateInput,
|
||||
Insight,
|
||||
InsightCategory,
|
||||
InsightStatus,
|
||||
@@ -6269,6 +6276,75 @@ export function generateFeatureSuggestions(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Todo API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all todo lists with their items */
|
||||
export function fetchTodoLists(projectId?: string): Promise<TodoListWithItems[]> {
|
||||
return api<TodoListWithItems[]>(withProjectId("/todos", projectId));
|
||||
}
|
||||
|
||||
/** Create a new todo list */
|
||||
export function createTodoList(title: string, projectId?: string): Promise<TodoList> {
|
||||
const input: TodoListCreateInput = { title };
|
||||
return api<TodoList>(withProjectId("/todos", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a todo list title */
|
||||
export function updateTodoList(id: string, title: string, projectId?: string): Promise<TodoList> {
|
||||
const updates: TodoListUpdateInput = { title };
|
||||
return api<TodoList>(withProjectId(`/todos/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a todo list and all its items */
|
||||
export function deleteTodoList(id: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/todos/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a new item in a todo list */
|
||||
export function createTodoItem(listId: string, text: string, projectId?: string): Promise<TodoItem> {
|
||||
const input: TodoItemCreateInput = { text };
|
||||
return api<TodoItem>(withProjectId(`/todos/${encodeURIComponent(listId)}/items`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a todo item (text and/or completed) */
|
||||
export function updateTodoItem(
|
||||
id: string,
|
||||
data: { text?: string; completed?: boolean },
|
||||
projectId?: string
|
||||
): Promise<TodoItem> {
|
||||
const updates: TodoItemUpdateInput = data;
|
||||
return api<TodoItem>(withProjectId(`/todos/items/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a todo item */
|
||||
export function deleteTodoItem(id: string, projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/todos/items/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Reorder items within a todo list */
|
||||
export function reorderTodoItems(listId: string, itemIds: string[], projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/todos/${encodeURIComponent(listId)}/items/reorder`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ itemIds }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
||||
|
||||
export interface AiSessionSummary {
|
||||
|
||||
422
packages/dashboard/src/__tests__/todo-routes.test.ts
Normal file
422
packages/dashboard/src/__tests__/todo-routes.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TodoItem, TodoList, TodoListWithItems } from "@fusion/core";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import { createTodoRouter } from "../todo-routes.js";
|
||||
import { ApiError } from "../api-error.js";
|
||||
|
||||
const mockGetOrCreateProjectStore = vi.fn();
|
||||
vi.mock("../project-store-resolver.js", () => ({
|
||||
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
|
||||
}));
|
||||
|
||||
function createMockTodoStore() {
|
||||
const lists = new Map<string, TodoList>();
|
||||
const items = new Map<string, TodoItem>();
|
||||
|
||||
const listItemsForList = (listId: string) =>
|
||||
Array.from(items.values())
|
||||
.filter((item) => item.listId === listId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return {
|
||||
createList: vi.fn((projectId: string, input: { title: string }) => {
|
||||
const id = `TDL-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
const now = new Date().toISOString();
|
||||
const list: TodoList = {
|
||||
id,
|
||||
projectId,
|
||||
title: input.title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
lists.set(id, list);
|
||||
return list;
|
||||
}),
|
||||
|
||||
updateList: vi.fn((id: string, updates: { title?: string }) => {
|
||||
const existing = lists.get(id);
|
||||
if (!existing) return undefined;
|
||||
const updated: TodoList = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
lists.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
deleteList: vi.fn((id: string) => {
|
||||
const existed = lists.delete(id);
|
||||
for (const item of Array.from(items.values())) {
|
||||
if (item.listId === id) {
|
||||
items.delete(item.id);
|
||||
}
|
||||
}
|
||||
return existed;
|
||||
}),
|
||||
|
||||
listLists: vi.fn((projectId: string) =>
|
||||
Array.from(lists.values()).filter((list) => list.projectId === projectId)
|
||||
),
|
||||
|
||||
getListsWithItems: vi.fn((projectId: string): TodoListWithItems[] =>
|
||||
Array.from(lists.values())
|
||||
.filter((list) => list.projectId === projectId)
|
||||
.map((list) => ({
|
||||
...list,
|
||||
items: listItemsForList(list.id),
|
||||
}))
|
||||
),
|
||||
|
||||
createItem: vi.fn((listId: string, input: { text: string }) => {
|
||||
if (!lists.has(listId)) {
|
||||
throw new Error(`Todo list ${listId} not found`);
|
||||
}
|
||||
const id = `TDI-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
const now = new Date().toISOString();
|
||||
const item: TodoItem = {
|
||||
id,
|
||||
listId,
|
||||
text: input.text,
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
sortOrder: listItemsForList(listId).length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
items.set(id, item);
|
||||
return item;
|
||||
}),
|
||||
|
||||
updateItem: vi.fn((id: string, updates: { text?: string; completed?: boolean }) => {
|
||||
const existing = items.get(id);
|
||||
if (!existing) return undefined;
|
||||
const now = new Date().toISOString();
|
||||
const updated: TodoItem = {
|
||||
...existing,
|
||||
...updates,
|
||||
completedAt:
|
||||
updates.completed === undefined
|
||||
? existing.completedAt
|
||||
: updates.completed
|
||||
? now
|
||||
: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
items.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
deleteItem: vi.fn((id: string) => items.delete(id)),
|
||||
|
||||
reorderItems: vi.fn((listId: string, itemIds: string[]) => {
|
||||
const listItems = listItemsForList(listId);
|
||||
const existingIds = listItems.map((item) => item.id);
|
||||
|
||||
if (new Set(itemIds).size !== itemIds.length) {
|
||||
throw new Error("Cannot reorder items: duplicate item IDs provided");
|
||||
}
|
||||
if (existingIds.length !== itemIds.length) {
|
||||
throw new Error("Cannot reorder items: provided IDs must include all items in the list");
|
||||
}
|
||||
for (let index = 0; index < itemIds.length; index += 1) {
|
||||
const item = items.get(itemIds[index]);
|
||||
if (item) {
|
||||
items.set(item.id, {
|
||||
...item,
|
||||
sortOrder: index,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return listItemsForList(listId);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Todo Routes", () => {
|
||||
let app: express.Express;
|
||||
let mockTodoStore: ReturnType<typeof createMockTodoStore>;
|
||||
let mockStore: { getTodoStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockTodoStore = createMockTodoStore();
|
||||
mockStore = {
|
||||
getTodoStore: vi.fn(() => mockTodoStore),
|
||||
getRootDir: vi.fn(() => "/test/root"),
|
||||
};
|
||||
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/todos", createTodoRouter(mockStore as never));
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.statusCode).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("list endpoints", () => {
|
||||
it("GET / returns all lists with items", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
mockTodoStore.createItem(list.id, { text: "Ship it" });
|
||||
|
||||
const response = await performGet(app, "/api/todos?projectId=proj-a");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(1);
|
||||
expect(response.body[0].title).toBe("Inbox");
|
||||
expect(response.body[0].items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /?projectId=X passes projectId to store", async () => {
|
||||
const response = await performGet(app, "/api/todos?projectId=proj-scope");
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockTodoStore.getListsWithItems).toHaveBeenCalledWith("proj-scope");
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj-scope");
|
||||
});
|
||||
|
||||
it("POST / creates a list with valid title", async () => {
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/todos",
|
||||
JSON.stringify({ title: " Today " }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe("Today");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, "title is required"],
|
||||
[{ title: "" }, "title is required"],
|
||||
[{ title: " " }, "title is required"],
|
||||
[{ title: "A".repeat(201) }, "200 characters"],
|
||||
])("POST / rejects invalid title %#", async (body, message) => {
|
||||
const response = await performRequest(app, "POST", "/api/todos", JSON.stringify(body), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain(message);
|
||||
});
|
||||
|
||||
it("PATCH /:id updates list title", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Old" });
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/todos/${list.id}`,
|
||||
JSON.stringify({ title: "New" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe("New");
|
||||
});
|
||||
|
||||
it("PATCH /:id returns 404 for nonexistent list", async () => {
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/todos/TDL-MISSING",
|
||||
JSON.stringify({ title: "Nope" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("DELETE /:id deletes list", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Delete me" });
|
||||
const response = await performRequest(app, "DELETE", `/api/todos/${list.id}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockTodoStore.deleteList).toHaveBeenCalledWith(list.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("item endpoints", () => {
|
||||
it("POST /:id/items creates item with valid text", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
`/api/todos/${list.id}/items`,
|
||||
JSON.stringify({ text: " Buy milk " }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.text).toBe("Buy milk");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, "text is required"],
|
||||
[{ text: "" }, "text is required"],
|
||||
[{ text: " ".repeat(3) }, "text is required"],
|
||||
[{ text: "A".repeat(2001) }, "2000 characters"],
|
||||
])("POST /:id/items rejects invalid text %#", async (payload, message) => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
`/api/todos/${list.id}/items`,
|
||||
JSON.stringify(payload),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain(message);
|
||||
});
|
||||
|
||||
it("PATCH /items/:id updates item text", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const item = mockTodoStore.createItem(list.id, { text: "Old text" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/todos/items/${item.id}`,
|
||||
JSON.stringify({ text: "New text" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.text).toBe("New text");
|
||||
});
|
||||
|
||||
it("PATCH /items/:id updates item completed status", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const item = mockTodoStore.createItem(list.id, { text: "Task" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/todos/items/${item.id}`,
|
||||
JSON.stringify({ completed: true }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.completed).toBe(true);
|
||||
});
|
||||
|
||||
it("PATCH /items/:id rejects invalid completed type", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const item = mockTodoStore.createItem(list.id, { text: "Task" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/todos/items/${item.id}`,
|
||||
JSON.stringify({ completed: "true" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("completed must be a boolean");
|
||||
});
|
||||
|
||||
it("PATCH /items/:id returns 404 for nonexistent item", async () => {
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/todos/items/TDI-MISSING",
|
||||
JSON.stringify({ text: "Nope" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("DELETE /items/:id deletes item", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const item = mockTodoStore.createItem(list.id, { text: "Task" });
|
||||
|
||||
const response = await performRequest(app, "DELETE", `/api/todos/items/${item.id}`);
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockTodoStore.deleteItem).toHaveBeenCalledWith(item.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorder endpoint", () => {
|
||||
it("POST /:id/items/reorder reorders items", async () => {
|
||||
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
|
||||
const item1 = mockTodoStore.createItem(list.id, { text: "One" });
|
||||
const item2 = mockTodoStore.createItem(list.id, { text: "Two" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
`/api/todos/${list.id}/items/reorder`,
|
||||
JSON.stringify({ itemIds: [item2.id, item1.id] }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockTodoStore.reorderItems).toHaveBeenCalledWith(list.id, [item2.id, item1.id]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, "itemIds must be an array"],
|
||||
[{ itemIds: "not-array" }, "itemIds must be an array"],
|
||||
[{ itemIds: ["ok", 123] }, "itemIds must be an array of strings"],
|
||||
])("POST /:id/items/reorder rejects invalid itemIds %#", async (body, message) => {
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/todos/TDL-1/items/reorder",
|
||||
JSON.stringify(body),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project scoping", () => {
|
||||
it("passes projectId from query param to list endpoint and resolver", async () => {
|
||||
const response = await performGet(app, "/api/todos?projectId=project-query");
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-query");
|
||||
expect(mockTodoStore.getListsWithItems).toHaveBeenCalledWith("project-query");
|
||||
});
|
||||
|
||||
it("passes projectId from body to create endpoint and resolver", async () => {
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/todos",
|
||||
JSON.stringify({ title: "List", projectId: "project-body" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-body");
|
||||
expect(mockTodoStore.createList).toHaveBeenCalledWith("project-body", { title: "List" });
|
||||
});
|
||||
|
||||
it("passes projectId from body to item update route via scoped resolver", async () => {
|
||||
const list = mockTodoStore.createList("project-body", { title: "Inbox" });
|
||||
const item = mockTodoStore.createItem(list.id, { text: "Initial" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/todos/items/${item.id}`,
|
||||
JSON.stringify({ text: "Updated", projectId: "project-body" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-body");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2788,6 +2788,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// - /missions
|
||||
// - /roadmaps
|
||||
// - /insights
|
||||
// - /todos
|
||||
registerIntegratedRouters({
|
||||
router,
|
||||
store,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ServerOptions } from "../server.js";
|
||||
import { createMissionRouter } from "../mission-routes.js";
|
||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
||||
import { createInsightsRouter } from "../insights-routes.js";
|
||||
import { createTodoRouter } from "../todo-routes.js";
|
||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||
import type { AiSessionStore } from "../ai-session-store.js";
|
||||
|
||||
@@ -32,6 +33,7 @@ export function registerIntegratedRouters({
|
||||
|
||||
router.use("/roadmaps", createRoadmapRouter(store));
|
||||
router.use("/insights", createInsightsRouter(store));
|
||||
router.use("/todos", createTodoRouter(store));
|
||||
}
|
||||
|
||||
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
||||
|
||||
209
packages/dashboard/src/todo-routes.ts
Normal file
209
packages/dashboard/src/todo-routes.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
throw internalError(error.message);
|
||||
}
|
||||
throw internalError(fallbackMessage);
|
||||
}
|
||||
|
||||
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 validateText(text: unknown): string {
|
||||
if (!text || typeof text !== "string" || !text.trim()) {
|
||||
throw badRequest("text is required");
|
||||
}
|
||||
if (text.length > 2000) {
|
||||
throw badRequest("text must not exceed 2000 characters");
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function validateBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw badRequest(`${field} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function createTodoRouter(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);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const projectId = getProjectIdFromRequest(req) ?? "";
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
const lists = todoStore.getListsWithItems(projectId);
|
||||
res.json(lists);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to list todo lists");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const projectId = getProjectIdFromRequest(req) ?? "";
|
||||
const title = validateTitle((req.body as { title?: unknown }).title);
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
const list = todoStore.createList(projectId, { title });
|
||||
res.status(201).json(list);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to create todo list");
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const input = req.body as { title?: unknown };
|
||||
|
||||
if (input.title === undefined) {
|
||||
throw badRequest("At least one field must be provided");
|
||||
}
|
||||
|
||||
const title = validateTitle(input.title);
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
const updated = todoStore.updateList(id, { title });
|
||||
|
||||
if (!updated) {
|
||||
throw notFound(`Todo list ${id} not found`);
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to update todo list");
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
todoStore.deleteList(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to delete todo list");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/items", async (req, res) => {
|
||||
try {
|
||||
const { id: listId } = req.params;
|
||||
const text = validateText((req.body as { text?: unknown }).text);
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
const item = todoStore.createItem(listId, { text });
|
||||
res.status(201).json(item);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to create todo item");
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/items/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const input = req.body as { text?: unknown; completed?: unknown };
|
||||
const updates: { text?: string; completed?: boolean } = {};
|
||||
|
||||
if (input.text !== undefined) {
|
||||
updates.text = validateText(input.text);
|
||||
}
|
||||
if (input.completed !== undefined) {
|
||||
updates.completed = validateBoolean(input.completed, "completed");
|
||||
}
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw badRequest("At least one field must be provided");
|
||||
}
|
||||
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
const item = todoStore.updateItem(id, updates);
|
||||
if (!item) {
|
||||
throw notFound(`Todo item ${id} not found`);
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to update todo item");
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/items/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
todoStore.deleteItem(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to delete todo item");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/items/reorder", async (req, res) => {
|
||||
try {
|
||||
const { id: listId } = req.params;
|
||||
const itemIds = validateStringArray((req.body as { itemIds?: unknown }).itemIds, "itemIds");
|
||||
const todoStore = getScopedStore().getTodoStore();
|
||||
todoStore.reorderItems(listId, itemIds);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to reorder todo items");
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user