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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
84
apps/backend/src/lib/sse.ts
Normal file
84
apps/backend/src/lib/sse.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
Reference in New Issue
Block a user