Merge remote-tracking branch 'origin/feat/plane-lift-schema' into feat/pl-3-cycles
# Conflicts: # apps/api/src/index.ts # apps/api/src/routes/cycles.ts
This commit is contained in:
@@ -29,6 +29,7 @@ import { notificationRoutes } from "./routes/notifications";
|
||||
import { stateRoutes } from "./routes/states";
|
||||
import { moduleRoutes } from "./routes/modules";
|
||||
import { cycleRoutes } from "./routes/cycles";
|
||||
import { linkRoutes } from "./routes/links";
|
||||
import { healthHandler } from "./routes/health";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -49,6 +50,8 @@ app.route("/api/auth", authRoutes);
|
||||
app.route("/api/domains", domainRoutes);
|
||||
app.route("/api/projects/:projectId/modules", moduleRoutes);
|
||||
app.route("/api/modules", moduleRoutes);
|
||||
app.route("/api/projects/:projectId/cycles", cycleRoutes);
|
||||
app.route("/api/cycles", cycleRoutes);
|
||||
app.route("/api/tasks", taskRoutes);
|
||||
app.route("/api/habits", habitRoutes);
|
||||
app.route("/api/projects", projectRoutes);
|
||||
@@ -68,7 +71,7 @@ app.route("/api/analytics", analyticsRoutes);
|
||||
app.route("/api/activity", activityRoutes);
|
||||
app.route("/api/notifications", notificationRoutes);
|
||||
app.route("/api/states", stateRoutes);
|
||||
app.route("/api/cycles", cycleRoutes);
|
||||
app.route("/api/links", linkRoutes);
|
||||
app.route("/api", importExportRoutes);
|
||||
app.route("/api", realtimeRoutes);
|
||||
app.route("/api/mcp", mcpRoutes);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
@@ -57,11 +57,11 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
|
||||
for (const s of sectionRows) addNode(s.id, s.name, 'section');
|
||||
for (const t of tagRows) addNode(t.id, t.name, 'tag');
|
||||
|
||||
// Read links from the canonical links table
|
||||
const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)];
|
||||
// Read links from the canonical links table (both directions)
|
||||
const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id), ...projectRows.map(p => p.id), ...sectionRows.map(s => s.id)];
|
||||
if (allIds.length > 0) {
|
||||
const linkRows = await db.select().from(links)
|
||||
.where(inArray(links.sourceId, allIds));
|
||||
.where(or(inArray(links.sourceId, allIds), inArray(links.targetId, allIds)));
|
||||
for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, links, tasks, notes, projects } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { z } from "zod";
|
||||
|
||||
export const linkRoutes = new Hono();
|
||||
|
||||
const createLinkSchema = z.object({
|
||||
sourceType: z.string().min(1),
|
||||
sourceId: z.string().uuid(),
|
||||
targetType: z.string().min(1),
|
||||
targetId: z.string().uuid(),
|
||||
linkType: z.enum(["relates", "blocks", "parent-child", "created-from"]),
|
||||
direction: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
async function resolveWorkspaceId(entityType: string, entityId: string): Promise<string | null> {
|
||||
if (entityType === "task") {
|
||||
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, entityId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
if (entityType === "note") {
|
||||
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, entityId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/links — List links for an entity (either source OR target)
|
||||
linkRoutes.get("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const url = new URL(c.req.url);
|
||||
const entityType = url.searchParams.get("entityType");
|
||||
const entityId = url.searchParams.get("entityId");
|
||||
|
||||
if (!entityType || !entityId) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 400);
|
||||
}
|
||||
|
||||
const workspaceId = await resolveWorkspaceId(entityType, entityId);
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
|
||||
const items = await db.select()
|
||||
.from(links)
|
||||
.where(or(
|
||||
and(eq(links.sourceType, entityType), eq(links.sourceId, entityId)),
|
||||
and(eq(links.targetType, entityType), eq(links.targetId, entityId)),
|
||||
));
|
||||
|
||||
return c.json({ items });
|
||||
} 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 /api/links — Create a link between two entities
|
||||
linkRoutes.post("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const body = await c.req.json();
|
||||
const data = createLinkSchema.parse(body);
|
||||
|
||||
// Prevent self-links
|
||||
if (data.sourceId === data.targetId) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot link an entity to itself" } }, 400);
|
||||
}
|
||||
|
||||
// Check for duplicate link
|
||||
const [existing] = await db.select({ id: links.id })
|
||||
.from(links)
|
||||
.where(and(
|
||||
eq(links.sourceId, data.sourceId),
|
||||
eq(links.targetId, data.targetId),
|
||||
eq(links.linkType, data.linkType),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 409);
|
||||
}
|
||||
|
||||
const workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId);
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
|
||||
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 (workspaceId) {
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "link",
|
||||
entityId: link.id,
|
||||
changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType },
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
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 /api/links/:id — Remove a link
|
||||
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);
|
||||
}
|
||||
|
||||
const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId);
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
|
||||
await db.delete(links).where(eq(links.id, id));
|
||||
|
||||
if (workspaceId) {
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "link",
|
||||
entityId: id,
|
||||
changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId },
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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, states as statesTable, activityFeed, webhooks, webhookDeliveries } 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,7 +81,7 @@ const tools: ToolDefinition[] = [
|
||||
type: "object",
|
||||
properties: {
|
||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
||||
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
|
||||
state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by workflow state group" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||
project_id: { type: "string" },
|
||||
search: { type: "string" },
|
||||
@@ -95,7 +95,16 @@ 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_group) {
|
||||
const groups = (params.state_group as string).split(",") as any[];
|
||||
conditions.push(
|
||||
exists(
|
||||
db.select({ one: sql`1` })
|
||||
.from(statesTable)
|
||||
.where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.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.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
|
||||
@@ -119,7 +128,6 @@ const tools: ToolDefinition[] = [
|
||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
status: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||
due_date: { type: "string" },
|
||||
project_id: { type: "string" },
|
||||
@@ -157,7 +165,6 @@ const tools: ToolDefinition[] = [
|
||||
task_id: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
status: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||
due_date: { type: "string" },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user