Files
AgendaPro/docs/superpowers/plans/2026-08-29-yola-nucleo-postgres.md
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

97 KiB
Raw Permalink Blame History

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

npm.cmd install pg && npm.cmd install -D @types/pg
  • Step 2: Escribir platform/docker-compose.yml
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
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
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
-- 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
// 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
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:

"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
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

// 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
-- ---------------------------------------------------------------------------
-- 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
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
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
// 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
/**
 * 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
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
// 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
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
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
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

// 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
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
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
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

// 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
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:

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
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
// 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
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
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
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
// 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
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
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
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

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ó:

  /** 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
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:

  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
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

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.

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:

  { 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
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
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:

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.