feat(states): add states CRUD API routes
This commit is contained in:
@@ -10,6 +10,7 @@ import { domainRoutes } from "./routes/domains";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { habitRoutes } from "./routes/habits";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { stateRoutes } from "./routes/states";
|
||||
import { noteRoutes } from "./routes/notes";
|
||||
import { searchRoutes } from "./routes/search";
|
||||
import { calendarRoutes } from "./routes/calendar";
|
||||
@@ -47,6 +48,7 @@ app.route("/api/domains", domainRoutes);
|
||||
app.route("/api/tasks", taskRoutes);
|
||||
app.route("/api/habits", habitRoutes);
|
||||
app.route("/api/projects", projectRoutes);
|
||||
app.route("/api/states", stateRoutes);
|
||||
app.route("/api/notes", noteRoutes);
|
||||
app.route("/api/search", searchRoutes);
|
||||
app.route("/api/calendar", calendarRoutes);
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, states, projects } from "@project-e/db";
|
||||
import { asc, eq, 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"),
|
||||
group: stateGroupEnum.optional().default("unstarted"),
|
||||
color: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const updateStateSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
color: z.string().nullable().optional(),
|
||||
group: stateGroupEnum.optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const reorderSchema = z.object({
|
||||
projectId: z.string().uuid("Invalid project id"),
|
||||
orderedIds: z.array(z.string().uuid("Invalid state id")),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a project and verify the user has access to the owning workspace.
|
||||
* Returns the project row on success, throws AuthError otherwise.
|
||||
*/
|
||||
async function resolveProject(c: any, projectId: string, user: { name: string }) {
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
throw new AuthError("Project not found", 404, "NOT_FOUND");
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, project.domainId);
|
||||
return project;
|
||||
}
|
||||
|
||||
// POST /api/states/reorder — bulk reorder states within a project
|
||||
// This MUST be registered before /:id routes to avoid route conflicts.
|
||||
stateRoutes.post("/reorder", async (c) => {
|
||||
try {
|
||||
await requireAuth(c);
|
||||
const body = await c.req.json();
|
||||
const data = reorderSchema.parse(body);
|
||||
|
||||
const project = await resolveProject(c, data.projectId, { name: "" });
|
||||
|
||||
// Verify all state IDs belong to this project
|
||||
const existingStates = await db
|
||||
.select({ id: states.id })
|
||||
.from(states)
|
||||
.where(eq(states.projectId, data.projectId));
|
||||
|
||||
const validIds = new Set(existingStates.map((s) => s.id));
|
||||
const invalidIds = data.orderedIds.filter((id) => !validIds.has(id));
|
||||
if (invalidIds.length > 0) {
|
||||
return c.json(
|
||||
{ error: { code: "VALIDATION_ERROR", message: `Invalid state ids: ${invalidIds.join(", ")}` } },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Assign sortOrder 0..n-1 in one transaction
|
||||
await db.transaction(async (tx) => {
|
||||
for (let i = 0; i < data.orderedIds.length; i++) {
|
||||
await tx
|
||||
.update(states)
|
||||
.set({ sortOrder: i, updatedAt: new Date() })
|
||||
.where(eq(states.id, data.orderedIds[i]));
|
||||
}
|
||||
});
|
||||
|
||||
return c.json({ success: true });
|
||||
} 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 /reorder error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder states" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/states?projectId=<uuid> — list states for a project
|
||||
stateRoutes.get("/", async (c) => {
|
||||
try {
|
||||
await requireAuth(c);
|
||||
const url = new URL(c.req.url);
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
|
||||
if (!projectId || !isUuid(projectId)) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "A valid projectId query parameter is required" } }, 400);
|
||||
}
|
||||
|
||||
const project = await resolveProject(c, projectId, { name: "" });
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(states)
|
||||
.where(eq(states.projectId, projectId))
|
||||
.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 resolveProject(c, data.projectId, user);
|
||||
|
||||
// If no sortOrder provided, default to max+1 within the project
|
||||
let sortOrder = data.sortOrder;
|
||||
if (sortOrder === undefined) {
|
||||
const [result] = await db
|
||||
.select({ maxSort: sql<number>`coalesce(max(${states.sortOrder}), -1) + 1` })
|
||||
.from(states)
|
||||
.where(eq(states.projectId, data.projectId));
|
||||
sortOrder = result.maxSort;
|
||||
}
|
||||
|
||||
const [state] = await db
|
||||
.insert(states)
|
||||
.values({
|
||||
name: data.name,
|
||||
group: data.group,
|
||||
color: data.color ?? null,
|
||||
projectId: data.projectId,
|
||||
sortOrder,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "state",
|
||||
entityId: state.id,
|
||||
changes: { name: state.name, group: state.group, color: state.color },
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/states/:id — get a single state
|
||||
stateRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
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 [state] = await db
|
||||
.select()
|
||||
.from(states)
|
||||
.where(eq(states.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!state) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
|
||||
}
|
||||
|
||||
await resolveProject(c, state.projectId, { name: "" });
|
||||
|
||||
return c.json(state);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[states] GET/:id error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get 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(eq(states.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
|
||||
}
|
||||
|
||||
const project = await resolveProject(c, existing.projectId, user);
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
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 },
|
||||
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 — delete a state
|
||||
// NOTE: The states table has no deleted_at column, so this is a hard delete.
|
||||
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(eq(states.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
|
||||
}
|
||||
|
||||
const project = await resolveProject(c, existing.projectId, user);
|
||||
|
||||
await db.delete(states).where(eq(states.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "state",
|
||||
entityId: id,
|
||||
changes: { name: existing.name, group: existing.group },
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user