feat: links CRUD API + MCP tools for states/modules/cycles/links (PL-4/6)
- Add links.ts: full CRUD for entity links (relates, blocks, parent-child, created-from) - Update MCP tools: add state_id, state_group, module_id, cycle_id filters - Remove deprecated dependency/status stubs from tasks.ts - Mount linkRoutes at /api/links
This commit is contained in:
@@ -28,6 +28,7 @@ import { importExportRoutes } from "./routes/import-export";
|
|||||||
import { notificationRoutes } from "./routes/notifications";
|
import { notificationRoutes } from "./routes/notifications";
|
||||||
import { stateRoutes } from "./routes/states";
|
import { stateRoutes } from "./routes/states";
|
||||||
import { moduleRoutes } from "./routes/modules";
|
import { moduleRoutes } from "./routes/modules";
|
||||||
|
import { linkRoutes } from "./routes/links";
|
||||||
import { healthHandler } from "./routes/health";
|
import { healthHandler } from "./routes/health";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -67,6 +68,7 @@ app.route("/api/analytics", analyticsRoutes);
|
|||||||
app.route("/api/activity", activityRoutes);
|
app.route("/api/activity", activityRoutes);
|
||||||
app.route("/api/notifications", notificationRoutes);
|
app.route("/api/notifications", notificationRoutes);
|
||||||
app.route("/api/states", stateRoutes);
|
app.route("/api/states", stateRoutes);
|
||||||
|
app.route("/api/links", linkRoutes);
|
||||||
app.route("/api", importExportRoutes);
|
app.route("/api", importExportRoutes);
|
||||||
app.route("/api", realtimeRoutes);
|
app.route("/api", realtimeRoutes);
|
||||||
app.route("/api/mcp", mcpRoutes);
|
app.route("/api/mcp", mcpRoutes);
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createHash } from "node:crypto";
|
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 { 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, ilike, isNull, or } from "drizzle-orm";
|
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { recordActivity } from "../middleware/activity";
|
import { recordActivity } from "../middleware/activity";
|
||||||
|
|
||||||
export const mcpRoutes = new Hono();
|
export const mcpRoutes = new Hono();
|
||||||
@@ -81,9 +81,12 @@ const tools: ToolDefinition[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
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"] },
|
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||||
project_id: { type: "string" },
|
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" },
|
search: { type: "string" },
|
||||||
limit: { type: "number", default: 50 },
|
limit: { type: "number", default: 50 },
|
||||||
offset: { type: "number", default: 0 },
|
offset: { type: "number", default: 0 },
|
||||||
@@ -95,9 +98,24 @@ const tools: ToolDefinition[] = [
|
|||||||
eq(tasks.domainId, params.domain_id as string),
|
eq(tasks.domainId, params.domain_id as string),
|
||||||
isNull(tasks.deletedAt),
|
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.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.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}%`));
|
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
|
||||||
|
|
||||||
const items = await db.select()
|
const items = await db.select()
|
||||||
@@ -119,21 +137,38 @@ const tools: ToolDefinition[] = [
|
|||||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
domain_id: { type: "string", description: "Workspace/domain ID" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
status: { type: "string" },
|
state_id: { type: "string", description: "State UUID" },
|
||||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||||
due_date: { type: "string" },
|
due_date: { type: "string" },
|
||||||
project_id: { type: "string" },
|
project_id: { type: "string" },
|
||||||
|
module_id: { type: "string", description: "Module UUID" },
|
||||||
|
cycle_id: { type: "string", description: "Cycle UUID" },
|
||||||
},
|
},
|
||||||
required: ["domain_id", "title"],
|
required: ["domain_id", "title"],
|
||||||
},
|
},
|
||||||
handler: async (params, auth) => {
|
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({
|
const [task] = await db.insert(tasks).values({
|
||||||
title: params.title as string,
|
title: params.title as string,
|
||||||
description: (params.description as string) ?? null,
|
description: (params.description as string) ?? null,
|
||||||
priority: (params.priority as any) ?? "medium",
|
priority: (params.priority as any) ?? "medium",
|
||||||
domainId: params.domain_id as string,
|
domainId: params.domain_id as string,
|
||||||
projectId: (params.project_id as string) ?? null,
|
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,
|
dueDate: params.due_date ? new Date(params.due_date as string) : null,
|
||||||
|
completedAt,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -141,7 +176,7 @@ const tools: ToolDefinition[] = [
|
|||||||
action: "created",
|
action: "created",
|
||||||
entityType: "task",
|
entityType: "task",
|
||||||
entityId: task.id,
|
entityId: task.id,
|
||||||
changes: { title: task.title },
|
changes: { title: task.title, stateId: task.stateId },
|
||||||
workspaceId: params.domain_id as string,
|
workspaceId: params.domain_id as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,9 +192,11 @@ const tools: ToolDefinition[] = [
|
|||||||
task_id: { type: "string" },
|
task_id: { type: "string" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
status: { type: "string" },
|
state_id: { type: "string", description: "State UUID" },
|
||||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||||
due_date: { type: "string" },
|
due_date: { type: "string" },
|
||||||
|
module_id: { type: "string", description: "Module UUID" },
|
||||||
|
cycle_id: { type: "string", description: "Cycle UUID" },
|
||||||
},
|
},
|
||||||
required: ["task_id"],
|
required: ["task_id"],
|
||||||
},
|
},
|
||||||
@@ -173,6 +210,24 @@ const tools: ToolDefinition[] = [
|
|||||||
if (params.description !== undefined) updateData.description = params.description;
|
if (params.description !== undefined) updateData.description = params.description;
|
||||||
if (params.priority !== undefined) updateData.priority = params.priority;
|
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.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();
|
updateData.updatedAt = new Date();
|
||||||
|
|
||||||
const [task] = await db.update(tasks)
|
const [task] = await db.update(tasks)
|
||||||
@@ -223,10 +278,13 @@ const tools: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tasks.complete",
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
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"],
|
required: ["task_id"],
|
||||||
},
|
},
|
||||||
handler: async (params, auth) => {
|
handler: async (params, auth) => {
|
||||||
@@ -234,8 +292,25 @@ const tools: ToolDefinition[] = [
|
|||||||
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
|
||||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
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)
|
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)))
|
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -244,6 +319,7 @@ const tools: ToolDefinition[] = [
|
|||||||
action: "completed",
|
action: "completed",
|
||||||
entityType: "task",
|
entityType: "task",
|
||||||
entityId: task.id,
|
entityId: task.id,
|
||||||
|
changes: { stateId: task.stateId },
|
||||||
workspaceId: task.domainId,
|
workspaceId: task.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -605,6 +681,719 @@ const tools: ToolDefinition[] = [
|
|||||||
return { items };
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Error helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
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 { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||||
import { recordActivity } from "../middleware/activity";
|
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)
|
// GET /api/tasks/:id/history — State change log (from activity feed)
|
||||||
taskRoutes.get("/:id/history", async (c) => {
|
taskRoutes.get("/:id/history", async (c) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user