Merge pull request 'feat: links CRUD API + MCP tools for states/modules/cycles/links (PL-4/6)' (#22) from feat/pl-456-backend into feat/plane-lift-schema

This commit is contained in:
2026-09-07 16:17:18 -04:00
4 changed files with 1126 additions and 26 deletions
+2
View File
@@ -28,6 +28,7 @@ import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { stateRoutes } from "./routes/states";
import { moduleRoutes } from "./routes/modules";
import { linkRoutes } from "./routes/links";
import { healthHandler } from "./routes/health";
const app = new Hono();
@@ -67,6 +68,7 @@ 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);
app.route("/api/mcp", mcpRoutes);
+324
View File
@@ -0,0 +1,324 @@
import { Hono } from "hono";
import { db, links, tasks, projects } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, or, 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 linkRoutes = new Hono();
const linkTypeEnum = z.enum(["relates", "blocks", "parent-child", "created-from"]);
const createLinkSchema = z.object({
sourceType: z.string().min(1, "Source type is required"),
sourceId: z.string().uuid("Invalid source id"),
targetType: z.string().min(1, "Target type is required"),
targetId: z.string().uuid("Invalid target id"),
linkType: linkTypeEnum,
direction: z.string().optional().nullable(),
});
// GET / — List links for an entity (bidirectional)
// Filters: source_type+source_id OR target_type+target_id (at least one pair required)
linkRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const sourceType = url.searchParams.get("source_type");
const sourceId = url.searchParams.get("source_id");
const targetType = url.searchParams.get("target_type");
const targetId = url.searchParams.get("target_id");
const linkType = url.searchParams.get("link_type");
const sort = url.searchParams.get("sort") || "-created";
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
const offset = parseInt(url.searchParams.get("offset") || "0");
// Need at least one filter pair
if ((!sourceType || !sourceId) && (!targetType || !targetId)) {
return c.json(
{ error: { code: "VALIDATION_ERROR", message: "Provide at least source_type+source_id or target_type+target_id" } },
400
);
}
if (sourceId && !isUuid(sourceId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid source_id" } }, 400);
}
if (targetId && !isUuid(targetId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid target_id" } }, 400);
}
// Resolve workspace access from the entity referenced
const resolveEntityDomainId = async (entityType: string, entityId: string): Promise<string | null> => {
if (entityType === "task") {
const [task] = await db.select({ domainId: tasks.domainId })
.from(tasks)
.where(eq(tasks.id, entityId))
.limit(1);
return task?.domainId ?? null;
}
if (entityType === "project") {
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, entityId))
.limit(1);
return project?.domainId ?? null;
}
return null;
};
const filterEntityType = sourceType || targetType || "task";
const filterEntityId = sourceId || targetId || "";
const domainId = await resolveEntityDomainId(filterEntityType, filterEntityId);
if (domainId) {
await requireWorkspaceAccess(c, domainId);
}
// Bidirectional: if filtering by source, also find links where entity is target
const conditions: any[] = [];
if (sourceType && sourceId) {
conditions.push(
or(
and(eq(links.sourceType, sourceType), eq(links.sourceId, sourceId)),
and(eq(links.targetType, sourceType), eq(links.targetId, sourceId)),
)!
);
} else if (targetType && targetId) {
conditions.push(
or(
and(eq(links.sourceType, targetType), eq(links.sourceId, targetId)),
and(eq(links.targetType, targetType), eq(links.targetId, targetId)),
)!
);
}
if (linkType) {
const types = linkType.split(",");
conditions.push(inArray(links.linkType, types as any));
}
const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortField = sort.replace(/^-/, "");
const sortColumns: Record<string, any> = {
created: links.createdAt,
created_at: links.createdAt,
};
const orderColumn = sortDir === "asc"
? asc(sortColumns[sortField] || links.createdAt)
: desc(sortColumns[sortField] || links.createdAt);
const [items, countResult] = await Promise.all([
db.select()
.from(links)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(links)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
return c.json({
items,
totalItems,
totalPages: Math.ceil(totalItems / limit),
limit,
offset,
});
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[links] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list links" } }, 500);
}
});
// POST / — Create a link
linkRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createLinkSchema.parse(body);
// Enforce unique constraint on (sourceType, sourceId, targetType, targetId, linkType)
const [existing] = await db.select()
.from(links)
.where(
and(
eq(links.sourceType, data.sourceType),
eq(links.sourceId, data.sourceId),
eq(links.targetType, data.targetType),
eq(links.targetId, data.targetId),
eq(links.linkType, data.linkType),
)
)
.limit(1);
if (existing) {
return c.json(
{ error: { code: "CONFLICT", message: "Link already exists with this combination" } },
409
);
}
// Resolve workspace access from the source entity
let domainId: string | null = null;
if (data.sourceType === "task") {
const [task] = await db.select({ domainId: tasks.domainId })
.from(tasks)
.where(eq(tasks.id, data.sourceId))
.limit(1);
domainId = task?.domainId ?? null;
} else if (data.sourceType === "project") {
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, data.sourceId))
.limit(1);
domainId = project?.domainId ?? null;
}
if (domainId) {
await requireWorkspaceAccess(c, domainId);
}
const [link] = await db.insert(links).values({
sourceType: data.sourceType,
sourceId: data.sourceId,
targetType: data.targetType,
targetId: data.targetId,
linkType: data.linkType,
direction: data.direction ?? null,
}).returning();
if (domainId) {
await recordActivity({
actor: user.name,
action: "created",
entityType: "link",
entityId: link.id,
changes: {
sourceType: link.sourceType,
sourceId: link.sourceId,
targetType: link.targetType,
targetId: link.targetId,
linkType: link.linkType,
},
workspaceId: domainId,
});
await enqueueWebhooks({
workspaceId: domainId,
event: "link.created",
entityType: "link",
entityId: link.id,
data: {
sourceType: link.sourceType,
sourceId: link.sourceId,
targetType: link.targetType,
targetId: link.targetId,
linkType: link.linkType,
},
});
}
return c.json(link, 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("[links] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create link" } }, 500);
}
});
// DELETE /:id — Delete a link (hard delete, links have no deletedAt)
linkRoutes.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(links)
.where(eq(links.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Link not found" } }, 404);
}
// Resolve workspace access from the source entity
let domainId: string | null = null;
if (existing.sourceType === "task") {
const [task] = await db.select({ domainId: tasks.domainId })
.from(tasks)
.where(eq(tasks.id, existing.sourceId))
.limit(1);
domainId = task?.domainId ?? null;
} else if (existing.sourceType === "project") {
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, existing.sourceId))
.limit(1);
domainId = project?.domainId ?? null;
}
if (domainId) {
await requireWorkspaceAccess(c, domainId);
}
// Hard delete — junction rows with no deletedAt column
await db.delete(links).where(eq(links.id, id));
if (domainId) {
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "link",
entityId: id,
changes: {
sourceType: existing.sourceType,
sourceId: existing.sourceId,
targetType: existing.targetType,
targetId: existing.targetId,
linkType: existing.linkType,
},
workspaceId: domainId,
});
await enqueueWebhooks({
workspaceId: domainId,
event: "link.deleted",
entityType: "link",
entityId: id,
data: {
sourceType: existing.sourceType,
sourceId: existing.sourceId,
targetType: existing.targetType,
targetId: existing.targetId,
linkType: existing.linkType,
},
});
}
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("[links] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete link" } }, 500);
}
});
+799 -10
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { createHash } from "node:crypto";
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm";
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries, states, modules, cycles, links } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { recordActivity } from "../middleware/activity";
export const mcpRoutes = new Hono();
@@ -81,9 +81,12 @@ const tools: ToolDefinition[] = [
type: "object",
properties: {
domain_id: { type: "string", description: "Workspace/domain ID" },
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
state_id: { type: "string", description: "Filter by state UUID" },
state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by state group" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
project_id: { type: "string" },
module_id: { type: "string", description: "Filter by module UUID" },
cycle_id: { type: "string", description: "Filter by cycle UUID" },
search: { type: "string" },
limit: { type: "number", default: 50 },
offset: { type: "number", default: 0 },
@@ -95,9 +98,24 @@ const tools: ToolDefinition[] = [
eq(tasks.domainId, params.domain_id as string),
isNull(tasks.deletedAt),
];
// TODO(phase-2): filter by state_group / state_id instead of old status
if (params.state_id) {
const stateIds = (params.state_id as string).split(",");
conditions.push(inArray(tasks.stateId, stateIds));
}
if (params.state_group) {
const groups = (params.state_group as string).split(",") as any[];
conditions.push(
exists(
db.select({ one: sql`1` })
.from(states)
.where(and(eq(states.id, tasks.stateId), inArray(states.group, groups)))
)
);
}
if (params.priority) conditions.push(eq(tasks.priority, params.priority as any));
if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string));
if (params.module_id) conditions.push(eq(tasks.moduleId, params.module_id as string));
if (params.cycle_id) conditions.push(eq(tasks.cycleId, params.cycle_id as string));
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
const items = await db.select()
@@ -119,21 +137,38 @@ const tools: ToolDefinition[] = [
domain_id: { type: "string", description: "Workspace/domain ID" },
title: { type: "string" },
description: { type: "string" },
status: { type: "string" },
state_id: { type: "string", description: "State UUID" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" },
project_id: { type: "string" },
module_id: { type: "string", description: "Module UUID" },
cycle_id: { type: "string", description: "Cycle UUID" },
},
required: ["domain_id", "title"],
},
handler: async (params, auth) => {
let completedAt: Date | null = null;
if (params.state_id) {
const [state] = await db.select({ id: states.id, group: states.group })
.from(states)
.where(eq(states.id, params.state_id as string))
.limit(1);
if (state?.group === "completed") {
completedAt = new Date();
}
}
const [task] = await db.insert(tasks).values({
title: params.title as string,
description: (params.description as string) ?? null,
priority: (params.priority as any) ?? "medium",
domainId: params.domain_id as string,
projectId: (params.project_id as string) ?? null,
stateId: (params.state_id as string) ?? null,
moduleId: (params.module_id as string) ?? null,
cycleId: (params.cycle_id as string) ?? null,
dueDate: params.due_date ? new Date(params.due_date as string) : null,
completedAt,
}).returning();
await recordActivity({
@@ -141,7 +176,7 @@ const tools: ToolDefinition[] = [
action: "created",
entityType: "task",
entityId: task.id,
changes: { title: task.title },
changes: { title: task.title, stateId: task.stateId },
workspaceId: params.domain_id as string,
});
@@ -157,9 +192,11 @@ const tools: ToolDefinition[] = [
task_id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
status: { type: "string" },
state_id: { type: "string", description: "State UUID" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" },
module_id: { type: "string", description: "Module UUID" },
cycle_id: { type: "string", description: "Cycle UUID" },
},
required: ["task_id"],
},
@@ -173,6 +210,24 @@ const tools: ToolDefinition[] = [
if (params.description !== undefined) updateData.description = params.description;
if (params.priority !== undefined) updateData.priority = params.priority;
if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null;
if (params.module_id !== undefined) updateData.moduleId = params.module_id;
if (params.cycle_id !== undefined) updateData.cycleId = params.cycle_id;
// Handle state change + completedAt
if (params.state_id !== undefined) {
updateData.stateId = params.state_id;
if (params.state_id) {
const [state] = await db.select({ id: states.id, group: states.group })
.from(states)
.where(eq(states.id, params.state_id as string))
.limit(1);
if (!state) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "State not found");
updateData.completedAt = state.group === "completed" ? new Date() : null;
} else {
updateData.completedAt = null;
}
}
updateData.updatedAt = new Date();
const [task] = await db.update(tasks)
@@ -223,10 +278,13 @@ const tools: ToolDefinition[] = [
},
{
name: "tasks.complete",
description: "Mark a task as done",
description: "Mark a task as done by setting its state to a completed group state",
inputSchema: {
type: "object",
properties: { task_id: { type: "string" } },
properties: {
task_id: { type: "string" },
state_id: { type: "string", description: "Optional specific completed state UUID. If omitted, finds a completed-group state from the task's project." },
},
required: ["task_id"],
},
handler: async (params, auth) => {
@@ -234,8 +292,25 @@ const tools: ToolDefinition[] = [
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await verifyDomainAccess(existing.domainId, auth.userId);
let completedStateId = params.state_id as string | undefined;
if (!completedStateId && existing.projectId) {
const [completedState] = await db.select({ id: states.id })
.from(states)
.where(and(eq(states.projectId, existing.projectId), eq(states.group, "completed"), isNull(states.deletedAt)))
.limit(1);
completedStateId = completedState?.id;
}
const updateData: Record<string, unknown> = {
completedAt: new Date(),
updatedAt: new Date(),
};
if (completedStateId) {
updateData.stateId = completedStateId;
}
const [task] = await db.update(tasks)
.set({ completedAt: new Date(), updatedAt: new Date() })
.set(updateData)
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning();
@@ -244,6 +319,7 @@ const tools: ToolDefinition[] = [
action: "completed",
entityType: "task",
entityId: task.id,
changes: { stateId: task.stateId },
workspaceId: task.domainId,
});
@@ -605,6 +681,719 @@ const tools: ToolDefinition[] = [
return { items };
},
},
// ── States ──────────────────────────────────────────────────────────────────────
{
name: "states.list",
description: "List workflow states for a project",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string", description: "Project UUID" },
},
required: ["project_id"],
},
handler: async (params) => {
const items = await db.select()
.from(states)
.where(and(eq(states.projectId, params.project_id as string), isNull(states.deletedAt)))
.orderBy(asc(states.sortOrder));
return { items };
},
},
{
name: "states.create",
description: "Create a workflow state",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
name: { type: "string" },
color: { type: "string" },
group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"] },
sort_order: { type: "number" },
},
required: ["project_id", "name"],
},
handler: async (params, auth) => {
const [project] = await db.select().from(projects).where(eq(projects.id, params.project_id as string)).limit(1);
if (!project) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project not found");
await verifyDomainAccess(project.domainId, auth.userId);
const [state] = await db.insert(states).values({
name: params.name as string,
color: (params.color as string) ?? null,
group: (params.group as any) ?? "unstarted",
projectId: params.project_id as string,
sortOrder: (params.sort_order as number) ?? 0,
}).returning();
await recordActivity({
actor: auth.userName,
action: "created",
entityType: "state",
entityId: state.id,
changes: { name: state.name, group: state.group },
workspaceId: project.domainId,
});
return state;
},
},
{
name: "states.update",
description: "Update a workflow state",
inputSchema: {
type: "object",
properties: {
state_id: { type: "string" },
name: { type: "string" },
color: { type: "string" },
group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"] },
sort_order: { type: "number" },
},
required: ["state_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(states).where(and(eq(states.id, params.state_id as string), isNull(states.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "State not found");
const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) updateData.name = params.name;
if (params.color !== undefined) updateData.color = params.color;
if (params.group !== undefined) updateData.group = params.group;
if (params.sort_order !== undefined) updateData.sortOrder = params.sort_order;
const [state] = await db.update(states)
.set(updateData)
.where(eq(states.id, params.state_id as string))
.returning();
if (project) {
await recordActivity({
actor: auth.userName,
action: "updated",
entityType: "state",
entityId: state.id,
changes: updateData,
workspaceId: project.domainId,
});
}
return state;
},
},
{
name: "states.delete",
description: "Soft-delete a workflow state",
inputSchema: {
type: "object",
properties: { state_id: { type: "string" } },
required: ["state_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(states).where(and(eq(states.id, params.state_id as string), isNull(states.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "State not found");
const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
await db.update(states)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(states.id, params.state_id as string));
if (project) {
await recordActivity({
actor: auth.userName,
action: "deleted",
entityType: "state",
entityId: params.state_id as string,
changes: { name: existing.name },
workspaceId: project.domainId,
});
}
return { deleted: true, id: params.state_id };
},
},
// ── Modules ─────────────────────────────────────────────────────────────────────
{
name: "modules.list",
description: "List modules for a project",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
},
required: ["project_id"],
},
handler: async (params) => {
const items = await db.select()
.from(modules)
.where(and(eq(modules.projectId, params.project_id as string), isNull(modules.deletedAt)))
.orderBy(asc(modules.sortOrder));
return { items };
},
},
{
name: "modules.create",
description: "Create a module",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
name: { type: "string" },
description: { type: "string" },
status: { type: "string", enum: ["planned", "in_progress", "completed", "cancelled"] },
start_date: { type: "string" },
target_date: { type: "string" },
},
required: ["project_id", "name"],
},
handler: async (params, auth) => {
const [project] = await db.select().from(projects).where(eq(projects.id, params.project_id as string)).limit(1);
if (!project) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project not found");
await verifyDomainAccess(project.domainId, auth.userId);
const [mod] = await db.insert(modules).values({
name: params.name as string,
description: (params.description as string) ?? null,
projectId: params.project_id as string,
status: (params.status as any) ?? "planned",
startDate: params.start_date ? new Date(params.start_date as string) : null,
targetDate: params.target_date ? new Date(params.target_date as string) : null,
}).returning();
await recordActivity({
actor: auth.userName,
action: "created",
entityType: "module",
entityId: mod.id,
changes: { name: mod.name },
workspaceId: project.domainId,
});
return mod;
},
},
{
name: "modules.update",
description: "Update a module",
inputSchema: {
type: "object",
properties: {
module_id: { type: "string" },
name: { type: "string" },
description: { type: "string" },
status: { type: "string", enum: ["planned", "in_progress", "completed", "cancelled"] },
start_date: { type: "string" },
target_date: { type: "string" },
},
required: ["module_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found");
const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) updateData.name = params.name;
if (params.description !== undefined) updateData.description = params.description;
if (params.status !== undefined) updateData.status = params.status;
if (params.start_date !== undefined) updateData.startDate = params.start_date ? new Date(params.start_date as string) : null;
if (params.target_date !== undefined) updateData.targetDate = params.target_date ? new Date(params.target_date as string) : null;
const [mod] = await db.update(modules)
.set(updateData)
.where(eq(modules.id, params.module_id as string))
.returning();
if (project) {
await recordActivity({
actor: auth.userName,
action: "updated",
entityType: "module",
entityId: mod.id,
changes: updateData,
workspaceId: project.domainId,
});
}
return mod;
},
},
{
name: "modules.delete",
description: "Soft-delete a module and clear module_id on its tasks",
inputSchema: {
type: "object",
properties: { module_id: { type: "string" } },
required: ["module_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found");
const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
await db.update(tasks)
.set({ moduleId: null, updatedAt: new Date() })
.where(eq(tasks.moduleId, params.module_id as string));
await db.update(modules)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(modules.id, params.module_id as string));
if (project) {
await recordActivity({
actor: auth.userName,
action: "deleted",
entityType: "module",
entityId: params.module_id as string,
changes: { name: existing.name },
workspaceId: project.domainId,
});
}
return { deleted: true, id: params.module_id };
},
},
{
name: "modules.add-task",
description: "Add a task to a module",
inputSchema: {
type: "object",
properties: {
module_id: { type: "string" },
task_id: { type: "string" },
},
required: ["module_id", "task_id"],
},
handler: async (params, auth) => {
const [mod] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1);
if (!mod) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found");
const [project] = await db.select().from(projects).where(eq(projects.id, mod.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
const [task] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await db.update(tasks)
.set({ moduleId: params.module_id as string, updatedAt: new Date() })
.where(eq(tasks.id, params.task_id as string));
if (project) {
await recordActivity({
actor: auth.userName,
action: "added_task",
entityType: "module",
entityId: params.module_id as string,
changes: { taskId: params.task_id, taskTitle: task.title },
workspaceId: project.domainId,
});
}
return { success: true };
},
},
{
name: "modules.remove-task",
description: "Remove a task from a module",
inputSchema: {
type: "object",
properties: {
module_id: { type: "string" },
task_id: { type: "string" },
},
required: ["module_id", "task_id"],
},
handler: async (params, auth) => {
const [mod] = await db.select().from(modules).where(and(eq(modules.id, params.module_id as string), isNull(modules.deletedAt))).limit(1);
if (!mod) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Module not found");
const [project] = await db.select().from(projects).where(eq(projects.id, mod.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
await db.update(tasks)
.set({ moduleId: null, updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), eq(tasks.moduleId, params.module_id as string)));
if (project) {
await recordActivity({
actor: auth.userName,
action: "removed_task",
entityType: "module",
entityId: params.module_id as string,
changes: { taskId: params.task_id },
workspaceId: project.domainId,
});
}
return { success: true };
},
},
// ── Cycles ──────────────────────────────────────────────────────────────────────
{
name: "cycles.list",
description: "List cycles for a project",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
},
required: ["project_id"],
},
handler: async (params) => {
const items = await db.select()
.from(cycles)
.where(eq(cycles.projectId, params.project_id as string))
.orderBy(desc(cycles.createdAt));
return { items };
},
},
{
name: "cycles.create",
description: "Create a cycle",
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
name: { type: "string" },
start_date: { type: "string" },
end_date: { type: "string" },
active: { type: "boolean" },
},
required: ["project_id", "name"],
},
handler: async (params, auth) => {
const [project] = await db.select().from(projects).where(eq(projects.id, params.project_id as string)).limit(1);
if (!project) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project not found");
await verifyDomainAccess(project.domainId, auth.userId);
const [cycle] = await db.insert(cycles).values({
name: params.name as string,
projectId: params.project_id as string,
startDate: params.start_date ? new Date(params.start_date as string) : null,
endDate: params.end_date ? new Date(params.end_date as string) : null,
active: (params.active as boolean) ?? false,
}).returning();
await recordActivity({
actor: auth.userName,
action: "created",
entityType: "cycle",
entityId: cycle.id,
changes: { name: cycle.name },
workspaceId: project.domainId,
});
return cycle;
},
},
{
name: "cycles.update",
description: "Update a cycle",
inputSchema: {
type: "object",
properties: {
cycle_id: { type: "string" },
name: { type: "string" },
start_date: { type: "string" },
end_date: { type: "string" },
active: { type: "boolean" },
},
required: ["cycle_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found");
const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) updateData.name = params.name;
if (params.start_date !== undefined) updateData.startDate = params.start_date ? new Date(params.start_date as string) : null;
if (params.end_date !== undefined) updateData.endDate = params.end_date ? new Date(params.end_date as string) : null;
if (params.active !== undefined) updateData.active = params.active;
const [cycle] = await db.update(cycles)
.set(updateData)
.where(eq(cycles.id, params.cycle_id as string))
.returning();
if (project) {
await recordActivity({
actor: auth.userName,
action: "updated",
entityType: "cycle",
entityId: cycle.id,
changes: updateData,
workspaceId: project.domainId,
});
}
return cycle;
},
},
{
name: "cycles.delete",
description: "Delete a cycle and clear cycle_id on its tasks",
inputSchema: {
type: "object",
properties: { cycle_id: { type: "string" } },
required: ["cycle_id"],
},
handler: async (params, auth) => {
const [existing] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found");
const [project] = await db.select().from(projects).where(eq(projects.id, existing.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
await db.update(tasks)
.set({ cycleId: null, updatedAt: new Date() })
.where(eq(tasks.cycleId, params.cycle_id as string));
await db.delete(cycles).where(eq(cycles.id, params.cycle_id as string));
if (project) {
await recordActivity({
actor: auth.userName,
action: "deleted",
entityType: "cycle",
entityId: params.cycle_id as string,
changes: { name: existing.name },
workspaceId: project.domainId,
});
}
return { deleted: true, id: params.cycle_id };
},
},
{
name: "cycles.add-task",
description: "Add a task to a cycle",
inputSchema: {
type: "object",
properties: {
cycle_id: { type: "string" },
task_id: { type: "string" },
},
required: ["cycle_id", "task_id"],
},
handler: async (params, auth) => {
const [cycle] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1);
if (!cycle) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found");
const [project] = await db.select().from(projects).where(eq(projects.id, cycle.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
const [task] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await db.update(tasks)
.set({ cycleId: params.cycle_id as string, updatedAt: new Date() })
.where(eq(tasks.id, params.task_id as string));
if (project) {
await recordActivity({
actor: auth.userName,
action: "added_task",
entityType: "cycle",
entityId: params.cycle_id as string,
changes: { taskId: params.task_id, taskTitle: task.title },
workspaceId: project.domainId,
});
}
return { success: true };
},
},
{
name: "cycles.remove-task",
description: "Remove a task from a cycle",
inputSchema: {
type: "object",
properties: {
cycle_id: { type: "string" },
task_id: { type: "string" },
},
required: ["cycle_id", "task_id"],
},
handler: async (params, auth) => {
const [cycle] = await db.select().from(cycles).where(eq(cycles.id, params.cycle_id as string)).limit(1);
if (!cycle) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Cycle not found");
const [project] = await db.select().from(projects).where(eq(projects.id, cycle.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
await db.update(tasks)
.set({ cycleId: null, updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), eq(tasks.cycleId, params.cycle_id as string)));
if (project) {
await recordActivity({
actor: auth.userName,
action: "removed_task",
entityType: "cycle",
entityId: params.cycle_id as string,
changes: { taskId: params.task_id },
workspaceId: project.domainId,
});
}
return { success: true };
},
},
{
name: "cycles.transfer-task",
description: "Transfer a task from one cycle to another",
inputSchema: {
type: "object",
properties: {
from_cycle_id: { type: "string" },
to_cycle_id: { type: "string" },
task_id: { type: "string" },
},
required: ["from_cycle_id", "to_cycle_id", "task_id"],
},
handler: async (params, auth) => {
const [fromCycle] = await db.select().from(cycles).where(eq(cycles.id, params.from_cycle_id as string)).limit(1);
if (!fromCycle) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Source cycle not found");
const [project] = await db.select().from(projects).where(eq(projects.id, fromCycle.projectId)).limit(1);
if (project) await verifyDomainAccess(project.domainId, auth.userId);
const [task] = await db.select().from(tasks).where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))).limit(1);
if (!task) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await db.update(tasks)
.set({ cycleId: params.to_cycle_id as string, updatedAt: new Date() })
.where(eq(tasks.id, params.task_id as string));
if (project) {
await recordActivity({
actor: auth.userName,
action: "transferred_task",
entityType: "cycle",
entityId: params.from_cycle_id as string,
changes: { taskId: params.task_id, fromCycleId: params.from_cycle_id, toCycleId: params.to_cycle_id },
workspaceId: project.domainId,
});
}
return { success: true };
},
},
// ── Links ───────────────────────────────────────────────────────────────────────
{
name: "links.list",
description: "List links for an entity (bidirectional)",
inputSchema: {
type: "object",
properties: {
source_type: { type: "string", description: "Entity type (e.g. task, project)" },
source_id: { type: "string", description: "Entity UUID" },
target_type: { type: "string" },
target_id: { type: "string" },
link_type: { type: "string", enum: ["relates", "blocks", "parent-child", "created-from"] },
limit: { type: "number", default: 50 },
},
},
handler: async (params) => {
const conditions: any[] = [];
if (params.source_type && params.source_id) {
conditions.push(
or(
and(eq(links.sourceType, params.source_type as string), eq(links.sourceId, params.source_id as string)),
and(eq(links.targetType, params.source_type as string), eq(links.targetId, params.source_id as string)),
)!
);
} else if (params.target_type && params.target_id) {
conditions.push(
or(
and(eq(links.sourceType, params.target_type as string), eq(links.sourceId, params.target_id as string)),
and(eq(links.targetType, params.target_type as string), eq(links.targetId, params.target_id as string)),
)!
);
}
if (params.link_type) {
conditions.push(eq(links.linkType, params.link_type as any));
}
const items = await db.select()
.from(links)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(links.createdAt))
.limit(Math.min(Number(params.limit) || 50, 200));
return { items, total: items.length };
},
},
{
name: "links.create",
description: "Create a link between two entities",
inputSchema: {
type: "object",
properties: {
source_type: { type: "string" },
source_id: { type: "string" },
target_type: { type: "string" },
target_id: { type: "string" },
link_type: { type: "string", enum: ["relates", "blocks", "parent-child", "created-from"] },
direction: { type: "string" },
},
required: ["source_type", "source_id", "target_type", "target_id", "link_type"],
},
handler: async (params) => {
const [existing] = await db.select()
.from(links)
.where(and(
eq(links.sourceType, params.source_type as string),
eq(links.sourceId, params.source_id as string),
eq(links.targetType, params.target_type as string),
eq(links.targetId, params.target_id as string),
eq(links.linkType, params.link_type as any),
))
.limit(1);
if (existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Link already exists");
const [link] = await db.insert(links).values({
sourceType: params.source_type as string,
sourceId: params.source_id as string,
targetType: params.target_type as string,
targetId: params.target_id as string,
linkType: params.link_type as any,
direction: (params.direction as string) ?? null,
}).returning();
return link;
},
},
{
name: "links.delete",
description: "Delete a link",
inputSchema: {
type: "object",
properties: { link_id: { type: "string" } },
required: ["link_id"],
},
handler: async (params) => {
const [existing] = await db.select().from(links).where(eq(links.id, params.link_id as string)).limit(1);
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Link not found");
await db.delete(links).where(eq(links.id, params.link_id as string));
return { deleted: true, id: params.link_id };
},
},
];
// ── Error helper ─────────────────────────────────────────────────────────────────
+1 -16
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, 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";
@@ -770,21 +770,6 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
}
});
// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2)
taskRoutes.post("/:id/dependencies", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
});
// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2)
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
});
// 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);
});
// GET /api/tasks/:id/history — State change log (from activity feed)
taskRoutes.get("/:id/history", async (c) => {
try {