diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0101a75..e6b1a9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -165,6 +165,7 @@ model Card { description String? position String // Fractional index (lexicographic) dueDate DateTime? @map("due_date") + startDate DateTime? @map("start_date") archived Boolean @default(false) dueReminderSent Boolean @default(false) @map("due_reminder_sent") cardNumber Int? @map("card_number") @@ -180,6 +181,8 @@ model Card { activities Activity[] watchers CardWatcher[] customFieldValues CustomFieldValue[] + blocks CardDependency[] @relation("CardBlocks") + blockedBy CardDependency[] @relation("CardBlockedBy") @@map("cards") } @@ -458,6 +461,21 @@ model AutomationRule { @@map("automation_rules") } +// ─── Card Dependencies ────────────────────────────────────── + +model CardDependency { + id String @id @default(uuid()) @db.Uuid + blockingCardId String @map("blocking_card_id") @db.Uuid + blockedCardId String @map("blocked_card_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + blockingCard Card @relation("CardBlocks", fields: [blockingCardId], references: [id], onDelete: Cascade) + blockedCard Card @relation("CardBlockedBy", fields: [blockedCardId], references: [id], onDelete: Cascade) + + @@unique([blockingCardId, blockedCardId]) + @@map("card_dependencies") +} + // ─── Tenant Branding & Theming ─────────────────────────────── model TenantTheme { diff --git a/scripts/setup-rls.sql b/scripts/setup-rls.sql index 73be03f..34bb610 100644 --- a/scripts/setup-rls.sql +++ b/scripts/setup-rls.sql @@ -78,6 +78,9 @@ ALTER TABLE custom_field_values FORCE ROW LEVEL SECURITY; ALTER TABLE automation_rules ENABLE ROW LEVEL SECURITY; ALTER TABLE automation_rules FORCE ROW LEVEL SECURITY; +ALTER TABLE card_dependencies ENABLE ROW LEVEL SECURITY; +ALTER TABLE card_dependencies FORCE ROW LEVEL SECURITY; + -- ─── Tenant-scoped tables (direct tenant_id) ────────── -- Users @@ -460,6 +463,28 @@ CREATE POLICY automation_rules_tenant_isolation ON automation_rules ) ); +-- Card Dependencies (via blocking_card_id -> card -> column -> board) +DROP POLICY IF EXISTS card_dependencies_tenant_isolation ON card_dependencies; +CREATE POLICY card_dependencies_tenant_isolation ON card_dependencies + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR blocking_card_id IN ( + SELECT ca.id FROM cards ca + JOIN columns c ON ca.column_id = c.id + JOIN boards b ON c.board_id = b.id + WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR blocking_card_id IN ( + SELECT ca.id FROM cards ca + JOIN columns c ON ca.column_id = c.id + JOIN boards b ON c.board_id = b.id + WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ); + -- ─── User-scoped tables ─────────────────────────────── -- Notifications diff --git a/src/lib/components/CardModal.svelte b/src/lib/components/CardModal.svelte index ed4a358..be50413 100644 --- a/src/lib/components/CardModal.svelte +++ b/src/lib/components/CardModal.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { trapFocus } from '$lib/utils/focus-trap.js'; import MarkdownEditor from './MarkdownEditor.svelte'; + import StartDatePicker from './StartDatePicker.svelte'; import DueDatePicker from './DueDatePicker.svelte'; import TagPicker from './TagPicker.svelte'; import AssigneePicker from './AssigneePicker.svelte'; @@ -10,6 +11,7 @@ import AttachmentList from './AttachmentList.svelte'; import ActivityFeed from './ActivityFeed.svelte'; import CustomFieldsSection from './CustomFieldsSection.svelte'; + import DependencySection from './DependencySection.svelte'; import { api } from '$lib/utils/api.js'; type Props = { @@ -91,6 +93,12 @@ onupdate(card); } + function updateStartDate(startDate: string | null) { + card.startDate = startDate; + if (fullCard) fullCard.startDate = startDate; + onupdate(card); + } + function updateDueDate(dueDate: string | null) { card.dueDate = dueDate; if (fullCard) fullCard.dueDate = dueDate; @@ -275,6 +283,15 @@
+ + + + {#if canEdit}
diff --git a/src/lib/components/DependencySection.svelte b/src/lib/components/DependencySection.svelte new file mode 100644 index 0000000..9520f9e --- /dev/null +++ b/src/lib/components/DependencySection.svelte @@ -0,0 +1,201 @@ + + +
+

Dependencies

+ + {#if loading} +
Loading...
+ {:else} + + {#if blocks.length > 0} +
+

Blocks

+ {#each blocks as dep} +
+ {dep.blockedCard.title} + {#if canEdit} + + {/if} +
+ {/each} +
+ {/if} + + + {#if blockedBy.length > 0} +
+

Blocked by

+ {#each blockedBy as dep} +
+ {dep.blockingCard.title} + {#if canEdit} + + {/if} +
+ {/each} +
+ {/if} + + + {#if canEdit} + {#if adding} +
+

+ {adding === 'blocks' ? 'This card blocks...' : 'This card is blocked by...'} +

+ + {#if searchResults.length > 0} +
+ {#each searchResults as result} + + {/each} +
+ {:else if searchQuery && !searching} +

No cards found

+ {/if} + +
+ {:else} +
+ + +
+ {/if} + {/if} + {/if} +
diff --git a/src/lib/components/GanttChart.svelte b/src/lib/components/GanttChart.svelte new file mode 100644 index 0000000..c789200 --- /dev/null +++ b/src/lib/components/GanttChart.svelte @@ -0,0 +1,513 @@ + + +
+ +
+ Zoom: +
+ + + +
+ {cards.length} card{cards.length !== 1 ? 's' : ''} +
+ + +
+ +
+ +
+ Tasks +
+ +
+ {#each rows as row, i} + {#if row.type === 'column'} +
+ + {row.column.title} + +
+ {:else} + + {/if} + {/each} +
+
+ + +
+ +
+ +
+ {#each monthHeaders as mh} +
+ {mh.label} +
+ {/each} +
+ +
+ {#each days as day, i} +
+ {#if zoom === 'day'} + {format(day, 'd')} + {:else if zoom === 'week' && day.getDay() === 1} + {format(day, 'd')} + {/if} +
+ {/each} +
+
+ + + + + {#each days as day, i} + {#if isWeekend(day)} + + {/if} + {/each} + + + {#each rows as row, i} + + {#if row.type === 'column'} + + {/if} + {/each} + + + {#if todayOffset !== null} + + {/if} + + + + + + + + {#each dependencyPaths as path} + + {/each} + + + {#each rows as row, i} + {#if row.type === 'card'} + {@const bar = getBarInfo(row.card)} + {@const color = getColumnColor(row.columnIndex)} + {#if bar} + {#if bar.type === 'bar'} + + handleCardClick(row.card)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') handleCardClick(row.card); }} + > + + {#if bar.width > 40} + + {row.card.title.length > Math.floor(bar.width / 6) ? row.card.title.slice(0, Math.floor(bar.width / 6) - 2) + '...' : row.card.title} + + {/if} + + {:else if bar.type === 'diamond'} + + handleCardClick(row.card)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') handleCardClick(row.card); }} + > + + + {:else if bar.type === 'dashed'} + + handleCardClick(row.card)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') handleCardClick(row.card); }} + > + + + {/if} + {/if} + {/if} + {/each} + +
+
+
diff --git a/src/lib/components/StartDatePicker.svelte b/src/lib/components/StartDatePicker.svelte new file mode 100644 index 0000000..c931165 --- /dev/null +++ b/src/lib/components/StartDatePicker.svelte @@ -0,0 +1,96 @@ + + +
+

Start Date

+ {#if picking && canEdit} +
+ setDate(e.currentTarget.value)} + class="rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm" + disabled={saving} + /> + +
+ {:else if value} +
+ + {formatted} + + {#if canEdit} + + + {/if} +
+ {:else if canEdit} + + {:else} +

No start date

+ {/if} +
diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index 1cb1941..d75757c 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -83,3 +83,8 @@ export const checklistItemToggleSchema = z.object({ export const commentSchema = z.object({ content: z.string().min(1, 'Content is required').max(10000) }); + +export const cardDependencySchema = z.object({ + blockingCardId: z.string().uuid(), + blockedCardId: z.string().uuid() +}); diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts index e75f98f..db7378f 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/+server.ts @@ -131,6 +131,9 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null; updateData.dueReminderSent = false; } + if (body.startDate !== undefined) { + updateData.startDate = body.startDate ? new Date(body.startDate) : null; + } if (body.archived !== undefined) updateData.archived = body.archived; const updated = await tx.card.update({ diff --git a/src/routes/api/boards/[boardId]/dependencies/+server.ts b/src/routes/api/boards/[boardId]/dependencies/+server.ts new file mode 100644 index 0000000..08b066b --- /dev/null +++ b/src/routes/api/boards/[boardId]/dependencies/+server.ts @@ -0,0 +1,172 @@ +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; +} diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index c5dfa21..08190ff 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -487,6 +487,12 @@ > Calendar + + Gantt +
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'} diff --git a/src/routes/boards/[boardId]/calendar/+page.svelte b/src/routes/boards/[boardId]/calendar/+page.svelte index 240dae8..2282258 100644 --- a/src/routes/boards/[boardId]/calendar/+page.svelte +++ b/src/routes/boards/[boardId]/calendar/+page.svelte @@ -28,6 +28,12 @@ Calendar + + Gantt +
diff --git a/src/routes/boards/[boardId]/gantt/+page.server.ts b/src/routes/boards/[boardId]/gantt/+page.server.ts new file mode 100644 index 0000000..6e4ef11 --- /dev/null +++ b/src/routes/boards/[boardId]/gantt/+page.server.ts @@ -0,0 +1,64 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireBoardView } from '$lib/server/guards.js'; +import { canEditBoard } from '$lib/server/permissions.js'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + + const result = await withTenant(tenant.id, async (tx) => { + const board = await requireBoardView(tx, locals.user, params.boardId, tenant.id); + + const dependencies = await tx.cardDependency.findMany({ + where: { + blockingCard: { + column: { board: { id: params.boardId, tenantId: tenant.id } } + } + }, + select: { + id: true, + blockingCardId: true, + blockedCardId: true + } + }); + + return { board, dependencies }; + }); + + // Build cards with column metadata, filter to those with startDate or dueDate + const cards = result.board.columns.flatMap((col: any) => + col.cards + .filter((c: any) => c.startDate || c.dueDate) + .map((c: any) => ({ + id: c.id, + title: c.title, + startDate: c.startDate, + dueDate: c.dueDate, + cardNumber: c.cardNumber, + columnId: col.id, + columnTitle: col.title, + assignees: c.assignees, + labels: c.labels + })) + ); + + // Column metadata for grouping + const columns = result.board.columns.map((col: any) => ({ + id: col.id, + title: col.title + })); + + return { + board: { + id: result.board.id, + title: result.board.title, + cardPrefix: (result.board as any).cardPrefix + }, + cards, + columns, + dependencies: result.dependencies, + canEdit: canEditBoard(locals.user, result.board.members) + }; +}; diff --git a/src/routes/boards/[boardId]/gantt/+page.svelte b/src/routes/boards/[boardId]/gantt/+page.svelte new file mode 100644 index 0000000..894a3f5 --- /dev/null +++ b/src/routes/boards/[boardId]/gantt/+page.svelte @@ -0,0 +1,107 @@ + + + + {data.board.title} - Gantt + + +
+ +
+ + + + + +

{data.board.title}

+ +
+ + Board + + + Calendar + + + Gantt + +
+
+ + +
+ {#if cards.length === 0} +
+

No cards with dates to display.

+

Set start dates or due dates on cards to see them here.

+ Back to board +
+ {:else} + + {/if} +
+