310 lines
12 KiB
Markdown
310 lines
12 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`
|
|
|
|
## 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.
|