# Kallpa

*Kallpa* quiere decir *fuerza* en quechua. Es el sistema de reservas de un
complejo deportivo comunal de Lima: dos canchas de fulbito, una de vóley y una
de básquet que los vecinos alquilan por horas. Los vecinos se registran una
vez en la administración y desde entonces reservan desde la página web; los
socios, que pagan una cuota mensual, tienen descuento.

Es una aplicación pequeña pero completa: una API HTTP en JSON, una base de
datos SQLite, una página web sin frameworks y una suite de pruebas. Está
escrita en TypeScript y corre directamente con Node.js 26, sin paso de
compilación y sin dependencias de npm: solo usa `node:http`, `node:sqlite` y
`node:test`. Las herramientas de desarrollo (tsc, ESLint) vienen en el
contenedor del curso.

## Cómo correrla

Dentro del contenedor del curso (`./env/run.sh` desde la carpeta que contiene
este repositorio):

```sh
make start      # servidor en http://localhost:3000
make check      # tsc + eslint + pruebas: lo mismo que revisa la integración continua
make test       # solo las pruebas
make seed       # borra la base de datos y carga los datos de ejemplo
make clean      # borra la base de datos
```

La primera vez que arranca, `make start` crea `data/kallpa.db` y carga los
datos de ejemplo: cuatro canchas, diez vecinos registrados y unas quince
reservas alrededor del día de hoy. Abran `http://localhost:3000`, elijan una
cancha, una fecha y una hora libre, y reserven.

Variables de entorno: `PORT` (3000 por omisión) y `KALLPA_DB` (el archivo de
SQLite, `data/kallpa.db` por omisión). El servidor escucha en `0.0.0.0` para
que se pueda llegar a él desde fuera del contenedor.

## Reglas del negocio

Así funciona el complejo, según su administración. Si el código hace otra
cosa, el código está mal.

**Horario y reservas**

- Se reserva por bloques de una hora completa, desde las 7:00 (la primera hora
  empieza a las 7:00) hasta las 22:00 (la última empieza a las 21:00). El
  complejo atiende todos los días con el mismo horario.
- Una reserva tiene de 1 a 3 horas seguidas en la misma cancha.
- No se puede reservar una hora que ya empezó ni con más de 14 días de
  anticipación.
- Una cancha no puede tener dos reservas confirmadas que se crucen.
- Cada reserva lleva el nombre del equipo (hasta 40 caracteres), que se
  muestra en la página para que se sepa quién tiene la cancha.
- Quien reserva tiene que estar registrado (miembro `regular` o `socio`).
  Cuando la reserva se confirma, se le avisa por WhatsApp (por ahora el
  mensaje solo se escribe en la consola del servidor).

**Precios**

- Tarifa por hora: fulbito S/ 90, vóley S/ 50, básquet S/ 60.
- Hora punta: de lunes a viernes desde las 18:00 hasta el cierre, y sábados y
  domingos todo el día. En hora punta la hora cuesta 25 % más (fulbito
  S/ 112.50, vóley S/ 62.50, básquet S/ 75).
- **Cada hora se cobra con la tarifa que le corresponde**: una reserva de
  fulbito de 17:00 a 19:00 un martes paga una hora normal y una en hora punta,
  S/ 202.50.
- Los socios tienen 10 % de descuento sobre el total de la reserva.
- Todo el dinero se maneja en **céntimos**, como enteros (S/ 112.50 es
  `11250`). Con números decimales, `0.1 + 0.2` no da `0.3` y los totales no
  cuadran con la caja.

**Cancelaciones**

- Una reserva se puede cancelar hasta 3 horas antes de que empiece. Después
  ya no.
- La reserva cancelada no se borra: queda con estado `cancelled`, la hora
  vuelve a estar libre y se guarda cuándo se canceló.

### Por qué las fechas son texto y las horas, enteros

Una reserva es "el sábado 14 de marzo de 19:00 a 21:00 en Lima", no un
instante en el tiempo. Por eso Kallpa guarda la fecha como texto
`'YYYY-MM-DD'` y la hora de inicio como un entero (`19`), en vez de un
timestamp. Con timestamps habría que decidir en qué zona horaria se
interpretan, y es fácil equivocarse: las 19:00 del sábado en Lima son las
00:00 del domingo en UTC, y un servidor configurado en UTC diría que la
reserva es "el domingo". Con fecha y hora de pared no hay conversión: lo que
el vecino ve es lo que se guarda.

La única conversión está en `src/domain/clock.ts`: el reloj da un instante
(`Date`) y `limaTime` lo traduce a fecha y hora de Lima para comparar con las
reservas ("¿ya pasó esta hora?"). El Perú no tiene horario de verano, pero la
conversión usa la zona `America/Lima` de todas formas, sin restar 5 horas a
mano.

El reloj se pasa como dependencia (`Clock`) en lugar de llamar a
`new Date()` en cualquier parte: las pruebas usan un reloj fijo y siempre dan
el mismo resultado.

## La API

Todas las respuestas son JSON. Los errores tienen siempre la misma forma, con
un código estable que los clientes HTTP pueden comparar sin leer el mensaje:

```json
{ "error": { "code": "SLOT_TAKEN", "message": "La cancha ya está reservada en ese horario." } }
```

| Método y ruta | Qué hace | Respuesta |
|---|---|---|
| `GET /api/courts` | Lista las canchas | `200 { courts: [{ id, name, sport }] }` |
| `GET /api/courts/:id/availability?date=YYYY-MM-DD` | Horas libres y ocupadas de una cancha ese día | `200 { courtId, date, slots: [{ hour, available, teamName? }] }` |
| `GET /api/members` | Lista a los vecinos registrados | `200 { members: [{ id, name, phone, plan }] }` |
| `GET /api/members/:id/bookings` | Las reservas de un vecino, también las canceladas | `200 [booking, ...]` |
| `POST /api/bookings` | Reserva | `200 { booking }` |
| `POST /api/bookings/:id/cancel` | Cancela una reserva | `200 { booking }` |
| `GET /api/reports/daily?date=YYYY-MM-DD` | La caja del día, para la administración: reservas, horas y dinero por cancha | `200 { date, bookings, hours, revenueCents, courts: [...] }` |

Cuerpo de `POST /api/bookings`:

```json
{ "courtId": 1, "memberId": 2, "date": "2026-03-14", "startHour": 19, "hours": 2, "teamName": "Real Barranco" }
```

`hours` es opcional (1 por omisión). Una reserva (`booking`) tiene `id`,
`courtId`, `memberId`, `date`, `startHour`, `hours`, `teamName`,
`priceCents`, `status` (`confirmed` o `cancelled`), `createdAt` y
`cancelledAt` (instantes en ISO 8601, UTC).

| Código | HTTP | Cuándo |
|---|---|---|
| `INVALID_JSON` | 400 | El cuerpo no es JSON |
| `VALIDATION_ERROR` | 400 | Falta un campo o tiene el tipo equivocado |
| `INVALID_DATE` | 400 | La fecha no tiene el formato `YYYY-MM-DD` |
| `COURT_NOT_FOUND`, `MEMBER_NOT_FOUND`, `BOOKING_NOT_FOUND` | 404 | No existe |
| `NOT_FOUND` | 404 | La ruta no existe |
| `METHOD_NOT_ALLOWED` | 405 | La ruta existe, con otro método |
| `SLOT_TAKEN` | 409 | La cancha ya está reservada en ese horario |
| `ALREADY_CANCELLED` | 409 | La reserva ya estaba cancelada |
| `BODY_TOO_LARGE` | 413 | El cuerpo pasa de 16 KiB |
| `INVALID_HOURS`, `TOO_MANY_HOURS` | 422 | Horas que no son un entero positivo, o más de 3 |
| `OUTSIDE_OPENING_HOURS` | 422 | Fuera del horario de atención |
| `IN_THE_PAST`, `TOO_FAR_AHEAD` | 422 | La hora ya pasó, o faltan más de 14 días |
| `TOO_LATE_TO_CANCEL` | 422 | Faltan menos de 3 horas para la reserva |
| `INTERNAL_ERROR` | 500 | Cualquier otro error |

Para probarla desde la terminal del contenedor:

```sh
curl -s localhost:3000/api/courts | jq
curl -s -X POST localhost:3000/api/bookings -H 'content-type: application/json' \
  -d '{"courtId":1,"memberId":2,"date":"2026-03-14","startHour":19,"hours":2,"teamName":"Real Barranco"}' | jq
```

(cambien la fecha por una de los próximos 14 días).

## Las carpetas

```
kallpa/
├── src/
│   ├── main.ts                  arranca el servidor
│   ├── config.ts                PORT y KALLPA_DB
│   ├── seed.ts                  make seed
│   ├── domain/                  reglas del negocio, sin base de datos ni HTTP
│   │   ├── booking.ts           horario, límites, cruces y cancelación
│   │   ├── calendar.ts          fechas 'YYYY-MM-DD'
│   │   ├── clock.ts             el reloj inyectable y la hora de Lima
│   │   ├── court.ts             canchas y deportes
│   │   ├── member.ts            vecinos registrados y socios
│   │   ├── pricing.ts           tarifas, hora punta y descuento
│   │   └── report.ts            la caja del día
│   ├── store/                   SQLite: esquema, migraciones y repositorios
│   ├── services/                booking-service.ts: acceso a los datos para la capa HTTP
│   ├── notifications/           el aviso por WhatsApp
│   └── http/                    servidor, router, handlers y archivos estáticos
├── public/                      la página: index.html, app.js, styles.css
├── test/                        pruebas (node:test); helpers.ts levanta un servidor de prueba
└── data/                        la base de datos (no se sube al repositorio)
```

Las dependencias van en un solo sentido: `http` usa `services`, `domain` y
`notifications`; `services` usa `store`; `store` y `domain` no saben nada de
HTTP.

## Pruebas

`make test` corre los archivos `test/*.test.ts` con el runner de Node. Las
pruebas de `domain` llaman a las funciones directamente; las de
`http.test.ts` levantan el servidor en un puerto libre con una base de datos
en memoria (`:memory:`), el catálogo de ejemplo sin reservas y un reloj fijo
en el lunes 9 de marzo de 2026 a las 10:00 (ver `test/helpers.ts`). Nada
depende de la fecha real ni de la zona horaria de la computadora.

```sh
node --test test/pricing.test.ts                         # un archivo
node --test --test-name-pattern="cancel" "test/**/*.test.ts"   # pruebas cuyo nombre contiene "cancel"
```

Las pruebas cuentan con los datos de `src/store/seed.ts`: la cancha 1 es de
fulbito, la 3 de vóley, el miembro 1 (Rosa Quispe) es regular y el 2 (Luis
Huamán) es socio.
