From c5682765e838fedc80c4325ec42a0b2c01ce61a3 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Mon, 7 Sep 2026 19:24:24 +0000 Subject: [PATCH] feat: add states CRUD API route with project-scoped workflow states - Add states.ts with GET/POST/PATCH/DELETE endpoints - States scoped to project via projectId FK; workspace access resolved through project - stateGroupEnum enforced (backlog|unstarted|started|completed|cancelled) - List ordered by sortOrder, auto-increment on create - Soft-delete via deletedAt column (added to schema) - 3-step contract on every write: DB write, activity feed, pg_notify - Register /api/states route in main index.ts - Default state seeding already present in projects.ts --- apps/api/src/index.ts | 2 + apps/api/src/routes/states.ts | 242 ++++++++++++++++++++++++++++++++++ packages/db/src/schema.ts | 2 + 3 files changed, 246 insertions(+) create mode 100644 apps/api/src/routes/states.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a5fac02..0d1e171 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,6 +26,7 @@ import { analyticsRoutes } from "./routes/analytics"; import { activityRoutes } from "./routes/activity"; import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; +import { stateRoutes } from "./routes/states"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -62,6 +63,7 @@ app.route("/api/error-log", errorLogRoutes); app.route("/api/analytics", analyticsRoutes); app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); +app.route("/api/states", stateRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/routes/states.ts b/apps/api/src/routes/states.ts new file mode 100644 index 0000000..d796be7 --- /dev/null +++ b/apps/api/src/routes/states.ts @@ -0,0 +1,242 @@ +import { Hono } from "hono"; +import { db, states, projects } from "@project-e/db"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; +import { z } from "zod"; + +export const stateRoutes = new Hono(); + +const stateGroupEnum = z.enum(["backlog", "unstarted", "started", "completed", "cancelled"]); + +const createStateSchema = z.object({ + projectId: z.string().uuid("Invalid project id"), + name: z.string().min(1, "Name is required"), + color: z.string().optional().nullable(), + group: stateGroupEnum.optional().default("unstarted"), + sortOrder: z.number().int().optional(), +}); + +const updateStateSchema = z.object({ + name: z.string().min(1).optional(), + color: z.string().optional().nullable(), + group: stateGroupEnum.optional(), + sortOrder: z.number().int().optional(), +}); + +// GET /api/states — List states filtered by projectId (exclude soft-deleted) +stateRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const projectId = url.searchParams.get("projectId") || url.searchParams.get("project_id"); + + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "projectId query parameter is required" } }, 400); + } + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const items = await db.select() + .from(states) + .where(and(eq(states.projectId, projectId), isNull(states.deletedAt))) + .orderBy(asc(states.sortOrder)); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[states] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list states" } }, 500); + } +}); + +// POST /api/states — Create a state +stateRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createStateSchema.parse(body); + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, data.projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + let sortOrder = data.sortOrder; + if (sortOrder === undefined) { + const [maxOrder] = await db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }) + .from(states) + .where(eq(states.projectId, data.projectId)); + sortOrder = Number(maxOrder?.max || -1) + 1; + } + + const [state] = await db.insert(states).values({ + name: data.name, + color: data.color ?? null, + group: data.group, + sortOrder, + projectId: data.projectId, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "state", + entityId: state.id, + changes: { name: state.name, group: state.group, projectId: data.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "state.created", entityType: "state", entityId: state.id, data: { name: state.name, group: state.group } }); + + return c.json(state, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[states] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create state" } }, 500); + } +}); + +// PATCH /api/states/:id — Update a state +stateRoutes.patch("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const data = updateStateSchema.parse(body); + + const [existing] = await db.select() + .from(states) + .where(and(eq(states.id, id), isNull(states.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.color !== undefined) updateValues.color = data.color; + if (data.group !== undefined) updateValues.group = data.group; + if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(states) + .set(updateValues) + .where(eq(states.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "state", + entityId: id, + changes: { ...data, previousName: existing.name, projectId: existing.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "state.updated", entityType: "state", entityId: id, data: { ...data, previousName: existing.name } }); + + return c.json(updated); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[states] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update state" } }, 500); + } +}); + +// DELETE /api/states/:id — Soft-delete a state +stateRoutes.delete("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [existing] = await db.select() + .from(states) + .where(and(eq(states.id, id), isNull(states.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + await db.update(states) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(states.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "state", + entityId: id, + changes: { name: existing.name, projectId: existing.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "state.deleted", entityType: "state", entityId: id, data: { name: existing.name } }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[states] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete state" } }, 500); + } +}); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 00048f6..4986d34 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -160,10 +160,12 @@ export const states = pgTable( sortOrder: integer('sort_order').default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), }, (table) => [ index('states_project_id_idx').on(table.projectId), index('states_sort_order_idx').on(table.projectId, table.sortOrder), + index('states_deleted_at_idx').on(table.deletedAt), ] );