Files
AgendaPro/docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md
T
AgendaPro DevandClaude Opus 5 6d67b23e55 feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.

## Multi-tenancy

El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.

- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
  fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
  acabaríamos mandando como credencial al CRM. La clave maestra vive en
  `CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
  `locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
  parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
  nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
  el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
  defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
  ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
  100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
  POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.

Lo único de la credencial que sale del servidor es la huella de 6 caracteres.

## Consola de superadministración

`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».

Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.

## Sincronización por identificador

`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.

- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
  002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
  escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
  habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.

## Verificado contra la subcuenta real, no deducido

Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.

47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.

110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.

## Deuda conocida, dicha sin rodeos

- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
  rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
  de esa base: no debe quedar expuesta a internet hasta endurecerla.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-30 15:07:20 -06:00

2636 lines
97 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Núcleo Yola sobre Postgres — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Levantar el backend Postgres de la plataforma de Yola Franco Spa y construir sobre él lo único que ninguna otra pieza resuelve — que el desenlace de cada cita quede registrado —, reutilizando sin cambios el frontend React/PWA de este repo.
**Architecture:** Un backend nuevo en `platform/` (TypeScript + Express + `pg`) que habla exactamente los mismos contratos `/api` que el frontend ya consume, contra un Postgres 16 en Docker. La doble reserva deja de ser un `if` dentro de una transacción y pasa a ser una restricción de exclusión por rango (`EXCLUDE USING gist`) que el motor rechaza. Sobre eso se añaden las tres piezas que hoy no existen en ninguna parte: `visits` separada de `appointments`, el toque de asistencia, y el cierre de día que no deja citas sin resolver. El `server/` de SQLite queda intacto: sigue siendo la demo de AgendaPro y el punto de comparación.
**Tech Stack:** Node 22.5+, TypeScript, Express 4, `pg` (node-postgres), PostgreSQL 16 (Docker), `node:test` + `tsx`, React 18 + Vite (el frontend existente, sin tocar su stack).
**Spec:**
- `docs/yola-franco-spa-plataforma-propuesta-tecnica.md` (§2.2, §3, §5, §10, §11 Fase 1, §12)
- `H:\MegaSync\Proyectos\Bucéfalo Agent\docs\plataforma-intermedia\05-caso-yola-spa.md` (§3 modelo de dominio, §5 flujos, §6 doble reserva, §7 alcance mínimo, §8 riesgos)
- `H:\MegaSync\Proyectos\Bucéfalo Agent\docs\plataforma-intermedia\03-sincronizacion-bidireccional.md` (§1 propiedad del dato — se usa aquí solo para no cerrarle la puerta a la Fase 2)
---
## Global Constraints
- **Node.js >= 22.5.** Todo script de servidor se lanza con `node scripts/run-tsx.mjs`, nunca con `npx tsx` directo.
- **Windows/PowerShell:** usar `npm.cmd` si `npm.ps1` está bloqueado por execution policy.
- **Español** en todo string visible al usuario y en todo mensaje de error de la API. Los identificadores de código y los nombres de tabla van en inglés, igual que el `server/` actual, porque el frontend ya consume esos nombres.
- **Multi-tenancy:** salvo `businesses` y `users`, toda tabla lleva `business_id` y **toda** query filtra por `req.user!.business_id`. Nunca se acepta un `business_id` que venga del body.
- **Zonas horarias:** todo instante se guarda en `timestamptz` UTC. La zona sale de `businesses.timezone` (default `America/Mexico_City`) y solo se aplica al presentar. **`new Date(y, m, d, hh, mm)` y `date('now')` están prohibidos en lógica de negocio.** Se usan los helpers puros de `server/lib/time.ts`, que ya están probados.
- **Puerto de Postgres: 5434.** El 5432 y el 5433 de esta máquina ya lo ocupan varios contenedores (`insta-postgres`, `webchat-db`, `analytics-pg-local`, `supabase_db_E3_Manager`, `andamios-postgres-dev`).
- **Gate de calidad:** `npm run typecheck` + los tests de este plan. `npm run lint` está roto en este repo (ESLint 9 sin `eslint.config.js`) y no cuenta como señal.
- **No hacer commits salvo que se pidan explícitamente.** Los pasos "Commit" de este plan quedan supeditados a esa política del repo: ejecútalos solo si el usuario lo autorizó en la sesión.
### Tres desviaciones respecto de la propuesta, con su argumento
1. **TypeScript + Express, no Python + FastAPI.** La propuesta pide FastAPI. Aquí se reutilizan `shared/types.ts` (el contrato cliente/servidor) y los helpers puros ya probados de `server/lib/time.ts` y `server/lib/scheduling.ts`, que codifican lecciones caras sobre zonas horarias. Reescribirlos en Python es rehacer trabajo verificado para no ganar nada en esta fase. El esquema SQL y las pruebas de este plan son portables si más adelante se decide mover el backend a Python.
2. **Identificadores `bigint`, no `uuid`.** El documento de Yola usa `uuid`. `shared/types.ts` declara `id: number` en todas las entidades y el frontend lo asume en rutas y en React Query. Cambiar a `uuid` obliga a tocar el frontend entero para no ganar nada en una sola sucursal. Si algún día hace falta un identificador opaco hacia fuera, se añade una columna `public_id uuid` sin mover la clave primaria.
3. **El enum de estado no crece todavía.** El documento propone `propuesta|confirmada|asistio|no_asistio|cancelada_clienta|cancelada_spa`. Aquí se conserva `scheduled|completed|cancelled|no_show` —lo que el frontend ya pinta— y el matiz «quién canceló» vive en una columna aparte, `cancelled_by`. Añadir estados nuevos es el alcance «cerrar huecos de agenda», que no es este entregable.
---
## File Structure
**Nuevos (backend Postgres):**
| Archivo | Responsabilidad |
|---|---|
| `platform/docker-compose.yml` | Postgres 16 en el puerto 5433, con volumen nombrado |
| `platform/.env.example` | `DATABASE_URL`, `PLATFORM_PORT` |
| `platform/db/pool.ts` | El pool de `pg` y `withTx()`; único sitio que abre conexiones |
| `platform/db/migrate.ts` | Corredor de migraciones idempotente sobre `schema_migrations` |
| `platform/db/migrations/001_core.sql` | Esquema del núcleo, con `btree_gist` y la restricción de exclusión |
| `platform/lib/phone.ts` | Normalización a E.164 con default México. **Puro** |
| `platform/lib/phone.test.ts` | Sus pruebas |
| `platform/lib/audit.ts` | `writeAudit()` — una fila por cambio relevante |
| `platform/lib/auth.ts` | `authRequired`, `ownerOnly`, `err` contra Postgres |
| `platform/routes/clients.ts` | Alta con anti-duplicados por teléfono normalizado |
| `platform/routes/appointments.ts` | Crear/mover, traduciendo la violación de exclusión a 409 |
| `platform/routes/attendance.ts` | El toque *Vino* / *No vino* |
| `platform/routes/dayClose.ts` | Citas sin resolver del día y cierre |
| `platform/index.ts` | Monta los routers, sirve en `PLATFORM_PORT` |
| `platform/test/helpers.ts` | Base de pruebas limpia + siembra mínima |
| `platform/test/*.test.ts` | Pruebas de integración por router |
**Modificados (frontend):**
| Archivo | Cambio |
|---|---|
| `shared/types.ts` | Añadir `phone_e164`, `contactable` a `Client`; `cancelled_by` a `Appointment`; tipos `Visit`, `DayCloseSummary` |
| `src/lib/api.ts` | Endpoints `attendance`, `dayClose`, `closeDay` |
| `src/pages/DayClosePage.tsx` | **Nueva.** La pantalla de cierre de día |
| `src/App.tsx` | Ruta `/cierre-dia` |
| `src/components/AppShell.tsx` | Entrada de menú «Cierre de día» |
---
## Task 1: Postgres en Docker y corredor de migraciones
**Files:**
- Create: `platform/docker-compose.yml`
- Create: `platform/.env.example`
- Create: `platform/db/pool.ts`
- Create: `platform/db/migrate.ts`
- Create: `platform/db/migrations/000_bootstrap.sql`
- Modify: `package.json` (scripts y dependencia `pg`)
- Test: `platform/test/migrate.test.ts`
**Interfaces:**
- Produces: `pool: Pool`, `withTx<T>(fn: (c: PoolClient) => Promise<T>): Promise<T>`, `runMigrations(): Promise<string[]>` (devuelve los nombres de archivo aplicados en esta corrida)
- [ ] **Step 1: Instalar la dependencia**
```bash
npm.cmd install pg && npm.cmd install -D @types/pg
```
- [ ] **Step 2: Escribir `platform/docker-compose.yml`**
```yaml
services:
db:
image: postgres:16-alpine
container_name: yola-postgres
environment:
POSTGRES_USER: yola
POSTGRES_PASSWORD: yola_dev
POSTGRES_DB: yola
ports:
- "5433:5432"
volumes:
- yola_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U yola -d yola"]
interval: 5s
timeout: 3s
retries: 10
volumes:
yola_pgdata:
```
- [ ] **Step 3: Escribir `platform/.env.example`**
```
DATABASE_URL=postgres://yola:[email protected]:5434/yola
TEST_DATABASE_URL=postgres://yola:[email protected]:5434/yola_test
PLATFORM_PORT=3100
```
- [ ] **Step 4: Levantar la base y crear la base de pruebas**
```bash
docker compose -f platform/docker-compose.yml up -d
docker exec yola-postgres psql -U yola -d yola -c "SELECT 1"
docker exec yola-postgres psql -U yola -d postgres -c "CREATE DATABASE yola_test OWNER yola"
```
Expected: `?column?` con `1`, y `CREATE DATABASE`.
- [ ] **Step 5: Escribir `platform/db/pool.ts`**
```ts
import pg from "pg";
const { Pool } = pg;
/**
* Postgres devuelve NUMERIC como string para no perder precisión, y bigint igual.
* El frontend declara `number` en shared/types.ts, así que se convierten aquí, en
* el único sitio que abre conexiones, y no en cada handler.
*/
pg.types.setTypeParser(1700, (v: string) => Number(v)); // numeric
pg.types.setTypeParser(20, (v: string) => Number(v)); // int8 / bigint
const connectionString =
process.env.DATABASE_URL || "postgres://yola:[email protected]:5434/yola";
export const pool = new Pool({ connectionString, max: 10 });
/** Ejecuta `fn` dentro de una transacción; hace ROLLBACK ante cualquier excepción. */
export async function withTx<T>(fn: (c: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const out = await fn(client);
await client.query("COMMIT");
return out;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
```
- [ ] **Step 6: Escribir `platform/db/migrations/000_bootstrap.sql`**
```sql
-- btree_gist permite mezclar un igualador (employee_id) con un operador de
-- solapamiento (&&) dentro de la misma restricción de exclusión. Sin esta
-- extensión, EXCLUDE USING gist (employee_id WITH =, during WITH &&) no compila.
CREATE EXTENSION IF NOT EXISTS btree_gist;
```
- [ ] **Step 7: Escribir el test que falla**
```ts
// platform/test/migrate.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { runMigrations } from "../db/migrate.ts";
import { pool } from "../db/pool.ts";
test("runMigrations aplica los archivos pendientes y es idempotente", async () => {
const first = await runMigrations();
assert.ok(first.includes("000_bootstrap.sql"), "debe aplicar el bootstrap");
const second = await runMigrations();
assert.deepEqual(second, [], "una segunda corrida no aplica nada");
const { rows } = await pool.query(
`SELECT count(*)::int AS c FROM schema_migrations WHERE filename = '000_bootstrap.sql'`
);
assert.equal(rows[0].c, 1, "no debe registrarse dos veces");
await pool.end();
});
```
- [ ] **Step 8: Correr el test y verificar que falla**
Run: `cross-env DATABASE_URL=postgres://yola:[email protected]:5434/yola_test node --import tsx --test platform/test/migrate.test.ts`
Expected: FAIL — `Cannot find module '../db/migrate.ts'`.
- [ ] **Step 9: Escribir `platform/db/migrate.ts`**
```ts
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pool } from "./pool.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
/**
* Aplica en orden alfabético los .sql que aún no estén en schema_migrations.
* Cada archivo corre dentro de su propia transacción: si falla a la mitad, no
* queda registrado y la siguiente corrida lo reintenta entero.
*/
export async function runMigrations(): Promise<string[]> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
`);
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql"))
.sort();
const { rows } = await pool.query<{ filename: string }>(
`SELECT filename FROM schema_migrations`
);
const applied = new Set(rows.map((r) => r.filename));
const ran: string[] = [];
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8");
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(sql);
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
await client.query("COMMIT");
ran.push(file);
console.log(`[migrate] aplicada ${file}`);
} catch (e) {
await client.query("ROLLBACK");
throw new Error(`Migración ${file} falló: ${(e as Error).message}`);
} finally {
client.release();
}
}
return ran;
}
// Permite `node scripts/run-tsx.mjs platform/db/migrate.ts` desde la línea de comandos.
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
runMigrations()
.then((ran) => {
console.log(ran.length ? `[migrate] ${ran.length} aplicadas` : "[migrate] al día");
return pool.end();
})
.catch((e) => {
console.error(e.message);
process.exit(1);
});
}
```
- [ ] **Step 10: Añadir los scripts a `package.json`**
Dentro de `"scripts"`, junto a los existentes:
```json
"pg:up": "docker compose -f platform/docker-compose.yml up -d",
"pg:down": "docker compose -f platform/docker-compose.yml down",
"pg:migrate": "node scripts/run-tsx.mjs platform/db/migrate.ts",
"test:platform": "cross-env DATABASE_URL=postgres://yola:[email protected]:5434/yola_test node --import tsx --test platform/test/*.test.ts platform/lib/*.test.ts"
```
- [ ] **Step 11: Correr el test y verificar que pasa**
Run: `npm.cmd run test:platform -- platform/test/migrate.test.ts`
Expected: PASS, 1 test.
- [ ] **Step 12: Commit**
```bash
git add platform package.json package-lock.json
git commit -m "feat(platform): Postgres en Docker y corredor de migraciones"
```
---
## Task 2: Esquema del núcleo con exclusión por rango
**Files:**
- Create: `platform/db/migrations/001_core.sql`
- Create: `platform/test/helpers.ts`
- Test: `platform/test/schema.test.ts`
**Interfaces:**
- Consumes: `pool`, `withTx`, `runMigrations` de la Task 1
- Produces: `resetDb(): Promise<void>` y `seedMinimal(): Promise<SeedIds>` donde
`SeedIds = { businessId: number; ownerUserId: number; employeeUserId: number; employeeId: number; serviceId: number; clientId: number }`
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/test/schema.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
before(async () => {
await resetDb();
ids = await seedMinimal();
});
after(async () => { await pool.end(); });
test("la base rechaza dos citas solapadas de la misma empleada", async () => {
const ins = `INSERT INTO appointments
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
VALUES ($1,$2,$3,$4,$5,$6,0) RETURNING id`;
await pool.query(ins, [
ids.businessId, ids.clientId, ids.employeeId, ids.serviceId,
"2026-09-01T16:00:00Z", "2026-09-01T17:00:00Z",
]);
await assert.rejects(
() => pool.query(ins, [
ids.businessId, ids.clientId, ids.employeeId, ids.serviceId,
"2026-09-01T16:30:00Z", "2026-09-01T17:30:00Z",
]),
(e: any) => e.code === "23P01",
"debe ser una violación de exclusión (23P01), no un error cualquiera"
);
});
test("una cita cancelada libera el hueco", async () => {
await pool.query(
`UPDATE appointments SET status = 'cancelled', cancelled_by = 'client'
WHERE business_id = $1`, [ids.businessId]
);
const { rows } = await pool.query(
`INSERT INTO appointments
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
VALUES ($1,$2,$3,$4,'2026-09-01T16:15:00Z','2026-09-01T17:15:00Z',0)
RETURNING id`,
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId]
);
assert.ok(rows[0].id > 0);
});
test("dos clientas del mismo negocio no pueden compartir teléfono normalizado", async () => {
const ins = `INSERT INTO clients (business_id, name, phone, phone_e164)
VALUES ($1,$2,$3,$4) RETURNING id`;
await pool.query(ins, [ids.businessId, "Ana", "55 1111 2222", "+525511112222"]);
await assert.rejects(
() => pool.query(ins, [ids.businessId, "Ana (dup)", "5511112222", "+525511112222"]),
(e: any) => e.code === "23505"
);
});
test("dos clientas sin teléfono sí pueden coexistir", async () => {
const ins = `INSERT INTO clients (business_id, name, phone, phone_e164)
VALUES ($1,$2,NULL,NULL) RETURNING id, contactable`;
const a = await pool.query(ins, [ids.businessId, "Sin teléfono 1"]);
const b = await pool.query(ins, [ids.businessId, "Sin teléfono 2"]);
assert.ok(a.rows[0].id !== b.rows[0].id);
assert.equal(a.rows[0].contactable, false, "sin teléfono = no contactable");
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `npm.cmd run test:platform -- platform/test/schema.test.ts`
Expected: FAIL — `Cannot find module './helpers.ts'`.
- [ ] **Step 3: Escribir `platform/db/migrations/001_core.sql`**
```sql
-- ---------------------------------------------------------------------------
-- Núcleo de la plataforma. Nombres de tabla y columna en inglés a propósito:
-- son los que shared/types.ts y el frontend ya consumen.
-- ---------------------------------------------------------------------------
CREATE TABLE businesses (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
industry text NOT NULL DEFAULT 'Estética y Spa',
currency text NOT NULL DEFAULT 'MXN',
currency_symbol text NOT NULL DEFAULT '$',
phone text,
address text,
slug text UNIQUE,
timezone text NOT NULL DEFAULT 'America/Mexico_City',
working_hours jsonb NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE employees (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
name text NOT NULL,
email text,
phone text,
color text NOT NULL DEFAULT '#3b66ff',
role text NOT NULL DEFAULT 'specialist',
active boolean NOT NULL DEFAULT true,
working_hours jsonb, -- NULL = hereda del negocio
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE services (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
category text NOT NULL DEFAULT 'General',
duration_min integer NOT NULL DEFAULT 60,
price numeric(10,2) NOT NULL DEFAULT 0,
color text NOT NULL DEFAULT '#3b66ff',
commission_pct numeric(5,2) NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE employee_services (
employee_id bigint NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
service_id bigint NOT NULL REFERENCES services(id) ON DELETE CASCADE,
PRIMARY KEY (employee_id, service_id)
);
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint REFERENCES businesses(id) ON DELETE CASCADE,
email text NOT NULL UNIQUE,
password text NOT NULL,
name text NOT NULL,
role text NOT NULL CHECK (role IN ('admin','owner','employee')),
employee_id bigint REFERENCES employees(id),
avatar_color text NOT NULL DEFAULT '#3b66ff',
created_at timestamptz NOT NULL DEFAULT now()
);
-- La clienta. `phone_e164` es la clave de identidad: es lo único que puede
-- reconciliar el mismo número que llega por canales distintos, y el índice
-- parcial de abajo es lo que impide el duplicado.
CREATE TABLE clients (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
name text NOT NULL,
email text,
phone text, -- lo que tecleó la persona, tal cual
phone_e164 text, -- lo normalizado; NULL si no se pudo
contactable boolean GENERATED ALWAYS AS (phone_e164 IS NOT NULL) STORED,
birth_date date,
notes text,
tags text,
source_channel text, -- whatsapp|facebook|instagram|mostrador|referido
-- Se declara desde el día uno aunque la Fase 2 aún no exista: es el ancla de
-- correlación con Bucéfalo CRM, y añadirla después obliga a un backfill que
-- no se puede hacer sin releer el CRM entero.
crm_contact_id text,
crm_synced_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz -- baja lógica: la clienta nunca se borra
);
-- Un mismo teléfono no puede repetirse dentro de un negocio. Es parcial porque
-- el 40.8 % del histórico medido no tiene teléfono y esas filas deben convivir.
CREATE UNIQUE INDEX clients_phone_unique
ON clients (business_id, phone_e164)
WHERE phone_e164 IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX clients_business_name ON clients (business_id, name);
CREATE TABLE appointments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
client_id bigint NOT NULL REFERENCES clients(id),
employee_id bigint NOT NULL REFERENCES employees(id),
service_id bigint NOT NULL REFERENCES services(id),
start_at timestamptz NOT NULL,
-- `end_at` se materializa, no se deriva: si mañana cambia la duración del
-- servicio, las citas ya agendadas no deben moverse.
end_at timestamptz NOT NULL,
during tstzrange GENERATED ALWAYS AS (tstzrange(start_at, end_at, '[)')) STORED,
status text NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled','completed','cancelled','no_show')),
cancelled_by text CHECK (cancelled_by IN ('client','business')),
cancel_reason text,
price numeric(10,2) NOT NULL DEFAULT 0,
notes text,
source_channel text,
created_by_user_id bigint REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT appointments_end_after_start CHECK (end_at > start_at),
CONSTRAINT appointments_cancelled_by_only_when_cancelled
CHECK (cancelled_by IS NULL OR status = 'cancelled'),
-- Aquí está la diferencia con el backend de SQLite: la doble reserva deja de
-- ser una validación que alguien puede saltarse y pasa a ser el motor
-- rechazando la fila. Las canceladas no reservan hueco.
CONSTRAINT appointments_no_overlap EXCLUDE USING gist (
employee_id WITH =,
during WITH &&
) WHERE (status <> 'cancelled')
);
CREATE INDEX appointments_business_start ON appointments (business_id, start_at);
CREATE INDEX appointments_employee_start ON appointments (employee_id, start_at);
CREATE INDEX appointments_client ON appointments (client_id);
-- La visita es el hecho consumado, y está separada de la cita a propósito:
-- una cita es una intención. Fusionarlas es el error que dejó 3 002
-- oportunidades congeladas en el CRM — un registro que sirve para planear y
-- para cerrar termina sin cerrarse nunca.
CREATE TABLE visits (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
appointment_id bigint UNIQUE REFERENCES appointments(id),
client_id bigint NOT NULL REFERENCES clients(id),
employee_id bigint NOT NULL REFERENCES employees(id),
occurred_at timestamptz NOT NULL,
total_charged numeric(10,2),
payment_method text CHECK (payment_method IN ('cash','card','transfer','other')),
recorded_by_user_id bigint REFERENCES users(id),
recorded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX visits_business_occurred ON visits (business_id, occurred_at);
CREATE INDEX visits_client ON visits (client_id);
-- Historial de la cita. Append-only: nunca se actualiza ni se borra.
CREATE TABLE appointment_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
appointment_id bigint NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
actor_user_id bigint REFERENCES users(id),
action text NOT NULL, -- created|rescheduled|cancelled|attended|no_show
from_status text,
to_status text,
detail jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX appointment_events_appointment ON appointment_events (appointment_id, created_at);
-- Quién cambió qué, cuándo y desde dónde.
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint,
actor_user_id bigint REFERENCES users(id),
entity text NOT NULL,
entity_id bigint,
action text NOT NULL,
before jsonb,
after jsonb,
ip text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX audit_log_business_created ON audit_log (business_id, created_at DESC);
CREATE INDEX audit_log_entity ON audit_log (entity, entity_id);
-- El cierre de día. Una fila por día cerrado; la restricción única es lo que
-- hace que cerrar dos veces no sea posible.
CREATE TABLE day_closures (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
business_date date NOT NULL,
closed_by_user_id bigint NOT NULL REFERENCES users(id),
closed_at timestamptz NOT NULL DEFAULT now(),
attended_count integer NOT NULL,
no_show_count integer NOT NULL,
cancelled_count integer NOT NULL,
UNIQUE (business_id, business_date)
);
```
- [ ] **Step 4: Escribir `platform/test/helpers.ts`**
```ts
import { pool } from "../db/pool.ts";
import { runMigrations } from "../db/migrate.ts";
export interface SeedIds {
businessId: number;
ownerUserId: number;
employeeUserId: number;
employeeId: number;
serviceId: number;
clientId: number;
}
const WORKING_HOURS = JSON.stringify({
1: { start: "09:00", end: "20:00" },
2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" },
4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" },
6: { start: "10:00", end: "18:00" },
7: null,
});
/** Deja la base vacía y con el esquema al día. Solo para la base de pruebas. */
export async function resetDb(): Promise<void> {
if (!/yola_test/.test(process.env.DATABASE_URL || "")) {
throw new Error("resetDb solo corre contra yola_test — revisa DATABASE_URL");
}
await pool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
await runMigrations();
}
export async function seedMinimal(): Promise<SeedIds> {
const biz = await pool.query(
`INSERT INTO businesses (name, slug, working_hours)
VALUES ('Yola Franco Spa', 'yola-franco', $1::jsonb) RETURNING id`,
[WORKING_HOURS]
);
const businessId = biz.rows[0].id as number;
const emp = await pool.query(
`INSERT INTO employees (business_id, name, email) VALUES ($1,'Karla Ruiz','[email protected]')
RETURNING id`, [businessId]
);
const employeeId = emp.rows[0].id as number;
const svc = await pool.query(
`INSERT INTO services (business_id, name, duration_min, price)
VALUES ($1,'Extensiones de pestañas',90,850) RETURNING id`, [businessId]
);
const serviceId = svc.rows[0].id as number;
await pool.query(
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
[employeeId, serviceId]
);
const owner = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner') RETURNING id`,
[businessId]
);
const empUser = await pool.query(
`INSERT INTO users (business_id, email, password, name, role, employee_id)
VALUES ($1,'[email protected]','demo1234','Karla Ruiz','employee',$2) RETURNING id`,
[businessId, employeeId]
);
const cli = await pool.query(
`INSERT INTO clients (business_id, name, phone, phone_e164)
VALUES ($1,'Mariana López','55 8888 7777','+525588887777') RETURNING id`,
[businessId]
);
return {
businessId,
ownerUserId: owner.rows[0].id,
employeeUserId: empUser.rows[0].id,
employeeId,
serviceId,
clientId: cli.rows[0].id,
};
}
```
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `npm.cmd run test:platform -- platform/test/schema.test.ts`
Expected: PASS, 4 tests. El de solape debe fallar con código `23P01`, no con otro.
- [ ] **Step 6: Commit**
```bash
git add platform/db/migrations/001_core.sql platform/test
git commit -m "feat(platform): esquema del núcleo con exclusión por rango"
```
---
## Task 3: Normalización de teléfono a E.164
**Files:**
- Create: `platform/lib/phone.ts`
- Test: `platform/lib/phone.test.ts`
**Interfaces:**
- Produces: `normalizePhone(raw: string | null | undefined, defaultCountry?: string): string | null`
Es una función pura y no toca la base, así que sus pruebas corren sin Postgres.
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/lib/phone.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { normalizePhone } from "./phone.ts";
test("normaliza las formas mexicanas de diez dígitos", () => {
assert.equal(normalizePhone("5588887777"), "+525588887777");
assert.equal(normalizePhone("55 8888 7777"), "+525588887777");
assert.equal(normalizePhone("(55) 8888-7777"), "+525588887777");
assert.equal(normalizePhone("55.8888.7777"), "+525588887777");
});
test("acepta el prefijo de larga distancia 01", () => {
assert.equal(normalizePhone("01 55 8888 7777"), "+525588887777");
});
test("acepta el 52 con y sin más", () => {
assert.equal(normalizePhone("+52 55 8888 7777"), "+525588887777");
assert.equal(normalizePhone("525588887777"), "+525588887777");
assert.equal(normalizePhone("0052 55 8888 7777"), "+525588887777");
});
test("colapsa el 521 heredado de WhatsApp al formato actual", () => {
// El 1 después del 52 era el marcador de móvil; desde 2019 ya no se disca,
// pero sigue apareciendo en los identificadores de mensajería.
assert.equal(normalizePhone("5215588887777"), "+525588887777");
assert.equal(normalizePhone("+52 1 55 8888 7777"), "+525588887777");
});
test("respeta un internacional que no es México", () => {
assert.equal(normalizePhone("+1 305 555 0134"), "+13055550134");
assert.equal(normalizePhone("+34 600 123 456"), "+34600123456");
});
test("devuelve null cuando no se puede normalizar", () => {
assert.equal(normalizePhone(null), null);
assert.equal(normalizePhone(""), null);
assert.equal(normalizePhone(" "), null);
assert.equal(normalizePhone("no tengo"), null);
assert.equal(normalizePhone("123"), null, "demasiado corto");
assert.equal(normalizePhone("12345678901234567"), null, "demasiado largo");
});
test("es idempotente sobre su propia salida", () => {
const once = normalizePhone("55 8888 7777")!;
assert.equal(normalizePhone(once), once);
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `node --import tsx --test platform/lib/phone.test.ts`
Expected: FAIL — `Cannot find module './phone.ts'`.
- [ ] **Step 3: Escribir `platform/lib/phone.ts`**
```ts
/**
* Normaliza un teléfono a E.164 (`+` seguido de 8 a 15 dígitos).
*
* Es la clave de identidad de la clienta: sin ella, el mismo número tecleado de
* dos formas produce dos fichas, y la auditoría del spa midió que el teléfono es
* el único campo con cobertura suficiente para reconciliar canales.
*
* Devuelve `null` cuando no se puede normalizar con certeza. `null` no es un
* error: significa "clienta no contactable", que es un estado legítimo y medido
* (40.8 % del histórico). Nunca se inventa un país para rellenarlo.
*/
export function normalizePhone(
raw: string | null | undefined,
defaultCountry = "52"
): string | null {
if (raw == null) return null;
const trimmed = String(raw).trim();
if (!trimmed) return null;
// Una letra en el campo significa texto libre ("no tengo", "el de su mamá"),
// no un teléfono mal escrito. No se intenta rescatar.
if (/[a-zA-Z]/.test(trimmed)) return null;
const explicitIntl = trimmed.startsWith("+") || /^00\d/.test(trimmed);
let digits = trimmed.replace(/\D/g, "");
if (trimmed.startsWith("00")) digits = digits.slice(2);
if (!digits) return null;
if (!explicitIntl) {
// Prefijo nacional de larga distancia mexicano.
digits = digits.replace(/^0+/, "");
}
// "52 1 XXXXXXXXXX": el 1 de móvil que WhatsApp sigue arrastrando.
if (digits.length === 13 && digits.startsWith(`${defaultCountry}1`)) {
digits = defaultCountry + digits.slice(3);
}
// Diez dígitos sueltos = número nacional.
if (!explicitIntl && digits.length === 10) {
digits = defaultCountry + digits;
}
if (digits.length < 8 || digits.length > 15) return null;
return `+${digits}`;
}
```
- [ ] **Step 4: Correr los tests y verificar que pasan**
Run: `node --import tsx --test platform/lib/phone.test.ts`
Expected: PASS, 7 tests.
- [ ] **Step 5: Commit**
```bash
git add platform/lib/phone.ts platform/lib/phone.test.ts
git commit -m "feat(platform): normalización de teléfono a E.164"
```
---
## Task 4: Autenticación y registro de auditoría
**Files:**
- Create: `platform/lib/auth.ts`
- Create: `platform/lib/audit.ts`
- Test: `platform/test/audit.test.ts`
**Interfaces:**
- Consumes: `pool`, `withTx`
- Produces:
- `AuthedRequest` (Express `Request` con `user?: PlatformUser`)
- `authRequired`, `ownerOnly`, `err(res, status, message)`, `h(fn)`
- `writeAudit(c: PoolClient, entry: AuditEntry): Promise<void>` con
`AuditEntry = { businessId: number | null; actorUserId: number | null; entity: string; entityId: number | null; action: string; before?: unknown; after?: unknown; ip?: string | null }`
**Nota de alcance:** la autenticación se porta **tal cual está** (token = id de usuario, contraseña sin hashear). Endurecerla es un entregable aparte y hacerlo a medias rompe login, `authRequired`, `src/lib/api.ts`, el `AuthProvider` y los `.mjs` de prueba a la vez. Queda anotado como deuda explícita.
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/test/audit.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool, withTx } from "../db/pool.ts";
import { writeAudit } from "../lib/audit.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
before(async () => { await resetDb(); ids = await seedMinimal(); });
after(async () => { await pool.end(); });
test("writeAudit guarda el antes y el después como jsonb", async () => {
await withTx(async (c) => {
await writeAudit(c, {
businessId: ids.businessId,
actorUserId: ids.ownerUserId,
entity: "clients",
entityId: ids.clientId,
action: "update",
before: { name: "Mariana" },
after: { name: "Mariana López" },
ip: "127.0.0.1",
});
});
const { rows } = await pool.query(
`SELECT entity, action, before, after, ip FROM audit_log
WHERE entity_id = $1 ORDER BY id DESC LIMIT 1`, [ids.clientId]
);
assert.equal(rows[0].entity, "clients");
assert.equal(rows[0].action, "update");
assert.deepEqual(rows[0].before, { name: "Mariana" });
assert.deepEqual(rows[0].after, { name: "Mariana López" });
assert.equal(rows[0].ip, "127.0.0.1");
});
test("writeAudit se apunta a la transacción que lo llama", async () => {
await assert.rejects(
withTx(async (c) => {
await writeAudit(c, {
businessId: ids.businessId, actorUserId: ids.ownerUserId,
entity: "clients", entityId: ids.clientId, action: "delete",
});
throw new Error("boom");
})
);
const { rows } = await pool.query(
`SELECT count(*)::int c FROM audit_log WHERE action = 'delete'`
);
assert.equal(rows[0].c, 0, "el rollback debe llevarse también la auditoría");
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `npm.cmd run test:platform -- platform/test/audit.test.ts`
Expected: FAIL — `Cannot find module '../lib/audit.ts'`.
- [ ] **Step 3: Escribir `platform/lib/audit.ts`**
```ts
import type { PoolClient } from "pg";
export interface AuditEntry {
businessId: number | null;
actorUserId: number | null;
entity: string;
entityId: number | null;
action: string;
before?: unknown;
after?: unknown;
ip?: string | null;
}
/**
* Escribe una fila de auditoría **con el cliente de la transacción en curso**.
* Recibe el `PoolClient` a propósito y no usa el pool por su cuenta: si el
* cambio se revierte, su rastro tiene que revertirse con él. Una auditoría que
* registra cambios que no ocurrieron es peor que no tener auditoría.
*/
export async function writeAudit(c: PoolClient, e: AuditEntry): Promise<void> {
await c.query(
`INSERT INTO audit_log
(business_id, actor_user_id, entity, entity_id, action, before, after, ip)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8)`,
[
e.businessId,
e.actorUserId,
e.entity,
e.entityId,
e.action,
e.before === undefined ? null : JSON.stringify(e.before),
e.after === undefined ? null : JSON.stringify(e.after),
e.ip ?? null,
]
);
}
```
- [ ] **Step 4: Escribir `platform/lib/auth.ts`**
```ts
import type { Request, Response, NextFunction } from "express";
import { pool } from "../db/pool.ts";
export interface PlatformUser {
id: number;
business_id: number | null;
email: string;
name: string;
role: "admin" | "owner" | "employee";
employee_id: number | null;
avatar_color: string;
}
export interface AuthedRequest extends Request {
user?: PlatformUser;
}
/**
* DEUDA CONOCIDA: el token es el id del usuario en texto plano y la contraseña
* se compara sin hashear. Se porta tal cual desde el backend de demo para no
* romper `src/lib/api.ts`, el AuthProvider y los .mjs de prueba en el mismo
* cambio. Endurecerlo es un entregable propio: bcrypt/Argon2id + sesión real +
* los cinco sitios a la vez.
*/
export async function authRequired(
req: AuthedRequest, res: Response, next: NextFunction
) {
const header = req.header("authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : req.header("x-user-id");
if (!token) return err(res, 401, "No autorizado");
const userId = Number(token);
if (!Number.isFinite(userId)) return err(res, 401, "Token inválido");
const { rows } = await pool.query<PlatformUser>(
`SELECT id, business_id, email, name, role, employee_id, avatar_color
FROM users WHERE id = $1`, [userId]
);
if (!rows[0]) return err(res, 401, "Usuario no encontrado");
req.user = rows[0];
next();
}
export function ownerOnly(req: AuthedRequest, res: Response, next: NextFunction) {
if (req.user?.role !== "owner") {
return err(res, 403, "Solo la administradora puede realizar esta acción");
}
next();
}
export function err(res: Response, status: number, message: string) {
return res.status(status).json({ error: message });
}
/** Envuelve un handler async para que un rechazo no cuelgue la petición. */
export function h(
fn: (req: AuthedRequest, res: Response) => Promise<unknown>
) {
return (req: AuthedRequest, res: Response, next: NextFunction) => {
fn(req, res).catch(next);
};
}
```
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `npm.cmd run test:platform -- platform/test/audit.test.ts`
Expected: PASS, 2 tests.
- [ ] **Step 6: Commit**
```bash
git add platform/lib/auth.ts platform/lib/audit.ts platform/test/audit.test.ts
git commit -m "feat(platform): auth portada y registro de auditoría transaccional"
```
---
## Task 5: Alta de clienta con anti-duplicados
**Files:**
- Create: `platform/routes/clients.ts`
- Create: `platform/index.ts`
- Test: `platform/test/clients.test.ts`
**Interfaces:**
- Consumes: `normalizePhone`, `writeAudit`, `authRequired`, `err`, `h`, `withTx`, `pool`
- Produces: el router `clientsRouter` montado en `/api/clients`, y `createApp(): express.Express` desde `platform/index.ts` para que las pruebas levanten el servidor sin puerto fijo.
Contrato del alta:
- `POST /api/clients` con `{ name, phone?, email?, notes?, source_channel? }`
- `201 { client }` si es nueva
- **`409 { error, existing: Client }`** si el teléfono normalizado ya existe en ese negocio. El 409 devuelve la ficha existente para que la interfaz pueda ofrecer abrirla en vez de duplicar.
- `GET /api/clients?q=` busca por nombre, por teléfono tal cual y **por teléfono normalizado**: tecleando `5588887777` encuentra a quien está guardada como `+52 55 8888 7777`.
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/test/clients.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
const addr = server.address() as { port: number };
base = `http://127.0.0.1:${addr.port}`;
});
after(async () => { server.close(); await pool.end(); });
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
test("crea una clienta y guarda el teléfono normalizado", async () => {
const r = await req("/api/clients", {
method: "POST",
body: JSON.stringify({ name: "Sofía Ramírez", phone: "(55) 4444-3333" }),
});
assert.equal(r.status, 201);
const { client } = await r.json();
assert.equal(client.phone, "(55) 4444-3333", "conserva lo que tecleó la persona");
assert.equal(client.phone_e164, "+525544443333");
assert.equal(client.contactable, true);
});
test("el segundo alta con el mismo número devuelve 409 con la ficha existente", async () => {
const r = await req("/api/clients", {
method: "POST",
body: JSON.stringify({ name: "Sofia R.", phone: "+52 55 4444 3333" }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ya existe/i);
assert.equal(body.existing.name, "Sofía Ramírez");
assert.equal(body.existing.phone_e164, "+525544443333");
});
test("permite dar de alta sin teléfono, marcada como no contactable", async () => {
const r = await req("/api/clients", {
method: "POST",
body: JSON.stringify({ name: "Clienta de mostrador" }),
});
assert.equal(r.status, 201);
const { client } = await r.json();
assert.equal(client.phone_e164, null);
assert.equal(client.contactable, false);
});
test("la búsqueda encuentra por teléfono sin formato", async () => {
const r = await req("/api/clients?q=5544443333");
const { clients } = await r.json();
assert.equal(clients.length, 1);
assert.equal(clients[0].name, "Sofía Ramírez");
});
test("el alta deja rastro en audit_log", async () => {
const { rows } = await pool.query(
`SELECT action, actor_user_id FROM audit_log
WHERE entity = 'clients' AND action = 'create' ORDER BY id DESC LIMIT 1`
);
assert.equal(rows[0].action, "create");
assert.equal(rows[0].actor_user_id, ids.ownerUserId);
});
test("no se ven clientas de otro negocio", async () => {
const other = await pool.query(
`INSERT INTO businesses (name, slug, working_hours) VALUES ('Otro Spa','otro','{}'::jsonb)
RETURNING id`
);
const otherUser = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','x','Otro','owner') RETURNING id`, [other.rows[0].id]
);
const r = await req("/api/clients", {}, otherUser.rows[0].id);
const { clients } = await r.json();
assert.equal(clients.length, 0);
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `npm.cmd run test:platform -- platform/test/clients.test.ts`
Expected: FAIL — `Cannot find module '../index.ts'`.
- [ ] **Step 3: Escribir `platform/routes/clients.ts`**
```ts
import { Router } from "express";
import { pool, withTx } from "../db/pool.ts";
import { normalizePhone } from "../lib/phone.ts";
import { writeAudit } from "../lib/audit.ts";
import { err, h, type AuthedRequest } from "../lib/auth.ts";
export const clientsRouter = Router();
const COLS = `id, business_id, name, email, phone, phone_e164, contactable,
birth_date, notes, tags, source_channel, created_at`;
clientsRouter.get("/", h(async (req: AuthedRequest, res) => {
const q = (req.query.q as string | undefined)?.trim();
const bid = req.user!.business_id;
if (!q) {
const { rows } = await pool.query(
`SELECT ${COLS} FROM clients
WHERE business_id = $1 AND deleted_at IS NULL
ORDER BY name LIMIT 50`, [bid]
);
return res.json({ clients: rows });
}
// Se busca por tres vías a la vez: el nombre, el teléfono tal cual se guardó,
// y el normalizado. La tercera es la que hace que teclear "5588887777"
// encuentre a quien está guardada como "+52 55 8888 7777".
const like = `%${q}%`;
const e164 = normalizePhone(q);
const { rows } = await pool.query(
`SELECT ${COLS} FROM clients
WHERE business_id = $1 AND deleted_at IS NULL
AND (name ILIKE $2 OR phone ILIKE $2 OR email ILIKE $2
OR ($3::text IS NOT NULL AND phone_e164 = $3))
ORDER BY name LIMIT 50`,
[bid, like, e164]
);
res.json({ clients: rows });
}));
clientsRouter.get("/:id", h(async (req: AuthedRequest, res) => {
const { rows } = await pool.query(
`SELECT ${COLS} FROM clients
WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
[Number(req.params.id), req.user!.business_id]
);
if (!rows[0]) return err(res, 404, "Clienta no encontrada");
res.json({ client: rows[0] });
}));
clientsRouter.post("/", h(async (req: AuthedRequest, res) => {
const { name, phone, email, notes, source_channel } = req.body ?? {};
if (!name || typeof name !== "string" || !name.trim()) {
return err(res, 400, "El nombre es obligatorio");
}
const bid = req.user!.business_id;
const e164 = normalizePhone(phone);
// Se pregunta antes de insertar para poder devolver la ficha existente. El
// índice único sigue siendo la garantía real: entre esta consulta y el INSERT
// cabe otra alta, y por eso abajo también se atrapa el 23505.
if (e164) {
const dup = await pool.query(
`SELECT ${COLS} FROM clients
WHERE business_id = $1 AND phone_e164 = $2 AND deleted_at IS NULL`,
[bid, e164]
);
if (dup.rows[0]) {
return res.status(409).json({
error: "Ya existe una clienta con ese teléfono",
existing: dup.rows[0],
});
}
}
try {
const client = await withTx(async (c) => {
const { rows } = await c.query(
`INSERT INTO clients (business_id, name, email, phone, phone_e164, notes, source_channel)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING ${COLS}`,
[bid, name.trim(), email || null, phone || null, e164, notes || null,
source_channel || null]
);
await writeAudit(c, {
businessId: bid, actorUserId: req.user!.id,
entity: "clients", entityId: rows[0].id, action: "create",
after: rows[0], ip: req.ip ?? null,
});
return rows[0];
});
res.status(201).json({ client });
} catch (e: any) {
if (e.code === "23505") {
const dup = await pool.query(
`SELECT ${COLS} FROM clients WHERE business_id = $1 AND phone_e164 = $2`,
[bid, e164]
);
return res.status(409).json({
error: "Ya existe una clienta con ese teléfono",
existing: dup.rows[0] ?? null,
});
}
throw e;
}
}));
```
- [ ] **Step 4: Escribir `platform/index.ts`**
```ts
import express from "express";
import cors from "cors";
import { authRequired } from "./lib/auth.ts";
import { clientsRouter } from "./routes/clients.ts";
export function createApp() {
const app = express();
app.use(cors());
app.use(express.json({ limit: "1mb" }));
app.use("/api/clients", authRequired, clientsRouter);
// Traductor final de errores: sin esto, un rechazo dentro de un handler async
// devuelve el HTML de stack de Express y el cliente no puede leer el mensaje.
app.use((e: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error("[platform]", e);
res.status(e?.status ?? 500).json({ error: e?.error ?? "Error interno del servidor" });
});
return app;
}
const isMain = process.argv[1]?.endsWith("platform/index.ts")
|| process.argv[1]?.endsWith("platform\\index.ts");
if (isMain) {
const port = Number(process.env.PLATFORM_PORT) || 3100;
createApp().listen(port, () => console.log(`[platform] escuchando en :${port}`));
}
```
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `npm.cmd run test:platform -- platform/test/clients.test.ts`
Expected: PASS, 6 tests.
- [ ] **Step 6: Commit**
```bash
git add platform/routes/clients.ts platform/index.ts platform/test/clients.test.ts
git commit -m "feat(platform): alta de clienta con anti-duplicados por E.164"
```
---
## Task 6: Citas — crear y reprogramar contra la exclusión
**Files:**
- Create: `platform/routes/appointments.ts`
- Modify: `platform/index.ts` (montar el router)
- Test: `platform/test/appointments.test.ts`
**Interfaces:**
- Produces: `appointmentsRouter` en `/api/appointments`
- `GET /api/appointments?from=&to=&employee_id=` → `{ appointments }`, instantes ISO-Z
- `POST /api/appointments` con `{ client_id, employee_id, service_id, start_at, notes?, source_channel? }` → `201 { appointment }` · **`409 { error }`** si choca
- `PATCH /api/appointments/:id` con `{ start_at?, employee_id?, notes? }` → `{ appointment }` · `409` si choca
- `POST /api/appointments/:id/cancel` con `{ cancelled_by, reason? }` → `{ appointment }`
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/test/appointments.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => { server.close(); await pool.end(); });
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
const nueva = (start: string) => ({
client_id: ids.clientId, employee_id: ids.employeeId,
service_id: ids.serviceId, start_at: start,
});
test("crea una cita y calcula el fin con la duración del servicio", async () => {
const r = await req("/api/appointments", {
method: "POST", body: JSON.stringify(nueva("2026-09-02T16:00:00Z")),
});
assert.equal(r.status, 201);
const { appointment } = await r.json();
// El servicio sembrado dura 90 min.
assert.equal(appointment.end_at, "2026-09-02T17:30:00Z");
assert.equal(appointment.price, 850);
assert.equal(appointment.status, "scheduled");
});
test("un solape devuelve 409 en español, no un 500", async () => {
const r = await req("/api/appointments", {
method: "POST", body: JSON.stringify(nueva("2026-09-02T17:00:00Z")),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ocupad/i);
});
test("reprogramar a un hueco libre funciona", async () => {
const { rows } = await pool.query(
`SELECT id FROM appointments ORDER BY id LIMIT 1`
);
const r = await req(`/api/appointments/${rows[0].id}`, {
method: "PATCH", body: JSON.stringify({ start_at: "2026-09-02T19:00:00Z" }),
});
assert.equal(r.status, 200);
const { appointment } = await r.json();
assert.equal(appointment.start_at, "2026-09-02T19:00:00Z");
assert.equal(appointment.end_at, "2026-09-02T20:30:00Z");
});
test("reprogramar encima de otra cita devuelve 409", async () => {
await req("/api/appointments", {
method: "POST", body: JSON.stringify(nueva("2026-09-02T12:00:00Z")),
});
const { rows } = await pool.query(
`SELECT id FROM appointments WHERE start_at = '2026-09-02T12:00:00Z'`
);
const r = await req(`/api/appointments/${rows[0].id}`, {
method: "PATCH", body: JSON.stringify({ start_at: "2026-09-02T19:30:00Z" }),
});
assert.equal(r.status, 409);
});
test("cancelar libera el hueco y registra quién canceló", async () => {
const { rows } = await pool.query(
`SELECT id FROM appointments WHERE start_at = '2026-09-02T19:00:00Z'`
);
const r = await req(`/api/appointments/${rows[0].id}/cancel`, {
method: "POST", body: JSON.stringify({ cancelled_by: "client", reason: "Se enfermó" }),
});
assert.equal(r.status, 200);
const { appointment } = await r.json();
assert.equal(appointment.status, "cancelled");
assert.equal(appointment.cancelled_by, "client");
const libre = await req("/api/appointments", {
method: "POST", body: JSON.stringify(nueva("2026-09-02T19:00:00Z")),
});
assert.equal(libre.status, 201, "el hueco de una cancelada vuelve a estar libre");
});
test("cada cambio deja un evento en appointment_events", async () => {
const { rows } = await pool.query(
`SELECT action FROM appointment_events ORDER BY id`
);
const acciones = rows.map((r) => r.action);
assert.ok(acciones.includes("created"));
assert.ok(acciones.includes("rescheduled"));
assert.ok(acciones.includes("cancelled"));
});
test("no se puede tocar una cita de otro negocio", async () => {
const other = await pool.query(
`INSERT INTO businesses (name, slug, working_hours)
VALUES ('Otro Spa 2','otro-2','{}'::jsonb) RETURNING id`
);
const otherUser = await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','x','Otra','owner') RETURNING id`, [other.rows[0].id]
);
const { rows } = await pool.query(`SELECT id FROM appointments ORDER BY id LIMIT 1`);
const r = await req(`/api/appointments/${rows[0].id}`, {
method: "PATCH", body: JSON.stringify({ notes: "intruso" }),
}, otherUser.rows[0].id);
assert.equal(r.status, 404);
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `npm.cmd run test:platform -- platform/test/appointments.test.ts`
Expected: FAIL — 404 en `POST /api/appointments` (el router no está montado).
- [ ] **Step 3: Escribir `platform/routes/appointments.ts`**
```ts
import { Router } from "express";
import { pool, withTx } from "../db/pool.ts";
import { writeAudit } from "../lib/audit.ts";
import { err, h, type AuthedRequest } from "../lib/auth.ts";
export const appointmentsRouter = Router();
// `start_at` y `end_at` se serializan a ISO-Z sin milisegundos, que es el
// formato que el frontend ya parsea. `to_char` sobre el valor en UTC evita
// depender de la zona del proceso de Node.
const COLS = `id, business_id, client_id, employee_id, service_id,
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
status, cancelled_by, cancel_reason, price, notes, source_channel, created_at`;
/** Traduce la violación de exclusión de Postgres a un 409 en español. */
function isOverlap(e: any) {
return e?.code === "23P01" && String(e?.constraint) === "appointments_no_overlap";
}
appointmentsRouter.get("/", h(async (req: AuthedRequest, res) => {
const { from, to, employee_id } = req.query as Record<string, string | undefined>;
const params: unknown[] = [req.user!.business_id];
let sql = `SELECT ${COLS} FROM appointments WHERE business_id = $1`;
if (from) { params.push(from); sql += ` AND start_at >= $${params.length}::timestamptz`; }
if (to) { params.push(to); sql += ` AND start_at < $${params.length}::timestamptz`; }
if (employee_id) {
params.push(Number(employee_id));
sql += ` AND employee_id = $${params.length}`;
}
sql += ` ORDER BY start_at`;
const { rows } = await pool.query(sql, params);
res.json({ appointments: rows });
}));
appointmentsRouter.post("/", h(async (req: AuthedRequest, res) => {
const { client_id, employee_id, service_id, start_at, notes, source_channel } = req.body ?? {};
if (!client_id || !employee_id || !service_id || !start_at) {
return err(res, 400, "Faltan datos de la cita");
}
const bid = req.user!.business_id;
const svc = await pool.query(
`SELECT duration_min, price FROM services WHERE id = $1 AND business_id = $2 AND active`,
[service_id, bid]
);
if (!svc.rows[0]) return err(res, 404, "Servicio no encontrado");
const emp = await pool.query(
`SELECT 1 FROM employees WHERE id = $1 AND business_id = $2 AND active`,
[employee_id, bid]
);
if (!emp.rows[0]) return err(res, 404, "Especialista no encontrada");
const cli = await pool.query(
`SELECT 1 FROM clients WHERE id = $1 AND business_id = $2 AND deleted_at IS NULL`,
[client_id, bid]
);
if (!cli.rows[0]) return err(res, 404, "Clienta no encontrada");
try {
const appointment = await withTx(async (c) => {
const { rows } = await c.query(
`INSERT INTO appointments
(business_id, client_id, employee_id, service_id, start_at, end_at,
price, notes, source_channel, created_by_user_id)
VALUES ($1,$2,$3,$4,$5::timestamptz,
$5::timestamptz + make_interval(mins => $6::int),
$7,$8,$9,$10)
RETURNING ${COLS}`,
[bid, client_id, employee_id, service_id, start_at,
svc.rows[0].duration_min, svc.rows[0].price, notes || null,
source_channel || null, req.user!.id]
);
await c.query(
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, to_status)
VALUES ($1,$2,'created','scheduled')`,
[rows[0].id, req.user!.id]
);
await writeAudit(c, {
businessId: bid, actorUserId: req.user!.id, entity: "appointments",
entityId: rows[0].id, action: "create", after: rows[0], ip: req.ip ?? null,
});
return rows[0];
});
res.status(201).json({ appointment });
} catch (e) {
if (isOverlap(e)) return err(res, 409, "Ese horario ya está ocupado para esta especialista");
throw e;
}
}));
appointmentsRouter.patch("/:id", h(async (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const bid = req.user!.business_id;
const { start_at, employee_id, notes } = req.body ?? {};
const cur = await pool.query(
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`,
[id, bid]
);
if (!cur.rows[0]) return err(res, 404, "Cita no encontrada");
if (cur.rows[0].status === "cancelled") {
return err(res, 409, "Una cita cancelada no se puede modificar");
}
try {
const appointment = await withTx(async (c) => {
const { rows } = await c.query(
`UPDATE appointments SET
start_at = COALESCE($3::timestamptz, start_at),
end_at = CASE WHEN $3::timestamptz IS NULL THEN end_at
ELSE $3::timestamptz + (end_at - start_at) END,
employee_id = COALESCE($4::bigint, employee_id),
notes = COALESCE($5::text, notes),
updated_at = now()
WHERE id = $1 AND business_id = $2
RETURNING ${COLS}`,
[id, bid, start_at ?? null, employee_id ?? null, notes ?? null]
);
if (start_at || employee_id) {
await c.query(
`INSERT INTO appointment_events (appointment_id, actor_user_id, action, detail)
VALUES ($1,$2,'rescheduled',$3::jsonb)`,
[id, req.user!.id, JSON.stringify({
from: { start_at: cur.rows[0].start_at, employee_id: cur.rows[0].employee_id },
to: { start_at: rows[0].start_at, employee_id: rows[0].employee_id },
})]
);
}
await writeAudit(c, {
businessId: bid, actorUserId: req.user!.id, entity: "appointments",
entityId: id, action: "update", before: cur.rows[0], after: rows[0],
ip: req.ip ?? null,
});
return rows[0];
});
res.json({ appointment });
} catch (e) {
if (isOverlap(e)) return err(res, 409, "Ese horario ya está ocupado para esta especialista");
throw e;
}
}));
appointmentsRouter.post("/:id/cancel", h(async (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const bid = req.user!.business_id;
const { cancelled_by, reason } = req.body ?? {};
if (cancelled_by !== "client" && cancelled_by !== "business") {
return err(res, 400, "Indica quién canceló: la clienta o el spa");
}
const cur = await pool.query(
`SELECT ${COLS} FROM appointments WHERE id = $1 AND business_id = $2`, [id, bid]
);
if (!cur.rows[0]) return err(res, 404, "Cita no encontrada");
const appointment = await withTx(async (c) => {
const { rows } = await c.query(
`UPDATE appointments
SET status = 'cancelled', cancelled_by = $3, cancel_reason = $4, updated_at = now()
WHERE id = $1 AND business_id = $2 RETURNING ${COLS}`,
[id, bid, cancelled_by, reason || null]
);
await c.query(
`INSERT INTO appointment_events
(appointment_id, actor_user_id, action, from_status, to_status, detail)
VALUES ($1,$2,'cancelled',$3,'cancelled',$4::jsonb)`,
[id, req.user!.id, cur.rows[0].status,
JSON.stringify({ cancelled_by, reason: reason || null })]
);
await writeAudit(c, {
businessId: bid, actorUserId: req.user!.id, entity: "appointments",
entityId: id, action: "cancel", before: cur.rows[0], after: rows[0], ip: req.ip ?? null,
});
return rows[0];
});
res.json({ appointment });
}));
```
- [ ] **Step 4: Montar el router en `platform/index.ts`**
Añadir el import y la línea de montaje junto a la de clientes:
```ts
import { appointmentsRouter } from "./routes/appointments.ts";
// …
app.use("/api/appointments", authRequired, appointmentsRouter);
```
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `npm.cmd run test:platform -- platform/test/appointments.test.ts`
Expected: PASS, 7 tests.
- [ ] **Step 6: Commit**
```bash
git add platform/routes/appointments.ts platform/index.ts platform/test/appointments.test.ts
git commit -m "feat(platform): citas con exclusión por rango traducida a 409"
```
---
## Task 7: El toque de asistencia
**Files:**
- Create: `platform/routes/attendance.ts`
- Modify: `platform/index.ts`
- Test: `platform/test/attendance.test.ts`
**Interfaces:**
- Produces: `attendanceRouter`, montado como `POST /api/appointments/:id/attendance`
- Cuerpo: `{ attended: true, total_charged?: number, payment_method?: 'cash'|'card'|'transfer'|'other' }` o `{ attended: false }`
- `attended: true` → crea la fila en `visits` y pone la cita en `completed`
- `attended: false` → pone la cita en `no_show`, **sin** fila en `visits`
- Respuesta `{ appointment, visit }` (`visit` es `null` cuando no vino)
Es la razón de ser del proyecto: es el único dato que hoy no existe en ninguna parte. La visita se crea **solo** cuando la clienta vino, porque una visita es un hecho consumado; el «no vino» es un estado de la cita, no una visita vacía.
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/test/attendance.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => { server.close(); await pool.end(); });
function req(path: string, init: RequestInit = {}, userId = ids.employeeUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
async function crearCita(start: string): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO appointments
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
VALUES ($1,$2,$3,$4,$5::timestamptz,$5::timestamptz + interval '90 minutes',850)
RETURNING id`,
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, start]
);
return rows[0].id;
}
test("marcar Vino crea la visita y completa la cita", async () => {
const id = await crearCita("2026-09-03T16:00:00Z");
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST",
body: JSON.stringify({ attended: true, total_charged: 900, payment_method: "cash" }),
});
assert.equal(r.status, 200);
const { appointment, visit } = await r.json();
assert.equal(appointment.status, "completed");
assert.equal(visit.total_charged, 900);
assert.equal(visit.payment_method, "cash");
assert.equal(visit.recorded_by_user_id, ids.employeeUserId);
});
test("marcar No vino no crea visita", async () => {
const id = await crearCita("2026-09-03T18:00:00Z");
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST", body: JSON.stringify({ attended: false }),
});
assert.equal(r.status, 200);
const { appointment, visit } = await r.json();
assert.equal(appointment.status, "no_show");
assert.equal(visit, null);
const { rows } = await pool.query(
`SELECT count(*)::int c FROM visits WHERE appointment_id = $1`, [id]
);
assert.equal(rows[0].c, 0);
});
test("marcar dos veces la misma cita devuelve 409", async () => {
const id = await crearCita("2026-09-03T20:00:00Z");
await req(`/api/appointments/${id}/attendance`, {
method: "POST", body: JSON.stringify({ attended: true }),
});
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST", body: JSON.stringify({ attended: false }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ya se resolvió/i);
});
test("no se puede marcar asistencia en una cita cancelada", async () => {
const id = await crearCita("2026-09-04T16:00:00Z");
await pool.query(
`UPDATE appointments SET status='cancelled', cancelled_by='client' WHERE id=$1`, [id]
);
const r = await req(`/api/appointments/${id}/attendance`, {
method: "POST", body: JSON.stringify({ attended: true }),
});
assert.equal(r.status, 409);
});
test("el toque deja evento y auditoría", async () => {
const { rows } = await pool.query(
`SELECT action FROM appointment_events WHERE action IN ('attended','no_show')`
);
assert.ok(rows.some((r) => r.action === "attended"));
assert.ok(rows.some((r) => r.action === "no_show"));
const audit = await pool.query(
`SELECT count(*)::int c FROM audit_log WHERE action = 'attendance'`
);
assert.ok(audit.rows[0].c >= 2);
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `npm.cmd run test:platform -- platform/test/attendance.test.ts`
Expected: FAIL — 404 en la ruta de asistencia.
- [ ] **Step 3: Escribir `platform/routes/attendance.ts`**
```ts
import { Router } from "express";
import { withTx, pool } from "../db/pool.ts";
import { writeAudit } from "../lib/audit.ts";
import { err, h, type AuthedRequest } from "../lib/auth.ts";
export const attendanceRouter = Router({ mergeParams: true });
const APPT_COLS = `id, business_id, client_id, employee_id, service_id,
to_char(start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
status, cancelled_by, price, notes, created_at`;
const VALID_PAYMENT = new Set(["cash", "card", "transfer", "other"]);
/**
* El toque de asistencia: un solo POST resuelve la cita.
*
* La visita se crea **solo** si la clienta vino. Una visita es un hecho con
* dinero; el "no vino" es un estado de la cita. Fusionar los dos conceptos es
* lo que produce registros que sirven para planear y para cerrar, y que
* terminan sin cerrarse nunca.
*/
attendanceRouter.post("/", h(async (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const bid = req.user!.business_id;
const { attended, total_charged, payment_method } = req.body ?? {};
if (typeof attended !== "boolean") {
return err(res, 400, "Indica si la clienta vino o no vino");
}
if (payment_method != null && !VALID_PAYMENT.has(payment_method)) {
return err(res, 400, "Método de pago no válido");
}
const cur = await pool.query(
`SELECT ${APPT_COLS} FROM appointments WHERE id = $1 AND business_id = $2`, [id, bid]
);
const appt = cur.rows[0];
if (!appt) return err(res, 404, "Cita no encontrada");
if (appt.status === "cancelled") {
return err(res, 409, "Esta cita está cancelada: no se le puede marcar asistencia");
}
if (appt.status === "completed" || appt.status === "no_show") {
return err(res, 409, "Esta cita ya se resolvió");
}
// Una empleada solo resuelve sus propias citas; la administradora, cualquiera.
if (req.user!.role === "employee" && req.user!.employee_id !== appt.employee_id) {
return err(res, 403, "Solo puedes marcar asistencia en tus propias citas");
}
const out = await withTx(async (c) => {
const nextStatus = attended ? "completed" : "no_show";
const { rows } = await c.query(
`UPDATE appointments SET status = $3, updated_at = now()
WHERE id = $1 AND business_id = $2 RETURNING ${APPT_COLS}`,
[id, bid, nextStatus]
);
let visit = null;
if (attended) {
const v = await c.query(
`INSERT INTO visits
(business_id, appointment_id, client_id, employee_id, occurred_at,
total_charged, payment_method, recorded_by_user_id)
SELECT business_id, id, client_id, employee_id, start_at, $2, $3, $4
FROM appointments WHERE id = $1
RETURNING id, business_id, appointment_id, client_id, employee_id,
to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS occurred_at,
total_charged, payment_method, recorded_by_user_id, recorded_at`,
[id, total_charged ?? null, payment_method ?? null, req.user!.id]
);
visit = v.rows[0];
}
await c.query(
`INSERT INTO appointment_events
(appointment_id, actor_user_id, action, from_status, to_status)
VALUES ($1,$2,$3,$4,$5)`,
[id, req.user!.id, attended ? "attended" : "no_show", appt.status, nextStatus]
);
await writeAudit(c, {
businessId: bid, actorUserId: req.user!.id, entity: "appointments",
entityId: id, action: "attendance",
before: { status: appt.status },
after: { status: nextStatus, visit_id: visit?.id ?? null },
ip: req.ip ?? null,
});
return { appointment: rows[0], visit };
});
res.json(out);
}));
```
- [ ] **Step 4: Montarlo en `platform/index.ts`**
```ts
import { attendanceRouter } from "./routes/attendance.ts";
// …
app.use("/api/appointments/:id/attendance", authRequired, attendanceRouter);
```
Debe montarse **antes** que `app.use("/api/appointments", …)` para que el `:id` no se lo trague el router de citas.
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `npm.cmd run test:platform -- platform/test/attendance.test.ts`
Expected: PASS, 5 tests.
- [ ] **Step 6: Commit**
```bash
git add platform/routes/attendance.ts platform/index.ts platform/test/attendance.test.ts
git commit -m "feat(platform): toque de asistencia con visita separada de la cita"
```
---
## Task 8: Cierre de día
**Files:**
- Create: `platform/routes/dayClose.ts`
- Modify: `platform/index.ts`
- Test: `platform/test/dayClose.test.ts`
**Interfaces:**
- Produces: `dayCloseRouter` en `/api/day-close`
- `GET /api/day-close?date=YYYY-MM-DD` → `{ date, closed_at, unresolved, attended, no_show, cancelled }`
- `POST /api/day-close` con `{ date }` → `200 { closure }` · **`409 { error, unresolved }`** si queda alguna cita sin resolver
El día se acota con `bizDayBoundsIsoFor(tz, dateIso)` de `server/lib/time.ts`, que ya está probado: `start_at` vive en UTC, así que una cita de las 19:00 de México cae en el día UTC siguiente y acotar con `date_trunc('day', start_at)` la dejaría fuera.
- [ ] **Step 1: Escribir el test que falla**
```ts
// platform/test/dayClose.test.ts
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { pool } from "../db/pool.ts";
import { createApp } from "../index.ts";
import { resetDb, seedMinimal } from "./helpers.ts";
import type { Server } from "node:http";
// La zona del proceso es UTC y la del negocio America/Mexico_City: nunca
// coinciden, así que una recaída de zona horaria falla aquí y no en producción.
process.env.TZ = "UTC";
let ids: Awaited<ReturnType<typeof seedMinimal>>;
let server: Server;
let base: string;
before(async () => {
await resetDb();
ids = await seedMinimal();
server = createApp().listen(0);
base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});
after(async () => { server.close(); await pool.end(); });
function req(path: string, init: RequestInit = {}, userId = ids.ownerUserId) {
return fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
authorization: `Bearer ${userId}`,
...(init.headers || {}),
},
});
}
async function crearCita(startUtc: string): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO appointments
(business_id, client_id, employee_id, service_id, start_at, end_at, price)
VALUES ($1,$2,$3,$4,$5::timestamptz,$5::timestamptz + interval '60 minutes',850)
RETURNING id`,
[ids.businessId, ids.clientId, ids.employeeId, ids.serviceId, startUtc]
);
return rows[0].id;
}
test("una cita de las 19:00 de México cuenta en su día local, no en el UTC", async () => {
// 2026-09-07 19:00 en México (UTC-6) = 2026-09-08 01:00 UTC.
await crearCita("2026-09-08T01:00:00Z");
const r = await req("/api/day-close?date=2026-09-07");
const body = await r.json();
assert.equal(body.unresolved.length, 1, "debe contarse en el 7, no en el 8");
});
test("no deja cerrar el día con citas sin resolver", async () => {
const r = await req("/api/day-close", {
method: "POST", body: JSON.stringify({ date: "2026-09-07" }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.equal(body.unresolved.length, 1);
assert.match(body.error, /sin resolver/i);
});
test("cierra el día cuando todas están resueltas y guarda el conteo", async () => {
const { rows } = await pool.query(
`SELECT id FROM appointments WHERE status = 'scheduled'`
);
await req(`/api/appointments/${rows[0].id}/attendance`, {
method: "POST", body: JSON.stringify({ attended: true, total_charged: 850 }),
});
const r = await req("/api/day-close", {
method: "POST", body: JSON.stringify({ date: "2026-09-07" }),
});
assert.equal(r.status, 200);
const { closure } = await r.json();
assert.equal(closure.attended_count, 1);
assert.equal(closure.no_show_count, 0);
assert.equal(closure.closed_by_user_id, ids.ownerUserId);
});
test("cerrar dos veces el mismo día devuelve 409", async () => {
const r = await req("/api/day-close", {
method: "POST", body: JSON.stringify({ date: "2026-09-07" }),
});
assert.equal(r.status, 409);
const body = await r.json();
assert.match(body.error, /ya está cerrado/i);
});
test("el resumen del día muestra la fecha de cierre", async () => {
const r = await req("/api/day-close?date=2026-09-07");
const body = await r.json();
assert.ok(body.closed_at, "un día cerrado reporta cuándo se cerró");
assert.equal(body.attended, 1);
});
```
- [ ] **Step 2: Correr el test y verificar que falla**
Run: `npm.cmd run test:platform -- platform/test/dayClose.test.ts`
Expected: FAIL — 404 en `/api/day-close`.
- [ ] **Step 3: Escribir `platform/routes/dayClose.ts`**
```ts
import { Router } from "express";
import { pool, withTx } from "../db/pool.ts";
import { writeAudit } from "../lib/audit.ts";
import { err, h, type AuthedRequest } from "../lib/auth.ts";
import { bizDayBoundsIsoFor, bizTodayISO, DEFAULT_TZ } from "../../server/lib/time.ts";
export const dayCloseRouter = Router();
const APPT_COLS = `a.id, a.client_id, a.employee_id, a.service_id,
to_char(a.start_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS start_at,
to_char(a.end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS end_at,
a.status, a.price, c.name AS client_name, e.name AS employee_name, s.name AS service_name`;
async function bizTz(businessId: number): Promise<string> {
const { rows } = await pool.query(
`SELECT timezone FROM businesses WHERE id = $1`, [businessId]
);
return rows[0]?.timezone || DEFAULT_TZ;
}
dayCloseRouter.get("/", h(async (req: AuthedRequest, res) => {
const bid = req.user!.business_id!;
const tz = await bizTz(bid);
const date = (req.query.date as string | undefined) || bizTodayISO(tz);
const { start, end } = bizDayBoundsIsoFor(tz, date);
const unresolved = await pool.query(
`SELECT ${APPT_COLS} FROM appointments a
JOIN clients c ON c.id = a.client_id
JOIN employees e ON e.id = a.employee_id
JOIN services s ON s.id = a.service_id
WHERE a.business_id = $1
AND a.start_at >= $2::timestamptz AND a.start_at < $3::timestamptz
AND a.status = 'scheduled'
ORDER BY a.start_at`,
[bid, start, end]
);
const counts = await pool.query(
`SELECT
count(*) FILTER (WHERE status = 'completed')::int AS attended,
count(*) FILTER (WHERE status = 'no_show')::int AS no_show,
count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled
FROM appointments
WHERE business_id = $1
AND start_at >= $2::timestamptz AND start_at < $3::timestamptz`,
[bid, start, end]
);
const closure = await pool.query(
`SELECT closed_at FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
[bid, date]
);
res.json({
date,
closed_at: closure.rows[0]?.closed_at ?? null,
unresolved: unresolved.rows,
attended: counts.rows[0].attended,
no_show: counts.rows[0].no_show,
cancelled: counts.rows[0].cancelled,
});
}));
dayCloseRouter.post("/", h(async (req: AuthedRequest, res) => {
const bid = req.user!.business_id!;
const tz = await bizTz(bid);
const date = (req.body?.date as string | undefined) || bizTodayISO(tz);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return err(res, 400, "Fecha no válida");
const { start, end } = bizDayBoundsIsoFor(tz, date);
const ya = await pool.query(
`SELECT 1 FROM day_closures WHERE business_id = $1 AND business_date = $2::date`,
[bid, date]
);
if (ya.rows[0]) return err(res, 409, "Ese día ya está cerrado");
// La regla que sostiene todo el proyecto: no se puede cerrar el día dejando
// citas sin desenlace. Es lo que convierte el registro en el camino más corto
// para trabajar, en vez de una tarea añadida al final.
const pend = await pool.query(
`SELECT ${APPT_COLS} FROM appointments a
JOIN clients c ON c.id = a.client_id
JOIN employees e ON e.id = a.employee_id
JOIN services s ON s.id = a.service_id
WHERE a.business_id = $1
AND a.start_at >= $2::timestamptz AND a.start_at < $3::timestamptz
AND a.status = 'scheduled'
ORDER BY a.start_at`,
[bid, start, end]
);
if (pend.rows.length) {
return res.status(409).json({
error: `Quedan ${pend.rows.length} cita(s) sin resolver: marca si vinieron o no antes de cerrar`,
unresolved: pend.rows,
});
}
const closure = await withTx(async (c) => {
const counts = await c.query(
`SELECT
count(*) FILTER (WHERE status = 'completed')::int AS attended,
count(*) FILTER (WHERE status = 'no_show')::int AS no_show,
count(*) FILTER (WHERE status = 'cancelled')::int AS cancelled
FROM appointments
WHERE business_id = $1
AND start_at >= $2::timestamptz AND start_at < $3::timestamptz`,
[bid, start, end]
);
const { rows } = await c.query(
`INSERT INTO day_closures
(business_id, business_date, closed_by_user_id,
attended_count, no_show_count, cancelled_count)
VALUES ($1,$2::date,$3,$4,$5,$6)
RETURNING id, business_id,
to_char(business_date, 'YYYY-MM-DD') AS business_date,
closed_by_user_id, closed_at,
attended_count, no_show_count, cancelled_count`,
[bid, date, req.user!.id, counts.rows[0].attended,
counts.rows[0].no_show, counts.rows[0].cancelled]
);
await writeAudit(c, {
businessId: bid, actorUserId: req.user!.id, entity: "day_closures",
entityId: rows[0].id, action: "close", after: rows[0], ip: req.ip ?? null,
});
return rows[0];
});
res.json({ closure });
}));
```
- [ ] **Step 4: Montarlo en `platform/index.ts`**
```ts
import { dayCloseRouter } from "./routes/dayClose.ts";
// …
app.use("/api/day-close", authRequired, dayCloseRouter);
```
- [ ] **Step 5: Correr los tests y verificar que pasan**
Run: `npm.cmd run test:platform -- platform/test/dayClose.test.ts`
Expected: PASS, 5 tests. El primero es el que importa: si falla, la acotación del día está usando la zona del proceso y no la del negocio.
- [ ] **Step 6: Commit**
```bash
git add platform/routes/dayClose.ts platform/index.ts platform/test/dayClose.test.ts
git commit -m "feat(platform): cierre de día que no deja citas sin resolver"
```
---
## Task 9: Tipos compartidos y cliente HTTP
**Files:**
- Modify: `shared/types.ts`
- Modify: `src/lib/api.ts`
- Test: `npm run typecheck`
**Interfaces:**
- Produces: `Visit`, `AttendanceResult`, `UnresolvedAppointment`, `DayCloseSummary`, `DayClosure`, `PaymentMethod`; y en `api`, los métodos `markAttendance`, `getDayClose`, `closeDay`.
- [ ] **Step 1: Añadir los campos nuevos a `Client` en `shared/types.ts`**
```ts
export interface Client {
id: number;
business_id: number;
name: string;
email: string | null;
phone: string | null;
/** El teléfono normalizado a E.164. `null` = no se pudo normalizar. */
phone_e164?: string | null;
/** Derivado: hay teléfono normalizado y por tanto se le puede avisar. */
contactable?: boolean;
notes: string | null;
tags: string | null;
source_channel?: string | null;
created_at: string;
stats?: ClientStats;
}
```
Y a `Appointment`, el matiz de quién canceló:
```ts
/** Solo tiene valor cuando `status === "cancelled"`. */
cancelled_by?: "client" | "business" | null;
cancel_reason?: string | null;
```
- [ ] **Step 2: Añadir los tipos nuevos al final de `shared/types.ts`**
```ts
export type PaymentMethod = "cash" | "card" | "transfer" | "other";
/** El hecho consumado: la clienta vino. Solo existe si asistió. */
export interface Visit {
id: number;
business_id: number;
appointment_id: number | null;
client_id: number;
employee_id: number;
occurred_at: string;
total_charged: number | null;
payment_method: PaymentMethod | null;
recorded_by_user_id: number | null;
recorded_at: string;
}
export interface AttendanceResult {
appointment: Appointment;
visit: Visit | null;
}
/** Una cita del día pendiente de desenlace, con los nombres ya resueltos. */
export interface UnresolvedAppointment {
id: number;
client_id: number;
employee_id: number;
service_id: number;
start_at: string;
end_at: string;
status: AppointmentStatus;
price: number;
client_name: string;
employee_name: string;
service_name: string;
}
export interface DayCloseSummary {
date: string;
closed_at: string | null;
unresolved: UnresolvedAppointment[];
attended: number;
no_show: number;
cancelled: number;
}
export interface DayClosure {
id: number;
business_id: number;
business_date: string;
closed_by_user_id: number;
closed_at: string;
attended_count: number;
no_show_count: number;
cancelled_count: number;
}
```
- [ ] **Step 3: Añadir los métodos a `src/lib/api.ts`**
Dentro del objeto `api`, siguiendo el patrón de los que ya existen, y añadiendo
`AttendanceResult`, `DayCloseSummary`, `DayClosure` y `PaymentMethod` al import
de `shared/types.ts`:
```ts
markAttendance: (
appointmentId: number,
body: { attended: boolean; total_charged?: number; payment_method?: PaymentMethod }
) =>
request<AttendanceResult>(`/appointments/${appointmentId}/attendance`, {
method: "POST",
body: JSON.stringify(body),
}),
getDayClose: (date?: string) =>
request<DayCloseSummary>(`/day-close${date ? `?date=${date}` : ""}`),
closeDay: (date: string) =>
request<{ closure: DayClosure }>(`/day-close`, {
method: "POST",
body: JSON.stringify({ date }),
}),
```
- [ ] **Step 4: Correr el typecheck y verificar que pasa**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 5: Commit**
```bash
git add shared/types.ts src/lib/api.ts
git commit -m "feat(shared): tipos de visita, asistencia y cierre de día"
```
---
## Task 10: La pantalla de cierre de día
**Files:**
- Create: `src/pages/DayClosePage.tsx`
- Modify: `src/App.tsx`
- Modify: `src/components/AppShell.tsx`
- Test: manual + `npm run typecheck`
**Interfaces:**
- Consumes: `api.getDayClose`, `api.closeDay`, `api.markAttendance`
Reglas de la interfaz, que salen del riesgo dominante del proyecto (que la plataforma se convierta en el nuevo registro vacío):
- **Un solo toque por cita:** *Vino* / *No vino*, sin diálogo intermedio, sin formulario. El importe es opcional y se captura después, no antes.
- Objetivos táctiles de 40px con `.tap-target`, porque se opera con una mano.
- El botón de cerrar el día está **deshabilitado** mientras queden citas sin resolver, y dice cuántas faltan. No se esconde: se explica.
- Nada de `h-screen` crudo — la página usa las utilidades `-safe` del repo.
- [ ] **Step 1: Escribir `src/pages/DayClosePage.tsx`**
```tsx
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, XCircle, Lock, CalendarCheck } from "lucide-react";
import { api } from "@/lib/api";
import { formatTime } from "@/lib/format";
import { PageHeader, EmptyState, Spinner } from "@/components/ui";
export default function DayClosePage() {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["day-close", date],
queryFn: () => api.getDayClose(date),
});
const attendance = useMutation({
mutationFn: (v: { id: number; attended: boolean }) =>
api.markAttendance(v.id, { attended: v.attended }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["day-close", date] }),
});
const close = useMutation({
mutationFn: () => api.closeDay(date),
onSuccess: () => qc.invalidateQueries({ queryKey: ["day-close", date] }),
});
if (isLoading || !data) return <Spinner />;
const pendientes = data.unresolved.length;
return (
<div className="space-y-6">
<PageHeader
title="Cierre de día"
subtitle="Marca si cada clienta vino o no vino. Es el dato que el negocio no tiene."
/>
<input
type="date"
className="input max-w-xs"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Metric label="Asistieron" value={data.attended} tone="text-emerald-600" />
<Metric label="No asistieron" value={data.no_show} tone="text-amber-600" />
<Metric label="Canceladas" value={data.cancelled} tone="text-slate-500" />
</div>
{pendientes === 0 ? (
<EmptyState
title="No queda ninguna cita sin resolver"
subtitle={data.closed_at ? "Este día ya está cerrado." : "Puedes cerrar el día."}
/>
) : (
<ul className="space-y-2">
{data.unresolved.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-slate-900">{a.client_name}</p>
<p className="truncate text-sm text-slate-500">
{formatTime(a.start_at)} · {a.service_name} · {a.employee_name}
</p>
</div>
<div className="flex gap-2">
<button
className="tap-target inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 text-white disabled:opacity-50"
disabled={attendance.isPending}
onClick={() => attendance.mutate({ id: a.id, attended: true })}
>
<CheckCircle2 size={18} /> Vino
</button>
<button
className="tap-target inline-flex items-center gap-1.5 rounded-lg border border-slate-300 px-3 text-slate-700 disabled:opacity-50"
disabled={attendance.isPending}
onClick={() => attendance.mutate({ id: a.id, attended: false })}
>
<XCircle size={18} /> No vino
</button>
</div>
</li>
))}
</ul>
)}
{attendance.isError && (
<p className="text-sm text-red-600">{(attendance.error as Error).message}</p>
)}
<div className="flex flex-wrap items-center gap-3 border-t border-slate-200 pt-4">
<button
className="btn-primary inline-flex items-center gap-2 disabled:opacity-50"
disabled={pendientes > 0 || !!data.closed_at || close.isPending}
onClick={() => close.mutate()}
>
{data.closed_at ? <Lock size={18} /> : <CalendarCheck size={18} />}
{data.closed_at ? "Día cerrado" : "Cerrar el día"}
</button>
{pendientes > 0 && (
<span className="text-sm text-slate-500">
Faltan {pendientes} cita{pendientes === 1 ? "" : "s"} por resolver.
</span>
)}
{close.isError && (
<span className="text-sm text-red-600">{(close.error as Error).message}</span>
)}
</div>
</div>
);
}
function Metric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">{label}</p>
<p className={`text-2xl font-semibold ${tone}`}>{value}</p>
</div>
);
}
```
- [ ] **Step 2: Registrar la ruta en `src/App.tsx`**
Junto a las otras rutas del panel. No va con `React.lazy`: la página no importa
`recharts` ni FullCalendar, así que no hay motivo medible para diferirla.
```tsx
import DayClosePage from "@/pages/DayClosePage";
// …
<Route path="/cierre-dia" element={<DayClosePage />} />
```
- [ ] **Step 3: Añadir la entrada de menú en `src/components/AppShell.tsx`**
En el arreglo de navegación, siguiendo el patrón `sidebarContent(mini)` existente
—cada entrada conserva su `title` para no perder el nombre accesible en modo mini.
Importar `CalendarCheck` de `lucide-react`:
```tsx
{ to: "/cierre-dia", label: "Cierre de día", icon: CalendarCheck },
```
- [ ] **Step 4: Correr el typecheck y verificar que pasa**
Run: `npm.cmd run typecheck`
Expected: sin errores.
- [ ] **Step 5: Verificación manual**
```bash
npm.cmd run pg:up
npm.cmd run pg:migrate
node scripts/run-tsx.mjs platform/index.ts
# en otra terminal:
API_URL=http://127.0.0.1:3100 npm.cmd run dev:web
```
Abrir `http://127.0.0.1:5173/cierre-dia` a 390px de ancho y comprobar:
1. Los botones *Vino* / *No vino* miden al menos 40px de alto.
2. Al marcar, la cita desaparece de la lista y el contador de arriba sube.
3. Con citas pendientes, «Cerrar el día» está deshabilitado y dice cuántas faltan.
4. Con cero pendientes, cierra y el botón pasa a «Día cerrado».
5. La página no desplaza horizontalmente.
- [ ] **Step 6: Commit**
```bash
git add src/pages/DayClosePage.tsx src/App.tsx src/components/AppShell.tsx
git commit -m "feat(web): pantalla de cierre de día con toque de asistencia"
```
---
## Verificación final del entregable
Corre las tres cosas y pega la salida real; no des nada por bueno sin verla:
```bash
npm.cmd run typecheck
npm.cmd run test:platform
node --import tsx --test platform/lib/phone.test.ts
```
Criterios de aceptación de este entregable, tomados de la §12 de la propuesta y de la §7 del caso Yola:
| Criterio | Cómo se comprueba |
|---|---|
| La búsqueda por teléfono encuentra a la clienta existente sin crear duplicado | `platform/test/clients.test.ts`, casos 2 y 4 |
| Dos usuarios no pueden reservar el mismo horario para la misma especialista | `platform/test/schema.test.ts` caso 1 (código `23P01`) y `appointments.test.ts` caso 2 (409) |
| Cada cambio relevante deja registro de usuario, fecha y acción | `audit.test.ts`, más los asertos de `audit_log` en clientes, citas y asistencia |
| Existe el dato «vino / no vino» | `attendance.test.ts` completo |
| El cierre de día no deja citas sin desenlace | `dayClose.test.ts` caso 2 |
| Una empleada no resuelve citas ajenas | Guarda de rol en `attendance.ts`; añadir el caso de prueba si el revisor lo pide |
## Lo que este entregable deliberadamente NO hace
- **No endurece la autenticación.** Token trivial y contraseña sin hashear, portados tal cual. Es deuda anotada, no un descuido.
- **No toca Bucéfalo CRM.** Ni bandeja de salida, ni webhooks, ni conversaciones. La columna `crm_contact_id` queda declarada y vacía.
- **No migra los datos del backend SQLite.** `server/` sigue en pie y sin cambios.
- **No añade multi-servicio por cita, buffers, salas ni bloqueos.** Es el alcance «cerrar huecos de agenda».
- **No hay reserva pública ni recordatorios** sobre este backend todavía.
## Antes de dar por bueno el diseño, hay que validar tres cosas con el cliente
Vienen del cierre del documento del caso Yola y ninguna es código:
1. Que existan calendarios configurados en la subcuenta del CRM. Si no hay ninguno, la Fase 2 cambia de forma.
2. Que la dueña confirme el catálogo de servicios y sus duraciones. El que circula sale de 38 menciones en una muestra de 150 hilos, no de su lista de precios.
3. Que el spa acepte crear citas **solo** en la plataforma. De eso depende que la exclusión por rango sirva de algo: la base no puede impedir una cita creada fuera.
---
## Addendum de ejecución — 2026-08-29
El plan se ejecutó completo. Seis cosas se desviaron de lo escrito, y esta es la
razón de cada una:
1. **Puerto 5434, no 5433.** El 5433 lo ocupaba `analytics-pg-local`. El 5432
también está tomado (`andamios-postgres-dev`).
2. **`--test-concurrency=1` en `test:platform`.** El corredor de `node:test`
lanza un proceso por archivo en paralelo y cada archivo hace `DROP SCHEMA
public`: se pisaban entre sí y fallaban con `relation "schema_migrations"
does not exist`. Serializados, los 38 pasan.
3. **`migrate.test.ts` parte de un esquema vacío.** Con la suite completa corría
después de otros archivos y su aserción («el bootstrap se aplica») era falsa.
Se añadió `dropSchema()` a los helpers y un `before` que lo llama.
4. **La acotación del día usa `<=`, no `<`.** `bizDayBoundsIsoFor` devuelve un
fin **inclusivo** (23:59:59 hora local). Con `<` se perdía la última cita.
5. **Se añadieron `platform/routes/auth.ts` y `business.ts`, y
`platform/scripts/seed.ts`.** No estaban en el plan y sin ellos el entregable
no se puede abrir en un navegador: el frontend pide `/api/auth/login`,
`/api/auth/me` y `/api/business` antes de pintar nada.
6. **`platform` se añadió a `include` de `tsconfig.json`.** Sin eso el
`typecheck` pasaba sin haber mirado una sola línea del backend nuevo.
Verificado con `tsc --listFiles`: 19 archivos de `platform/` compilan.
### Dos defectos de interfaz encontrados en la verificación a 390px
Ninguno lo habría detectado un test de API; salieron de mirar la captura:
- Las tres métricas apiladas (`grid-cols-1 sm:grid-cols-3`) ocupaban media
pantalla y empujaban la lista fuera de la vista, que es justo lo que la
empleada viene a tocar. Ahora son tres columnas desde el teléfono.
- El nombre del servicio se truncaba a «Exte…» porque competía por el ancho con
los dos botones. El bloque de texto pasa a `basis-full` en teléfono y los
botones ocupan su propia fila a ancho completo.
### Evidencia de la verificación
```
npm run typecheck → sin errores
npm run test:platform → 38 pruebas, 38 pass, 0 fail
npm run test:unit → 43 pruebas, 43 pass, 0 fail (no se rompió nada existente)
```
WebKit a 390×844: 5 citas pendientes pintadas, botones de 40px de alto, 0px de
desborde horizontal, «Cerrar el día» deshabilitado con el aviso de cuántas
faltan, y al marcar una la lista baja a 4 y el contador de asistidas sube a 1.
Sin errores de consola.
Flujo completo contra la API real (`curl`): cerrar con 5 pendientes → 409 con el
mensaje en español; resolver las 5 → 200 cada una; cerrar → `attended_count: 4`,
`no_show_count: 1`.