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:
13
AGENTS.md
13
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.
|
- **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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {
|
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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/')) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user