e5f2c64e14
Add activity logging to all write API routes, notification system with real-time delivery via Socket.IO, @mention parsing in comments, activity feed sidebar on board page, per-card activity in card modal, and notification bell with unread badge in the navbar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
4.6 KiB
JavaScript
157 lines
4.6 KiB
JavaScript
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();
|
|
const server = createServer(app);
|
|
|
|
const io = new Server(server, {
|
|
cors: {
|
|
origin: process.env.ORIGIN || 'http://localhost:3000',
|
|
credentials: true
|
|
}
|
|
});
|
|
|
|
// ─── 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) => {
|
|
const user = socket.data.user;
|
|
console.log(`Socket connected: ${socket.id} (user: ${user?.name ?? 'anon'})`);
|
|
|
|
// Join user-specific room for notifications
|
|
if (user) {
|
|
socket.join('user:' + user.id);
|
|
}
|
|
|
|
// Track which boards this socket has joined (for cleanup on disconnect)
|
|
const joinedBoards = new Set();
|
|
|
|
socket.on('join-board', (boardId) => {
|
|
socket.join(`board:${boardId}`);
|
|
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}`);
|
|
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 SvelteKit API routes can broadcast via realtime.ts
|
|
globalThis.__socketIO = io;
|
|
|
|
// SvelteKit handler
|
|
app.use(handler);
|
|
|
|
const port = process.env.PORT || 3000;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`Server running on http://${host}:${port}`);
|
|
});
|