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
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
let { data } = $props();
let showCreate = $state(false);
let newTitle = $state('');
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
let creating = $state(false);
let error = $state('');
async function createBoard(e: Event) {
e.preventDefault();
if (!newTitle.trim()) return;
creating = true;
error = '';
try {
const res = await fetch('/api/boards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle, visibility: newVisibility })
});
const result = await res.json();
if (!res.ok) {
error = result.error || 'Failed to create board';
return;
}
newTitle = '';
showCreate = false;
invalidateAll();
} catch {
error = 'Network error';
} finally {
creating = false;
}
}
</script>
<svelte:head>
<title>Boards</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Boards</h1>
{#if data.user}
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
>
+ New Board
</button>
{/if}
</div>
{#if showCreate}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
{#if error}
<div class="mb-3 rounded bg-red-50 border border-red-200 p-2 text-sm text-red-700">{error}</div>
{/if}
<form onsubmit={createBoard} class="flex gap-3 items-end">
<div class="flex-1">
<label for="board-title" class="block text-sm font-medium text-gray-700 mb-1">Board title</label>
<input
id="board-title"
type="text"
bind:value={newTitle}
required
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)]"
placeholder="My new board"
/>
</div>
<div>
<label for="board-vis" class="block text-sm font-medium text-gray-700 mb-1">Visibility</label>
<select
id="board-vis"
bind:value={newVisibility}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="PRIVATE">Private</option>
<option value="PUBLIC">Public</option>
</select>
</div>
<button
type="submit"
disabled={creating}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onclick={() => (showCreate = false)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 transition"
>
Cancel
</button>
</form>
</div>
{/if}
{#if data.boards.length === 0}
<div class="text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="4" rx="1" />
<rect x="14" y="10" width="7" height="7" rx="1" />
<rect x="3" y="13" width="7" height="8" rx="1" />
</svg>
<p class="text-gray-500">No boards yet.</p>
{#if data.user}
<p class="text-gray-400 text-sm mt-1">Create your first board to get started.</p>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each data.boards as board}
<a
href="/boards/{board.id}"
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition"
>
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
{board.title}
</h2>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
<span>{board._count.columns} columns</span>
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
</div>
{#if board.members.length > 0}
<div class="mt-3 flex -space-x-1">
{#each board.members.slice(0, 5) as member}
<div
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center border-2 border-white"
title={member.user.name}
>
{member.user.name[0].toUpperCase()}
</div>
{/each}
{#if board.members.length > 5}
<div class="w-6 h-6 rounded-full bg-gray-300 text-gray-600 text-xs flex items-center justify-center border-2 border-white">
+{board.members.length - 5}
</div>
{/if}
</div>
{/if}
</a>
{/each}
</div>
{/if}
</div>