# AGENTS.md

## Commands

```sh
# 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:**
1. Update `packages/api-contract` first (schemas/types)
2. Implement handler in `apps/backend`
3. Consume from `apps/frontend` via 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.

```typescript
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.

```typescript
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`.

```typescript
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 `zValidator` directly — always use `validate.*`

## Authentication (Better Auth)

The project uses **Better Auth** for session management, replacing the legacy Supabase Auth.

- **Backend Context**: `AppContext` (mapped via `AppEnv`) provides `c.get('user')` and `c.get('session')`.
- **User Roles**: The `User` model includes a `role` field (default: `member`). Use `requireSuperAdmin` middleware for protected admin routes.
- **Session Persistence**: 
  - Backend: Hono CORS must have `credentials: true`.
  - Frontend: `authClient` must have `fetchOptions.credentials: 'include'`.
  - Frontend: Axios instance must have `withCredentials: true`.

## Key Quirks

- **Prisma 7**: Uses `prisma.config.ts`, requires `DATABASE_URL` env var at generate time.
- **Gravatar Fallback**: Users without an `image` get an automatic Gravatar Identicon.
  - Backend: Handled in `user.service.ts` for profile API.
  - Frontend: Handled in `AuthProvider` (`auth.tsx`) for the session state.
- **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)
- **Dockerfile**: `Dockerfile` (in root, not `apps/backend/`)
- **Required args**: `DATABASE_URL`, `VITE_API_BASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`
- **bun.lock must be tracked**: It's in `.gitignore` by 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 core
- `APP_BASE_URL` - Frontend URL for trusted origins
- `CORS_ORIGIN` - Frontend URL for CORS policy
- `GOOGLE_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:

1. Create OAuth credentials in Google Cloud Console:
   - Application type: Web application
   - Authorized redirect URI: `{BETTER_AUTH_URL}/api/auth/callback/google`

2. Add env vars to `.env.example`:
   ```
   GOOGLE_CLIENT_ID=your-google-client-id
   GOOGLE_CLIENT_SECRET=your-google-client-secret
   ```

3. The backend configures `socialProviders.google` automatically.

4. 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

1. **Login** (password or Google OAuth):
   - After auth, call `GET /api/complexes/mine` to get user's complexes
   - If 1 complex → auto-select via `POST /api/complexes/select` → redirect to `/`
   - If >1 complexes → redirect to `/select-complex`

2. **Select Complex Page** (`/select-complex`):
   - List all complexes with roles
   - User selects one → `POST /api/complexes/select` → set cookie → redirect to `/`

3. **Home Page** (`/`):
   - If no cookie (first visit) → auto-select if 1 complex, else redirect
   - Use `GET /api/complexes/me` to 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

```typescript
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

1. Create `lib/api/resources/[resource].ts`
2. Export functions using `http` from `../http`
3. 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á en `apps/frontend/public/`)
- **Sin `min-height: 100vh`**: la card empieza arriba, no centrada verticalmente

```typescript
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.
