diff --git a/server.js b/server.js index de9d740..29a27e5 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,7 @@ import express from 'express'; import { createServer } from 'http'; import { Server } from 'socket.io'; +import { PrismaClient } from '@prisma/client'; import { handler } from './build/handler.js'; const app = express(); @@ -13,26 +14,130 @@ const io = new Server(server, { } }); -// Socket.IO auth + room management +// ─── Prisma client for session validation ─────────────────── +const prisma = new PrismaClient(); + +// ─── Presence tracking ────────────────────────────────────── +// Map> +const presence = new Map(); + +function getPresenceList(boardId) { + const room = presence.get(boardId); + if (!room) return []; + return Array.from(room.values()); +} + +// ─── Cookie parser ────────────────────────────────────────── +function parseCookies(cookieHeader) { + const cookies = {}; + if (!cookieHeader) return cookies; + cookieHeader.split(';').forEach((cookie) => { + const [name, ...rest] = cookie.trim().split('='); + cookies[name] = decodeURIComponent(rest.join('=')); + }); + return cookies; +} + +// ─── Socket.IO auth middleware ─────────────────────────────── +io.use(async (socket, next) => { + try { + const cookies = parseCookies(socket.handshake.headers.cookie); + const sessionId = cookies['session']; + if (!sessionId) { + socket.data.user = null; + return next(); + } + + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + include: { + user: { + select: { id: true, name: true, avatarUrl: true, tenantId: true } + } + } + }); + + if (session && session.expiresAt > new Date()) { + socket.data.user = session.user; + } else { + socket.data.user = null; + } + next(); + } catch (err) { + console.error('Socket auth error:', err); + socket.data.user = null; + next(); + } +}); + +// ─── Socket.IO connection handler ──────────────────────────── io.on('connection', (socket) => { - console.log(`Socket connected: ${socket.id}`); + const user = socket.data.user; + console.log(`Socket connected: ${socket.id} (user: ${user?.name ?? 'anon'})`); + + // Track which boards this socket has joined (for cleanup on disconnect) + const joinedBoards = new Set(); socket.on('join-board', (boardId) => { socket.join(`board:${boardId}`); - socket.to(`board:${boardId}`).emit('user-joined', { socketId: socket.id }); + joinedBoards.add(boardId); + + if (user) { + if (!presence.has(boardId)) presence.set(boardId, new Map()); + presence.get(boardId).set(socket.id, { id: user.id, name: user.name, avatarUrl: user.avatarUrl }); + } + + // Notify others + socket.to(`board:${boardId}`).emit('user-joined', { + user: user ? { id: user.id, name: user.name, avatarUrl: user.avatarUrl } : null, + socketId: socket.id + }); + + // Send full presence list to everyone in the room (including the joiner) + io.to(`board:${boardId}`).emit('presence:update', getPresenceList(boardId)); }); socket.on('leave-board', (boardId) => { socket.leave(`board:${boardId}`); - socket.to(`board:${boardId}`).emit('user-left', { socketId: socket.id }); + joinedBoards.delete(boardId); + + if (presence.has(boardId)) { + presence.get(boardId).delete(socket.id); + if (presence.get(boardId).size === 0) presence.delete(boardId); + } + + socket.to(`board:${boardId}`).emit('user-left', { + user: user ? { id: user.id, name: user.name } : null, + socketId: socket.id + }); + + // Update presence for remaining + const room = presence.get(boardId); + io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []); }); socket.on('disconnect', () => { console.log(`Socket disconnected: ${socket.id}`); + for (const boardId of joinedBoards) { + if (presence.has(boardId)) { + presence.get(boardId).delete(socket.id); + if (presence.get(boardId).size === 0) { + presence.delete(boardId); + } + } + + socket.to(`board:${boardId}`).emit('user-left', { + user: user ? { id: user.id, name: user.name } : null, + socketId: socket.id + }); + + const room = presence.get(boardId); + io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []); + } }); }); -// Store io globally so it can be accessed if needed +// Store io globally so SvelteKit API routes can broadcast via realtime.ts globalThis.__socketIO = io; // SvelteKit handler diff --git a/src/lib/server/realtime.ts b/src/lib/server/realtime.ts new file mode 100644 index 0000000..2c96972 --- /dev/null +++ b/src/lib/server/realtime.ts @@ -0,0 +1,14 @@ +declare global { + // eslint-disable-next-line no-var + var __socketIO: import('socket.io').Server | undefined; +} + +/** + * Broadcast an event to all clients in a board room. + * No-op when Socket.IO is not available (dev mode / vite dev server). + */ +export function broadcast(boardId: string, event: string, data: Record = {}) { + const io = globalThis.__socketIO; + if (!io) return; + io.to(`board:${boardId}`).emit(event, data); +} diff --git a/src/lib/stores/socket.ts b/src/lib/stores/socket.ts index fea6e01..3502d4c 100644 --- a/src/lib/stores/socket.ts +++ b/src/lib/stores/socket.ts @@ -15,6 +15,10 @@ export function getSocket(): Socket { return socket; } +export function getSocketId(): string | null { + return socket?.id ?? null; +} + export function connectSocket() { const s = getSocket(); if (!s.connected) { @@ -37,3 +41,13 @@ export function leaveBoard(boardId: string) { const s = getSocket(); s.emit('leave-board', boardId); } + +export function onBoardEvent(event: string, handler: (...args: any[]) => void) { + const s = getSocket(); + s.on(event, handler); +} + +export function offBoardEvent(event: string, handler: (...args: any[]) => void) { + const s = getSocket(); + s.off(event, handler); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a8b2879..a9091af 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,8 +1,17 @@
diff --git a/src/routes/api/attachments/[attachmentId]/+server.ts b/src/routes/api/attachments/[attachmentId]/+server.ts index 07d028a..87f9f71 100644 --- a/src/routes/api/attachments/[attachmentId]/+server.ts +++ b/src/routes/api/attachments/[attachmentId]/+server.ts @@ -5,6 +5,7 @@ import { requireAuth } from '$lib/server/guards.js'; import { canViewBoard } from '$lib/server/permissions.js'; import { readFile, unlink } from 'fs/promises'; import { json } from '@sveltejs/kit'; +import { broadcast } from '$lib/server/realtime.js'; export const GET: RequestHandler = async ({ params, locals }) => { const tenant = locals.tenant; @@ -48,12 +49,12 @@ export const GET: RequestHandler = async ({ params, locals }) => { }); }; -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); requireAuth(locals.user); - await withTenant(tenant.id, async (tx) => { + const deleted = await withTenant(tenant.id, async (tx) => { const att = await tx.attachment.findFirst({ where: { id: params.attachmentId }, include: { @@ -61,7 +62,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { include: { column: { include: { - board: { select: { tenantId: true, members: true } } + board: { select: { id: true, tenantId: true, members: true } } } } } @@ -84,7 +85,12 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { } catch { // File may already be gone } + + return { boardId: att.card.column.board.id, cardId: att.cardId, columnId: att.card.columnId }; }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(deleted.boardId, 'card:updated', { cardId: deleted.cardId, columnId: deleted.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/+server.ts b/src/routes/api/boards/[boardId]/+server.ts index fa8c904..40c4628 100644 --- a/src/routes/api/boards/[boardId]/+server.ts +++ b/src/routes/api/boards/[boardId]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { boardSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireBoardView, requireBoardEditor, requireBoardOwner } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; // Get single board with all columns and cards export const GET: RequestHandler = async ({ params, locals }) => { @@ -36,11 +37,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'board:updated', { board: updated, socketId }); + return json({ board: updated }); }; // Delete board -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -49,5 +53,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.board.delete({ where: { id: params.boardId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'board:deleted', { boardId: params.boardId, socketId }); + 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 9dadbca..8c92cf0 100644 --- a/src/routes/api/boards/[boardId]/columns/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/+server.ts @@ -4,6 +4,7 @@ import { columnSchema } from '$lib/server/validation.js'; import { generatePosition } from '$lib/utils/fractional-index.js'; import { withTenant } from '$lib/server/rls.js'; import { requireBoardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; // Create column export const POST: RequestHandler = async ({ params, request, locals }) => { @@ -39,5 +40,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'column:created', { column, socketId }); + 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 6e977ba..b9ed201 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { columnSchema, moveColumnSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireColumnEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; // Update column title or position export const PATCH: RequestHandler = async ({ params, request, locals }) => { @@ -37,11 +38,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'column:updated', { column: updated, socketId }); + return json({ column: updated }); }; // Delete column -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -50,5 +54,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.column.delete({ where: { id: params.columnId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'column:deleted', { columnId: params.columnId, socketId }); + 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 b00cf73..fe08741 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/+server.ts @@ -4,6 +4,7 @@ import { cardSchema } from '$lib/server/validation.js'; import { generatePosition } from '$lib/utils/fractional-index.js'; import { withTenant } from '$lib/server/rls.js'; import { requireColumnEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; // Create card in column export const POST: RequestHandler = async ({ params, request, locals }) => { @@ -44,5 +45,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, socketId }); + 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 93b949d..4a753fa 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 @@ -4,6 +4,7 @@ import { cardSchema, moveCardSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireBoardView, requireCardEditor } from '$lib/server/guards.js'; import { canViewBoard } from '$lib/server/permissions.js'; +import { broadcast } from '$lib/server/realtime.js'; // Get single card with full details export const GET: RequestHandler = async ({ params, locals }) => { @@ -110,11 +111,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { card: updated, cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ card: updated }); }; // Delete card -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -123,5 +127,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.card.delete({ where: { id: params.cardId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:deleted', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts index 397ac34..06ffea5 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { cardAssigneeSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; export const POST: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -27,6 +28,9 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ assignee }, { status: 201 }); }; @@ -49,5 +53,8 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => { await tx.cardAssignee.delete({ where: { id: assignee.id } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts index 0b1c560..06b7b30 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts @@ -5,6 +5,7 @@ import { requireCardEditor } from '$lib/server/guards.js'; import { writeFile, mkdir } from 'fs/promises'; import { join } from 'path'; import { randomUUID } from 'crypto'; +import { broadcast } from '$lib/server/realtime.js'; const MAX_SIZE = 10 * 1024 * 1024; // 10MB const UPLOAD_ROOT = '/app/uploads'; @@ -42,5 +43,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ attachment }, { status: 201 }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts index d582002..f40ab3e 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts @@ -4,6 +4,7 @@ import { checklistSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; import { generatePosition } from '$lib/utils/fractional-index.js'; +import { broadcast } from '$lib/server/realtime.js'; export const POST: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -33,5 +34,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ checklist }, { status: 201 }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts index 44cde83..c699a8d 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { checklistSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; export const PATCH: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -27,10 +28,13 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ checklist }); }; -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -45,5 +49,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.checklist.delete({ where: { id: params.checklistId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts index cb7e878..ca8a214 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts @@ -4,6 +4,7 @@ import { checklistItemSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; import { generatePosition } from '$lib/utils/fractional-index.js'; +import { broadcast } from '$lib/server/realtime.js'; export const POST: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -37,5 +38,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ item }, { status: 201 }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts index 7013c37..4acd3f1 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts @@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; export const PATCH: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -31,10 +32,13 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ item }); }; -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -52,5 +56,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.checklistItem.delete({ where: { id: params.itemId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts index 1afdf05..4f424da 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { commentSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; export const POST: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -27,5 +28,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ comment }, { status: 201 }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts index 1d65972..a3a8810 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { commentSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor, requireAuth } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; export const PATCH: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -33,10 +34,13 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ comment }); }; -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -60,5 +64,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.comment.delete({ where: { id: params.commentId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts index 3cd581b..1288ce6 100644 --- a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts +++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { cardLabelSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireCardEditor } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; export const POST: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; @@ -26,6 +27,9 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ label }, { status: 201 }); }; @@ -48,5 +52,8 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => { await tx.cardLabel.delete({ where: { id: label.id } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/api/boards/[boardId]/members/+server.ts b/src/routes/api/boards/[boardId]/members/+server.ts index 8c421af..3f00274 100644 --- a/src/routes/api/boards/[boardId]/members/+server.ts +++ b/src/routes/api/boards/[boardId]/members/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { addMemberSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireBoardMemberManager } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; // Add member by email export const POST: RequestHandler = async ({ params, request, locals }) => { @@ -44,5 +45,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'member:added', { member, socketId }); + return json({ member }, { status: 201 }); }; diff --git a/src/routes/api/boards/[boardId]/members/[memberId]/+server.ts b/src/routes/api/boards/[boardId]/members/[memberId]/+server.ts index b7c28e3..940b782 100644 --- a/src/routes/api/boards/[boardId]/members/[memberId]/+server.ts +++ b/src/routes/api/boards/[boardId]/members/[memberId]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js'; import { changeMemberRoleSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireBoardMemberManager } from '$lib/server/guards.js'; +import { broadcast } from '$lib/server/realtime.js'; // Change member role export const PATCH: RequestHandler = async ({ params, request, locals }) => { @@ -40,11 +41,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => { }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'member:updated', { member: updated, socketId }); + return json({ member: updated }); }; // Remove member -export const DELETE: RequestHandler = async ({ params, locals }) => { +export const DELETE: RequestHandler = async ({ params, request, locals }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); @@ -67,5 +71,8 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { await tx.boardMember.delete({ where: { id: params.memberId } }); }); + const socketId = request.headers.get('X-Socket-ID'); + broadcast(params.boardId, 'member:removed', { memberId: params.memberId, socketId }); + return json({ ok: true }); }; diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 8f8ed84..340b973 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -1,8 +1,10 @@