Files
playzer/AGENTS.md

254 lines
9.8 KiB
Markdown

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