Phase 5: Real-time updates via Socket.IO
Wire Socket.IO broadcast from all write API routes so board changes propagate instantly to other connected clients. Add session-based auth on socket connections, in-memory presence tracking, and a presence UI in the board header showing who else is viewing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { handler } from './build/handler.js';
|
import { handler } from './build/handler.js';
|
||||||
|
|
||||||
const app = express();
|
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<boardId, Map<socketId, { id, name }>>
|
||||||
|
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) => {
|
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.on('join-board', (boardId) => {
|
||||||
socket.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.on('leave-board', (boardId) => {
|
||||||
socket.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', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log(`Socket disconnected: ${socket.id}`);
|
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;
|
globalThis.__socketIO = io;
|
||||||
|
|
||||||
// SvelteKit handler
|
// SvelteKit handler
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
const io = globalThis.__socketIO;
|
||||||
|
if (!io) return;
|
||||||
|
io.to(`board:${boardId}`).emit(event, data);
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ export function getSocket(): Socket {
|
|||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSocketId(): string | null {
|
||||||
|
return socket?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function connectSocket() {
|
export function connectSocket() {
|
||||||
const s = getSocket();
|
const s = getSocket();
|
||||||
if (!s.connected) {
|
if (!s.connected) {
|
||||||
@@ -37,3 +41,13 @@ export function leaveBoard(boardId: string) {
|
|||||||
const s = getSocket();
|
const s = getSocket();
|
||||||
s.emit('leave-board', boardId);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
|
||||||
|
|
||||||
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
|
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (data.user) {
|
||||||
|
connectSocket();
|
||||||
|
return () => disconnectSocket();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireAuth } from '$lib/server/guards.js';
|
|||||||
import { canViewBoard } from '$lib/server/permissions.js';
|
import { canViewBoard } from '$lib/server/permissions.js';
|
||||||
import { readFile, unlink } from 'fs/promises';
|
import { readFile, unlink } from 'fs/promises';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown tenant');
|
if (!tenant) throw error(400, 'Unknown tenant');
|
||||||
requireAuth(locals.user);
|
requireAuth(locals.user);
|
||||||
|
|
||||||
await withTenant(tenant.id, async (tx) => {
|
const deleted = await withTenant(tenant.id, async (tx) => {
|
||||||
const att = await tx.attachment.findFirst({
|
const att = await tx.attachment.findFirst({
|
||||||
where: { id: params.attachmentId },
|
where: { id: params.attachmentId },
|
||||||
include: {
|
include: {
|
||||||
@@ -61,7 +62,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
|||||||
include: {
|
include: {
|
||||||
column: {
|
column: {
|
||||||
include: {
|
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 {
|
} catch {
|
||||||
// File may already be gone
|
// 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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { boardSchema } from '$lib/server/validation.js';
|
import { boardSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireBoardView, requireBoardEditor, requireBoardOwner } from '$lib/server/guards.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
|
// Get single board with all columns and cards
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
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 });
|
return json({ board: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete board
|
// Delete board
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { columnSchema } from '$lib/server/validation.js';
|
|||||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireBoardEditor } from '$lib/server/guards.js';
|
import { requireBoardEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
// Create column
|
// Create column
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
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 });
|
return json({ column }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { columnSchema, moveColumnSchema } from '$lib/server/validation.js';
|
import { columnSchema, moveColumnSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireColumnEditor } from '$lib/server/guards.js';
|
import { requireColumnEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
// Update column title or position
|
// Update column title or position
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
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 });
|
return json({ column: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete column
|
// Delete column
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cardSchema } from '$lib/server/validation.js';
|
|||||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireColumnEditor } from '$lib/server/guards.js';
|
import { requireColumnEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
// Create card in column
|
// Create card in column
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
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 });
|
return json({ card }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cardSchema, moveCardSchema } from '$lib/server/validation.js';
|
|||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireBoardView, requireCardEditor } from '$lib/server/guards.js';
|
import { requireBoardView, requireCardEditor } from '$lib/server/guards.js';
|
||||||
import { canViewBoard } from '$lib/server/permissions.js';
|
import { canViewBoard } from '$lib/server/permissions.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
// Get single card with full details
|
// Get single card with full details
|
||||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
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 });
|
return json({ card: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete card
|
// Delete card
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { cardAssigneeSchema } from '$lib/server/validation.js';
|
import { cardAssigneeSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
+4
@@ -5,6 +5,7 @@ import { requireCardEditor } from '$lib/server/guards.js';
|
|||||||
import { writeFile, mkdir } from 'fs/promises';
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const UPLOAD_ROOT = '/app/uploads';
|
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 });
|
return json({ attachment }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
+4
@@ -4,6 +4,7 @@ import { checklistSchema } from '$lib/server/validation.js';
|
|||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
return json({ checklist }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-1
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { checklistSchema } from '$lib/server/validation.js';
|
import { checklistSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
return json({ checklist });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
+4
@@ -4,6 +4,7 @@ import { checklistItemSchema } from '$lib/server/validation.js';
|
|||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
return json({ item }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-1
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
return json({ item });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { commentSchema } from '$lib/server/validation.js';
|
import { commentSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
return json({ comment }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-1
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { commentSchema } from '$lib/server/validation.js';
|
import { commentSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor, requireAuth } from '$lib/server/guards.js';
|
import { requireCardEditor, requireAuth } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
return json({ comment });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { cardLabelSchema } from '$lib/server/validation.js';
|
import { cardLabelSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireCardEditor } from '$lib/server/guards.js';
|
import { requireCardEditor } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
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 });
|
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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { addMemberSchema } from '$lib/server/validation.js';
|
import { addMemberSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
// Add member by email
|
// Add member by email
|
||||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
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 });
|
return json({ member }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
|||||||
import { changeMemberRoleSchema } from '$lib/server/validation.js';
|
import { changeMemberRoleSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
||||||
|
import { broadcast } from '$lib/server/realtime.js';
|
||||||
|
|
||||||
// Change member role
|
// Change member role
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
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 });
|
return json({ member: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove member
|
// Remove member
|
||||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||||
const tenant = locals.tenant;
|
const tenant = locals.tenant;
|
||||||
if (!tenant) throw error(400, 'Unknown 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 } });
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { dndzone } from 'svelte-dnd-action';
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||||
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
|
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
|
||||||
|
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
|
||||||
import CardModal from '$lib/components/CardModal.svelte';
|
import CardModal from '$lib/components/CardModal.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -22,9 +24,136 @@
|
|||||||
let selectedCard = $state<any>(null);
|
let selectedCard = $state<any>(null);
|
||||||
let editingColumnId = $state<string | null>(null);
|
let editingColumnId = $state<string | null>(null);
|
||||||
let editingColumnTitle = $state('');
|
let editingColumnTitle = $state('');
|
||||||
|
let viewingUsers = $state<any[]>([]);
|
||||||
|
|
||||||
const flipDurationMs = 200;
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
|
// ─── Socket.IO header for self-dedup ─────────────
|
||||||
|
function socketHeaders(): Record<string, string> {
|
||||||
|
const sid = getSocketId();
|
||||||
|
return sid ? { 'X-Socket-ID': sid } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Real-Time Event Handlers ────────────────────
|
||||||
|
function isSelf(d: any) {
|
||||||
|
return d?.socketId && d.socketId === getSocketId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onColumnCreated(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
if (!columns.find((c: any) => c.id === d.column.id)) {
|
||||||
|
columns = [...columns, { ...d.column, cards: d.column.cards ?? [] }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onColumnUpdated(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
const col = columns.find((c: any) => c.id === d.column.id);
|
||||||
|
if (col) {
|
||||||
|
col.title = d.column.title;
|
||||||
|
col.position = d.column.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onColumnDeleted(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
columns = columns.filter((c: any) => c.id !== d.columnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardCreated(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
const col = columns.find((c: any) => c.id === d.columnId);
|
||||||
|
if (col && !col.cards.find((c: any) => c.id === d.card.id)) {
|
||||||
|
col.cards = [...col.cards, d.card];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCardUpdated(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
// Re-fetch card from API to get full updated data
|
||||||
|
const colId = d.columnId || d.card?.columnId;
|
||||||
|
const cardId = d.cardId || d.card?.id;
|
||||||
|
if (!colId || !cardId) return;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/boards/${data.board.id}/columns/${colId}/cards/${cardId}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { card: freshCard } = await res.json();
|
||||||
|
|
||||||
|
// The card might have moved columns — remove from old location first
|
||||||
|
for (const col of columns) {
|
||||||
|
const idx = col.cards.findIndex((c: any) => c.id === cardId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
if (col.id === freshCard.columnId) {
|
||||||
|
// Same column — update in-place
|
||||||
|
col.cards[idx] = { ...col.cards[idx], ...freshCard };
|
||||||
|
} else {
|
||||||
|
// Moved to a different column — remove from old
|
||||||
|
col.cards = col.cards.filter((c: any) => c.id !== cardId);
|
||||||
|
// Add to new column
|
||||||
|
const newCol = columns.find((c: any) => c.id === freshCard.columnId);
|
||||||
|
if (newCol) newCol.cards = [...newCol.cards, freshCard];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected card if it's the one being viewed
|
||||||
|
if (selectedCard?.id === cardId) {
|
||||||
|
selectedCard = { ...selectedCard, ...freshCard };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardDeleted(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
const col = columns.find((c: any) => c.id === d.columnId);
|
||||||
|
if (col) col.cards = col.cards.filter((c: any) => c.id !== d.cardId);
|
||||||
|
if (selectedCard?.id === d.cardId) selectedCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBoardUpdated(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
if (d.board) {
|
||||||
|
data.board.title = d.board.title;
|
||||||
|
data.board.visibility = d.board.visibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBoardDeleted(d: any) {
|
||||||
|
if (isSelf(d)) return;
|
||||||
|
window.location.href = '/boards';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPresenceUpdate(users: any[]) {
|
||||||
|
viewingUsers = users;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
joinBoard(data.board.id);
|
||||||
|
|
||||||
|
onBoardEvent('column:created', onColumnCreated);
|
||||||
|
onBoardEvent('column:updated', onColumnUpdated);
|
||||||
|
onBoardEvent('column:deleted', onColumnDeleted);
|
||||||
|
onBoardEvent('card:created', onCardCreated);
|
||||||
|
onBoardEvent('card:updated', onCardUpdated);
|
||||||
|
onBoardEvent('card:deleted', onCardDeleted);
|
||||||
|
onBoardEvent('board:updated', onBoardUpdated);
|
||||||
|
onBoardEvent('board:deleted', onBoardDeleted);
|
||||||
|
onBoardEvent('presence:update', onPresenceUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
leaveBoard(data.board.id);
|
||||||
|
offBoardEvent('column:created', onColumnCreated);
|
||||||
|
offBoardEvent('column:updated', onColumnUpdated);
|
||||||
|
offBoardEvent('column:deleted', onColumnDeleted);
|
||||||
|
offBoardEvent('card:created', onCardCreated);
|
||||||
|
offBoardEvent('card:updated', onCardUpdated);
|
||||||
|
offBoardEvent('card:deleted', onCardDeleted);
|
||||||
|
offBoardEvent('board:updated', onBoardUpdated);
|
||||||
|
offBoardEvent('board:deleted', onBoardDeleted);
|
||||||
|
offBoardEvent('presence:update', onPresenceUpdate);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function getChecklistProgress(card: any): { done: number; total: number } | null {
|
function getChecklistProgress(card: any): { done: number; total: number } | null {
|
||||||
if (!card.checklists || card.checklists.length === 0) return null;
|
if (!card.checklists || card.checklists.length === 0) return null;
|
||||||
let done = 0, total = 0;
|
let done = 0, total = 0;
|
||||||
@@ -49,7 +178,7 @@
|
|||||||
col.position = newPos;
|
col.position = newPos;
|
||||||
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
|
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||||
body: JSON.stringify({ position: newPos })
|
body: JSON.stringify({ position: newPos })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,7 +204,7 @@
|
|||||||
|
|
||||||
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||||
body: JSON.stringify({ columnId, position: newPos })
|
body: JSON.stringify({ columnId, position: newPos })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,7 +215,7 @@
|
|||||||
if (!addingColumnTitle.trim()) return;
|
if (!addingColumnTitle.trim()) return;
|
||||||
const res = await fetch(`/api/boards/${data.board.id}/columns`, {
|
const res = await fetch(`/api/boards/${data.board.id}/columns`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||||
body: JSON.stringify({ title: addingColumnTitle })
|
body: JSON.stringify({ title: addingColumnTitle })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -102,7 +231,7 @@
|
|||||||
if (!newCardTitle.trim()) return;
|
if (!newCardTitle.trim()) return;
|
||||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
|
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||||
body: JSON.stringify({ title: newCardTitle })
|
body: JSON.stringify({ title: newCardTitle })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -117,7 +246,8 @@
|
|||||||
// ─── Delete Column ───────────────────────────────
|
// ─── Delete Column ───────────────────────────────
|
||||||
async function deleteColumn(columnId: string) {
|
async function deleteColumn(columnId: string) {
|
||||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: { ...socketHeaders() }
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
columns = columns.filter((c: any) => c.id !== columnId);
|
columns = columns.filter((c: any) => c.id !== columnId);
|
||||||
@@ -129,7 +259,7 @@
|
|||||||
if (!editingColumnTitle.trim()) return;
|
if (!editingColumnTitle.trim()) return;
|
||||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...socketHeaders() },
|
||||||
body: JSON.stringify({ title: editingColumnTitle })
|
body: JSON.stringify({ title: editingColumnTitle })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -142,7 +272,8 @@
|
|||||||
// ─── Delete Card ─────────────────────────────────
|
// ─── Delete Card ─────────────────────────────────
|
||||||
async function deleteCard(columnId: string, cardId: string) {
|
async function deleteCard(columnId: string, cardId: string) {
|
||||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: { ...socketHeaders() }
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const col = columns.find((c: any) => c.id === columnId);
|
const col = columns.find((c: any) => c.id === columnId);
|
||||||
@@ -202,6 +333,22 @@
|
|||||||
>
|
>
|
||||||
{data.board.members.length} {data.board.members.length === 1 ? 'member' : 'members'}
|
{data.board.members.length} {data.board.members.length === 1 ? 'member' : 'members'}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{#if viewingUsers.filter((u) => u.id !== data.user?.id).length > 0}
|
||||||
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-gray-400">Also here:</span>
|
||||||
|
<div class="flex -space-x-1.5">
|
||||||
|
{#each viewingUsers.filter((u) => u.id !== data.user?.id) as viewer}
|
||||||
|
<div
|
||||||
|
class="w-6 h-6 rounded-full bg-emerald-500 text-white text-[11px] flex items-center justify-center border-2 border-white"
|
||||||
|
title={viewer.name}
|
||||||
|
>
|
||||||
|
{viewer.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Columns container -->
|
<!-- Columns container -->
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Server as SocketIOServer } from 'socket.io';
|
|
||||||
import type { Server as HttpServer } from 'http';
|
|
||||||
import { validateSession, sessionCookieName } from '$lib/server/auth.js';
|
|
||||||
|
|
||||||
let io: SocketIOServer | null = null;
|
|
||||||
|
|
||||||
export function initSocketIO(server: HttpServer): SocketIOServer {
|
|
||||||
io = new SocketIOServer(server, {
|
|
||||||
cors: {
|
|
||||||
origin: process.env.ORIGIN || 'http://localhost:5173',
|
|
||||||
credentials: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auth middleware — validate session cookie
|
|
||||||
io.use(async (socket, next) => {
|
|
||||||
const cookieHeader = socket.handshake.headers.cookie;
|
|
||||||
if (!cookieHeader) {
|
|
||||||
// Allow anonymous connections (for public boards)
|
|
||||||
socket.data.user = null;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookies = parseCookies(cookieHeader);
|
|
||||||
const sessionId = cookies[sessionCookieName()];
|
|
||||||
if (!sessionId) {
|
|
||||||
socket.data.user = null;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await validateSession(sessionId);
|
|
||||||
if (session) {
|
|
||||||
socket.data.user = session.user;
|
|
||||||
} else {
|
|
||||||
socket.data.user = null;
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
// Join board room
|
|
||||||
socket.on('join-board', (boardId: string) => {
|
|
||||||
socket.join(`board:${boardId}`);
|
|
||||||
// Broadcast presence
|
|
||||||
socket.to(`board:${boardId}`).emit('user-joined', {
|
|
||||||
user: socket.data.user
|
|
||||||
? { id: socket.data.user.id, name: socket.data.user.name }
|
|
||||||
: null,
|
|
||||||
socketId: socket.id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Leave board room
|
|
||||||
socket.on('leave-board', (boardId: string) => {
|
|
||||||
socket.leave(`board:${boardId}`);
|
|
||||||
socket.to(`board:${boardId}`).emit('user-left', {
|
|
||||||
socketId: socket.id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
// Socket.IO handles room cleanup automatically
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return io;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getIO(): SocketIOServer | null {
|
|
||||||
return io;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit an event to all clients in a board room.
|
|
||||||
*/
|
|
||||||
export function emitToBoardRoom(boardId: string, event: string, data: unknown) {
|
|
||||||
if (io) {
|
|
||||||
io.to(`board:${boardId}`).emit(event, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a notification to a specific user across all their connections.
|
|
||||||
*/
|
|
||||||
export function emitToUser(userId: string, event: string, data: unknown) {
|
|
||||||
if (io) {
|
|
||||||
io.to(`user:${userId}`).emit(event, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
||||||
const cookies: Record<string, string> = {};
|
|
||||||
cookieHeader.split(';').forEach((cookie) => {
|
|
||||||
const [name, ...rest] = cookie.trim().split('=');
|
|
||||||
cookies[name] = decodeURIComponent(rest.join('='));
|
|
||||||
});
|
|
||||||
return cookies;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user