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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user