174 lines
5.8 KiB
TypeScript
174 lines
5.8 KiB
TypeScript
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);
|
||
|
|
}
|
||
|
|
});
|