feat(platform): multi-tenancy con credenciales por negocio y sincronización por id
El backend Postgres de `platform/` asumía un solo negocio con un solo token del
CRM. Este cambio lo convierte en una plataforma multi-cuenta y añade la
sincronización selectiva de las cinco entidades del encargo.
## Multi-tenancy
El `locationId` ya era por negocio, pero el token vivía en la variable de entorno
`CRM_TOKEN`, una sola para todo el proceso. Con dos negocios eso usaba el token
del primero contra la subcuenta del segundo: 401 en el mejor caso, escritura en
la subcuenta equivocada en el peor.
- `lib/crypto.ts` — AES-256-GCM para los tokens. Autenticado a propósito: una
fila manipulada hace que el descifrado FALLE, en vez de devolver basura que
acabaríamos mandando como credencial al CRM. La clave maestra vive en
`CRM_MASTER_KEY`, fuera de la base.
- `crm/ctx.ts` — `CrmCtx { businessId, locationId, token }` sustituye al
`locationId: string` suelto que viajaba por once firmas. Es un objeto y no dos
parámetros porque dos `string` seguidos se cruzan sin que el compilador diga
nada, y cruzarlos aquí manda el token de un cliente a la subcuenta de otro. Es
el único sitio donde el token existe descifrado, y solo en memoria.
- `crm/client.ts` — `CrmOptions.token` pasa a ser OBLIGATORIO, sin valor por
defecto: olvidarlo es ahora un error de compilación. El estrangulador pasa a
ser por token y aprende la cuota de las cabeceras `x-ratelimit-*`, que declaran
100 peticiones por 10 s — el cliente iba 6,5x por debajo con una estimación.
- Migración 003: credencial cifrada, calendario y la red de seguridad de mensajes
POR NEGOCIO. Como variable global decidía por todas las cuentas a la vez.
Lo único de la credencial que sale del servidor es la huella de 6 caracteres.
## Consola de superadministración
`/api/admin`, solo para el rol `admin`: alta de cuentas con su dueña en una
transacción, vínculo, desvínculo y suspensión. Las credenciales se COMPRUEBAN
contra el CRM antes de guardarse — un token sin validar traslada el fallo al
primer intento de sincronizar, lejos de donde se cometió. El error distingue
«token inválido» de «subcuenta inexistente» de «token de otra subcuenta».
Pantalla en `/admin/cuentas`, verificada en navegador: el campo del token es de
contraseña y viene vacío, porque no hay valor que traer.
## Sincronización por identificador
`POST /api/crm/sync/:entidad/:id` para contacto, conversación, mensaje, cita y
servicio. La dirección la decide la entidad: las tres primeras se TRAEN porque el
CRM es su dueño; las dos últimas se EMPUJAN, porque el calendario del CRM tiene
una sola cita en dos años y su catálogo de servicios está vacío.
- `crm/conversations.ts` — lectura por id de conversaciones y mensajes sueltos.
- `crm/syncConversations.ts` — el espejo persistido. Las tablas existían desde
002_crm.sql y nadie escribía en ellas: la bandeja consultaba el CRM en vivo.
- `crm/calendars.ts` — escritura de citas al calendario. `isoConDesplazamiento`
escribe la hora de pared del negocio con su desplazamiento; `toISOString()`
habría movido la hora que el CRM enseña en su interfaz.
- `crm/services.ts` — publicación de servicios al catálogo.
## Verificado contra la subcuenta real, no deducido
Las cinco entidades se ejercieron contra el CRM del cliente. Las escrituras van
en un ciclo crear → releer → borrar → confirmar borrado, con la limpieza en un
`finally`, y antes se comprobó que el borrado existe: preguntar si se puede
deshacer ANTES de escribir en el CRM de un cliente, no después. La subcuenta
quedó como estaba.
47 hallazgos medidos en `crm/HALLAZGOS.md`, y la referencia de endpoints en
`crm/API.md`, con la lista explícita de dónde la documentación oficial falla.
110 pruebas de plataforma en verde, typecheck limpio, build correcto. El backend
de demo de `server/` no se ha tocado y sigue con sus 43 pruebas.
## Deuda conocida, dicha sin rodeos
- La bandeja de mensajes todavía lee en vivo del CRM, no del espejo.
- La autenticación sigue siendo el id del usuario en texto plano, también para el
rol admin. Esta consola crea cuentas y guarda credenciales de clientes encima
de esa base: no debe quedar expuesta a internet hasta endurecerla.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcbf750c09
commit
6d67b23e55
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,199 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 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)
|
||||
);
|
||||
@@ -0,0 +1,157 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Integración con Bucéfalo CRM.
|
||||
--
|
||||
-- Todo lo de aquí está diseñado contra hallazgos MEDIDOS contra la subcuenta
|
||||
-- real de Yola Franco Spa (Pk89Wa23QaxvkOfKgwjZ) el 2026-08-29, no contra la
|
||||
-- especificación. Ver platform/crm/HALLAZGOS.md.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- La conexión con la subcuenta. Una fila por negocio.
|
||||
-- El token NO vive aquí: vive en el entorno del servidor. Esta tabla guarda
|
||||
-- qué subcuenta, qué pipeline y qué etapas usa cada negocio.
|
||||
CREATE TABLE crm_connections (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL UNIQUE REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
location_id text NOT NULL,
|
||||
pipeline_id text,
|
||||
stage_open_id text,
|
||||
stage_won_id text,
|
||||
stage_lost_id text,
|
||||
-- MEDIDO: la subcuenta trae `allowDuplicateOpportunity: false`, así que el
|
||||
-- CRM rechaza una segunda oportunidad por contacto AUNQUE la anterior esté
|
||||
-- cerrada. Mientras esté en false, la plataforma recicla la oportunidad
|
||||
-- existente en vez de crear una por cita. Si el cliente activa el ajuste,
|
||||
-- esta bandera pasa a true y cada cita estrena la suya.
|
||||
allow_duplicate_opp boolean NOT NULL DEFAULT false,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_status text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Atribución de la clienta. Se separa de `clients` porque son 10+ columnas que
|
||||
-- solo existen si el contacto vino del CRM, y porque el CRM las declara
|
||||
-- inmutables: se escriben en el alta y un PUT posterior devuelve 200 sin
|
||||
-- guardar nada. Aquí son espejo de lectura.
|
||||
ALTER TABLE clients
|
||||
ADD COLUMN crm_source text,
|
||||
ADD COLUMN attr_session_source text,
|
||||
ADD COLUMN attr_medium text,
|
||||
ADD COLUMN attr_campaign text,
|
||||
ADD COLUMN attr_campaign_id text,
|
||||
ADD COLUMN attr_utm_source text,
|
||||
ADD COLUMN attr_utm_medium text,
|
||||
ADD COLUMN attr_utm_content text,
|
||||
ADD COLUMN attr_ad_id text,
|
||||
ADD COLUMN attr_referrer text,
|
||||
ADD COLUMN crm_tags text,
|
||||
ADD COLUMN crm_date_added timestamptz;
|
||||
|
||||
CREATE INDEX clients_crm_contact ON clients (crm_contact_id)
|
||||
WHERE crm_contact_id IS NOT NULL;
|
||||
|
||||
-- La cita se proyecta al CRM como oportunidad.
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN crm_opportunity_id text,
|
||||
ADD COLUMN crm_synced_at timestamptz,
|
||||
ADD COLUMN crm_status text; -- lo que el CRM cree: open|won|lost
|
||||
|
||||
CREATE INDEX appointments_crm_opp ON appointments (crm_opportunity_id)
|
||||
WHERE crm_opportunity_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Bandeja de salida. Existe desde el día uno a propósito: la API del CRM falla,
|
||||
-- y sin cola un fallo se traga la cita de una clienta sin que nadie lo sepa.
|
||||
-- El cambio local y su fila de bandeja se escriben en la MISMA transacción; sin
|
||||
-- eso aparece la escritura perdida (el usuario ve "guardado", el proceso muere
|
||||
-- antes de encolar, y nadie lo reclama nunca).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE crm_outbox (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
entity text NOT NULL, -- client | appointment | message
|
||||
entity_id bigint NOT NULL,
|
||||
operation text NOT NULL, -- create | update | status | send
|
||||
payload jsonb NOT NULL,
|
||||
-- pendiente → enviando → confirmado | fallido | indeterminado
|
||||
--
|
||||
-- `indeterminado` no es un adorno: es donde cae un fallo de TRANSPORTE
|
||||
-- (timeout, conexión caída). Un 5xx es una respuesta —el servidor habló—;
|
||||
-- un timeout no dice nada sobre si la escritura entró. Reenviarlo es
|
||||
-- fabricar la doble creación, así que se resuelve leyendo, nunca reenviando.
|
||||
status text NOT NULL DEFAULT 'pendiente'
|
||||
CHECK (status IN ('pendiente','enviando','confirmado','fallido','indeterminado')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
last_error text,
|
||||
-- Clave de deduplicación propia y estable. NUNCA se deriva del contenido:
|
||||
-- dos ediciones que dejan el mismo valor son dos intenciones distintas.
|
||||
dedup_key text NOT NULL,
|
||||
crm_id text, -- se llena tras RELEER, no tras el 200
|
||||
evidence text, -- relectura | 400_meta | busqueda
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
sent_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX crm_outbox_pendientes ON crm_outbox (business_id, status, id)
|
||||
WHERE status IN ('pendiente','indeterminado');
|
||||
CREATE INDEX crm_outbox_entidad ON crm_outbox (entity, entity_id);
|
||||
CREATE UNIQUE INDEX crm_outbox_dedup ON crm_outbox (dedup_key);
|
||||
|
||||
-- Historial de cada corrida del botón de sincronización.
|
||||
CREATE TABLE crm_sync_runs (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, -- contacts | appointments
|
||||
direction text NOT NULL, -- pull | push
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
status text NOT NULL DEFAULT 'corriendo'
|
||||
CHECK (status IN ('corriendo','ok','error')),
|
||||
fetched integer NOT NULL DEFAULT 0,
|
||||
created integer NOT NULL DEFAULT 0,
|
||||
updated integer NOT NULL DEFAULT 0,
|
||||
skipped integer NOT NULL DEFAULT 0,
|
||||
error text,
|
||||
started_by_user_id bigint REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX crm_sync_runs_business ON crm_sync_runs (business_id, started_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Espejo de conversaciones y mensajes. El CRM es el dueño: aquí solo se
|
||||
-- guardan metadatos y referencias, y nunca se editan — se reescriben desde el
|
||||
-- CRM. La plataforma solo CREA mensajes salientes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE conversations (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
crm_conversation_id text NOT NULL,
|
||||
client_id bigint REFERENCES clients(id),
|
||||
crm_contact_id text,
|
||||
contact_name text,
|
||||
last_message_type text,
|
||||
last_message_body text,
|
||||
last_message_at timestamptz,
|
||||
unread_count integer NOT NULL DEFAULT 0,
|
||||
synced_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (business_id, crm_conversation_id)
|
||||
);
|
||||
|
||||
CREATE INDEX conversations_reciente ON conversations (business_id, last_message_at DESC);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
business_id bigint NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
conversation_id bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
crm_message_id text,
|
||||
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
|
||||
channel text NOT NULL, -- Email | SMS | WhatsApp | FB | IG…
|
||||
body text,
|
||||
subject text,
|
||||
status text, -- del CRM: queued|sent|delivered|failed
|
||||
sent_by_user_id bigint REFERENCES users(id),
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (business_id, crm_message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX messages_conversacion ON messages (conversation_id, sent_at);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- De un negocio con un token global, a N negocios con credencial propia.
|
||||
--
|
||||
-- Hasta aquí `crm_connections.location_id` ya era por negocio, pero el token
|
||||
-- vivía en la variable de entorno CRM_TOKEN, una sola para todo el proceso
|
||||
-- (platform/crm/client.ts). Con dos negocios eso usa el token del primero
|
||||
-- contra la subcuenta del segundo: 401 en el mejor caso, escritura en la
|
||||
-- subcuenta equivocada en el peor.
|
||||
--
|
||||
-- El token se guarda CIFRADO con AES-256-GCM (platform/lib/crypto.ts). La clave
|
||||
-- maestra vive en CRM_MASTER_KEY, fuera de la base: quien consiga un volcado de
|
||||
-- Postgres no consigue los tokens de los clientes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE crm_connections
|
||||
ADD COLUMN token_cipher bytea,
|
||||
ADD COLUMN token_nonce bytea,
|
||||
ADD COLUMN token_tag bytea,
|
||||
-- Los 6 últimos caracteres. Permite que la interfaz diga «termina en …f4a2c1»
|
||||
-- y detectar una rotación, sin exponer nunca la credencial.
|
||||
ADD COLUMN token_fingerprint text,
|
||||
ADD COLUMN token_updated_at timestamptz,
|
||||
-- MEDIDO (hallazgo 29): la subcuenta tiene 7 calendarios y la única cita real
|
||||
-- está en «Servicio Spa». Sin fijar cuál, empujar una cita al calendario del
|
||||
-- CRM sería adivinar a cuál.
|
||||
ADD COLUMN calendar_id text,
|
||||
-- La red de seguridad de mensajes pasa a ser POR NEGOCIO. Como variable de
|
||||
-- entorno global decidía por todas las cuentas a la vez: o se abrían los
|
||||
-- envíos reales para todas, o ninguna podía salir de pruebas.
|
||||
ADD COLUMN test_email text,
|
||||
ADD COLUMN allow_real_sends boolean NOT NULL DEFAULT false,
|
||||
-- Nombre legible de la subcuenta, para que la administración no tenga que
|
||||
-- reconocer cuentas por un identificador opaco.
|
||||
ADD COLUMN label text;
|
||||
|
||||
-- La credencial va completa o no va. Media credencial produce un descifrado que
|
||||
-- falla en tiempo de petición, y eso es un fallo lejos de su causa.
|
||||
ALTER TABLE crm_connections
|
||||
ADD CONSTRAINT crm_connections_credencial_completa CHECK (
|
||||
(token_cipher IS NULL AND token_nonce IS NULL AND token_tag IS NULL)
|
||||
OR
|
||||
(token_cipher IS NOT NULL AND token_nonce IS NOT NULL AND token_tag IS NOT NULL)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN crm_connections.token_cipher IS
|
||||
'Token privado de la subcuenta, cifrado con AES-256-GCM. Nunca se devuelve por la API.';
|
||||
COMMENT ON COLUMN crm_connections.token_fingerprint IS
|
||||
'Los 6 ultimos caracteres del token. Lo unico de la credencial que puede salir del servidor.';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sincronización por id de las cinco entidades.
|
||||
--
|
||||
-- Las tablas `conversations` y `messages` se declararon en 002_crm.sql y hasta
|
||||
-- ahora NADIE escribía en ellas: la bandeja consultaba el CRM en vivo en cada
|
||||
-- carga. Eso significa que sin red no hay bandeja, que cada visita gasta cuota,
|
||||
-- y que no se puede cruzar un hilo con una clienta sin volver a salir a internet.
|
||||
-- Aquí se añade lo que faltaba para llenarlas.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE messages
|
||||
-- De qué contacto del CRM es el mensaje, para cruzarlo con la clienta sin
|
||||
-- pasar por la conversación.
|
||||
ADD COLUMN crm_contact_id text,
|
||||
-- El canal tal cual lo devolvió el CRM, además del normalizado. La API da el
|
||||
-- tipo como número o como cadena según el endpoint, y guardar solo la versión
|
||||
-- traducida perdería el dato original si mañana cambia la traducción.
|
||||
ADD COLUMN channel_raw text;
|
||||
|
||||
CREATE INDEX messages_crm_contact ON messages (business_id, crm_contact_id)
|
||||
WHERE crm_contact_id IS NOT NULL;
|
||||
|
||||
-- Cursor de la última sincronización de conversaciones, para continuar donde se
|
||||
-- quedó en vez de releer las 3 213 cada vez.
|
||||
ALTER TABLE crm_connections
|
||||
ADD COLUMN conv_cursor_date bigint;
|
||||
|
||||
-- El servicio de la plataforma, una vez publicado en el catálogo del CRM.
|
||||
-- MEDIDO (hallazgos 6 y 32): el catálogo del CRM está VACÍO, así que
|
||||
-- «sincronizar servicios» solo puede significar empujar, nunca traer.
|
||||
ALTER TABLE services
|
||||
ADD COLUMN crm_service_id text,
|
||||
ADD COLUMN crm_synced_at timestamptz;
|
||||
|
||||
CREATE INDEX services_crm ON services (crm_service_id) WHERE crm_service_id IS NOT NULL;
|
||||
|
||||
-- La cita de la plataforma, una vez escrita como evento en el calendario del
|
||||
-- CRM. Es distinto de `crm_opportunity_id`: la oportunidad es el embudo de
|
||||
-- ventas y el evento es la agenda. Una cita puede tener las dos cosas.
|
||||
ALTER TABLE appointments
|
||||
ADD COLUMN crm_event_id text;
|
||||
|
||||
CREATE INDEX appointments_crm_event ON appointments (crm_event_id)
|
||||
WHERE crm_event_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN crm_sync_runs.kind IS
|
||||
'contacts | appointments | conversations | one — "one" es la sincronizacion de una sola entidad por id';
|
||||
@@ -0,0 +1,32 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user