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
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
type Props = {
card: any;
boardId: string;
columnId: string | undefined;
canEdit: boolean;
onclose: () => void;
ondelete: (cardId: string) => void;
};
let { card, boardId, columnId, canEdit, onclose, ondelete }: Props = $props();
let editingTitle = $state(false);
let title = $state(card.title);
let description = $state(card.description || '');
let editingDescription = $state(false);
let saving = $state(false);
async function saveTitle() {
if (!title.trim()) return;
saving = true;
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
card.title = title;
editingTitle = false;
saving = false;
}
async function saveDescription() {
saving = true;
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description })
});
card.description = description;
editingDescription = false;
saving = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onclose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-16 overflow-y-auto"
onclick={handleBackdropClick}
>
<div class="w-full max-w-2xl bg-white rounded-xl shadow-2xl">
<!-- Header -->
<div class="flex items-start justify-between p-4 border-b border-gray-100">
<div class="flex-1">
{#if editingTitle && canEdit}
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
<input
bind:value={title}
class="text-lg font-semibold w-full rounded border border-gray-300 px-2 py-1"
autofocus
onblur={saveTitle}
/>
</form>
{:else}
<h2
class="text-lg font-semibold text-gray-900 {canEdit ? 'cursor-pointer hover:bg-gray-50 rounded px-2 py-1 -mx-2 -my-1' : ''}"
ondblclick={() => canEdit && (editingTitle = true)}
>
{card.title}
</h2>
{/if}
</div>
<button onclick={onclose} class="text-gray-400 hover:text-gray-600 transition p-1 ml-2">
<svg class="w-5 h-5" 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>
</div>
<!-- Body -->
<div class="p-4 space-y-4">
<!-- Labels -->
{#if card.labels && card.labels.length > 0}
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3>
<div class="flex flex-wrap gap-1">
{#each card.labels as label}
<span
class="rounded px-2 py-0.5 text-xs text-white"
style="background-color: {label.tag.color}"
>
{label.tag.name}
</span>
{/each}
</div>
</div>
{/if}
<!-- Description -->
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Description</h3>
{#if editingDescription && canEdit}
<div>
<textarea
bind:value={description}
rows="6"
class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
placeholder="Add a more detailed description..."
></textarea>
<div class="flex gap-2 mt-2">
<button
onclick={saveDescription}
disabled={saving}
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
>
Save
</button>
<button
onclick={() => { editingDescription = false; description = card.description || ''; }}
class="text-sm text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</div>
{:else}
<div
class="{canEdit ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]"
onclick={() => canEdit && (editingDescription = true)}
role={canEdit ? 'button' : undefined}
tabindex={canEdit ? 0 : undefined}
onkeydown={(e) => { if (canEdit && (e.key === 'Enter' || e.key === ' ')) editingDescription = true; }}
>
{#if card.description}
<p class="text-sm text-gray-700 whitespace-pre-wrap">{card.description}</p>
{:else if canEdit}
<p class="text-sm text-gray-400">Add a more detailed description...</p>
{:else}
<p class="text-sm text-gray-400 italic">No description</p>
{/if}
</div>
{/if}
</div>
<!-- Due date -->
{#if card.dueDate}
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3>
<p class="text-sm text-gray-700">{new Date(card.dueDate).toLocaleDateString()}</p>
</div>
{/if}
<!-- Assignees -->
{#if card.assignees && card.assignees.length > 0}
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3>
<div class="flex flex-wrap gap-2">
{#each card.assignees as assignee}
<div class="flex items-center gap-1.5 rounded-full bg-gray-100 pl-1 pr-2.5 py-0.5">
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
{assignee.user.name[0].toUpperCase()}
</div>
<span class="text-xs text-gray-700">{assignee.user.name}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Footer -->
{#if canEdit}
<div class="flex justify-end gap-2 px-4 py-3 border-t border-gray-100">
<button
onclick={() => ondelete(card.id)}
class="rounded px-3 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition"
>
Delete card
</button>
</div>
{/if}
</div>
</div>