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
+331
View File
@@ -0,0 +1,331 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Multi-Tenancy ────────────────────────────────────────────
model Tenant {
id String @id @default(uuid()) @db.Uuid
name String
slug String @unique
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
hostnames TenantHostname[]
users User[]
boards Board[]
tags Tag[]
categories Category[]
boardTemplates BoardTemplate[]
@@map("tenants")
}
model TenantHostname {
id String @id @default(uuid()) @db.Uuid
hostname String @unique
tenantId String @map("tenant_id") @db.Uuid
isPrimary Boolean @default(false) @map("is_primary")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("tenant_hostnames")
}
// ─── Users & Auth ─────────────────────────────────────────────
enum GlobalRole {
SITE_ADMIN
USER
}
enum TenantRole {
ADMIN
MEMBER
}
model User {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
email String
name String
passwordHash String @map("password_hash")
globalRole GlobalRole @default(USER) @map("global_role")
tenantRole TenantRole @default(MEMBER) @map("tenant_role")
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
sessions Session[]
boardMembers BoardMember[]
cardAssignees CardAssignee[]
comments Comment[]
activities Activity[]
notifications Notification[]
@@unique([tenantId, email])
@@map("users")
}
model Session {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
// ─── Boards ───────────────────────────────────────────────────
enum BoardVisibility {
PUBLIC
PRIVATE
}
enum BoardMemberRole {
OWNER
EDITOR
VIEWER
}
model Board {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
title String
description String?
visibility BoardVisibility @default(PRIVATE)
background String?
categoryId String? @map("category_id") @db.Uuid
archived Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
columns Column[]
members BoardMember[]
activities Activity[]
@@map("boards")
}
model BoardMember {
id String @id @default(uuid()) @db.Uuid
boardId String @map("board_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role BoardMemberRole @default(EDITOR)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([boardId, userId])
@@map("board_members")
}
// ─── Columns ──────────────────────────────────────────────────
model Column {
id String @id @default(uuid()) @db.Uuid
boardId String @map("board_id") @db.Uuid
title String
position String // Fractional index (lexicographic)
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
cards Card[]
@@map("columns")
}
// ─── Cards ────────────────────────────────────────────────────
model Card {
id String @id @default(uuid()) @db.Uuid
columnId String @map("column_id") @db.Uuid
title String
description String?
position String // Fractional index (lexicographic)
dueDate DateTime? @map("due_date")
archived Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
column Column @relation(fields: [columnId], references: [id], onDelete: Cascade)
assignees CardAssignee[]
labels CardLabel[]
checklists Checklist[]
comments Comment[]
attachments Attachment[]
activities Activity[]
@@map("cards")
}
model CardAssignee {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
userId String @map("user_id") @db.Uuid
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([cardId, userId])
@@map("card_assignees")
}
// ─── Tags & Labels ────────────────────────────────────────────
model Tag {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
color String // hex color
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
cards CardLabel[]
@@unique([tenantId, name])
@@map("tags")
}
model CardLabel {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
tagId String @map("tag_id") @db.Uuid
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([cardId, tagId])
@@map("card_labels")
}
// ─── Categories ───────────────────────────────────────────────
model Category {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
boards Board[]
@@unique([tenantId, name])
@@map("categories")
}
// ─── Checklists ───────────────────────────────────────────────
model Checklist {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
title String
position String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
items ChecklistItem[]
@@map("checklists")
}
model ChecklistItem {
id String @id @default(uuid()) @db.Uuid
checklistId String @map("checklist_id") @db.Uuid
title String
completed Boolean @default(false)
position String
checklist Checklist @relation(fields: [checklistId], references: [id], onDelete: Cascade)
@@map("checklist_items")
}
// ─── Comments ─────────────────────────────────────────────────
model Comment {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
userId String @map("user_id") @db.Uuid
content String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("comments")
}
// ─── Attachments ──────────────────────────────────────────────
model Attachment {
id String @id @default(uuid()) @db.Uuid
cardId String @map("card_id") @db.Uuid
filename String
filepath String
mimetype String
size Int
createdAt DateTime @default(now()) @map("created_at")
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@map("attachments")
}
// ─── Activity Log ─────────────────────────────────────────────
model Activity {
id String @id @default(uuid()) @db.Uuid
boardId String @map("board_id") @db.Uuid
cardId String? @map("card_id") @db.Uuid
userId String @map("user_id") @db.Uuid
action String // e.g. "card.created", "card.moved", "comment.added"
metadata Json? // additional context
createdAt DateTime @default(now()) @map("created_at")
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("activities")
}
// ─── Notifications ────────────────────────────────────────────
model Notification {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
type String // e.g. "assigned", "mentioned", "due_soon"
title String
body String?
link String?
read Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("notifications")
}
// ─── Board Templates ──────────────────────────────────────────
model BoardTemplate {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid // null = system template
name String
description String?
template Json // JSON structure with columns/cards
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("board_templates")
}