Private
Public Access
1
0
Files
Kanban/server.js
T
Catherine Renelle 8c41b33313 Add 17 features: My Work view, card copy/numbering, calendar, bulk ops, custom fields, automations, webhooks, invites, import/export, and more
Features implemented across 6 batches:
- Batch A: Fix ColorPicker gradient sync, tenant custom logo, unarchive cards, board backgrounds
- Batch B: My Work view, card copy/duplicate, card numbering (BOARD-123)
- Batch C: Due date reminders (cron), card watching with notifications
- Batch D: Bulk card operations, column sorting, calendar view
- Batch E: Invite by link, board import/export (Pounce + Trello JSON)
- Batch F: Webhooks with HMAC signing, custom fields, automation rules engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 08:50:15 -05:00

213 lines
6.0 KiB
JavaScript

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<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) {
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');
});