Merge pull request 'feat: cycles API route with CRUD, membership, and transfer' (#21) from feat/pl-3-cycles into feat/plane-lift-schema
This commit is contained in:
+200
-23
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, cycles, tasks, projects } from "@project-e/db";
|
import { db, cycles, tasks, projects } from "@project-e/db";
|
||||||
import { and, asc, eq, isNull, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
@@ -9,10 +9,11 @@ import { z } from "zod";
|
|||||||
export const cycleRoutes = new Hono();
|
export const cycleRoutes = new Hono();
|
||||||
|
|
||||||
const createCycleSchema = z.object({
|
const createCycleSchema = z.object({
|
||||||
|
projectId: z.string().uuid("Invalid project id"),
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
startDate: z.string().datetime().optional().nullable(),
|
startDate: z.string().datetime().optional().nullable(),
|
||||||
endDate: z.string().datetime().optional().nullable(),
|
endDate: z.string().datetime().optional().nullable(),
|
||||||
active: z.boolean().optional().default(false),
|
active: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCycleSchema = z.object({
|
const updateCycleSchema = z.object({
|
||||||
@@ -27,10 +28,16 @@ cycleRoutes.get("/", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const projectId = c.req.param("projectId") || url.searchParams.get("project_id");
|
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||||
|
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||||
|
const search = url.searchParams.get("search");
|
||||||
|
const active = url.searchParams.get("active");
|
||||||
|
const sort = url.searchParams.get("sort") || "-created";
|
||||||
|
|
||||||
|
const projectId = url.searchParams.get("projectId") || url.searchParams.get("project_id");
|
||||||
if (!projectId || !isUuid(projectId)) {
|
if (!projectId || !isUuid(projectId)) {
|
||||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required" } }, 400);
|
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 })
|
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
|
||||||
@@ -44,12 +51,54 @@ cycleRoutes.get("/", async (c) => {
|
|||||||
|
|
||||||
await requireWorkspaceAccess(c, project.domainId);
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
const items = await db.select()
|
const conditions: any[] = [eq(cycles.projectId, projectId)];
|
||||||
.from(cycles)
|
|
||||||
.where(eq(cycles.projectId, projectId))
|
|
||||||
.orderBy(asc(cycles.createdAt));
|
|
||||||
|
|
||||||
return c.json({ items });
|
if (search) {
|
||||||
|
conditions.push(ilike(cycles.name, `%${search}%`));
|
||||||
|
}
|
||||||
|
if (active === "true" || active === "false") {
|
||||||
|
conditions.push(eq(cycles.active, active === "true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||||
|
const sortField = sort.replace(/^-/, "");
|
||||||
|
const sortColumns: Record<string, any> = {
|
||||||
|
created: cycles.createdAt,
|
||||||
|
updated: cycles.updatedAt,
|
||||||
|
name: cycles.name,
|
||||||
|
active: cycles.active,
|
||||||
|
start_date: cycles.startDate,
|
||||||
|
end_date: cycles.endDate,
|
||||||
|
created_at: cycles.createdAt,
|
||||||
|
updated_at: cycles.updatedAt,
|
||||||
|
};
|
||||||
|
const orderColumn = sortDir === "asc"
|
||||||
|
? asc(sortColumns[sortField] || cycles.createdAt)
|
||||||
|
: desc(sortColumns[sortField] || cycles.createdAt);
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db.select()
|
||||||
|
.from(cycles)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(orderColumn)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset || (page - 1) * limit),
|
||||||
|
db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(cycles)
|
||||||
|
.where(and(...conditions)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalItems = Number(countResult[0]?.count || 0);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
items,
|
||||||
|
totalItems,
|
||||||
|
totalPages: Math.ceil(totalItems / limit),
|
||||||
|
page,
|
||||||
|
perPage: limit,
|
||||||
|
limit,
|
||||||
|
offset: offset || (page - 1) * limit,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
@@ -66,14 +115,9 @@ cycleRoutes.post("/", async (c) => {
|
|||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const data = createCycleSchema.parse(body);
|
const data = createCycleSchema.parse(body);
|
||||||
|
|
||||||
const projectId = c.req.param("projectId");
|
|
||||||
if (!projectId || !isUuid(projectId)) {
|
|
||||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required in URL path" } }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
|
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
|
.where(and(eq(projects.id, data.projectId), isNull(projects.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -84,10 +128,10 @@ cycleRoutes.post("/", async (c) => {
|
|||||||
|
|
||||||
const [cycle] = await db.insert(cycles).values({
|
const [cycle] = await db.insert(cycles).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
projectId,
|
projectId: data.projectId,
|
||||||
startDate: data.startDate ? new Date(data.startDate) : null,
|
startDate: data.startDate ? new Date(data.startDate) : null,
|
||||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||||
active: data.active,
|
active: data.active ?? true,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -95,11 +139,11 @@ cycleRoutes.post("/", async (c) => {
|
|||||||
action: "created",
|
action: "created",
|
||||||
entityType: "cycle",
|
entityType: "cycle",
|
||||||
entityId: cycle.id,
|
entityId: cycle.id,
|
||||||
changes: { name: cycle.name, projectId },
|
changes: { name: cycle.name, active: cycle.active, projectId: data.projectId },
|
||||||
workspaceId: project.domainId,
|
workspaceId: project.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await enqueueWebhooks({ workspaceId: project.domainId, event: "cycle.created", entityType: "cycle", entityId: cycle.id, data: { name: cycle.name, projectId } });
|
await enqueueWebhooks({ workspaceId: project.domainId, event: "cycle.created", entityType: "cycle", entityId: cycle.id, data: { name: cycle.name, projectId: data.projectId } });
|
||||||
|
|
||||||
return c.json(cycle, 201);
|
return c.json(cycle, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -114,7 +158,7 @@ cycleRoutes.post("/", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id — Get a single cycle with its tasks
|
// GET /:id — Get a single cycle
|
||||||
cycleRoutes.get("/:id", async (c) => {
|
cycleRoutes.get("/:id", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
@@ -217,7 +261,7 @@ cycleRoutes.patch("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id — Delete a cycle
|
// DELETE /:id — Delete a cycle (clears cycleId on tasks)
|
||||||
cycleRoutes.delete("/:id", async (c) => {
|
cycleRoutes.delete("/:id", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
@@ -298,6 +342,23 @@ cycleRoutes.post("/:id/tasks", async (c) => {
|
|||||||
|
|
||||||
await requireWorkspaceAccess(c, project?.domainId || "");
|
await requireWorkspaceAccess(c, project?.domainId || "");
|
||||||
|
|
||||||
|
const [task] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-cycle constraint: if the task is already in another cycle, remove it first
|
||||||
|
if (task.cycleId && task.cycleId !== cycleId) {
|
||||||
|
await db.update(tasks)
|
||||||
|
.set({ cycleId: null, updatedAt: new Date() })
|
||||||
|
.where(eq(tasks.id, taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign task to this cycle
|
||||||
await db.update(tasks)
|
await db.update(tasks)
|
||||||
.set({ cycleId, updatedAt: new Date() })
|
.set({ cycleId, updatedAt: new Date() })
|
||||||
.where(eq(tasks.id, taskId));
|
.where(eq(tasks.id, taskId));
|
||||||
@@ -307,7 +368,7 @@ cycleRoutes.post("/:id/tasks", async (c) => {
|
|||||||
action: "added_task",
|
action: "added_task",
|
||||||
entityType: "cycle",
|
entityType: "cycle",
|
||||||
entityId: cycleId,
|
entityId: cycleId,
|
||||||
changes: { taskId },
|
changes: { taskId, taskTitle: task.title, previousCycleId: task.cycleId },
|
||||||
workspaceId: project?.domainId || "",
|
workspaceId: project?.domainId || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,6 +411,19 @@ cycleRoutes.delete("/:id/tasks/:taskId", async (c) => {
|
|||||||
|
|
||||||
await requireWorkspaceAccess(c, project?.domainId || "");
|
await requireWorkspaceAccess(c, project?.domainId || "");
|
||||||
|
|
||||||
|
const [task] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.cycleId !== cycleId) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Task is not in this cycle" } }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
await db.update(tasks)
|
await db.update(tasks)
|
||||||
.set({ cycleId: null, updatedAt: new Date() })
|
.set({ cycleId: null, updatedAt: new Date() })
|
||||||
.where(eq(tasks.id, taskId));
|
.where(eq(tasks.id, taskId));
|
||||||
@@ -359,7 +433,7 @@ cycleRoutes.delete("/:id/tasks/:taskId", async (c) => {
|
|||||||
action: "removed_task",
|
action: "removed_task",
|
||||||
entityType: "cycle",
|
entityType: "cycle",
|
||||||
entityId: cycleId,
|
entityId: cycleId,
|
||||||
changes: { taskId },
|
changes: { taskId, taskTitle: task.title },
|
||||||
workspaceId: project?.domainId || "",
|
workspaceId: project?.domainId || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,3 +446,106 @@ cycleRoutes.delete("/:id/tasks/:taskId", async (c) => {
|
|||||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from cycle" } }, 500);
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from cycle" } }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /:id/transfer — Move tasks from this cycle to a target cycle
|
||||||
|
cycleRoutes.post("/:id/transfer", async (c) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth(c);
|
||||||
|
const sourceCycleId = c.req.param("id");
|
||||||
|
if (!isUuid(sourceCycleId)) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||||
|
}
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { target_cycle_id, task_ids } = z.object({
|
||||||
|
target_cycle_id: z.string().uuid("Invalid target cycle id"),
|
||||||
|
task_ids: z.array(z.string().uuid("Invalid task id")).min(1, "At least one task id is required"),
|
||||||
|
}).parse(body);
|
||||||
|
|
||||||
|
const [sourceCycle] = await db.select()
|
||||||
|
.from(cycles)
|
||||||
|
.where(eq(cycles.id, sourceCycleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!sourceCycle) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Source cycle not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [targetCycle] = await db.select()
|
||||||
|
.from(cycles)
|
||||||
|
.where(eq(cycles.id, target_cycle_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetCycle) {
|
||||||
|
return c.json({ error: { code: "NOT_FOUND", message: "Target cycle not found" } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceCycle.projectId !== targetCycle.projectId) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Target cycle must be in the same project" } }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [project] = await db.select({ domainId: projects.domainId })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, sourceCycle.projectId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const workspaceId = project?.domainId || "";
|
||||||
|
await requireWorkspaceAccess(c, workspaceId);
|
||||||
|
|
||||||
|
// Validate all tasks exist, are not deleted, and belong to the source cycle
|
||||||
|
const taskRows = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(inArray(tasks.id, task_ids), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
if (taskRows.length !== task_ids.length) {
|
||||||
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "One or more tasks not found or are deleted" } }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonMemberTasks = taskRows.filter((t) => t.cycleId !== sourceCycleId);
|
||||||
|
if (nonMemberTasks.length > 0) {
|
||||||
|
return c.json({
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
message: "One or more tasks do not belong to the source cycle",
|
||||||
|
details: { task_ids: nonMemberTasks.map((t) => t.id) },
|
||||||
|
},
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move all tasks to the target cycle
|
||||||
|
await db.update(tasks)
|
||||||
|
.set({ cycleId: target_cycle_id, updatedAt: new Date() })
|
||||||
|
.where(inArray(tasks.id, task_ids));
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: "transferred_tasks",
|
||||||
|
entityType: "cycle",
|
||||||
|
entityId: sourceCycleId,
|
||||||
|
changes: {
|
||||||
|
targetCycleId: target_cycle_id,
|
||||||
|
taskIds: task_ids,
|
||||||
|
count: task_ids.length,
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({
|
||||||
|
workspaceId,
|
||||||
|
event: "cycle.tasks_transferred",
|
||||||
|
entityType: "cycle",
|
||||||
|
entityId: sourceCycleId,
|
||||||
|
data: { targetCycleId: target_cycle_id, taskIds: task_ids, count: task_ids.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true, transferred: task_ids.length });
|
||||||
|
} 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("[cycles] POST /:id/transfer error:", error);
|
||||||
|
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to transfer tasks" } }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user