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]>
200 lines
8.9 KiB
SQL
200 lines
8.9 KiB
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)
|
|
);
|