merge: resolve origin/main into feat/plane-lift-merge

Merge origin/main into feat/plane-lift-merge to resolve PR #25 conflicts.

Conflict resolutions:
- apps/api/src/routes/states.ts: took main's complete version (reorder,
  GET /:id, resolveProject helper) since plane-lift's states CRUD was
  already incorporated via main
- apps/api/src/routes/tasks.ts: merged both imports (statesTable +
  taskDependencies), combined filter params (stateId/stateGroup/moduleId/
  cycleId from plane-lift + status removed since tasks.status column no
  longer exists), added stateId to zod schemas and insert/update logic

Additional fixes for clean typecheck:
- Removed deleted files that main re-introduced via merge (automations,
  statuses, timeline, gantt, nlp-parser, canvas, notifications,
  automation-engine, quick-add-bar, drizzle 0007-0009, migrate script)
- Fixed tasks.ts: removed all tasks.status references (column replaced by
  stateId), removed taskStatusEnum, removed /:id/status endpoint
- Fixed analytics.ts: replaced tasks.status checks with completedAt checks
- Fixed index.ts: removed duplicate stateRoutes import and route registration
- Cleaned up drizzle journal duplicate entries

Preserved from main:
- taskDependencies table definition and junction table
- Komodo/CI changes (ci.yml, docker-compose.yml)
- DEPLOY.md host fixes
- taskDependencies route code (dependencies/dependents endpoints)
This commit is contained in:
2026-09-07 22:10:01 +00:00
9 changed files with 390 additions and 362 deletions
+2 -2
View File
@@ -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";
@@ -26,7 +27,6 @@ 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 { moduleRoutes } from "./routes/modules";
import { cycleRoutes } from "./routes/cycles";
import { linkRoutes } from "./routes/links";
@@ -55,6 +55,7 @@ app.route("/api/cycles", cycleRoutes);
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);
@@ -70,7 +71,6 @@ 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/links", linkRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
+2 -2
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, inArray, isNull, isNotNull, or } from "drizzle-orm";
import { and, eq, gte, inArray, isNotNull, isNull, or } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono();
@@ -163,7 +163,7 @@ analyticsRoutes.get("/projects", async (c) => {
const projectIds = allProjects.map((p) => p.id);
// Count tasks per project for the domain
// Count tasks per project (any status, including non-done) for the domain
const taskRows = projectIds.length > 0
? await db.select({ projectId: tasks.projectId, completedAt: tasks.completedAt })
.from(tasks)
+187 -77
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, states, projects } from "@project-e/db";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
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";
@@ -13,43 +13,124 @@ const stateGroupEnum = z.enum(["backlog", "unstarted", "started", "completed", "
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(),
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().optional().nullable(),
color: z.string().nullable().optional(),
group: stateGroupEnum.optional(),
sortOrder: z.number().int().optional(),
sortOrder: z.number().int().min(0).optional(),
});
// GET /api/states — List states filtered by projectId (exclude soft-deleted)
stateRoutes.get("/", async (c) => {
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 {
const user = await requireAuth(c);
const body = await c.req.json();
const data = reorderSchema.parse(body);
const project = await resolveProject(c, data.projectId, user);
// 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]));
}
});
await recordActivity({
actor: user.name,
action: "reordered",
entityType: "state",
entityId: data.projectId,
changes: { orderedIds: data.orderedIds },
workspaceId: project.domainId,
});
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.reordered",
entityType: "state",
entityId: data.projectId,
data: { orderedIds: data.orderedIds },
});
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") || url.searchParams.get("project_id");
const projectId = url.searchParams.get("projectId");
if (!projectId || !isUuid(projectId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "projectId query parameter is required" } }, 400);
return c.json({ error: { code: "VALIDATION_ERROR", message: "A valid 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);
const project = await resolveProject(c, projectId, { name: "" });
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const items = await db.select()
const items = await db
.select()
.from(states)
.where(and(eq(states.projectId, projectId), isNull(states.deletedAt)))
.where(eq(states.projectId, projectId))
.orderBy(asc(states.sortOrder));
return c.json({ items });
@@ -62,50 +143,52 @@ stateRoutes.get("/", async (c) => {
}
});
// POST /api/states — Create a state
// 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);
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 [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
const [result] = await db
.select({ maxSort: sql<number>`coalesce(max(${states.sortOrder}), -1) + 1` })
.from(states)
.where(eq(states.projectId, data.projectId));
sortOrder = Number(maxOrder?.max || -1) + 1;
sortOrder = result.maxSort;
}
const [state] = await db.insert(states).values({
name: data.name,
color: data.color ?? null,
group: data.group,
sortOrder,
projectId: data.projectId,
}).returning();
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, projectId: data.projectId },
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 } });
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) {
@@ -120,7 +203,38 @@ stateRoutes.post("/", async (c) => {
}
});
// PATCH /api/states/:id — Update a state
// 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);
@@ -131,25 +245,17 @@ stateRoutes.patch("/:id", async (c) => {
const body = await c.req.json();
const data = updateStateSchema.parse(body);
const [existing] = await db.select()
const [existing] = await db
.select()
.from(states)
.where(and(eq(states.id, id), isNull(states.deletedAt)))
.where(eq(states.id, id))
.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 project = await resolveProject(c, existing.projectId, user);
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
@@ -158,7 +264,8 @@ stateRoutes.patch("/:id", async (c) => {
if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder;
updateValues.updatedAt = new Date();
const [updated] = await db.update(states)
const [updated] = await db
.update(states)
.set(updateValues)
.where(eq(states.id, id))
.returning();
@@ -168,11 +275,17 @@ stateRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "state",
entityId: id,
changes: { ...data, previousName: existing.name, projectId: existing.projectId },
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 } });
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.updated",
entityType: "state",
entityId: id,
data: { ...data, previousName: existing.name },
});
return c.json(updated);
} catch (error) {
@@ -187,7 +300,8 @@ stateRoutes.patch("/:id", async (c) => {
}
});
// DELETE /api/states/:id — Soft-delete a state
// 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);
@@ -196,40 +310,36 @@ stateRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
const [existing] = await db
.select()
.from(states)
.where(and(eq(states.id, id), isNull(states.deletedAt)))
.where(eq(states.id, id))
.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);
const project = await resolveProject(c, existing.projectId, user);
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 db.delete(states).where(eq(states.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "state",
entityId: id,
changes: { name: existing.name, projectId: existing.projectId },
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 } });
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.deleted",
entityType: "state",
entityId: id,
data: { name: existing.name },
});
return c.body(null, 204);
} catch (error) {
+142 -27
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db";
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -19,8 +19,6 @@ const createTaskSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
stateId: z.string().uuid().optional().nullable(),
moduleId: z.string().uuid().optional().nullable(),
cycleId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
@@ -38,8 +36,6 @@ const updateTaskSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
stateId: z.string().uuid().optional().nullable(),
moduleId: z.string().uuid().optional().nullable(),
cycleId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
@@ -117,10 +113,6 @@ taskRoutes.get("/", async (c) => {
isNull(tasks.deletedAt),
];
if (stateId) {
const stateIds = stateId.split(",");
conditions.push(inArray(tasks.stateId, stateIds));
}
if (priority) {
const priorities = priority.split(",");
conditions.push(inArray(tasks.priority, priorities as any));
@@ -147,6 +139,9 @@ taskRoutes.get("/", async (c) => {
if (sectionId) {
conditions.push(eq(tasks.sectionId, sectionId));
}
if (stateId) {
conditions.push(eq(tasks.stateId, stateId));
}
if (moduleId) {
conditions.push(eq(tasks.moduleId, moduleId));
}
@@ -338,8 +333,6 @@ taskRoutes.post("/", async (c) => {
projectId: data.projectId ?? null,
sectionId: data.sectionId ?? null,
stateId: data.stateId ?? null,
moduleId: data.moduleId ?? null,
cycleId: data.cycleId ?? null,
parentId: data.parentId ?? null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
estimatedMinutes: data.estimatedMinutes ?? null,
@@ -497,9 +490,23 @@ taskRoutes.get("/:id", async (c) => {
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(eq(taskTags.taskId, id));
// Dependencies are now managed via the links table (Phase 2)
const depRows: { id: string; title: string }[] = [];
const dependentRows: { id: string; title: string }[] = [];
// Fetch dependencies (tasks this task depends on)
const depRows = await db.select({
id: tasks.id,
title: tasks.title,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
// Fetch dependents (tasks that depend on this task)
const dependentRows = await db.select({
id: tasks.id,
title: tasks.title,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
return c.json({
...task,
@@ -565,9 +572,6 @@ taskRoutes.patch("/:id", async (c) => {
if (data.priority !== undefined) updateValues.priority = data.priority;
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
if (data.stateId !== undefined) updateValues.stateId = data.stateId;
if (data.moduleId !== undefined) updateValues.moduleId = data.moduleId;
if (data.cycleId !== undefined) updateValues.cycleId = data.cycleId;
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
@@ -585,8 +589,10 @@ taskRoutes.patch("/:id", async (c) => {
if (!state) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
updateValues.stateId = data.stateId;
updateValues.completedAt = state.group === "completed" ? new Date() : null;
} else {
updateValues.stateId = null;
updateValues.completedAt = null;
}
}
@@ -603,11 +609,11 @@ taskRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "task",
entityId: id,
changes: { ...data, previousStateId: existing.stateId },
changes: { ...data },
workspaceId: existing.domainId,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStateId: existing.stateId } });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data } });
if (data.recurrenceRule !== undefined) {
await syncScheduledJob(id, data.recurrenceRule);
@@ -770,19 +776,128 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
}
});
// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2)
// POST /api/tasks/:id/dependencies — Make this task depend on another task
taskRoutes.post("/:id/dependencies", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
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 { dependsOnTaskId } = z.object({
dependsOnTaskId: z.string().uuid("Invalid task id"),
}).parse(body);
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// A task cannot depend on itself
if (dependsOnTaskId === id) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
}
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
.limit(1);
if (!depTask) {
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
}
if (depTask.domainId !== task.domainId) {
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
}
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
let currentId: string | null = dependsOnTaskId;
const visited = new Set<string>([id]);
while (currentId) {
if (visited.has(currentId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
}
visited.add(currentId);
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, currentId))
.limit(1);
currentId = next?.dependsOnTaskId ?? null;
}
// Junction table has a composite PK — ignore duplicate edges
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "dependency_added",
entityType: "task",
entityId: id,
changes: { dependsOnTaskId },
workspaceId: task.domainId,
});
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
return c.json({ success: true }, 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("[tasks] POST /:id/dependencies error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
}
});
// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2)
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
});
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 depId = c.req.param("depId");
// POST /api/tasks/:id/status — Deprecated: use state_id instead (Phase 2)
taskRoutes.post("/:id/status", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404);
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
if (!task) {
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
}
await requireWorkspaceAccess(c, task.domainId);
// Junction table has no deleted_at — hard delete is correct here
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
await recordActivity({
actor: user.name,
action: "dependency_removed",
entityType: "task",
entityId: id,
changes: { removedDependsOnTaskId: depId },
workspaceId: task.domainId,
});
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("[tasks] DELETE /:id/dependencies/:depId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
}
});
// GET /api/tasks/:id/history — State change log (from activity feed)