diff --git a/Dockerfile b/Dockerfile index 3faec82..dcbc76a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,9 @@ RUN npx prisma generate COPY --from=builder /app/build ./build COPY --from=builder /app/server.js ./server.js +COPY scripts ./scripts +RUN apk add --no-cache postgresql-client RUN apk del python3 make g++ && rm -rf /var/cache/apk/* RUN mkdir -p /app/uploads diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 6aa61bc..90ac540 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -20,5 +20,12 @@ if [ $RETRY -ge $MAX_RETRIES ]; then exit 1 fi +echo "Applying RLS policies..." +if psql "$DATABASE_URL" -f ./scripts/setup-rls.sql; then + echo "RLS policies applied successfully." +else + echo "WARNING: Failed to apply RLS policies. Continuing anyway..." +fi + echo "Starting server..." exec node server.js diff --git a/scripts/setup-rls.sql b/scripts/setup-rls.sql index f3a8eff..c5d5678 100644 --- a/scripts/setup-rls.sql +++ b/scripts/setup-rls.sql @@ -1,65 +1,144 @@ -- Row-Level Security (RLS) Policies -- Run this after migrations to enable tenant isolation at the DB level. +-- Idempotent: safe to re-run on every deploy. --- Helper: current tenant context (set via SET LOCAL in Prisma extension) --- Usage: SET LOCAL app.current_tenant_id = ''; +-- Helper: current tenant context (set via set_config() in Prisma transaction) +-- Usage: SELECT set_config('app.current_tenant_id', '', true); --- ─── Enable RLS ────────────────────────────────────────── +-- ─── Tables WITHOUT RLS (cross-tenant by design) ────── +ALTER TABLE tenants DISABLE ROW LEVEL SECURITY; +ALTER TABLE tenant_hostnames DISABLE ROW LEVEL SECURITY; +ALTER TABLE sessions DISABLE ROW LEVEL SECURITY; + +-- ─── Enable + Force RLS ──────────────────────────────── ALTER TABLE users ENABLE ROW LEVEL SECURITY; -ALTER TABLE boards ENABLE ROW LEVEL SECURITY; -ALTER TABLE board_members ENABLE ROW LEVEL SECURITY; -ALTER TABLE columns ENABLE ROW LEVEL SECURITY; -ALTER TABLE cards ENABLE ROW LEVEL SECURITY; -ALTER TABLE card_assignees ENABLE ROW LEVEL SECURITY; -ALTER TABLE tags ENABLE ROW LEVEL SECURITY; -ALTER TABLE card_labels ENABLE ROW LEVEL SECURITY; -ALTER TABLE categories ENABLE ROW LEVEL SECURITY; -ALTER TABLE checklists ENABLE ROW LEVEL SECURITY; -ALTER TABLE checklist_items ENABLE ROW LEVEL SECURITY; -ALTER TABLE comments ENABLE ROW LEVEL SECURITY; -ALTER TABLE attachments ENABLE ROW LEVEL SECURITY; -ALTER TABLE activities ENABLE ROW LEVEL SECURITY; -ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; -ALTER TABLE board_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE users FORCE ROW LEVEL SECURITY; --- ─── Tenant-scoped tables (direct tenant_id) ──────────── +ALTER TABLE boards ENABLE ROW LEVEL SECURITY; +ALTER TABLE boards FORCE ROW LEVEL SECURITY; + +ALTER TABLE board_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE board_members FORCE ROW LEVEL SECURITY; + +ALTER TABLE columns ENABLE ROW LEVEL SECURITY; +ALTER TABLE columns FORCE ROW LEVEL SECURITY; + +ALTER TABLE cards ENABLE ROW LEVEL SECURITY; +ALTER TABLE cards FORCE ROW LEVEL SECURITY; + +ALTER TABLE card_assignees ENABLE ROW LEVEL SECURITY; +ALTER TABLE card_assignees FORCE ROW LEVEL SECURITY; + +ALTER TABLE tags ENABLE ROW LEVEL SECURITY; +ALTER TABLE tags FORCE ROW LEVEL SECURITY; + +ALTER TABLE card_labels ENABLE ROW LEVEL SECURITY; +ALTER TABLE card_labels FORCE ROW LEVEL SECURITY; + +ALTER TABLE categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE categories FORCE ROW LEVEL SECURITY; + +ALTER TABLE checklists ENABLE ROW LEVEL SECURITY; +ALTER TABLE checklists FORCE ROW LEVEL SECURITY; + +ALTER TABLE checklist_items ENABLE ROW LEVEL SECURITY; +ALTER TABLE checklist_items FORCE ROW LEVEL SECURITY; + +ALTER TABLE comments ENABLE ROW LEVEL SECURITY; +ALTER TABLE comments FORCE ROW LEVEL SECURITY; + +ALTER TABLE attachments ENABLE ROW LEVEL SECURITY; +ALTER TABLE attachments FORCE ROW LEVEL SECURITY; + +ALTER TABLE activities ENABLE ROW LEVEL SECURITY; +ALTER TABLE activities FORCE ROW LEVEL SECURITY; + +ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE notifications FORCE ROW LEVEL SECURITY; + +ALTER TABLE board_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE board_templates FORCE ROW LEVEL SECURITY; + +-- ─── Tenant-scoped tables (direct tenant_id) ────────── -- Users DROP POLICY IF EXISTS users_tenant_isolation ON users; CREATE POLICY users_tenant_isolation ON users - USING (tenant_id::text = current_setting('app.current_tenant_id', true)); + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ); -- Boards DROP POLICY IF EXISTS boards_tenant_isolation ON boards; CREATE POLICY boards_tenant_isolation ON boards - USING (tenant_id::text = current_setting('app.current_tenant_id', true)); + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ); -- Tags DROP POLICY IF EXISTS tags_tenant_isolation ON tags; CREATE POLICY tags_tenant_isolation ON tags - USING (tenant_id::text = current_setting('app.current_tenant_id', true)); + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ); -- Categories DROP POLICY IF EXISTS categories_tenant_isolation ON categories; CREATE POLICY categories_tenant_isolation ON categories - USING (tenant_id::text = current_setting('app.current_tenant_id', true)); + USING ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ); -- Board Templates (null tenant_id = system template, visible to all) DROP POLICY IF EXISTS board_templates_tenant_isolation ON board_templates; CREATE POLICY board_templates_tenant_isolation ON board_templates USING ( - tenant_id IS NULL + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id IS NULL + OR tenant_id::text = current_setting('app.current_tenant_id', true) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR tenant_id IS NULL OR tenant_id::text = current_setting('app.current_tenant_id', true) ); --- ─── Board-scoped tables (via join to boards) ─────────── +-- ─── Board-scoped tables (via join to boards) ───────── -- Board Members DROP POLICY IF EXISTS board_members_tenant_isolation ON board_members; CREATE POLICY board_members_tenant_isolation ON board_members USING ( - board_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( SELECT id FROM boards WHERE tenant_id::text = current_setting('app.current_tenant_id', true) ) @@ -69,7 +148,15 @@ CREATE POLICY board_members_tenant_isolation ON board_members DROP POLICY IF EXISTS columns_tenant_isolation ON columns; CREATE POLICY columns_tenant_isolation ON columns USING ( - board_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( SELECT id FROM boards WHERE tenant_id::text = current_setting('app.current_tenant_id', true) ) @@ -79,7 +166,15 @@ CREATE POLICY columns_tenant_isolation ON columns DROP POLICY IF EXISTS activities_tenant_isolation ON activities; CREATE POLICY activities_tenant_isolation ON activities USING ( - board_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( + SELECT id FROM boards + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR board_id IN ( SELECT id FROM boards WHERE tenant_id::text = current_setting('app.current_tenant_id', true) ) @@ -91,7 +186,16 @@ CREATE POLICY activities_tenant_isolation ON activities DROP POLICY IF EXISTS cards_tenant_isolation ON cards; CREATE POLICY cards_tenant_isolation ON cards USING ( - column_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR column_id IN ( + SELECT c.id FROM columns c + 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 column_id IN ( SELECT c.id FROM columns c JOIN boards b ON c.board_id = b.id WHERE b.tenant_id::text = current_setting('app.current_tenant_id', true) @@ -102,7 +206,17 @@ CREATE POLICY cards_tenant_isolation ON cards DROP POLICY IF EXISTS card_assignees_tenant_isolation ON card_assignees; CREATE POLICY card_assignees_tenant_isolation ON card_assignees USING ( - card_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR 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 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 @@ -114,7 +228,17 @@ CREATE POLICY card_assignees_tenant_isolation ON card_assignees DROP POLICY IF EXISTS card_labels_tenant_isolation ON card_labels; CREATE POLICY card_labels_tenant_isolation ON card_labels USING ( - card_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR 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 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 @@ -126,7 +250,17 @@ CREATE POLICY card_labels_tenant_isolation ON card_labels DROP POLICY IF EXISTS checklists_tenant_isolation ON checklists; CREATE POLICY checklists_tenant_isolation ON checklists USING ( - card_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR 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 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 @@ -138,7 +272,18 @@ CREATE POLICY checklists_tenant_isolation ON checklists DROP POLICY IF EXISTS checklist_items_tenant_isolation ON checklist_items; CREATE POLICY checklist_items_tenant_isolation ON checklist_items USING ( - checklist_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR checklist_id IN ( + SELECT ch.id FROM checklists ch + JOIN cards ca ON ch.card_id = ca.id + 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 checklist_id IN ( SELECT ch.id FROM checklists ch JOIN cards ca ON ch.card_id = ca.id JOIN columns c ON ca.column_id = c.id @@ -151,7 +296,17 @@ CREATE POLICY checklist_items_tenant_isolation ON checklist_items DROP POLICY IF EXISTS comments_tenant_isolation ON comments; CREATE POLICY comments_tenant_isolation ON comments USING ( - card_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR 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 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 @@ -163,7 +318,17 @@ CREATE POLICY comments_tenant_isolation ON comments DROP POLICY IF EXISTS attachments_tenant_isolation ON attachments; CREATE POLICY attachments_tenant_isolation ON attachments USING ( - card_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR 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 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 @@ -171,17 +336,22 @@ CREATE POLICY attachments_tenant_isolation ON attachments ) ); --- ─── User-scoped tables ───────────────────────────────── +-- ─── User-scoped tables ─────────────────────────────── -- Notifications DROP POLICY IF EXISTS notifications_tenant_isolation ON notifications; CREATE POLICY notifications_tenant_isolation ON notifications USING ( - user_id IN ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR user_id IN ( + SELECT id FROM users + WHERE tenant_id::text = current_setting('app.current_tenant_id', true) + ) + ) + WITH CHECK ( + current_setting('app.current_tenant_id', true) = '__bypass__' + OR user_id IN ( SELECT id FROM users WHERE tenant_id::text = current_setting('app.current_tenant_id', true) ) ); - --- Sessions (no RLS - cross-tenant by design for session validation) --- Sessions are validated in application code with tenant check diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 125a36b..0740118 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,5 +1,6 @@ import bcrypt from 'bcrypt'; import { prisma } from './db.js'; +import { withBypassRLS } from './rls.js'; const SALT_ROUNDS = 12; const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -21,30 +22,34 @@ export async function createSession(userId: string): Promise<{ id: string; expir } export async function validateSession(sessionId: string) { - const session = await prisma.session.findUnique({ - where: { id: sessionId }, - include: { - user: { - select: { - id: true, - email: true, - name: true, - globalRole: true, - tenantRole: true, - tenantId: true + // Uses withBypassRLS because this joins to the users table (RLS-protected) + // and runs before tenant context is established in hooks.server.ts. + return withBypassRLS(async (tx) => { + const session = await tx.session.findUnique({ + where: { id: sessionId }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + globalRole: true, + tenantRole: true, + tenantId: true + } } } + }); + + if (!session) return null; + + if (session.expiresAt < new Date()) { + await tx.session.delete({ where: { id: sessionId } }); + return null; } + + return session; }); - - if (!session) return null; - - if (session.expiresAt < new Date()) { - await prisma.session.delete({ where: { id: sessionId } }); - return null; - } - - return session; } export async function deleteSession(sessionId: string) { diff --git a/src/lib/server/rls.ts b/src/lib/server/rls.ts index 8a4b79a..3c18461 100644 --- a/src/lib/server/rls.ts +++ b/src/lib/server/rls.ts @@ -1,56 +1,36 @@ -import { PrismaClient } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; +import { prisma } from './db.js'; + +type TxClient = Prisma.TransactionClient; /** - * Prisma client extension for Row-Level Security. + * Executes a callback within an interactive Prisma transaction + * with the RLS tenant context set to the given tenantId. * - * Usage: - * const db = forTenant(tenantId); - * const boards = await db.board.findMany(); // automatically filtered - * - * const db = bypassRLS(); - * const allBoards = await db.board.findMany(); // no RLS + * All queries inside the callback are scoped to that tenant + * by PostgreSQL RLS policies. */ - -const basePrisma = new PrismaClient(); - -/** - * Returns a Prisma client that sets the tenant context for RLS policies. - * All queries through this client will be filtered to the specified tenant. - */ -export function forTenant(tenantId: string): PrismaClient { - return basePrisma.$extends({ - query: { - $allOperations({ args, query }) { - return basePrisma.$transaction(async (tx) => { - await tx.$executeRawUnsafe( - `SET LOCAL app.current_tenant_id = '${tenantId}'` - ); - return query(args); - }); - } - } - }) as unknown as PrismaClient; +export async function withTenant( + tenantId: string, + callback: (tx: TxClient) => Promise +): Promise { + return prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`; + return callback(tx); + }); } /** - * Returns a Prisma client that bypasses RLS. - * Use for Site Admin operations that need cross-tenant access. + * Executes a callback that bypasses RLS. + * Uses the '__bypass__' sentinel recognized by RLS policies. + * + * Use for cross-tenant operations like session validation. */ -export function bypassRLS(): PrismaClient { - return basePrisma.$extends({ - query: { - $allOperations({ args, query }) { - return basePrisma.$transaction(async (tx) => { - await tx.$executeRawUnsafe( - `SET LOCAL app.current_tenant_id = ''` - ); - // With RLS, setting an empty tenant_id effectively shows nothing. - // For bypass, we need to use a superuser or disable RLS. - // In practice, the app Prisma client (db.ts) doesn't enable RLS - // on the connection, so this is used when RLS is fully enabled. - return query(args); - }); - } - } - }) as unknown as PrismaClient; +export async function withBypassRLS( + callback: (tx: TxClient) => Promise +): Promise { + return prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${'__bypass__'}, true)`; + return callback(tx); + }); } diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 94edd15..0ffd4ec 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -1,8 +1,8 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { verifyPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js'; import { loginSchema } from '$lib/server/validation.js'; +import { withTenant } from '$lib/server/rls.js'; export const POST: RequestHandler = async ({ request, locals, cookies }) => { const tenant = locals.tenant; @@ -16,13 +16,17 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { const { email, password } = result.data; - const user = await prisma.user.findUnique({ - where: { tenantId_email: { tenantId: tenant.id, email } } + const user = await withTenant(tenant.id, async (tx) => { + return tx.user.findUnique({ + where: { tenantId_email: { tenantId: tenant.id, email } } + }); }); + if (!user) { return json({ error: 'Invalid email or password' }, { status: 401 }); } + // Verify password outside transaction const valid = await verifyPassword(password, user.passwordHash); if (!valid) { return json({ error: 'Invalid email or password' }, { status: 401 }); diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index 293ec8e..3f816d4 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -1,8 +1,8 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { hashPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js'; import { registerSchema } from '$lib/server/validation.js'; +import { withTenant } from '$lib/server/rls.js'; export const POST: RequestHandler = async ({ request, locals, cookies }) => { const tenant = locals.tenant; @@ -16,24 +16,32 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { const { email, name, password } = result.data; - // Check if user already exists in this tenant - const existing = await prisma.user.findUnique({ - where: { tenantId_email: { tenantId: tenant.id, email } } + // Hash password outside transaction to avoid holding DB connection during bcrypt + const passwordHash = await hashPassword(password); + + const user = await withTenant(tenant.id, async (tx) => { + // Check if user already exists in this tenant + const existing = await tx.user.findUnique({ + where: { tenantId_email: { tenantId: tenant.id, email } } + }); + if (existing) { + return null; + } + + return tx.user.create({ + data: { + tenantId: tenant.id, + email, + name, + passwordHash + } + }); }); - if (existing) { + + if (!user) { return json({ error: 'An account with this email already exists' }, { status: 409 }); } - const passwordHash = await hashPassword(password); - const user = await prisma.user.create({ - data: { - tenantId: tenant.id, - email, - name, - passwordHash - } - }); - const session = await createSession(user.id); cookies.set(sessionCookieName(), session.id, sessionCookieOptions(session.expiresAt)); diff --git a/src/routes/api/boards/+server.ts b/src/routes/api/boards/+server.ts index 0cab7f0..4373e86 100644 --- a/src/routes/api/boards/+server.ts +++ b/src/routes/api/boards/+server.ts @@ -1,36 +1,37 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { boardSchema } from '$lib/server/validation.js'; +import { withTenant } from '$lib/server/rls.js'; // List boards for current tenant export const GET: RequestHandler = async ({ locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); - const where: Record = { tenantId: tenant.id, archived: false }; + const boards = await withTenant(tenant.id, async (tx) => { + const where: Record = { tenantId: tenant.id, archived: false }; - // If not logged in, only show public boards - if (!locals.user) { - where.visibility = 'PUBLIC'; - } else { - // Show boards user is a member of + public boards - where.OR = [ - { visibility: 'PUBLIC' }, - { members: { some: { userId: locals.user.id } } } - ]; - delete where.visibility; - } + // If not logged in, only show public boards + if (!locals.user) { + where.visibility = 'PUBLIC'; + } else { + // Show boards user is a member of + public boards + where.OR = [ + { visibility: 'PUBLIC' }, + { members: { some: { userId: locals.user.id } } } + ]; + } - const boards = await prisma.board.findMany({ - where, - include: { - members: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } + return tx.board.findMany({ + where, + include: { + members: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + _count: { select: { columns: true } } }, - _count: { select: { columns: true } } - }, - orderBy: { updatedAt: 'desc' } + orderBy: { updatedAt: 'desc' } + }); }); return json({ boards }); @@ -50,24 +51,26 @@ export const POST: RequestHandler = async ({ request, locals }) => { const { title, description, visibility } = result.data; - const board = await prisma.board.create({ - data: { - tenantId: tenant.id, - title, - description, - visibility, - members: { - create: { - userId: locals.user.id, - role: 'OWNER' + const board = await withTenant(tenant.id, async (tx) => { + return tx.board.create({ + data: { + tenantId: tenant.id, + title, + description, + visibility, + members: { + create: { + userId: locals.user!.id, + role: 'OWNER' + } + } + }, + include: { + members: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } } } - }, - include: { - members: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } - } - } + }); }); return json({ board }, { status: 201 }); diff --git a/src/routes/api/boards/[boardId]/+server.ts b/src/routes/api/boards/[boardId]/+server.ts index 1d5efad..a09d66d 100644 --- a/src/routes/api/boards/[boardId]/+server.ts +++ b/src/routes/api/boards/[boardId]/+server.ts @@ -1,36 +1,38 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { boardSchema } from '$lib/server/validation.js'; +import { withTenant } from '$lib/server/rls.js'; // Get single board with all columns and cards export const GET: RequestHandler = async ({ params, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); - const board = await prisma.board.findFirst({ - where: { id: params.boardId, tenantId: tenant.id }, - include: { - columns: { - orderBy: { position: 'asc' }, - include: { - cards: { - where: { archived: false }, - orderBy: { position: 'asc' }, - include: { - assignees: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } - }, - labels: { include: { tag: true } }, - _count: { select: { comments: true, checklists: true, attachments: true } } + const board = await withTenant(tenant.id, async (tx) => { + return tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { + columns: { + orderBy: { position: 'asc' }, + include: { + cards: { + where: { archived: false }, + orderBy: { position: 'asc' }, + include: { + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + _count: { select: { comments: true, checklists: true, attachments: true } } + } } } + }, + members: { + include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } } - }, - members: { - include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } } - } + }); }); if (!board) throw error(404, 'Board not found'); @@ -53,27 +55,29 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const board = await prisma.board.findFirst({ - where: { id: params.boardId, tenantId: tenant.id }, - include: { members: true } - }); - if (!board) throw error(404, 'Board not found'); - - const member = board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = - locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (!member && !isAdmin) throw error(403, 'Access denied'); - if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit boards'); - const body = await request.json(); const result = boardSchema.partial().safeParse(body); if (!result.success) { return json({ error: result.error.issues[0].message }, { status: 400 }); } - const updated = await prisma.board.update({ - where: { id: params.boardId }, - data: result.data + const updated = await withTenant(tenant.id, async (tx) => { + const board = await tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { members: true } + }); + if (!board) throw error(404, 'Board not found'); + + const member = board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = + locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (!member && !isAdmin) throw error(403, 'Access denied'); + if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit boards'); + + return tx.board.update({ + where: { id: params.boardId }, + data: result.data + }); }); return json({ board: updated }); @@ -85,18 +89,20 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const board = await prisma.board.findFirst({ - where: { id: params.boardId, tenantId: tenant.id }, - include: { members: true } + await withTenant(tenant.id, async (tx) => { + const board = await tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { members: true } + }); + if (!board) throw error(404, 'Board not found'); + + const member = board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = + locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (member?.role !== 'OWNER' && !isAdmin) throw error(403, 'Only owners can delete boards'); + + await tx.board.delete({ where: { id: params.boardId } }); }); - if (!board) throw error(404, 'Board not found'); - - const member = board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = - locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (member?.role !== 'OWNER' && !isAdmin) throw error(403, 'Only owners can delete boards'); - - await prisma.board.delete({ where: { id: params.boardId } }); return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/+server.ts b/src/routes/api/boards/[boardId]/columns/+server.ts index 8c06b2f..84112aa 100644 --- a/src/routes/api/boards/[boardId]/columns/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/+server.ts @@ -1,8 +1,8 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { columnSchema } from '$lib/server/validation.js'; import { generatePosition } from '$lib/utils/fractional-index.js'; +import { withTenant } from '$lib/server/rls.js'; // Create column export const POST: RequestHandler = async ({ params, request, locals }) => { @@ -10,40 +10,42 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const board = await prisma.board.findFirst({ - where: { id: params.boardId, tenantId: tenant.id }, - include: { members: true } - }); - if (!board) throw error(404, 'Board not found'); - - const member = board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (!member && !isAdmin) throw error(403, 'Access denied'); - if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot add columns'); - const body = await request.json(); const result = columnSchema.safeParse(body); if (!result.success) { return json({ error: result.error.issues[0].message }, { status: 400 }); } - // Get the last column position - const lastColumn = await prisma.column.findFirst({ - where: { boardId: params.boardId }, - orderBy: { position: 'desc' } - }); + const column = await withTenant(tenant.id, async (tx) => { + const board = await tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { members: true } + }); + if (!board) throw error(404, 'Board not found'); - const position = generatePosition(lastColumn?.position, null); + const member = board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (!member && !isAdmin) throw error(403, 'Access denied'); + if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot add columns'); - const column = await prisma.column.create({ - data: { - boardId: params.boardId, - title: result.data.title, - position - }, - include: { - cards: true - } + // Get the last column position + const lastColumn = await tx.column.findFirst({ + where: { boardId: params.boardId }, + orderBy: { position: 'desc' } + }); + + const position = generatePosition(lastColumn?.position, null); + + return tx.column.create({ + data: { + boardId: params.boardId, + title: result.data.title, + position + }, + include: { + cards: true + } + }); }); return json({ column }, { status: 201 }); diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/+server.ts index 310a5f9..ad752f7 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/+server.ts @@ -1,7 +1,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { columnSchema, moveColumnSchema } from '$lib/server/validation.js'; +import { withTenant } from '$lib/server/rls.js'; // Update column title export const PATCH: RequestHandler = async ({ params, request, locals }) => { @@ -9,40 +9,41 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const column = await prisma.column.findFirst({ - where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }, - include: { board: { include: { members: true } } } - }); - if (!column) throw error(404, 'Column not found'); - - const member = column.board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (!member && !isAdmin) throw error(403, 'Access denied'); - if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit columns'); - const body = await request.json(); - // Allow both title update and position update - if (body.position !== undefined) { - const result = moveColumnSchema.safeParse(body); - if (!result.success) { - return json({ error: result.error.issues[0].message }, { status: 400 }); - } - const updated = await prisma.column.update({ - where: { id: params.columnId }, - data: { position: result.data.position } + const updated = await withTenant(tenant.id, async (tx) => { + const column = await tx.column.findFirst({ + where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }, + include: { board: { include: { members: true } } } }); - return json({ column: updated }); - } + if (!column) throw error(404, 'Column not found'); - const result = columnSchema.safeParse(body); - if (!result.success) { - return json({ error: result.error.issues[0].message }, { status: 400 }); - } + const member = column.board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (!member && !isAdmin) throw error(403, 'Access denied'); + if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit columns'); - const updated = await prisma.column.update({ - where: { id: params.columnId }, - data: { title: result.data.title } + // Allow both title update and position update + if (body.position !== undefined) { + const result = moveColumnSchema.safeParse(body); + if (!result.success) { + throw error(400, result.error.issues[0].message); + } + return tx.column.update({ + where: { id: params.columnId }, + data: { position: result.data.position } + }); + } + + const result = columnSchema.safeParse(body); + if (!result.success) { + throw error(400, result.error.issues[0].message); + } + + return tx.column.update({ + where: { id: params.columnId }, + data: { title: result.data.title } + }); }); return json({ column: updated }); @@ -54,18 +55,20 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const column = await prisma.column.findFirst({ - where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }, - include: { board: { include: { members: true } } } + await withTenant(tenant.id, async (tx) => { + const column = await tx.column.findFirst({ + where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }, + include: { board: { include: { members: true } } } + }); + if (!column) throw error(404, 'Column not found'); + + const member = column.board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (member?.role === 'VIEWER' && !isAdmin) throw error(403, 'Viewers cannot delete columns'); + if (!member && !isAdmin) throw error(403, 'Access denied'); + + await tx.column.delete({ where: { id: params.columnId } }); }); - if (!column) throw error(404, 'Column not found'); - - const member = column.board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (member?.role === 'VIEWER' && !isAdmin) throw error(403, 'Viewers cannot delete columns'); - if (!member && !isAdmin) throw error(403, 'Access denied'); - - await prisma.column.delete({ where: { id: params.columnId } }); return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts index b81f902..14114f4 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts @@ -1,8 +1,8 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { cardSchema } from '$lib/server/validation.js'; import { generatePosition } from '$lib/utils/fractional-index.js'; +import { withTenant } from '$lib/server/rls.js'; // Create card in column export const POST: RequestHandler = async ({ params, request, locals }) => { @@ -10,45 +10,47 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const column = await prisma.column.findFirst({ - where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }, - include: { board: { include: { members: true } } } - }); - if (!column) throw error(404, 'Column not found'); - - const member = column.board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (!member && !isAdmin) throw error(403, 'Access denied'); - if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot add cards'); - const body = await request.json(); const result = cardSchema.safeParse(body); if (!result.success) { return json({ error: result.error.issues[0].message }, { status: 400 }); } - // Get the last card position in this column - const lastCard = await prisma.card.findFirst({ - where: { columnId: params.columnId }, - orderBy: { position: 'desc' } - }); + const card = await withTenant(tenant.id, async (tx) => { + const column = await tx.column.findFirst({ + where: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }, + include: { board: { include: { members: true } } } + }); + if (!column) throw error(404, 'Column not found'); - const position = generatePosition(lastCard?.position, null); + const member = column.board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (!member && !isAdmin) throw error(403, 'Access denied'); + if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot add cards'); - const card = await prisma.card.create({ - data: { - columnId: params.columnId, - title: result.data.title, - description: result.data.description, - position - }, - include: { - assignees: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } + // Get the last card position in this column + const lastCard = await tx.card.findFirst({ + where: { columnId: params.columnId }, + orderBy: { position: 'desc' } + }); + + const position = generatePosition(lastCard?.position, null); + + return tx.card.create({ + data: { + columnId: params.columnId, + title: result.data.title, + description: result.data.description, + position }, - labels: { include: { tag: true } }, - _count: { select: { comments: true, checklists: true, attachments: true } } - } + include: { + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + _count: { select: { comments: true, checklists: true, attachments: true } } + } + }); }); return json({ card }, { status: 201 }); 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 c513f2a..ae7716f 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 @@ -1,34 +1,36 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { prisma } from '$lib/server/db.js'; import { cardSchema, moveCardSchema } from '$lib/server/validation.js'; +import { withTenant } from '$lib/server/rls.js'; // Get single card with full details export const GET: RequestHandler = async ({ params, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); - const card = await prisma.card.findFirst({ - where: { - id: params.cardId, - column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } } - }, - include: { - assignees: { - include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } + const card = await withTenant(tenant.id, async (tx) => { + return tx.card.findFirst({ + where: { + id: params.cardId, + column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } } }, - labels: { include: { tag: true } }, - checklists: { - orderBy: { position: 'asc' }, - include: { items: { orderBy: { position: 'asc' } } } - }, - comments: { - orderBy: { createdAt: 'desc' }, - include: { user: { select: { id: true, name: true, avatarUrl: true } } } - }, - attachments: { orderBy: { createdAt: 'desc' } }, - column: { select: { id: true, title: true } } - } + include: { + assignees: { + include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + checklists: { + orderBy: { position: 'asc' }, + include: { items: { orderBy: { position: 'asc' } } } + }, + comments: { + orderBy: { createdAt: 'desc' }, + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + attachments: { orderBy: { createdAt: 'desc' } }, + column: { select: { id: true, title: true } } + } + }); }); if (!card) throw error(404, 'Card not found'); @@ -42,31 +44,55 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const card = await prisma.card.findFirst({ - where: { - id: params.cardId, - column: { board: { id: params.boardId, tenantId: tenant.id } } - }, - include: { column: { include: { board: { include: { members: true } } } } } - }); - if (!card) throw error(404, 'Card not found'); - - const member = card.column.board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (!member && !isAdmin) throw error(403, 'Access denied'); - if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit cards'); - const body = await request.json(); - // Handle move (column change + position) - if (body.columnId !== undefined || body.position !== undefined) { - const data: Record = {}; - if (body.columnId) data.columnId = body.columnId; - if (body.position) data.position = body.position; + const updated = await withTenant(tenant.id, async (tx) => { + const card = await tx.card.findFirst({ + where: { + id: params.cardId, + column: { board: { id: params.boardId, tenantId: tenant.id } } + }, + include: { column: { include: { board: { include: { members: true } } } } } + }); + if (!card) throw error(404, 'Card not found'); - const updated = await prisma.card.update({ + const member = card.column.board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (!member && !isAdmin) throw error(403, 'Access denied'); + if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot edit cards'); + + // Handle move (column change + position) + if (body.columnId !== undefined || body.position !== undefined) { + const data: Record = {}; + if (body.columnId) data.columnId = body.columnId; + if (body.position) data.position = body.position; + + return tx.card.update({ + where: { id: params.cardId }, + data, + include: { + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + _count: { select: { comments: true, checklists: true, attachments: true } } + } + }); + } + + // Handle content update + const result = cardSchema.partial().safeParse(body); + if (!result.success) { + throw error(400, result.error.issues[0].message); + } + + const updateData: Record = { ...result.data }; + if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null; + if (body.archived !== undefined) updateData.archived = body.archived; + + return tx.card.update({ where: { id: params.cardId }, - data, + data: updateData, include: { assignees: { include: { user: { select: { id: true, name: true, avatarUrl: true } } } @@ -75,29 +101,6 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { _count: { select: { comments: true, checklists: true, attachments: true } } } }); - return json({ card: updated }); - } - - // Handle content update - const result = cardSchema.partial().safeParse(body); - if (!result.success) { - return json({ error: result.error.issues[0].message }, { status: 400 }); - } - - const updateData: Record = { ...result.data }; - if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null; - if (body.archived !== undefined) updateData.archived = body.archived; - - const updated = await prisma.card.update({ - where: { id: params.cardId }, - data: updateData, - include: { - assignees: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } - }, - labels: { include: { tag: true } }, - _count: { select: { comments: true, checklists: true, attachments: true } } - } }); return json({ card: updated }); @@ -109,21 +112,23 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); if (!locals.user) throw error(401, 'Not authenticated'); - const card = await prisma.card.findFirst({ - where: { - id: params.cardId, - column: { board: { id: params.boardId, tenantId: tenant.id } } - }, - include: { column: { include: { board: { include: { members: true } } } } } + await withTenant(tenant.id, async (tx) => { + const card = await tx.card.findFirst({ + where: { + id: params.cardId, + column: { board: { id: params.boardId, tenantId: tenant.id } } + }, + include: { column: { include: { board: { include: { members: true } } } } } + }); + if (!card) throw error(404, 'Card not found'); + + const member = card.column.board.members.find((m) => m.userId === locals.user!.id); + const isAdmin = locals.user!.globalRole === 'SITE_ADMIN' || locals.user!.tenantRole === 'ADMIN'; + if (!member && !isAdmin) throw error(403, 'Access denied'); + if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot delete cards'); + + await tx.card.delete({ where: { id: params.cardId } }); }); - if (!card) throw error(404, 'Card not found'); - - const member = card.column.board.members.find((m) => m.userId === locals.user!.id); - const isAdmin = locals.user.globalRole === 'SITE_ADMIN' || locals.user.tenantRole === 'ADMIN'; - if (!member && !isAdmin) throw error(403, 'Access denied'); - if (member && member.role === 'VIEWER') throw error(403, 'Viewers cannot delete cards'); - - await prisma.card.delete({ where: { id: params.cardId } }); return json({ ok: true }); }; diff --git a/src/routes/boards/+page.server.ts b/src/routes/boards/+page.server.ts index 33d4d25..6031db5 100644 --- a/src/routes/boards/+page.server.ts +++ b/src/routes/boards/+page.server.ts @@ -1,30 +1,32 @@ import type { PageServerLoad } from './$types.js'; -import { prisma } from '$lib/server/db.js'; +import { withTenant } from '$lib/server/rls.js'; export const load: PageServerLoad = async ({ locals }) => { const tenant = locals.tenant; if (!tenant) return { boards: [] }; - const where: any = { tenantId: tenant.id, archived: false }; + const boards = await withTenant(tenant.id, async (tx) => { + const where: any = { tenantId: tenant.id, archived: false }; - if (!locals.user) { - where.visibility = 'PUBLIC'; - } else { - where.OR = [ - { visibility: 'PUBLIC' }, - { members: { some: { userId: locals.user.id } } } - ]; - } + if (!locals.user) { + where.visibility = 'PUBLIC'; + } else { + where.OR = [ + { visibility: 'PUBLIC' }, + { members: { some: { userId: locals.user.id } } } + ]; + } - const boards = await prisma.board.findMany({ - where, - include: { - members: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } + return tx.board.findMany({ + where, + include: { + members: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + _count: { select: { columns: true } } }, - _count: { select: { columns: true } } - }, - orderBy: { updatedAt: 'desc' } + orderBy: { updatedAt: 'desc' } + }); }); return { boards }; diff --git a/src/routes/boards/[boardId]/+page.server.ts b/src/routes/boards/[boardId]/+page.server.ts index 2d29cde..f639d85 100644 --- a/src/routes/boards/[boardId]/+page.server.ts +++ b/src/routes/boards/[boardId]/+page.server.ts @@ -1,34 +1,36 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types.js'; -import { prisma } from '$lib/server/db.js'; +import { withTenant } from '$lib/server/rls.js'; export const load: PageServerLoad = async ({ params, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); - const board = await prisma.board.findFirst({ - where: { id: params.boardId, tenantId: tenant.id }, - include: { - columns: { - orderBy: { position: 'asc' }, - include: { - cards: { - where: { archived: false }, - orderBy: { position: 'asc' }, - include: { - assignees: { - include: { user: { select: { id: true, name: true, avatarUrl: true } } } - }, - labels: { include: { tag: true } }, - _count: { select: { comments: true, checklists: true, attachments: true } } + const board = await withTenant(tenant.id, async (tx) => { + return tx.board.findFirst({ + where: { id: params.boardId, tenantId: tenant.id }, + include: { + columns: { + orderBy: { position: 'asc' }, + include: { + cards: { + where: { archived: false }, + orderBy: { position: 'asc' }, + include: { + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + }, + labels: { include: { tag: true } }, + _count: { select: { comments: true, checklists: true, attachments: true } } + } } } + }, + members: { + include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } } - }, - members: { - include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } } } - } + }); }); if (!board) throw error(404, 'Board not found');