feat: implement real-time booking updates using Server-Sent Events (SSE) and add public booking URL sharing to the dashboard

This commit is contained in:
Jose Selesan
2026-04-17 17:25:46 -03:00
parent a075f13587
commit e0d755bef7
10 changed files with 226 additions and 8 deletions

View File

@@ -1,5 +1,5 @@
import nodemailer from 'nodemailer';
import { MailtrapClientConfig, MailtrapTransport } from 'mailtrap';
import nodemailer from 'nodemailer';
const TOKEN = Bun.env.MAILTRAP_API_TOKEN;
@@ -16,13 +16,11 @@ function getTransporter() {
token: TOKEN,
sandbox: Bun.env.MAIL_SANDBOX === 'true',
testInboxId: Bun.env.MAIL_INBOX_ID ? Number(Bun.env.MAIL_INBOX_ID) : undefined,
}
};
console.log(JSON.stringify(config, null, 2));
cachedTransporter = nodemailer.createTransport(
MailtrapTransport(config)
);
cachedTransporter = nodemailer.createTransport(MailtrapTransport(config));
return cachedTransporter;
}

View File

@@ -0,0 +1,84 @@
import type { AppEnv } from '@/types/hono';
import { Hono } from 'hono';
type EventCallback = (data: string) => void;
class SSEManager {
private channels: Map<string, Set<EventCallback>> = new Map();
subscribe(channel: string, callback: EventCallback): () => void {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
this.channels.get(channel)!.add(callback);
return () => {
const subs = this.channels.get(channel);
if (subs) {
subs.delete(callback);
if (subs.size === 0) {
this.channels.delete(channel);
}
}
};
}
emit(channel: string, data: string): void {
const subs = this.channels.get(channel);
if (subs) {
for (const callback of subs) {
callback(data);
}
}
}
}
export const sseManager = new SSEManager();
export function registerEventsRoutes(app: Hono<AppEnv>) {
app.get('/api/events/:channel', async (c) => {
const channel = c.req.param('channel');
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const send = (data: string) => {
try {
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
} catch {
// Controller closed
}
};
send('connected');
const unsubscribe = sseManager.subscribe(channel, send);
const keepAliveInterval = setInterval(() => {
send('ping');
}, 30000);
c.req.raw.signal.addEventListener('abort', () => {
console.log(`[SSE] Connection closed for channel: ${channel}`);
clearInterval(keepAliveInterval);
unsubscribe();
try {
controller.close();
} catch {
// Already closed
}
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
});
}

View File

@@ -1,3 +1,4 @@
import { sseManager } from '@/lib/sse';
import {
PublicBookingServiceError,
createPublicBooking,
@@ -13,6 +14,22 @@ export async function createPublicBookingHandler(c: AppContext) {
try {
const booking = await createPublicBooking(complexSlug, payload);
const channel = `complex-${booking.complexId}`;
sseManager.emit(
channel,
JSON.stringify({
type: 'booking_created',
booking: {
id: booking.id,
courtId: booking.courtId,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
},
})
);
return c.json(booking, 201);
} catch (error) {
if (error instanceof PublicBookingServiceError) {

View File

@@ -183,6 +183,7 @@ async function getComplexWithBookingData(complexSlug: string) {
id: true,
complexName: true,
complexSlug: true,
physicalAddress: true,
plan: {
select: {
rules: true,
@@ -414,6 +415,7 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA
return {
complexId: complex.id,
complexName: complex.complexName,
complexAddress: complex.physicalAddress ?? null,
complexSlug: complex.complexSlug,
date: query.date,
sportSelectionRequired,

View File

@@ -12,6 +12,7 @@ import { userRoutes } from '@/modules/user/user.routes';
import type { AppEnv } from '@/types/hono';
import type { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { registerEventsRoutes } from './lib/sse';
import { healthCheckRoutes } from './modules/health-check/health-check.routes';
export function registerRoutes(app: Hono<AppEnv>) {
@@ -28,6 +29,8 @@ export function registerRoutes(app: Hono<AppEnv>) {
.route('/api/admin-bookings', adminBookingRoutes)
.route('/api/health', healthCheckRoutes);
registerEventsRoutes(app);
app.use('*', serveStatic({ root: './public' }));
app.get('*', async (c) => {
if (c.req.path === '/api' || c.req.path.startsWith('/api/')) {