# 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(fn: (c: PoolClient) => Promise): Promise`, `runMigrations(): Promise` (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:yola_dev@127.0.0.1:5434/yola TEST_DATABASE_URL=postgres://yola:yola_dev@127.0.0.1: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:yola_dev@127.0.0.1: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(fn: (c: pg.PoolClient) => Promise): Promise { 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:yola_dev@127.0.0.1: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 { 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:yola_dev@127.0.0.1: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` y `seedMinimal(): Promise` 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>; 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 { 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 { 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','karla@yola.mx') 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,'yola@yola.mx','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,'karla@yola.mx','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` 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>; 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 { 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( `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 ) { 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>; 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,'otro@otro.mx','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>; 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,'otro2@otro.mx','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; 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>; 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 { 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>; 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 { 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 { 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(`/appointments/${appointmentId}/attendance`, { method: "POST", body: JSON.stringify(body), }), getDayClose: (date?: string) => request(`/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 ; const pendientes = data.unresolved.length; return (
setDate(e.target.value)} />
{pendientes === 0 ? ( ) : (
    {data.unresolved.map((a) => (
  • {a.client_name}

    {formatTime(a.start_at)} · {a.service_name} · {a.employee_name}

  • ))}
)} {attendance.isError && (

{(attendance.error as Error).message}

)}
{pendientes > 0 && ( Faltan {pendientes} cita{pendientes === 1 ? "" : "s"} por resolver. )} {close.isError && ( {(close.error as Error).message} )}
); } function Metric({ label, value, tone }: { label: string; value: number; tone: string }) { return (

{label}

{value}

); } ``` - [ ] **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"; // … } /> ``` - [ ] **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`.