Private
Public Access
1
0

Initial implementation: multi-tenant Kanban board (Phase 1)

Complete Phase 1 foundation with working board, column, and card CRUD:
- SvelteKit + TypeScript + Tailwind CSS v4 + adapter-node
- Full Prisma schema with 18 tables (tenants, users, boards, columns, cards, etc.)
- Multi-tenant architecture with hostname-based tenant resolution
- Email/password auth with bcrypt + session cookies
- Board/Column/Card API routes with full CRUD
- Fractional indexing for drag-and-drop ordering
- Board view with svelte-dnd-action for column and card drag-and-drop
- Card modal with inline editing
- Auth pages (login, register)
- Board listing with create form
- RLS policies SQL script for tenant isolation
- Prisma RLS client extensions (forTenant/bypassRLS)
- Permission helpers (canEditBoard, canManageMembers, etc.)
- Socket.IO server setup (dev Vite plugin + production Express server)
- Client-side socket store for real-time updates
- Docker Compose (app + PostgreSQL 16) + multi-stage Dockerfile
- Database seed script (default tenant + admin user)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-25 20:58:49 -05:00
commit 2f398711c8
52 changed files with 7369 additions and 0 deletions
+385
View File
@@ -0,0 +1,385 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { generatePosition } from '$lib/utils/fractional-index.js';
import CardModal from '$lib/components/CardModal.svelte';
let { data } = $props();
// Local mutable state derived from server data
let columns = $state(
data.board.columns.map((col: any) => ({
...col,
cards: col.cards.map((card: any) => ({ ...card, id: card.id }))
}))
);
let addingColumnTitle = $state('');
let showAddColumn = $state(false);
let addingCardColumnId = $state<string | null>(null);
let newCardTitle = $state('');
let selectedCard = $state<any>(null);
let editingColumnId = $state<string | null>(null);
let editingColumnTitle = $state('');
const flipDurationMs = 200;
// ─── Column DnD ──────────────────────────────────
function handleColumnSort(e: CustomEvent<any>) {
columns = e.detail.items;
if (e.detail.info.trigger === 'droppedIntoZone') {
// Update positions based on new order
columns.forEach((col: any, idx: number) => {
const before = idx > 0 ? columns[idx - 1].position : null;
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
if (col.id === e.detail.info.id) {
const newPos = generatePosition(before, after);
col.position = newPos;
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ position: newPos })
});
}
});
}
}
// ─── Card DnD ────────────────────────────────────
function handleCardSort(columnId: string, e: CustomEvent<any>) {
const col = columns.find((c: any) => c.id === columnId);
if (!col) return;
col.cards = e.detail.items;
if (e.detail.info.trigger === 'droppedIntoZone') {
const cardId = e.detail.info.id;
const cardIdx = col.cards.findIndex((c: any) => c.id === cardId);
if (cardIdx === -1) return;
const before = cardIdx > 0 ? col.cards[cardIdx - 1].position : null;
const after = cardIdx < col.cards.length - 1 ? col.cards[cardIdx + 1].position : null;
const newPos = generatePosition(before, after);
col.cards[cardIdx].position = newPos;
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId, position: newPos })
});
}
}
// ─── Add Column ──────────────────────────────────
async function addColumn() {
if (!addingColumnTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: addingColumnTitle })
});
if (res.ok) {
const { column } = await res.json();
columns = [...columns, { ...column, cards: [] }];
addingColumnTitle = '';
showAddColumn = false;
}
}
// ─── Add Card ────────────────────────────────────
async function addCard(columnId: string) {
if (!newCardTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newCardTitle })
});
if (res.ok) {
const { card } = await res.json();
const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = [...col.cards, card];
newCardTitle = '';
addingCardColumnId = null;
}
}
// ─── Delete Column ───────────────────────────────
async function deleteColumn(columnId: string) {
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'DELETE'
});
if (res.ok) {
columns = columns.filter((c: any) => c.id !== columnId);
}
}
// ─── Rename Column ───────────────────────────────
async function renameColumn(columnId: string) {
if (!editingColumnTitle.trim()) return;
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: editingColumnTitle })
});
if (res.ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.title = editingColumnTitle;
}
editingColumnId = null;
}
// ─── Delete Card ─────────────────────────────────
async function deleteCard(columnId: string, cardId: string) {
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
method: 'DELETE'
});
if (res.ok) {
const col = columns.find((c: any) => c.id === columnId);
if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId);
if (selectedCard?.id === cardId) selectedCard = null;
}
}
</script>
<svelte:head>
<title>{data.board.title}</title>
</svelte:head>
{#if selectedCard}
<CardModal
card={selectedCard}
boardId={data.board.id}
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
canEdit={data.canEdit}
onclose={() => (selectedCard = null)}
ondelete={(cardId) => {
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
if (col) deleteCard(col.id, cardId);
}}
/>
{/if}
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
<!-- Board header -->
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200">
<a href="/boards" class="text-gray-400 hover:text-gray-600 transition" aria-label="Back to boards">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-lg font-bold text-gray-900">{data.board.title}</h1>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span>
</div>
<!-- Columns container -->
<div class="flex-1 overflow-x-auto p-4">
<div
class="flex gap-4 h-full items-start"
use:dndzone={{
items: columns,
flipDurationMs,
type: 'columns',
dragDisabled: !data.canEdit
}}
onconsider={handleColumnSort}
onfinalize={handleColumnSort}
>
{#each columns as column (column.id)}
<div
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
animate:flip={{ duration: flipDurationMs }}
>
<!-- Column header -->
<div class="flex items-center justify-between px-3 py-2.5">
{#if editingColumnId === column.id}
<form
onsubmit={(e) => { e.preventDefault(); renameColumn(column.id); }}
class="flex-1 flex gap-1"
>
<input
bind:value={editingColumnTitle}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm font-semibold"
autofocus
onblur={() => renameColumn(column.id)}
/>
</form>
{:else}
<button
class="text-sm font-semibold text-gray-700 hover:text-gray-900 text-left flex-1"
ondblclick={() => {
if (data.canEdit) {
editingColumnId = column.id;
editingColumnTitle = column.title;
}
}}
>
{column.title}
<span class="ml-1 text-xs text-gray-400 font-normal">{column.cards.length}</span>
</button>
{/if}
{#if data.canEdit}
<button
onclick={() => deleteColumn(column.id)}
class="text-gray-400 hover:text-[var(--color-danger)] transition p-1"
title="Delete column"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<!-- Cards zone -->
<div
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
use:dndzone={{
items: column.cards,
flipDurationMs,
type: 'cards',
dragDisabled: !data.canEdit
}}
onconsider={(e) => handleCardSort(column.id, e)}
onfinalize={(e) => handleCardSort(column.id, e)}
>
{#each column.cards as card (card.id)}
<button
class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 transition cursor-pointer"
animate:flip={{ duration: flipDurationMs }}
onclick={() => (selectedCard = card)}
>
{#if card.labels && card.labels.length > 0}
<div class="flex flex-wrap gap-1 mb-1.5">
{#each card.labels as label}
<span
class="inline-block h-2 w-8 rounded-full"
style="background-color: {label.tag.color}"
title={label.tag.name}
></span>
{/each}
</div>
{/if}
<span class="text-sm text-gray-800">{card.title}</span>
{#if card.dueDate || card._count?.comments > 0 || card._count?.checklists > 0}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400">
{#if card.dueDate}
<span>Due {new Date(card.dueDate).toLocaleDateString()}</span>
{/if}
{#if card._count?.comments > 0}
<span>💬 {card._count.comments}</span>
{/if}
</div>
{/if}
{#if card.assignees && card.assignees.length > 0}
<div class="flex -space-x-1 mt-2">
{#each card.assignees.slice(0, 3) as assignee}
<div
class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center border border-white"
title={assignee.user.name}
>
{assignee.user.name[0].toUpperCase()}
</div>
{/each}
</div>
{/if}
</button>
{/each}
</div>
<!-- Add card button -->
{#if data.canEdit}
{#if addingCardColumnId === column.id}
<div class="px-2 pb-2">
<form
onsubmit={(e) => { e.preventDefault(); addCard(column.id); }}
>
<textarea
bind:value={newCardTitle}
placeholder="Enter a title for this card..."
class="w-full rounded-lg border border-gray-300 p-2 text-sm resize-none focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
rows="2"
autofocus
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
addCard(column.id);
}
if (e.key === 'Escape') {
addingCardColumnId = null;
newCardTitle = '';
}
}}
></textarea>
<div class="flex gap-2 mt-1">
<button
type="submit"
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
>
Add card
</button>
<button
type="button"
onclick={() => { addingCardColumnId = null; newCardTitle = ''; }}
class="text-gray-500 hover:text-gray-700 transition text-sm"
>
Cancel
</button>
</div>
</form>
</div>
{:else}
<button
onclick={() => { addingCardColumnId = column.id; newCardTitle = ''; }}
class="mx-2 mb-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-200/50 transition text-left"
>
+ Add a card
</button>
{/if}
{/if}
</div>
{/each}
<!-- Add column -->
{#if data.canEdit}
<div class="flex-shrink-0 w-72">
{#if showAddColumn}
<div class="bg-[var(--color-column-bg)] rounded-xl p-3">
<form onsubmit={(e) => { e.preventDefault(); addColumn(); }}>
<input
bind:value={addingColumnTitle}
placeholder="Enter list title..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
autofocus
onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }}
/>
<div class="flex gap-2 mt-2">
<button
type="submit"
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
>
Add list
</button>
<button
type="button"
onclick={() => { showAddColumn = false; addingColumnTitle = ''; }}
class="text-gray-500 hover:text-gray-700 transition text-sm"
>
Cancel
</button>
</div>
</form>
</div>
{:else}
<button
onclick={() => (showAddColumn = true)}
class="w-full rounded-xl bg-white/60 hover:bg-white/80 border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 transition text-left"
>
+ Add another list
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>