@x4lt7ab/tab-for-projects 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +266 -0
  2. package/package.json +39 -0
  3. package/src/domain/args.ts +41 -0
  4. package/src/domain/bootstrap.ts +26 -0
  5. package/src/domain/db/connection.ts +21 -0
  6. package/src/domain/db/schema.ts +35 -0
  7. package/src/domain/entities.ts +21 -0
  8. package/src/domain/errors.ts +9 -0
  9. package/src/domain/index.ts +12 -0
  10. package/src/domain/inputs.ts +13 -0
  11. package/src/domain/repositories/projects.ts +75 -0
  12. package/src/domain/repositories/tasks.ts +62 -0
  13. package/src/domain/services/projects.ts +68 -0
  14. package/src/domain/services/tasks.ts +62 -0
  15. package/src/domain/services.ts +22 -0
  16. package/src/domain/statuses.ts +5 -0
  17. package/src/index.ts +4 -0
  18. package/src/mcp/index.ts +3 -0
  19. package/src/mcp/server.ts +140 -0
  20. package/src/mcp/standalone.ts +48 -0
  21. package/src/server/index.ts +102 -0
  22. package/src/server/routes/projects.ts +58 -0
  23. package/src/server/routes/tasks.ts +58 -0
  24. package/src/web/dist/assets/index-Bonqd4_2.js +49 -0
  25. package/src/web/dist/index.html +28 -0
  26. package/src/web/index.html +28 -0
  27. package/src/web/src/App.tsx +829 -0
  28. package/src/web/src/api.ts +1 -0
  29. package/src/web/src/components/Badge.tsx +54 -0
  30. package/src/web/src/components/Button.tsx +58 -0
  31. package/src/web/src/components/Card.tsx +40 -0
  32. package/src/web/src/components/Icon.tsx +22 -0
  33. package/src/web/src/components/IconButton.tsx +50 -0
  34. package/src/web/src/components/Input.tsx +47 -0
  35. package/src/web/src/components/Select.tsx +41 -0
  36. package/src/web/src/components/Stack.tsx +38 -0
  37. package/src/web/src/components/ThemeContext.tsx +53 -0
  38. package/src/web/src/components/ThemeSwitcher.tsx +49 -0
  39. package/src/web/src/components/TopBar.tsx +38 -0
  40. package/src/web/src/components/index.ts +12 -0
  41. package/src/web/src/components/theme.ts +185 -0
  42. package/src/web/src/main.tsx +12 -0
  43. package/src/web/tsconfig.json +17 -0
  44. package/src/web/vite.config.ts +16 -0
@@ -0,0 +1,68 @@
1
+ import type { Project } from "../entities";
2
+ import type { CreateProjectInput, UpdateProjectInput } from "../inputs";
3
+ import type { IProjectService } from "../services";
4
+ import { ServiceError } from "../errors";
5
+ import type { ProjectRepository } from "../repositories/projects";
6
+ import { PROJECT_STATUSES } from "../statuses";
7
+
8
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
+
10
+ export class ProjectService implements IProjectService {
11
+ constructor(private repo: ProjectRepository) {}
12
+
13
+ findAll(): Project[] {
14
+ return this.repo.findAll();
15
+ }
16
+
17
+ findBySlug(slug: string): Project | null {
18
+ return this.repo.findBySlug(slug);
19
+ }
20
+
21
+ create(input: CreateProjectInput): Project {
22
+ if (!input.name?.trim()) {
23
+ throw new ServiceError("name is required", 400);
24
+ }
25
+ if (input.name.length > 255) {
26
+ throw new ServiceError("name must be 255 characters or fewer", 400);
27
+ }
28
+ if (!input.slug?.trim()) {
29
+ throw new ServiceError("slug is required", 400);
30
+ }
31
+ if (input.slug.length > 100) {
32
+ throw new ServiceError("slug must be 100 characters or fewer", 400);
33
+ }
34
+ if (!SLUG_RE.test(input.slug)) {
35
+ throw new ServiceError("slug must be lowercase alphanumeric with hyphens only", 400);
36
+ }
37
+ if (input.description !== undefined && input.description.length > 10000) {
38
+ throw new ServiceError("description must be 10000 characters or fewer", 400);
39
+ }
40
+ if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
41
+ throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
42
+ }
43
+ if (this.repo.findBySlug(input.slug)) {
44
+ throw new ServiceError("slug already exists", 409);
45
+ }
46
+ return this.repo.create(input);
47
+ }
48
+
49
+ update(slug: string, input: UpdateProjectInput): Project | null {
50
+ if (input.name !== undefined && !input.name.trim()) {
51
+ throw new ServiceError("name cannot be empty", 400);
52
+ }
53
+ if (input.name !== undefined && input.name.length > 255) {
54
+ throw new ServiceError("name must be 255 characters or fewer", 400);
55
+ }
56
+ if (input.description !== undefined && input.description.length > 10000) {
57
+ throw new ServiceError("description must be 10000 characters or fewer", 400);
58
+ }
59
+ if (input.status !== undefined && !(PROJECT_STATUSES as readonly string[]).includes(input.status)) {
60
+ throw new ServiceError(`status must be one of: ${PROJECT_STATUSES.join(", ")}`, 400);
61
+ }
62
+ return this.repo.update(slug, input);
63
+ }
64
+
65
+ delete(slug: string): boolean {
66
+ return this.repo.delete(slug);
67
+ }
68
+ }
@@ -0,0 +1,62 @@
1
+ import type { Task } from "../entities";
2
+ import type { CreateTaskInput, UpdateTaskInput } from "../inputs";
3
+ import type { ITaskService } from "../services";
4
+ import { ServiceError } from "../errors";
5
+ import type { TaskRepository } from "../repositories/tasks";
6
+ import type { ProjectRepository } from "../repositories/projects";
7
+ import { TASK_STATUSES } from "../statuses";
8
+
9
+ export class TaskService implements ITaskService {
10
+ constructor(
11
+ private taskRepo: TaskRepository,
12
+ private projectRepo: ProjectRepository
13
+ ) {}
14
+
15
+ findByProjectSlug(projectSlug: string): Task[] {
16
+ const project = this.projectRepo.findBySlug(projectSlug);
17
+ if (!project) {
18
+ throw new ServiceError("project not found", 404);
19
+ }
20
+ return this.taskRepo.findByProject(project.id);
21
+ }
22
+
23
+ create(projectSlug: string, input: CreateTaskInput): Task {
24
+ const project = this.projectRepo.findBySlug(projectSlug);
25
+ if (!project) {
26
+ throw new ServiceError("project not found", 404);
27
+ }
28
+ if (!input.title?.trim()) {
29
+ throw new ServiceError("title is required", 400);
30
+ }
31
+ if (input.title.length > 500) {
32
+ throw new ServiceError("title must be 500 characters or fewer", 400);
33
+ }
34
+ if (input.description !== undefined && input.description.length > 10000) {
35
+ throw new ServiceError("description must be 10000 characters or fewer", 400);
36
+ }
37
+ if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
38
+ throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
39
+ }
40
+ return this.taskRepo.create(project.id, input);
41
+ }
42
+
43
+ update(id: string, input: UpdateTaskInput): Task | null {
44
+ if (input.title !== undefined && !input.title.trim()) {
45
+ throw new ServiceError("title cannot be empty", 400);
46
+ }
47
+ if (input.title !== undefined && input.title.length > 500) {
48
+ throw new ServiceError("title must be 500 characters or fewer", 400);
49
+ }
50
+ if (input.description !== undefined && input.description.length > 10000) {
51
+ throw new ServiceError("description must be 10000 characters or fewer", 400);
52
+ }
53
+ if (input.status !== undefined && !(TASK_STATUSES as readonly string[]).includes(input.status)) {
54
+ throw new ServiceError(`status must be one of: ${TASK_STATUSES.join(", ")}`, 400);
55
+ }
56
+ return this.taskRepo.update(id, input);
57
+ }
58
+
59
+ delete(id: string): boolean {
60
+ return this.taskRepo.delete(id);
61
+ }
62
+ }
@@ -0,0 +1,22 @@
1
+ import type { Project, Task } from "./entities";
2
+ import type {
3
+ CreateProjectInput,
4
+ UpdateProjectInput,
5
+ CreateTaskInput,
6
+ UpdateTaskInput,
7
+ } from "./inputs";
8
+
9
+ export interface IProjectService {
10
+ findAll(): Project[];
11
+ findBySlug(slug: string): Project | null;
12
+ create(input: CreateProjectInput): Project;
13
+ update(slug: string, input: UpdateProjectInput): Project | null;
14
+ delete(slug: string): boolean;
15
+ }
16
+
17
+ export interface ITaskService {
18
+ findByProjectSlug(projectSlug: string): Task[];
19
+ create(projectSlug: string, input: CreateTaskInput): Task;
20
+ update(id: string, input: UpdateTaskInput): Task | null;
21
+ delete(id: string): boolean;
22
+ }
@@ -0,0 +1,5 @@
1
+ export const PROJECT_STATUSES = ["active", "paused", "completed", "archived"] as const;
2
+ export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
3
+
4
+ export const TASK_STATUSES = ["todo", "in_progress", "done"] as const;
5
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { Server } from "./server";
3
+
4
+ new Server().start();
@@ -0,0 +1,3 @@
1
+ export { createMcpServer, handleMcpHttp } from "./server";
2
+ export type { McpServiceContext } from "./server";
3
+ export { McpStandaloneServer } from "./standalone";
@@ -0,0 +1,140 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
+ import { z } from "zod";
4
+ import {
5
+ ServiceError,
6
+ PROJECT_STATUSES,
7
+ TASK_STATUSES,
8
+ type IProjectService,
9
+ type ITaskService,
10
+ } from "../domain";
11
+
12
+ export interface McpServiceContext {
13
+ projectService: IProjectService;
14
+ taskService: ITaskService;
15
+ }
16
+
17
+ function handle<T>(fn: () => T) {
18
+ try {
19
+ const result = fn();
20
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
21
+ } catch (err) {
22
+ if (err instanceof ServiceError) {
23
+ return {
24
+ content: [{ type: "text" as const, text: err.message }],
25
+ isError: true,
26
+ };
27
+ }
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ /** Create an McpServer with all tools registered. */
33
+ export function createMcpServer(ctx: McpServiceContext): McpServer {
34
+ const { projectService, taskService } = ctx;
35
+
36
+ const server = new McpServer({
37
+ name: "tab-for-projects",
38
+ version: "0.1.0",
39
+ });
40
+
41
+ // ── Projects ──────────────────────────────────────────────
42
+
43
+ server.registerTool("list_projects", { description: "List all projects" }, () =>
44
+ handle(() => projectService.findAll())
45
+ );
46
+
47
+ server.registerTool(
48
+ "get_project",
49
+ { description: "Get a project by slug", inputSchema: { slug: z.string() } },
50
+ ({ slug }) => handle(() => projectService.findBySlug(slug))
51
+ );
52
+
53
+ server.registerTool(
54
+ "create_project",
55
+ {
56
+ description: "Create a new project",
57
+ inputSchema: {
58
+ name: z.string(),
59
+ slug: z.string(),
60
+ description: z.string().optional(),
61
+ status: z.enum(PROJECT_STATUSES).optional(),
62
+ },
63
+ },
64
+ (input) => handle(() => projectService.create(input))
65
+ );
66
+
67
+ server.registerTool(
68
+ "update_project",
69
+ {
70
+ description: "Update an existing project",
71
+ inputSchema: {
72
+ slug: z.string(),
73
+ name: z.string().optional(),
74
+ description: z.string().optional(),
75
+ status: z.enum(PROJECT_STATUSES).optional(),
76
+ },
77
+ },
78
+ ({ slug, ...updates }) => handle(() => projectService.update(slug, updates))
79
+ );
80
+
81
+ server.registerTool(
82
+ "delete_project",
83
+ { description: "Delete a project by slug", inputSchema: { slug: z.string() } },
84
+ ({ slug }) => handle(() => projectService.delete(slug))
85
+ );
86
+
87
+ // ── Tasks ─────────────────────────────────────────────────
88
+
89
+ server.registerTool(
90
+ "list_tasks",
91
+ { description: "List tasks for a project", inputSchema: { project_slug: z.string() } },
92
+ ({ project_slug }) => handle(() => taskService.findByProjectSlug(project_slug))
93
+ );
94
+
95
+ server.registerTool(
96
+ "create_task",
97
+ {
98
+ description: "Create a task in a project",
99
+ inputSchema: {
100
+ project_slug: z.string(),
101
+ title: z.string(),
102
+ description: z.string().optional(),
103
+ status: z.enum(TASK_STATUSES).optional(),
104
+ },
105
+ },
106
+ ({ project_slug, ...input }) => handle(() => taskService.create(project_slug, input))
107
+ );
108
+
109
+ server.registerTool(
110
+ "update_task",
111
+ {
112
+ description: "Update a task by ID",
113
+ inputSchema: {
114
+ id: z.string(),
115
+ title: z.string().optional(),
116
+ description: z.string().optional(),
117
+ status: z.enum(TASK_STATUSES).optional(),
118
+ },
119
+ },
120
+ ({ id, ...updates }) => handle(() => taskService.update(id, updates))
121
+ );
122
+
123
+ server.registerTool(
124
+ "delete_task",
125
+ { description: "Delete a task by ID", inputSchema: { id: z.string() } },
126
+ ({ id }) => handle(() => taskService.delete(id))
127
+ );
128
+
129
+ return server;
130
+ }
131
+
132
+ /** Handle a single MCP-over-HTTP request (stateless, one server per request). */
133
+ export async function handleMcpHttp(ctx: McpServiceContext, req: Request): Promise<Response> {
134
+ const server = createMcpServer(ctx);
135
+ const transport = new WebStandardStreamableHTTPServerTransport();
136
+ await server.connect(transport);
137
+ const response = await transport.handleRequest(req);
138
+ await server.close();
139
+ return response;
140
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bun
2
+ import { Hono } from "hono";
3
+ import { cors } from "hono/cors";
4
+ import { bootstrap } from "../domain";
5
+ import { parseArgs, logListening, type ServerOptions } from "../domain/args";
6
+ import { handleMcpHttp } from "./server";
7
+
8
+ export class McpStandaloneServer {
9
+ private options: ServerOptions;
10
+
11
+ constructor(options?: Partial<ServerOptions>) {
12
+ const defaults = parseArgs({ port: 3001, portEnv: "PM_MCP_PORT" });
13
+ this.options = { ...defaults, ...options };
14
+ }
15
+
16
+ start(): void {
17
+ const { port, host, dbPath } = this.options;
18
+ const ctx = bootstrap(dbPath);
19
+
20
+ const app = new Hono();
21
+ app.use(
22
+ "*",
23
+ cors({
24
+ origin: (origin) =>
25
+ origin?.startsWith("http://localhost") || origin?.startsWith("http://127.0.0.1")
26
+ ? origin
27
+ : null,
28
+ allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
29
+ allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
30
+ exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
31
+ })
32
+ );
33
+ app.all("/*", (c) => handleMcpHttp(ctx, c.req.raw));
34
+
35
+ logListening("tab-for-projects mcp (standalone)", host, port);
36
+
37
+ Bun.serve({
38
+ port,
39
+ hostname: host,
40
+ fetch: app.fetch,
41
+ });
42
+ }
43
+ }
44
+
45
+ // Direct execution
46
+ if (import.meta.main) {
47
+ new McpStandaloneServer().start();
48
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+ import { Hono } from "hono";
3
+ import { logger } from "hono/logger";
4
+ import { cors } from "hono/cors";
5
+ import { serveStatic } from "hono/bun";
6
+ import { compress } from "hono/compress";
7
+ import { secureHeaders } from "hono/secure-headers";
8
+ import { etag } from "hono/etag";
9
+ import { join } from "path";
10
+ import { readFileSync, existsSync } from "fs";
11
+ import { bootstrap, ServiceError } from "../domain";
12
+ import { parseArgs, logListening, type ServerOptions } from "../domain/args";
13
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
14
+ import { projectRoutes } from "./routes/projects";
15
+ import { taskRoutes } from "./routes/tasks";
16
+ import { handleMcpHttp } from "../mcp/server";
17
+
18
+ export class Server {
19
+ private options: ServerOptions;
20
+
21
+ constructor(options?: Partial<ServerOptions>) {
22
+ const defaults = parseArgs({ port: 3000, portEnv: "PM_PORT" });
23
+ this.options = { ...defaults, ...options };
24
+ }
25
+
26
+ start(): void {
27
+ const { port, host, dbPath } = this.options;
28
+ const ctx = bootstrap(dbPath);
29
+
30
+ const app = new Hono();
31
+
32
+ // ── Global middleware ──────────────────────────────────
33
+ app.use("*", secureHeaders());
34
+ app.use("*", compress());
35
+ app.use(
36
+ "*",
37
+ cors({
38
+ origin: (origin) =>
39
+ origin?.startsWith("http://localhost") || origin?.startsWith("http://127.0.0.1")
40
+ ? origin
41
+ : null,
42
+ allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
43
+ allowHeaders: ["Content-Type", "mcp-session-id", "Last-Event-ID", "mcp-protocol-version"],
44
+ exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
45
+ })
46
+ );
47
+
48
+ // ── API (with logging) ────────────────────────────────
49
+ app.use("/api/*", logger((str) => process.stderr.write(str + "\n")));
50
+ app.route("/api/projects/:projectSlug/tasks", taskRoutes(ctx.taskService));
51
+ app.route("/api/projects", projectRoutes(ctx.projectService));
52
+ app.get("/api/health", (c) => c.json({ status: "ok" }));
53
+
54
+ // ── MCP (no logging) ──────────────────────────────────
55
+ app.all("/mcp", (c) => handleMcpHttp(ctx, c.req.raw));
56
+
57
+ // ── Static web assets ─────────────────────────────────
58
+ const dist = join(import.meta.dir, "../web/dist");
59
+ const indexPath = join(dist, "index.html");
60
+ const indexHtml = existsSync(indexPath)
61
+ ? readFileSync(indexPath, "utf-8")
62
+ : null;
63
+
64
+ app.use(
65
+ "/assets/*",
66
+ etag(),
67
+ async (c, next) => {
68
+ await next();
69
+ c.header("Cache-Control", "public, max-age=31536000, immutable");
70
+ }
71
+ );
72
+ app.use("/*", etag());
73
+ app.use("/*", serveStatic({ root: dist }));
74
+
75
+ // SPA fallback
76
+ app.get("/*", (c) => {
77
+ if (indexHtml) return c.html(indexHtml);
78
+ return c.text("Not found — run `bun run build` first", 404);
79
+ });
80
+
81
+ // ── Error handling ────────────────────────────────────
82
+ app.onError((err, c) => {
83
+ if (err instanceof SyntaxError) return c.json({ error: "invalid JSON body" }, 400);
84
+ if (err instanceof ServiceError) return c.json({ error: err.message }, err.statusCode as ContentfulStatusCode);
85
+ console.error(err);
86
+ return c.json({ error: "internal server error" }, 500);
87
+ });
88
+
89
+ logListening("tab-for-projects", host, port);
90
+
91
+ Bun.serve({
92
+ port,
93
+ hostname: host,
94
+ fetch: app.fetch,
95
+ });
96
+ }
97
+ }
98
+
99
+ // Direct execution
100
+ if (import.meta.main) {
101
+ new Server().start();
102
+ }
@@ -0,0 +1,58 @@
1
+ import { Hono } from "hono";
2
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
3
+ import {
4
+ ServiceError,
5
+ type IProjectService,
6
+ type CreateProjectInput,
7
+ type UpdateProjectInput,
8
+ } from "../../domain";
9
+
10
+ export function projectRoutes(service: IProjectService): Hono {
11
+ const app = new Hono();
12
+
13
+ // GET /api/projects
14
+ app.get("/", (c) => {
15
+ return c.json(service.findAll());
16
+ });
17
+
18
+ // POST /api/projects
19
+ app.post("/", async (c) => {
20
+ const body = await c.req.json<CreateProjectInput>();
21
+ try {
22
+ const project = service.create(body);
23
+ return c.json(project, 201);
24
+ } catch (e: unknown) {
25
+ if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
26
+ throw e;
27
+ }
28
+ });
29
+
30
+ // GET /api/projects/:slug
31
+ app.get("/:slug", (c) => {
32
+ const project = service.findBySlug(c.req.param("slug"));
33
+ if (!project) return c.json({ error: "project not found" }, 404);
34
+ return c.json(project);
35
+ });
36
+
37
+ // PATCH /api/projects/:slug
38
+ app.patch("/:slug", async (c) => {
39
+ const body = await c.req.json<UpdateProjectInput>();
40
+ try {
41
+ const project = service.update(c.req.param("slug"), body);
42
+ if (!project) return c.json({ error: "project not found" }, 404);
43
+ return c.json(project);
44
+ } catch (e: unknown) {
45
+ if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
46
+ throw e;
47
+ }
48
+ });
49
+
50
+ // DELETE /api/projects/:slug
51
+ app.delete("/:slug", (c) => {
52
+ const deleted = service.delete(c.req.param("slug"));
53
+ if (!deleted) return c.json({ error: "project not found" }, 404);
54
+ return c.json({ ok: true });
55
+ });
56
+
57
+ return app;
58
+ }
@@ -0,0 +1,58 @@
1
+ import { Hono } from "hono";
2
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
3
+ import {
4
+ ServiceError,
5
+ type ITaskService,
6
+ type CreateTaskInput,
7
+ type UpdateTaskInput,
8
+ } from "../../domain";
9
+
10
+ export function taskRoutes(service: ITaskService): Hono {
11
+ const app = new Hono();
12
+
13
+ // GET /api/projects/:projectSlug/tasks
14
+ app.get("/", (c) => {
15
+ const projectSlug = c.req.param("projectSlug")!;
16
+ try {
17
+ return c.json(service.findByProjectSlug(projectSlug));
18
+ } catch (e: unknown) {
19
+ if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
20
+ throw e;
21
+ }
22
+ });
23
+
24
+ // POST /api/projects/:projectSlug/tasks
25
+ app.post("/", async (c) => {
26
+ const projectSlug = c.req.param("projectSlug")!;
27
+ const body = await c.req.json<CreateTaskInput>();
28
+ try {
29
+ const task = service.create(projectSlug, body);
30
+ return c.json(task, 201);
31
+ } catch (e: unknown) {
32
+ if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
33
+ throw e;
34
+ }
35
+ });
36
+
37
+ // PATCH /api/projects/:projectSlug/tasks/:id
38
+ app.patch("/:id", async (c) => {
39
+ const body = await c.req.json<UpdateTaskInput>();
40
+ try {
41
+ const task = service.update(c.req.param("id")!, body);
42
+ if (!task) return c.json({ error: "task not found" }, 404);
43
+ return c.json(task);
44
+ } catch (e: unknown) {
45
+ if (e instanceof ServiceError) return c.json({ error: e.message }, e.statusCode as ContentfulStatusCode);
46
+ throw e;
47
+ }
48
+ });
49
+
50
+ // DELETE /api/projects/:projectSlug/tasks/:id
51
+ app.delete("/:id", (c) => {
52
+ const deleted = service.delete(c.req.param("id")!);
53
+ if (!deleted) return c.json({ error: "task not found" }, 404);
54
+ return c.json({ ok: true });
55
+ });
56
+
57
+ return app;
58
+ }