12 KiB
AGENTS.md
Commands
# Root (runs both frontend + backend)
bun run dev
# Individual packages
bun run dev:frontend
bun run dev:backend
bun run build:frontend
# Linting (Biome - ESLint was replaced)
bun run lint
bun run lint:fix # fixes and formats
# Prisma (run from backend)
bun --filter backend prisma:generate # regenerate client after schema changes
bun --filter backend prisma:migrate # apply migrations
Architecture
apps/frontend/ # React + Vite + TanStack Router/Query
apps/backend/ # Bun + Hono + Prisma
packages/api-contract/ # Shared Zod schemas, types, route definitions
Shared contract pattern:
- Update
packages/api-contractfirst (schemas/types) - Implement handler in
apps/backend - Consume from
apps/frontendvia workspace import@repo/api-contract
Endpoint Pattern
Every new endpoint must follow the Result pattern with the validate helper.
Files
| File | Purpose |
|---|---|
apps/backend/src/lib/result.ts |
Result<T> type, ok(), err() |
apps/backend/src/lib/errors.ts |
AppError discriminated union, Errors factory |
apps/backend/src/lib/http/handle-result.ts |
handleResult() — bridges Result to HTTP |
apps/backend/src/lib/http/validate.ts |
validate.json(), .query(), .param(), .header(), .form() |
Layers
1. Route — Use validate.json(schema), validate.param(schema), etc. as Hono middleware.
import { validate } from '@/lib/http/validate';
import { z } from 'zod';
const idParams = z.object({ id: z.string() });
router.post('/', validate.json(createSchema), createHandler);
router.get('/:id', validate.param(idParams), getHandler);
2. Service — Return Result<T>. Use ok(value) on success, err(Errors.*(...)) on failure.
import { Result, ok, err } from '@/lib/result';
import { Errors } from '@/lib/errors';
export async function doSomething(input: Input): Promise<Result<Output>> {
if (conflict) return err(Errors.conflict('Already exists'));
return ok(result);
}
3. Handler — Call the service and pass the Result to handleResult.
import { handleResult } from '@/lib/http/handle-result';
import { doSomething } from './service';
export async function myHandler(c: AppContext) {
const payload = c.req.valid('json') as Input;
return handleResult(c, await doSomething(payload), 201);
}
Never
- ❌ Throw custom error classes from services
- ❌ Use try/catch in handlers for business logic errors
- ❌ Use
zValidatordirectly — always usevalidate.*
Authentication (Better Auth)
The project uses Better Auth for session management, replacing the legacy Supabase Auth.
- Backend Context:
AppContext(mapped viaAppEnv) providesc.get('user')andc.get('session'). - User Roles: The
Usermodel includes arolefield (default:member). UserequireSuperAdminmiddleware for protected admin routes. - Session Persistence:
- Backend: Hono CORS must have
credentials: true. - Frontend:
authClientmust havefetchOptions.credentials: 'include'. - Frontend: Axios instance must have
withCredentials: true.
- Backend: Hono CORS must have
Key Quirks
- Prisma 7: Uses
prisma.config.ts, requiresDATABASE_URLenv var at generate time. - Gravatar Fallback: Users without an
imageget an automatic Gravatar Identicon.- Backend: Handled in
user.service.tsfor profile API. - Frontend: Handled in
AuthProvider(auth.tsx) for the session state.
- Backend: Handled in
- Zod v4: Use
.issuesinstead of.errorsfor validation errors. - TanStack Router: Routes are code-generated. Use
--filter frontend buildnot 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.tsprovides thesseManagerand SSE endpoint/api/events/:channel. - Event flow: When a public booking is created, the handler emits to channel
complex:{complexId}. - Frontend:
home-page.tsxconnects 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-serveror a Redis client - Replace
sseManagerwith Redis pub/sub - Or fall back to Socket.io with Redis adapter
Docker Deployment (Dokploy)
- Build Context:
./(monorepo root) - Dockerfile:
Dockerfile(in root, notapps/backend/) - Required args:
DATABASE_URL,VITE_API_BASE_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL - bun.lock must be tracked: It's in
.gitignoreby default - remove it for Docker builds
Linting
Biome is used (not ESLint). Config in biome.json:
- Line width: 100
- Quote style: single
- Semicolons: always
noUnusedVariables: warn
Environment Variables
Backend (apps/backend/.env):
DATABASE_URL- PostgreSQL connection (required for Prisma)BETTER_AUTH_SECRET,BETTER_AUTH_URL- Auth coreAPP_BASE_URL- Frontend URL for trusted originsCORS_ORIGIN- Frontend URL for CORS policyGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET- Google OAuth (see below)
Frontend (apps/frontend/.env):
VITE_*prefix required (Vite embeds these at build time)VITE_API_BASE_URL- Backend URL (default: http://localhost:3000)
Google OAuth
To enable login with Google:
-
Create OAuth credentials in Google Cloud Console:
- Application type: Web application
- Authorized redirect URI:
{BETTER_AUTH_URL}/api/auth/callback/google
-
Add env vars to
.env.example:GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret -
The backend configures
socialProviders.googleautomatically. -
Frontend uses
authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:5173' }).
Complex Selection (Multi-tenancy)
The app supports users with multiple complexes. Each user has a role per complex (ADMIN or EMPLOYEE), stored in ComplexUser table.
Flow
-
Login (password or Google OAuth):
- After auth, call
GET /api/complexes/mineto get user's complexes - If 1 complex → auto-select via
POST /api/complexes/select→ redirect to/ - If >1 complexes → redirect to
/select-complex
- After auth, call
-
Select Complex Page (
/select-complex):- List all complexes with roles
- User selects one →
POST /api/complexes/select→ set cookie → redirect to/
-
Home Page (
/):- If no cookie (first visit) → auto-select if 1 complex, else redirect
- Use
GET /api/complexes/meto get current selected complex
Cookie
- Name:
selected-complex-id - httpOnly, secure (prod), sameSite: strict
- MaxAge: 30 days
- Set by
POST /api/complexes/select
Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/complexes/mine |
List all complexes with role |
| GET | /api/complexes/me |
Get currently selected complex |
| POST | /api/complexes/select |
Select complex (sets cookie) |
Profile Role
The profile page shows the user's role for the selected complex, not the global user role. This is fetched from ComplexUser table using the cookie value.
Frontend API Client
The API client is split into modular files for better maintainability and testability.
Structure
lib/
├── api-client.ts # Main barrel file (~70 lines)
└── api/
├── base.ts # ApiClientError, configureApiClient, apiBaseUrl
├── http.ts # Axios instance with interceptors
├── index.ts # Re-exports all resources
└── resources/
├── complexes.ts # Complex endpoints
├── courts.ts # Court endpoints
├── bookings.ts # Public & admin booking endpoints
├── user.ts # User profile endpoint
├── sports.ts # Sport endpoints
├── onboarding.ts # Onboarding endpoints
└── plans.ts # Plan endpoints
Using the API Client
import { apiClient, authClient, ApiClientError, configureApiClient } from '@/lib/api-client';
// Via apiClient (barrel)
await apiClient.complexes.listMine();
await apiClient.publicBookings.getAvailability('my-club', { date: '2026-04-20' });
// Direct imports (for better tree-shaking)
import { complexes, getAvailability } from '@/lib/api';
await complexes.listMine();
await getAvailability('my-club', { date: '2026-04-20' });
Adding a New Resource
- Create
lib/api/resources/[resource].ts - Export functions using
httpfrom../http - Add exports in
lib/api/index.ts
Email Templates
Todos los emails deben usar la misma estética. El layout compartido está en apps/backend/src/emails/booking-confirmation.ts.
Layout (wrapLayout)
Exportado como wrapLayout(content). Proporciona:
- Fondo:
#edf7f4 - Card blanca:
max-width: 520px,border-radius: 28px,box-shadow: 0 24px 70px rgba(15,23,42,0.12),border: 1px solid rgba(5,9,20,0.1) - Playzer favicon:
{APP_BASE_URL}/playzer-favicon-512-transparent.png(está enapps/frontend/public/) - Sin
min-height: 100vh: la card empieza arriba, no centrada verticalmente
import { wrapLayout } from '@/emails/booking-confirmation';
const html = wrapLayout(`<tr>...contenido...</tr>`);
Estructura de cada email
| Sección | Descripción |
|---|---|
| Header | Dos columnas: badge pill a la izquierda + Playzer (favicon + texto) a la derecha. El badge usa border-radius: 999px, padding: 4px 12px, font-size: 11px, font-weight: 700, letter-spacing: 0.14em, text-transform: uppercase. |
| Card fecha/hora | Fondo #f0fdf4, borde 1px solid rgba(5,150,105,0.3), border-radius: 24px. Siempre verde aunque el email sea de cancelación. |
| Grilla detalles | border: 1px solid #e5e7eb, border-radius: 22px, dos celdas de 50% con border-right en la primera. |
| Botón CTA | Tabla con fondo #059669, border-radius: 12px, link blanco con padding: 14px 32px. |
| Pie | Sin pie de marca. Solo texto secundario opcional centrado si es necesario. |
| Sin botones de acción | Los emails de booking no incluyen los botones "Compartir por WhatsApp" ni "Hacer otra reserva". |
Padding estándar
| Ubicación | Valor |
|---|---|
Wrapper (outer <td>) |
padding: 24px 12px |
Primer <td> del contenido (header) |
padding: 24px 20px 16px |
<td> intermedios (cards, texto) |
padding: 0 20px 16px |
Último <td> del contenido |
padding: 0 20px 24px |
Colores de badges según estado
| Estado | Fondo badge | Texto badge |
|---|---|---|
| Confirmado | #f0fdf4 |
#15803d |
| Cancelado | #fef2f2 |
#dc2626 |
| No concretado | #fffbeb |
#d97706 |
| Neutro (verificación, etc.) | #f4f4f5 |
#71717a |
Archivos de templates
| Archivo | Templates |
|---|---|
apps/backend/src/emails/booking-confirmation.ts |
bookingConfirmationHtml, bookingCancelledHtml, bookingNoShowHtml + exporta wrapLayout |
apps/backend/src/lib/auth.ts |
Email de verificación de Better Auth (usa wrapLayout inline) |
Regla general
Para emails nuevos: importar wrapLayout, construir el HTML interno con <tr>s, usar la misma estructura de cabecera (badge + Playzer), y nunca incluir el pie "Playzer — Reserva de canchas online". Usar APP_BASE_URL para construir URLs absolutas al logo.