import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { cardDependencySchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireBoardEditor } from '$lib/server/guards.js'; import { broadcast } from '$lib/server/realtime.js'; import { logActivity } from '$lib/server/activity.js'; // Get all dependencies for cards on this board export const GET: RequestHandler = async ({ params, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); const dependencies = await withTenant(tenant.id, async (tx) => { return tx.cardDependency.findMany({ where: { blockingCard: { column: { board: { id: params.boardId, tenantId: tenant.id } } } }, include: { blockingCard: { select: { id: true, title: true } }, blockedCard: { select: { id: true, title: true } } } }); }); return json({ dependencies }); }; // Create a dependency export const POST: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); const body = await request.json(); const result = cardDependencySchema.safeParse(body); if (!result.success) { throw error(400, result.error.issues[0].message); } const { blockingCardId, blockedCardId } = result.data; if (blockingCardId === blockedCardId) { throw error(400, 'A card cannot depend on itself'); } const dependency = await withTenant(tenant.id, async (tx) => { await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); // Verify both cards belong to this board const blockingCard = await tx.card.findFirst({ where: { id: blockingCardId, column: { board: { id: params.boardId } } }, select: { id: true, title: true } }); const blockedCard = await tx.card.findFirst({ where: { id: blockedCardId, column: { board: { id: params.boardId } } }, select: { id: true, title: true } }); if (!blockingCard || !blockedCard) { throw error(400, 'Both cards must belong to this board'); } // Check for existing dependency const existing = await tx.cardDependency.findUnique({ where: { blockingCardId_blockedCardId: { blockingCardId, blockedCardId } } }); if (existing) { throw error(400, 'This dependency already exists'); } // Cycle detection via DFS const hasCycle = await detectCycle(tx, blockingCardId, blockedCardId); if (hasCycle) { throw error(400, 'Adding this dependency would create a circular dependency'); } const dep = await tx.cardDependency.create({ data: { blockingCardId, blockedCardId }, include: { blockingCard: { select: { id: true, title: true } }, blockedCard: { select: { id: true, title: true } } } }); await logActivity(tx, { boardId: params.boardId, userId: locals.user!.id, action: 'dependency.created', cardId: blockedCardId, metadata: { blockingCardTitle: blockingCard.title, blockedCardTitle: blockedCard.title } }); return dep; }); const socketId = request.headers.get('X-Socket-ID'); broadcast(params.boardId, 'card:updated', { cardId: blockingCardId, socketId }); broadcast(params.boardId, 'card:updated', { cardId: blockedCardId, socketId }); return json({ dependency }, { status: 201 }); }; // Delete a dependency export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); const body = await request.json(); const { id } = body; if (!id) throw error(400, 'Dependency id is required'); const deleted = await withTenant(tenant.id, async (tx) => { await requireBoardEditor(tx, locals.user, params.boardId, tenant.id); const dep = await tx.cardDependency.findUnique({ where: { id }, include: { blockingCard: { select: { id: true, title: true, column: { select: { board: { select: { id: true, tenantId: true } } } } } }, blockedCard: { select: { id: true, title: true } } } }); if (!dep) throw error(404, 'Dependency not found'); if (dep.blockingCard.column.board.id !== params.boardId) { throw error(404, 'Dependency not found'); } await tx.cardDependency.delete({ where: { id } }); await logActivity(tx, { boardId: params.boardId, userId: locals.user!.id, action: 'dependency.removed', cardId: dep.blockedCardId, metadata: { blockingCardTitle: dep.blockingCard.title, blockedCardTitle: dep.blockedCard.title } }); return dep; }); const socketId = request.headers.get('X-Socket-ID'); broadcast(params.boardId, 'card:updated', { cardId: deleted.blockingCardId, socketId }); broadcast(params.boardId, 'card:updated', { cardId: deleted.blockedCardId, socketId }); return json({ ok: true }); }; // DFS cycle detection: would adding blockingCardId -> blockedCardId create a cycle? // A cycle exists if we can reach blockingCardId by following dependencies from blockedCardId async function detectCycle(tx: any, blockingCardId: string, blockedCardId: string): Promise { const visited = new Set(); const stack = [blockedCardId]; while (stack.length > 0) { const current = stack.pop()!; if (current === blockingCardId) return true; if (visited.has(current)) continue; visited.add(current); // Find all cards that `current` blocks const deps = await tx.cardDependency.findMany({ where: { blockingCardId: current }, select: { blockedCardId: true } }); for (const dep of deps) { stack.push(dep.blockedCardId); } } return false; }