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

@@ -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. - **Zod v4**: Use `.issues` instead of `.errors` for validation errors.
- **TanStack Router**: Routes are code-generated. Use `--filter frontend build` not raw vite build. - **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) ## Docker Deployment (Dokploy)
- **Build Context**: `./` (monorepo root) - **Build Context**: `./` (monorepo root)

View File

@@ -34,4 +34,4 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/nodemailer": "^8.0.0" "@types/nodemailer": "^8.0.0"
} }
} }

View File

@@ -1,5 +1,5 @@
import nodemailer from 'nodemailer';
import { MailtrapClientConfig, MailtrapTransport } from 'mailtrap'; import { MailtrapClientConfig, MailtrapTransport } from 'mailtrap';
import nodemailer from 'nodemailer';
const TOKEN = Bun.env.MAILTRAP_API_TOKEN; const TOKEN = Bun.env.MAILTRAP_API_TOKEN;
@@ -16,13 +16,11 @@ function getTransporter() {
token: TOKEN, token: TOKEN,
sandbox: Bun.env.MAIL_SANDBOX === 'true', sandbox: Bun.env.MAIL_SANDBOX === 'true',
testInboxId: Bun.env.MAIL_INBOX_ID ? Number(Bun.env.MAIL_INBOX_ID) : undefined, testInboxId: Bun.env.MAIL_INBOX_ID ? Number(Bun.env.MAIL_INBOX_ID) : undefined,
} };
console.log(JSON.stringify(config, null, 2)); console.log(JSON.stringify(config, null, 2));
cachedTransporter = nodemailer.createTransport( cachedTransporter = nodemailer.createTransport(MailtrapTransport(config));
MailtrapTransport(config)
);
return cachedTransporter; 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 { import {
PublicBookingServiceError, PublicBookingServiceError,
createPublicBooking, createPublicBooking,
@@ -13,6 +14,22 @@ export async function createPublicBookingHandler(c: AppContext) {
try { try {
const booking = await createPublicBooking(complexSlug, payload); 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); return c.json(booking, 201);
} catch (error) { } catch (error) {
if (error instanceof PublicBookingServiceError) { if (error instanceof PublicBookingServiceError) {

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import {
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import type { AdminBooking } from '@repo/api-contract'; import type { AdminBooking } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -131,6 +132,7 @@ export function HomePage() {
const [selectedCourtId, setSelectedCourtId] = useState<string>(''); const [selectedCourtId, setSelectedCourtId] = useState<string>('');
const [selectedStartTime, setSelectedStartTime] = useState<string>(''); const [selectedStartTime, setSelectedStartTime] = useState<string>('');
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
const [urlCopied, setUrlCopied] = useState(false);
useEffect(() => { useEffect(() => {
setCurrentComplexSlug(getCurrentComplexSlug()); setCurrentComplexSlug(getCurrentComplexSlug());
@@ -162,6 +164,61 @@ export function HomePage() {
persistCurrentComplexSlug(selectedComplex.complexSlug); persistCurrentComplexSlug(selectedComplex.complexSlug);
}, [selectedComplex]); }, [selectedComplex]);
useEffect(() => {
if (!selectedComplex?.id) return;
const channel = `complex-${selectedComplex.id}`;
let eventSource: EventSource | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | 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({ const bookingsQuery = useQuery({
queryKey: ['admin-bookings', selectedComplex?.id, fromDate], queryKey: ['admin-bookings', selectedComplex?.id, fromDate],
enabled: Boolean(selectedComplex?.id), enabled: Boolean(selectedComplex?.id),
@@ -302,6 +359,18 @@ export function HomePage() {
await createManualBookingMutation.mutateAsync(values); 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) { if (myComplexesQuery.isLoading) {
return ( return (
<main className="mx-auto w-full max-w-6xl px-6 py-8"> <main className="mx-auto w-full max-w-6xl px-6 py-8">
@@ -337,6 +406,28 @@ export function HomePage() {
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Gestiona turnos del día y futuros para {selectedComplex.complexName}. Gestiona turnos del día y futuros para {selectedComplex.complexName}.
</p> </p>
<div className="mt-2 flex items-center gap-2">
<a
href={publicBookingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground underline hover:text-foreground"
>
{publicBookingUrl}
</a>
<button
type="button"
onClick={copyToClipboard}
className="text-muted-foreground hover:text-foreground"
title="Copiar URL"
>
{urlCopied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div> </div>
<ResponsiveDialog <ResponsiveDialog
open={isManualBookingOpen} open={isManualBookingOpen}

View File

@@ -332,8 +332,17 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
<main className="min-h-screen bg-linear-to-b from-background to-muted/40"> <main className="min-h-screen bg-linear-to-b from-background to-muted/40">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-4 sm:gap-6 sm:px-6 sm:py-8"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-4 sm:gap-6 sm:px-6 sm:py-8">
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6"> <section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h1 className="text-xl font-semibold sm:text-2xl">Reserva tu turno</h1> <div className="flex flex-col gap-1">
<p className="mt-2 text-sm text-muted-foreground"> <h1 className="text-2xl font-bold text-primary sm:text-3xl">
{availabilityQuery.data?.complexName}
</h1>
{availabilityQuery.data?.complexAddress && (
<p className="text-sm text-muted-foreground">
{availabilityQuery.data.complexAddress}
</p>
)}
</div>
<p className="mt-4 text-sm text-muted-foreground">
Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono. Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.
</p> </p>
</section> </section>

View File

@@ -33,6 +33,7 @@ export const publicAvailabilityCourtSchema = z.object({
export const publicAvailabilityResponseSchema = z.object({ export const publicAvailabilityResponseSchema = z.object({
complexId: z.uuid(), complexId: z.uuid(),
complexName: z.string(), complexName: z.string(),
complexAddress: z.string().optional(),
complexSlug: z.string(), complexSlug: z.string(),
date: z.string().regex(ISO_DATE_REGEX), date: z.string().regex(ISO_DATE_REGEX),
sportSelectionRequired: z.boolean(), sportSelectionRequired: z.boolean(),