import express from 'express'; import { createServer } from 'http'; import { Server } from 'socket.io'; import { PrismaClient } from '@prisma/client'; import pino from 'pino'; import cron from 'node-cron'; import { handler } from './build/handler.js'; const logger = pino({ name: 'pounce' }); 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> 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) { logger.error({ err }, 'Socket auth error'); socket.data.user = null; next(); } }); // ─── Socket.IO connection handler ──────────────────────────── io.on('connection', (socket) => { const user = socket.data.user; logger.info({ socketId: socket.id, user: user?.name ?? 'anon' }, 'Socket connected'); // 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', () => { logger.info({ socketId: socket.id }, 'Socket disconnected'); 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; // ─── Due Date Reminder Cron (daily at 8:00 UTC) ──────────── cron.schedule('0 8 * * *', async () => { try { const now = new Date(); const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000); const cards = await prisma.card.findMany({ where: { archived: false, dueReminderSent: false, dueDate: { gte: now, lte: in24h } }, include: { assignees: { select: { userId: true } }, column: { select: { board: { select: { id: true, title: true } } } } } }); for (const card of cards) { const boardId = card.column.board.id; const boardTitle = card.column.board.title; for (const assignee of card.assignees) { await prisma.notification.create({ data: { userId: assignee.userId, type: 'due_soon', title: `"${card.title}" is due soon`, body: `Card in ${boardTitle} is due within 24 hours`, link: `/boards/${boardId}?card=${card.id}` } }); io.to('user:' + assignee.userId).emit('notification:new', { type: 'due_soon', title: `"${card.title}" is due soon` }); } await prisma.card.update({ where: { id: card.id }, data: { dueReminderSent: true } }); } if (cards.length > 0) { logger.info({ count: cards.length }, 'Due date reminders sent'); } } catch (err) { logger.error({ err }, 'Due date reminder cron failed'); } }); // SvelteKit handler app.use(handler); const port = process.env.PORT || 3000; const host = process.env.HOST || '0.0.0.0'; server.listen(port, host, () => { logger.info({ host, port }, 'Server running'); });