From e0d755bef76d1d52e1458c815dab436682a70041 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Fri, 17 Apr 2026 17:25:46 -0300 Subject: [PATCH] feat: implement real-time booking updates using Server-Sent Events (SSE) and add public booking URL sharing to the dashboard --- AGENTS.md | 13 +++ apps/backend/package.json | 2 +- apps/backend/src/lib/mailer.ts | 8 +- apps/backend/src/lib/sse.ts | 84 +++++++++++++++++ .../handlers/create-public-booking.handler.ts | 17 ++++ .../services/public-booking.service.ts | 2 + apps/backend/src/register-routes.ts | 3 + apps/frontend/src/features/home/home-page.tsx | 91 +++++++++++++++++++ .../public-booking/public-booking-page.tsx | 13 ++- packages/api-contract/src/public-booking.ts | 1 + 10 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/lib/sse.ts diff --git a/AGENTS.md b/AGENTS.md index cef168f..2d66844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,19 @@ The project uses **Better Auth** for session management, replacing the legacy Su - **Zod v4**: Use `.issues` instead of `.errors` for validation errors. - **TanStack Router**: Routes are code-generated. Use `--filter frontend build` not raw vite build. +## Real-time Updates (SSE) + +The project uses **Server-Sent Events (SSE)** for real-time booking updates in the admin panel. + +- **Backend**: `apps/backend/src/lib/sse.ts` provides the `sseManager` and SSE endpoint `/api/events/:channel`. +- **Event flow**: When a public booking is created, the handler emits to channel `complex:{complexId}`. +- **Frontend**: `home-page.tsx` connects to the SSE endpoint and invalidates the bookings query on new events. + +**Important - Multi-process limitation**: The current SSE implementation works with a single Bun process only. If you deploy with multiple workers (e.g., clustering in Dokploy), events won't be shared across workers. For production with multiple workers, use Redis for pub/sub: +- Install `@hono/node-server` or a Redis client +- Replace `sseManager` with Redis pub/sub +- Or fall back to Socket.io with Redis adapter + ## Docker Deployment (Dokploy) - **Build Context**: `./` (monorepo root) diff --git a/apps/backend/package.json b/apps/backend/package.json index 3be8e97..e0106fa 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -34,4 +34,4 @@ "@types/bun": "latest", "@types/nodemailer": "^8.0.0" } -} \ No newline at end of file +} diff --git a/apps/backend/src/lib/mailer.ts b/apps/backend/src/lib/mailer.ts index c9f2ca0..74c1afd 100644 --- a/apps/backend/src/lib/mailer.ts +++ b/apps/backend/src/lib/mailer.ts @@ -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; } diff --git a/apps/backend/src/lib/sse.ts b/apps/backend/src/lib/sse.ts new file mode 100644 index 0000000..c96694b --- /dev/null +++ b/apps/backend/src/lib/sse.ts @@ -0,0 +1,84 @@ +import type { AppEnv } from '@/types/hono'; +import { Hono } from 'hono'; + +type EventCallback = (data: string) => void; + +class SSEManager { + private channels: Map> = 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) { + 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', + }, + }); + }); +} diff --git a/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts b/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts index 4c11372..ac1012a 100644 --- a/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts +++ b/apps/backend/src/modules/public-booking/handlers/create-public-booking.handler.ts @@ -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) { diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts index b27c4de..1f3d075 100644 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts @@ -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, diff --git a/apps/backend/src/register-routes.ts b/apps/backend/src/register-routes.ts index 3219085..c9a34fb 100644 --- a/apps/backend/src/register-routes.ts +++ b/apps/backend/src/register-routes.ts @@ -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) { @@ -28,6 +29,8 @@ export function registerRoutes(app: Hono) { .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/')) { diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index 44027bb..0ff90c9 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -27,6 +27,7 @@ import { import { zodResolver } from '@hookform/resolvers/zod'; import type { AdminBooking } from '@repo/api-contract'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Check, Copy } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -131,6 +132,7 @@ export function HomePage() { const [selectedCourtId, setSelectedCourtId] = useState(''); const [selectedStartTime, setSelectedStartTime] = useState(''); const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); useEffect(() => { setCurrentComplexSlug(getCurrentComplexSlug()); @@ -162,6 +164,61 @@ export function HomePage() { persistCurrentComplexSlug(selectedComplex.complexSlug); }, [selectedComplex]); + useEffect(() => { + if (!selectedComplex?.id) return; + + const channel = `complex-${selectedComplex.id}`; + + let eventSource: EventSource | null = null; + let reconnectTimeout: ReturnType | null = null; + let reconnectAttempts = 0; + const maxReconnectAttempts = 5; + + const connect = () => { + if (eventSource) { + eventSource.close(); + } + + if (reconnectAttempts >= maxReconnectAttempts) { + console.log('[SSE] Max reconnect attempts reached'); + return; + } + + const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/events/${encodeURIComponent(channel)}`; + + eventSource = new EventSource(url); + + eventSource.onerror = () => { + if (eventSource?.readyState === EventSource.CLOSED) { + reconnectAttempts++; + const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000); + console.log( + `[SSE] Connection closed, retry ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms` + ); + reconnectTimeout = setTimeout(() => { + connect(); + }, delay); + } + }; + + eventSource.addEventListener('message', (event) => { + if (event.data && event.data !== 'connected' && event.data !== 'ping') { + reconnectAttempts = 0; + void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] }); + } + }); + }; + + connect(); + + return () => { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + } + eventSource?.close(); + }; + }, [selectedComplex?.id, queryClient]); + const bookingsQuery = useQuery({ queryKey: ['admin-bookings', selectedComplex?.id, fromDate], enabled: Boolean(selectedComplex?.id), @@ -302,6 +359,18 @@ export function HomePage() { await createManualBookingMutation.mutateAsync(values); }; + const publicBookingUrl = selectedComplex + ? `${window.location.origin}/${selectedComplex.complexSlug}/booking` + : ''; + + const copyToClipboard = async () => { + await navigator.clipboard.writeText(publicBookingUrl); + setUrlCopied(true); + setTimeout(() => { + setUrlCopied(false); + }, 3000); + }; + if (myComplexesQuery.isLoading) { return (
@@ -337,6 +406,28 @@ export function HomePage() {

Gestiona turnos del día y futuros para {selectedComplex.complexName}.

+
+ + {publicBookingUrl} + + +
-

Reserva tu turno

-

+

+

+ {availabilityQuery.data?.complexName} +

+ {availabilityQuery.data?.complexAddress && ( +

+ {availabilityQuery.data.complexAddress} +

+ )} +
+

Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.

diff --git a/packages/api-contract/src/public-booking.ts b/packages/api-contract/src/public-booking.ts index 2c39b5d..00302d3 100644 --- a/packages/api-contract/src/public-booking.ts +++ b/packages/api-contract/src/public-booking.ts @@ -33,6 +33,7 @@ export const publicAvailabilityCourtSchema = z.object({ export const publicAvailabilityResponseSchema = z.object({ complexId: z.uuid(), complexName: z.string(), + complexAddress: z.string().optional(), complexSlug: z.string(), date: z.string().regex(ISO_DATE_REGEX), sportSelectionRequired: z.boolean(),