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]>
97 KiB
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 connpx tsxdirecto. - Windows/PowerShell: usar
npm.cmdsinpm.ps1está 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
businessesyusers, toda tabla llevabusiness_idy toda query filtra porreq.user!.business_id. Nunca se acepta unbusiness_idque venga del body. - Zonas horarias: todo instante se guarda en
timestamptzUTC. La zona sale debusinesses.timezone(defaultAmerica/Mexico_City) y solo se aplica al presentar.new Date(y, m, d, hh, mm)ydate('now')están prohibidos en lógica de negocio. Se usan los helpers puros deserver/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 lintestá roto en este repo (ESLint 9 sineslint.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
- 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 deserver/lib/time.tsyserver/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. - Identificadores
bigint, nouuid. El documento de Yola usauuid.shared/types.tsdeclaraid: numberen todas las entidades y el frontend lo asume en rutas y en React Query. Cambiar auuidobliga 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 columnapublic_id uuidsin mover la clave primaria. - El enum de estado no crece todavía. El documento propone
propuesta|confirmada|asistio|no_asistio|cancelada_clienta|cancelada_spa. Aquí se conservascheduled|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 dependenciapg) - 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,runMigrationsde la Task 1 -
Produces:
resetDb(): Promise<void>yseedMinimal(): Promise<SeedIds>dondeSeedIds = { 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(ExpressRequestconuser?: PlatformUser)authRequired,ownerOnly,err(res, status, message),h(fn)writeAudit(c: PoolClient, entry: AuditEntry): Promise<void>conAuditEntry = { 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
clientsRoutermontado en/api/clients, ycreateApp(): express.Expressdesdeplatform/index.tspara que las pruebas levanten el servidor sin puerto fijo.
Contrato del alta:
-
POST /api/clientscon{ 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: tecleando5588887777encuentra 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:
appointmentsRouteren/api/appointmentsGET /api/appointments?from=&to=&employee_id=→{ appointments }, instantes ISO-ZPOST /api/appointmentscon{ client_id, employee_id, service_id, start_at, notes?, source_channel? }→201 { appointment }·409 { error }si chocaPATCH /api/appointments/:idcon{ start_at?, employee_id?, notes? }→{ appointment }·409si chocaPOST /api/appointments/:id/cancelcon{ 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 comoPOST /api/appointments/:id/attendance- Cuerpo:
{ attended: true, total_charged?: number, payment_method?: 'cash'|'card'|'transfer'|'other' }o{ attended: false } attended: true→ crea la fila envisitsy pone la cita encompletedattended: false→ pone la cita enno_show, sin fila envisits- Respuesta
{ appointment, visit }(visitesnullcuando no vino)
- Cuerpo:
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:
dayCloseRouteren/api/day-closeGET /api/day-close?date=YYYY-MM-DD→{ date, closed_at, unresolved, attended, no_show, cancelled }POST /api/day-closecon{ 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 enapi, los métodosmarkAttendance,getDayClose,closeDay. -
Step 1: Añadir los campos nuevos a
Clientenshared/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-screencrudo — la página usa las utilidades-safedel 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:
- Los botones Vino / No vino miden al menos 40px de alto.
- Al marcar, la cita desaparece de la lista y el contador de arriba sube.
- Con citas pendientes, «Cerrar el día» está deshabilitado y dice cuántas faltan.
- Con cero pendientes, cierra y el botón pasa a «Día cerrado».
- 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_idqueda 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:
- Que existan calendarios configurados en la subcuenta del CRM. Si no hay ninguno, la Fase 2 cambia de forma.
- 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.
- 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:
- Puerto 5434, no 5433. El 5433 lo ocupaba
analytics-pg-local. El 5432 también está tomado (andamios-postgres-dev). --test-concurrency=1entest:platform. El corredor denode:testlanza un proceso por archivo en paralelo y cada archivo haceDROP SCHEMA public: se pisaban entre sí y fallaban conrelation "schema_migrations" does not exist. Serializados, los 38 pasan.migrate.test.tsparte 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 unbeforeque lo llama.- La acotación del día usa
<=, no<.bizDayBoundsIsoFordevuelve un fin inclusivo (23:59:59 hora local). Con<se perdía la última cita. - Se añadieron
platform/routes/auth.tsybusiness.ts, yplatform/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/mey/api/businessantes de pintar nada. platformse añadió aincludedetsconfig.json. Sin eso eltypecheckpasaba sin haber mirado una sola línea del backend nuevo. Verificado contsc --listFiles: 19 archivos deplatform/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-fullen 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.