2fbd3bc93d
Adds a new Gantt chart board view with custom SVG timeline rendering, card-to-card dependency tracking with cycle detection, and a startDate field on cards. Includes zoom controls (day/week/month), dependency arrows, column-grouped task list, today marker, and real-time updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
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<boolean> {
|
|
const visited = new Set<string>();
|
|
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;
|
|
}
|