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:
AgendaPro Dev
2026-08-30 15:07:20 -06:00
co-authored by Claude Opus 5
parent dcbf750c09
commit 6d67b23e55
95 changed files with 16132 additions and 41 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* Comprueba que la consola de cuentas se pinta de verdad y que el token NO
* aparece en ningún sitio del DOM.
*
* Un typecheck limpio y un build correcto no dicen nada sobre si la pantalla
* renderiza: eso hay que mirarlo.
*
* node platform/scripts/admin-ui-check.mjs
*/
import { chromium } from "playwright";
const BASE = process.env.UI_BASE_URL || "http://localhost:5176";
const EMAIL = "[email protected]";
const PASS = "demo1234";
let fallos = 0;
const check = (nombre, ok, detalle = "") => {
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
if (!ok) fallos++;
};
const navegador = await chromium.launch();
const pagina = await navegador.newPage();
const erroresConsola = [];
pagina.on("console", (m) => m.type() === "error" && erroresConsola.push(m.text()));
pagina.on("pageerror", (e) => erroresConsola.push(String(e)));
try {
await pagina.goto(`${BASE}/login`, { waitUntil: "networkidle" });
await pagina.fill('input[type="email"]', EMAIL);
await pagina.fill('input[type="password"]', PASS);
await pagina.click('button[type="submit"]');
await pagina.waitForURL(/\/admin/, { timeout: 15000 });
check("entra como administración de plataforma", true, pagina.url());
await pagina.goto(`${BASE}/admin/cuentas`, { waitUntil: "networkidle" });
await pagina.waitForSelector("table", { timeout: 15000 });
const texto = await pagina.innerText("body");
check("se pinta la tabla de cuentas", /Cuentas de la plataforma/.test(texto));
check("aparece el negocio real", /Yola Franco Spa/.test(texto));
check("muestra la huella del token", /token …f89621|token \.\.\.f89621/.test(texto), "huella visible");
// Lo que NO puede pasar bajo ningún concepto.
const html = await pagina.content();
check("el token completo NO está en el DOM", !/pit-/i.test(html) && !html.includes("f89621f"), "");
// El modal de vínculo: el campo del token debe ser de contraseña y venir vacío.
await pagina.click("text=Cambiar token");
await pagina.waitForSelector("#cr-tok", { timeout: 8000 });
const tipo = await pagina.getAttribute("#cr-tok", "type");
const valor = await pagina.inputValue("#cr-tok");
check("el campo del token es de contraseña", tipo === "password", `type=${tipo}`);
check("y viene vacío: no hay token que traer", valor === "", `valor=«${valor}»`);
const locId = await pagina.inputValue("#cr-loc");
check("la subcuenta sí se prerrellena", locId === "Pk89Wa23QaxvkOfKgwjZ", locId);
// Etiquetas asociadas a su control.
const sinLabel = await pagina.$$eval("#cr-tok, #cr-loc", (els) =>
els.filter((el) => !document.querySelector(`label[for="${el.id}"]`)).map((el) => el.id)
);
check("cada campo tiene su etiqueta asociada", sinLabel.length === 0, sinLabel.join(", "));
check("sin errores de consola", erroresConsola.length === 0, erroresConsola.slice(0, 2).join(" | "));
await pagina.screenshot({ path: "screenshots/admin-cuentas.png", fullPage: true });
console.log("\n captura en screenshots/admin-cuentas.png");
} finally {
await navegador.close();
}
console.log(`\n${fallos === 0 ? "Todo en verde" : `${fallos} comprobaciones fallaron`}`);
process.exit(fallos === 0 ? 0 : 1);
+52
View File
@@ -0,0 +1,52 @@
/**
* Conecta el negocio de la plataforma con su subcuenta de Bucéfalo CRM y
* autodetecta pipeline y etapas.
*
* node scripts/run-tsx.mjs platform/scripts/crm-conectar.ts [slug]
*/
import { pool } from "../db/pool.ts";
import { loadEnv, requireEnv } from "../lib/env.ts";
import { autoconfigurar } from "../crm/connection.ts";
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
loadEnv();
async function main() {
const slug = process.argv[2] || "yola-franco";
const locationId = requireEnv("CRM_LOCATION_ID");
const b = await pool.query<{ id: number; name: string }>(
`SELECT id, name FROM businesses WHERE slug = $1`,
[slug]
);
if (!b.rows[0]) {
console.error(`No existe el negocio con slug «${slug}»`);
process.exit(1);
}
// El script vincula la subcuenta configurada en el entorno: guarda la
// credencial cifrada y después autodetecta pipeline y etapas. Antes solo
// hacía lo segundo, porque el token era global.
await guardarCredencial(b.rows[0].id, locationId, ctxDesdeEnv().token);
const c = await autoconfigurar(await ctxDe(b.rows[0].id));
console.log(`Conectado «${b.rows[0].name}» ↔ subcuenta ${c.location_id}`);
console.log(` pipeline : ${c.pipeline_id}`);
console.log(` etapa «en espera» : ${c.stage_open_id}`);
console.log(` etapa «ganado» : ${c.stage_won_id}`);
console.log(` etapa «perdido» : ${c.stage_lost_id}`);
console.log(` permite duplicados : ${c.allow_duplicate_opp}`);
console.log(` nota : ${c.last_sync_status}`);
if (!c.allow_duplicate_opp) {
console.log(
`\n >> Con «allow duplicate opportunity» desactivado, cada clienta tiene UNA\n` +
` oportunidad que se recicla en cada cita. Para que cada cita estrene la\n` +
` suya, hay que activar ese ajuste en la UI del CRM.`
);
}
await pool.end();
}
main().catch((e) => {
console.error(e.message);
process.exit(1);
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Borra del CRM lo que crearon los spikes.
*
* Los spikes escriben en la subcuenta REAL del cliente. Todo lo que crean lleva
* el tag `agendamax:prueba` y el correo autorizado; esto lo busca por ese tag y
* lo elimina, para no dejar basura en la base de un negocio en producción.
*
* node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts (lista)
* node scripts/run-tsx.mjs platform/scripts/crm-limpiar-pruebas.ts --borrar (borra)
*/
import { loadEnv, requireEnv } from "../lib/env.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";
import { oportunidadesDeContacto } from "../crm/opportunities.ts";
loadEnv();
const LOC = requireEnv("CRM_LOCATION_ID");
const CTX = ctxDesdeEnv();
const CORREO = process.env.CRM_TEST_EMAIL || "[email protected]";
const BORRAR = process.argv.includes("--borrar");
async function main() {
const r = await crmRequest<any>("GET", "/contacts/", { token: CTX.token,
query: { locationId: LOC, query: CORREO, limit: 20 },
});
const contactos: any[] = (r?.contacts ?? []).filter(
(c: any) =>
(c.email ?? "").toLowerCase() === CORREO.toLowerCase() ||
(c.tags ?? []).includes("agendamax:prueba")
);
if (!contactos.length) {
console.log("No hay contactos de prueba en la subcuenta.");
return;
}
for (const c of contactos) {
console.log(`\nContacto ${c.id} — ${c.contactName ?? c.firstName} <${c.email}>`);
console.log(` tags: ${(c.tags ?? []).join(", ") || "(ninguno)"}`);
const opps = await oportunidadesDeContacto(CTX, c.id);
for (const o of opps) {
console.log(` oportunidad ${o.id} — «${o.name}» (${o.status})`);
if (BORRAR) {
await crmRequest("DELETE", `/opportunities/${o.id}`, { token: CTX.token });
console.log(" borrada");
}
}
if (BORRAR) {
await crmRequest("DELETE", `/contacts/${c.id}`, { token: CTX.token });
console.log(" contacto borrado");
}
}
if (!BORRAR) {
console.log("\n(Solo listado. Añade --borrar para eliminarlos de verdad.)");
}
}
main().catch((e) => {
console.error(e.message);
process.exit(1);
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Mueve la credencial de `platform/.env` a la base, cifrada, para un negocio
* que ya estaba vinculado.
*
* Es de un solo uso por negocio: después, las credenciales se ponen desde la
* consola de administración (`PUT /api/admin/businesses/:id/crm`).
*
* node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>
*/
import { pool } from "../db/pool.ts";
import { crmRequest } from "../crm/client.ts";
import { ctxDe, ctxDesdeEnv, guardarCredencial } from "../crm/ctx.ts";
const businessId = Number(process.argv[2]);
if (!Number.isFinite(businessId)) {
console.error("Uso: node scripts/run-tsx.mjs platform/scripts/crm-migrar-credencial.ts <businessId>");
process.exit(1);
}
const desdeEnv = ctxDesdeEnv(businessId);
const { rows } = await pool.query(`SELECT id, name FROM businesses WHERE id = $1`, [businessId]);
if (!rows[0]) {
console.error(`No existe el negocio ${businessId}`);
process.exit(1);
}
console.log(`Negocio ${businessId}: ${rows[0].name}`);
// Se comprueba la credencial ANTES de guardarla, igual que hace la consola.
const loc = await crmRequest<any>("GET", `/locations/${desdeEnv.locationId}`, {
token: desdeEnv.token,
});
const nombre = loc?.location?.name ?? null;
if (loc?.location?.id && loc.location.id !== desdeEnv.locationId) {
console.error("El token pertenece a otra subcuenta distinta de CRM_LOCATION_ID");
process.exit(1);
}
console.log(`Subcuenta verificada: ${nombre} (${desdeEnv.locationId})`);
await guardarCredencial(businessId, desdeEnv.locationId, desdeEnv.token, nombre ?? undefined);
// No se acepta el guardado como prueba: se relee de la base, se descifra y se
// USA contra el CRM. Es la única forma de saber que el ciclo entero funciona.
const ctx = await ctxDe(businessId);
const rel = await crmRequest<any>("GET", `/locations/${ctx.locationId}`, { token: ctx.token });
if (rel?.location?.id === desdeEnv.locationId) {
const { rows: f } = await pool.query(
`SELECT token_fingerprint, label FROM crm_connections WHERE business_id = $1`,
[businessId]
);
console.log(
`\n✔ Credencial cifrada, releída de la base y verificada contra el CRM.` +
`\n etiqueta: ${f[0].label}` +
`\n huella : …${f[0].token_fingerprint}` +
`\n\nYa puedes quitar CRM_TOKEN de platform/.env para este negocio.`
);
} else {
console.error("✖ La relectura no coincide: la credencial guardada no sirve");
process.exit(1);
}
await pool.end();
+53
View File
@@ -0,0 +1,53 @@
/**
* ¿Se pueden BORRAR citas y servicios? Sin crear nada.
*
* Se pregunta antes de escribir, no después: si el borrado no existe o el token
* no lo tiene, cualquier prueba de escritura dejaría basura permanente en el CRM
* de un cliente real. Se manda un DELETE contra un id inventado y se mira el
* error:
*
* 401 «not authorized for this scope» → no hay permiso de borrado
* 404 / 422 → el permiso está; solo falta el id real
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike-borrado.ts
*/
import { requireEnv, loadEnv } from "../lib/env.ts";
loadEnv();
const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com";
const TOKEN = requireEnv("CRM_TOKEN");
async function sonda(method: string, path: string, version: string) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
authorization: `Bearer ${TOKEN}`,
version,
accept: "application/json",
},
});
const texto = (await res.text()).slice(0, 260);
const sinPermiso = res.status === 401;
console.log(`\n── ${method} ${path}`);
console.log(
` ${sinPermiso ? "✖ SIN PERMISO DE BORRADO" : "✔ EL BORRADO EXISTE (falla por el id, no por el token)"}`
);
console.log(` ${res.status} · ${texto}`);
return !sinPermiso;
}
const ID_FALSO = "idQueNoExisteJamas123";
const cita = await sonda("DELETE", `/calendars/events/${ID_FALSO}`, "v3");
const servicio = await sonda("DELETE", `/calendars/services/catalog/${ID_FALSO}`, "v3");
console.log(
`\n────────────\ncitas: ${cita ? "se pueden borrar" : "NO se pueden borrar"} · ` +
`servicios: ${servicio ? "se pueden borrar" : "NO se pueden borrar"}`
);
if (!cita || !servicio) {
console.log(
"\nNo se debe ejercer la escritura de lo que no se pueda deshacer en la\n" +
"subcuenta de un cliente real."
);
}
+101
View File
@@ -0,0 +1,101 @@
/**
* ¿Hay citas en los calendarios de la subcuenta? Solo lectura.
*
* El sondeo anterior miró únicamente el PRIMER calendario, que resultó ser uno
* personal, y concluir «no hay citas» a partir de eso habría sido un error: el
* que importa es «Servicio Spa». Aquí se recorren los SIETE, y se prueban las
* dos formas de acotar que admite la API (por calendario y por usuario).
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike-calendarios.ts
*/
import { crmRequest, CrmError, VERSION_CALENDARS } from "../crm/client.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { requireEnv } from "../lib/env.ts";
const LOC = requireEnv("CRM_LOCATION_ID");
const CTX = ctxDesdeEnv();
const DIA = 86400000;
async function eventos(query: Record<string, string | number>): Promise<any[] | string> {
try {
const r = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
query,
version: VERSION_CALENDARS,
});
return r?.events ?? [];
} catch (e: any) {
if (e instanceof CrmError) {
const cuerpo = typeof e.body === "string" ? e.body : JSON.stringify(e.body);
return `${e.status}: ${String(e.message).slice(0, 120)}${cuerpo ? ` | ${cuerpo.slice(0, 160)}` : ""}`;
}
return String(e?.message ?? e);
}
}
async function main() {
const r = await crmRequest<any>("GET", "/calendars/", { token: CTX.token,
query: { locationId: LOC },
version: VERSION_CALENDARS,
});
const cals: any[] = r?.calendars ?? [];
console.log(`${cals.length} calendarios en la subcuenta ${LOC}\n`);
const ahora = Date.now();
// Ventana amplia: dos años hacia atrás y uno hacia adelante.
const desde = String(ahora - 730 * DIA);
const hasta = String(ahora + 365 * DIA);
let totalEventos = 0;
for (const c of cals) {
const res = await eventos({
locationId: LOC,
calendarId: c.id,
startTime: desde,
endTime: hasta,
});
const activo = c.isActive === false ? " (inactivo)" : "";
if (typeof res === "string") {
console.log(` ✖ ${String(c.name).padEnd(42)}${activo} → ${res}`);
} else {
totalEventos += res.length;
const marca = res.length ? "✔" : "·";
console.log(` ${marca} ${String(c.name).padEnd(42)}${activo} → ${res.length} citas`);
if (res.length) {
const e = res[0];
console.log(` ejemplo: ${JSON.stringify(e).slice(0, 300)}`);
}
}
}
console.log(`\nTotal de citas en los 7 calendarios (2 años atrás → 1 adelante): ${totalEventos}`);
// La otra forma de acotar que documenta la API: por usuario en vez de por
// calendario. Si por calendario no sale nada, conviene descartar que las
// citas cuelguen de un usuario y no de un calendario.
console.log("\n── Prueba alterna: acotar por usuario en vez de por calendario");
const porUsuario = await eventos({
locationId: LOC,
userId: "x",
startTime: desde,
endTime: hasta,
});
console.log(
typeof porUsuario === "string"
? ` respuesta: ${porUsuario}`
: ` ${porUsuario.length} citas`
);
console.log("\n── ¿Y sin acotar por calendario ni usuario?");
const sinFiltro = await eventos({ locationId: LOC, startTime: desde, endTime: hasta });
console.log(
typeof sinFiltro === "string"
? ` respuesta: ${sinFiltro}`
: ` ${sinFiltro.length} citas`
);
}
main().catch((e) => {
console.error("Se detuvo:", e?.message ?? e);
process.exit(1);
});
+110
View File
@@ -0,0 +1,110 @@
/**
* MEDIDO: el CRM rechaza una segunda oportunidad para el mismo contacto aunque
* la primera esté en `won` (400 OPPORTUNITY_NO_DUPLICATE).
*
* Antes de rediseñar el mapeo hay que saber si eso es un límite duro o un
* ajuste de la subcuenta que el cliente puede cambiar. Tres sondeos:
* a) ¿el ajuste aparece en la ficha de la subcuenta?
* b) ¿el bloqueo es por (contacto, pipeline) o global por contacto?
* c) ¿el buscador de oportunidades permite recuperar la existente para
* reciclarla? — es el plan B, y tiene que funcionar sí o sí.
*/
import { loadEnv } from "../lib/env.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";
loadEnv();
const LOC = process.env.CRM_LOCATION_ID!;
const CTX = ctxDesdeEnv();
const CONTACTO = "WzBTBaHkNnpmjMb1Avx3";
const ok = (s: string) => console.log(` ✔ ${s}`);
const fail = (s: string) => console.log(` ✗ ${s}`);
async function main() {
console.log("── a) ¿La ficha de la subcuenta expone algún ajuste de duplicados?");
try {
const r: any = await crmRequest("GET", `/locations/${LOC}`, { token: CTX.token });
const loc = r?.location ?? {};
const claves = Object.keys(loc).sort();
console.log(" claves:", claves.join(", "));
const settings = loc.settings ?? null;
console.log(" settings:", JSON.stringify(settings, null, 2));
} catch (e: any) {
fail(e.message);
}
console.log("\n── b) ¿Se pueden listar los pipelines y crear uno propio de spa?");
try {
const r: any = await crmRequest("POST", "/opportunities/pipelines", { token: CTX.token,
body: { locationId: LOC, name: "AgendaMax — Agenda Spa (prueba)" },
});
ok(`pipeline creado id=${r?.pipeline?.id ?? JSON.stringify(r).slice(0, 200)}`);
console.log(" >> Se puede rediseñar el pipeline a etapas de spa desde la plataforma.");
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
console.log(" >> El token no tiene `pipelines.create`; el pipeline se rediseña a mano.");
}
console.log("\n── c) PLAN B: recuperar la oportunidad existente para reciclarla");
try {
const r: any = await crmRequest("GET", "/opportunities/search", { token: CTX.token,
query: { location_id: LOC, contact_id: CONTACTO, limit: 20 },
});
const opps = r?.opportunities ?? [];
ok(`el buscador devuelve ${opps.length} oportunidad(es) del contacto`);
console.log(
JSON.stringify(
opps.map((o: any) => ({
id: o.id,
name: o.name,
status: o.status,
monetaryValue: o.monetaryValue,
pipelineId: o.pipelineId,
pipelineStageId: o.pipelineStageId,
})),
null,
2
)
);
console.log(" >> Con esto se puede localizar y actualizar la existente en vez de crear.");
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
}
console.log("\n── d) ¿El PUT general permite renombrar y cambiar el importe (reciclar)?");
const OPP = "IMkYdAkBowggN9aKVbfc";
try {
const nuevoNombre = `Pedicura — Prueba AgendaMax (reciclada ${Date.now()})`;
await crmRequest("PUT", `/opportunities/${OPP}`, { token: CTX.token,
body: { pipelineId: "Mrclt4VzRZV1DI4Vbt5c", name: nuevoNombre, monetaryValue: 400 },
});
const r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
const o = r?.opportunity;
if (o?.name === nuevoNombre && o?.monetaryValue === 400) {
ok(`releído: nombre e importe reciclados (status sigue «${o.status}»)`);
console.log(" >> PLAN B VIABLE: la oportunidad se reutiliza por clienta.");
} else {
fail(`al releer nombre=${o?.name} importe=${o?.monetaryValue}`);
}
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
}
console.log("\n── e) ¿Y volver a abrirla (open) tras haberla cerrado?");
try {
await crmRequest("PUT", `/opportunities/${OPP}/status`, { token: CTX.token, body: { status: "open" } });
const r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
if (r?.opportunity?.status === "open") ok("sí: una oportunidad cerrada se puede reabrir");
else fail(`al releer status=${r?.opportunity?.status}`);
} catch (e: any) {
fail(e.message);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,177 @@
/**
* Ejerce por primera vez la ESCRITURA de citas y servicios contra el CRM real.
*
* Ciclo completo y autolimpiable: crear → RELEER → borrar → confirmar que ya no
* está. Nada queda en la subcuenta del cliente aunque el script falle a mitad:
* lo creado se registra y se borra en el `finally`.
*
* Usa las funciones de producción (`crearCita`, `obtenerCita`, `publicarServicio`)
* y no una implementación paralela: lo que se valida aquí es el código que va a
* correr, no un primo suyo.
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike-escritura-cita-servicio.ts
*/
import { pool } from "../db/pool.ts";
import { crmRequest, VERSION_CALENDARS } from "../crm/client.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import {
crearCita,
obtenerCita,
listarCalendarios,
listarPersonal,
isoConDesplazamiento,
} from "../crm/calendars.ts";
import { catalogoDelCrm } from "../crm/services.ts";
const CTX = ctxDesdeEnv(1);
const CONTACTO_PRUEBA = "WzBTBaHkNnpmjMb1Avx3"; // el que ya lleva el tag agendamax:prueba
const TZ = "America/Mexico_City";
const creados: { que: string; id: string; ruta: string }[] = [];
let spaId = "";
let fallos = 0;
const check = (nombre: string, ok: boolean, detalle = "") => {
console.log(` ${ok ? "✔" : "✖"} ${nombre}${detalle ? ` — ${detalle}` : ""}`);
if (!ok) fallos++;
};
async function main() {
// ── CITA ──────────────────────────────────────────────────────────────────
console.log("\n══ ESCRITURA DE CITA AL CALENDARIO ══");
const cals = await listarCalendarios(CTX);
const spa = cals.find((c) => /servicio spa/i.test(c.name)) ?? cals[0];
spaId = spa.id;
console.log(` calendario: ${spa.name} (${spa.id})`);
const personal = await listarPersonal(CTX);
const quien = personal.find((p) => /recepci/i.test(p.name)) ?? personal[0];
console.log(` asignada a: ${quien.name} (${quien.id})`);
// Una fecha lejana y a una hora inequívoca, para que se distinga de lo real.
const inicio = new Date(Date.now() + 120 * 86400000);
inicio.setUTCHours(20, 0, 0, 0); // 14:00 en México
const fin = new Date(inicio.getTime() + 45 * 60000);
const startTime = isoConDesplazamiento(inicio, TZ);
const endTime = isoConDesplazamiento(fin, TZ);
console.log(` ventana : ${startTime} → ${endTime}`);
const { id: eventoId } = await crearCita(CTX, {
calendarId: spa.id,
contactId: CONTACTO_PRUEBA,
startTime,
endTime,
title: "[agendamax:prueba] Cita de verificación — BORRAR",
assignedUserId: quien.id,
appointmentStatus: "confirmed",
});
creados.push({ que: "cita", id: eventoId, ruta: `/calendars/events/${eventoId}` });
check("el CRM aceptó la cita y devolvió id", Boolean(eventoId), eventoId);
// No se acepta el 200 como prueba: se relee.
const releida = await obtenerCita(CTX, eventoId);
check("la cita existe al releerla", Boolean(releida));
check(
"el contacto es el correcto",
releida?.contactId === CONTACTO_PRUEBA,
releida?.contactId ?? "(sin contacto)"
);
check(
"la hora de pared coincide con la que escribimos",
String(releida?.startTime ?? "").startsWith(startTime.slice(0, 16)),
`escrita ${startTime} · releída ${releida?.startTime}`
);
check(
"el estado quedó confirmado",
releida?.appointmentStatus === "confirmed",
String(releida?.appointmentStatus)
);
// ── SERVICIO ──────────────────────────────────────────────────────────────
console.log("\n══ PUBLICACIÓN DE SERVICIO AL CATÁLOGO ══");
const antes = await catalogoDelCrm(CTX);
console.log(` catálogo antes: ${antes.length} servicios`);
const r = await crmRequest<any>("POST", "/calendars/services/catalog", {
token: CTX.token,
version: VERSION_CALENDARS,
body: {
locationId: CTX.locationId,
name: "[agendamax:prueba] Servicio de verificación",
slug: `agendamax-prueba-${Date.now()}`,
serviceDuration: 45,
serviceDurationUnit: "mins",
staff: [{ id: quien.id }],
variations: [],
},
});
const servicioId = r?.service?.id ?? r?.id;
if (servicioId) {
creados.push({
que: "servicio",
id: servicioId,
ruta: `/calendars/services/catalog/${servicioId}`,
});
}
check("el CRM aceptó el servicio y devolvió id", Boolean(servicioId), String(servicioId));
const despues = await catalogoDelCrm(CTX);
check(
"el servicio aparece al releer el catálogo",
despues.some((s) => s.id === servicioId),
`${antes.length} → ${despues.length} servicios`
);
const nuevo = despues.find((s) => s.id === servicioId);
check("con la duración que le pusimos", nuevo?.serviceDuration === 45, String(nuevo?.serviceDuration));
}
try {
await main();
} catch (e: any) {
fallos++;
console.error("\n✖ El sondeo falló:", e?.error ?? e?.message ?? e);
if (e?.body) console.error(" cuerpo:", JSON.stringify(e.body).slice(0, 400));
} finally {
// ── LIMPIEZA: pase lo que pase, no se deja nada en la subcuenta ───────────
console.log("\n══ LIMPIEZA ══");
for (const c of creados) {
try {
await crmRequest("DELETE", c.ruta, { token: CTX.token, version: VERSION_CALENDARS });
console.log(` ✔ ${c.que} ${c.id} borrada`);
} catch (e: any) {
fallos++;
console.error(` ✖ NO se pudo borrar ${c.que} ${c.id}: ${e?.message ?? e}`);
console.error(` BÓRRALA A MANO en el CRM.`);
}
}
// Se confirma el borrado releyendo, no fiándose del 200.
for (const c of creados) {
if (c.que === "cita") {
// MEDIDO: `GET /calendars/events/appointments/{id}` SIGUE devolviendo la
// cita después de borrarla — es un borrado lógico. La comprobación fiable
// es listar el rango del calendario, donde ya no aparece.
const ahora = Date.now();
const ev = await crmRequest<any>("GET", "/calendars/events", {
token: CTX.token,
version: VERSION_CALENDARS,
query: {
locationId: CTX.locationId,
calendarId: spaId,
startTime: String(ahora),
endTime: String(ahora + 365 * 86400000),
},
});
const sigue = (ev?.events ?? []).some((e: any) => e.id === c.id);
check("la cita ya no aparece en el calendario", !sigue);
} else {
const cat = await catalogoDelCrm(CTX);
check("el servicio ya no está en el catálogo", !cat.some((s) => s.id === c.id));
}
}
await pool.end();
console.log(`\n${fallos === 0 ? "Todo en verde y la subcuenta queda limpia" : `${fallos} fallos`}`);
process.exit(fallos === 0 ? 0 : 1);
}
+177
View File
@@ -0,0 +1,177 @@
/**
* Sondeo de SOLO LECTURA de las rutas que hacen falta para sincronizar por id.
*
* El objetivo es «traer por id» contactos, conversaciones, mensajes, citas y
* servicios. De esas cinco, solo contactos está ejercida hoy (HALLAZGOS 1-23);
* el resto está sin medir, y en este proyecto lo medido gana a lo documentado.
*
* NO escribe nada en la subcuenta. Todas las peticiones son GET.
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike-lectura-id.ts
*/
import { crmRequest, CrmError, VERSION_CALENDARS } from "../crm/client.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { requireEnv } from "../lib/env.ts";
const LOC = requireEnv("CRM_LOCATION_ID");
const CTX = ctxDesdeEnv();
let ok = 0;
let fail = 0;
async function probe(titulo: string, fn: () => Promise<string>) {
process.stdout.write(`\n── ${titulo}\n`);
try {
const detalle = await fn();
ok++;
console.log(` ✔ ${detalle}`);
} catch (e: any) {
fail++;
if (e instanceof CrmError) {
const cuerpo = typeof e.body === "string" ? e.body.slice(0, 200) : JSON.stringify(e.body)?.slice(0, 300);
console.log(` ✖ ${e.status} — ${e.message}`);
if (cuerpo && cuerpo !== "null") console.log(` cuerpo: ${cuerpo}`);
} else {
console.log(` ✖ ${e?.message ?? e}`);
}
}
}
const claves = (o: unknown, n = 14) =>
o && typeof o === "object" ? Object.keys(o as object).slice(0, n).join(", ") : String(o);
async function main() {
console.log(`Subcuenta ${LOC} — sondeo de lectura por id (sin escrituras)`);
// ── CONVERSACIONES ────────────────────────────────────────────────────────
let convId = "";
let contactoDeConv = "";
await probe("GET /conversations/search — listar para obtener un id real", async () => {
const r = await crmRequest<any>("GET", "/conversations/search", { token: CTX.token,
query: { locationId: LOC, limit: 3 },
});
const c = r?.conversations?.[0];
if (!c) throw new Error("no devolvió ninguna conversación");
convId = c.id;
contactoDeConv = c.contactId ?? "";
return `total=${r.total} · primera id=${convId} · campos: ${claves(c)}`;
});
await probe("GET /conversations/{id} — traer UNA conversación por su id", async () => {
if (!convId) throw new Error("sin id de conversación");
const r = await crmRequest<any>("GET", `/conversations/${convId}`, { token: CTX.token });
const c = r?.conversation ?? r;
return `campos: ${claves(c)}`;
});
await probe("GET /conversations/search?contactId= — conversaciones de un contacto", async () => {
if (!contactoDeConv) throw new Error("la conversación no traía contactId");
const r = await crmRequest<any>("GET", "/conversations/search", { token: CTX.token,
query: { locationId: LOC, contactId: contactoDeConv, limit: 5 },
});
return `contacto ${contactoDeConv} → ${r?.conversations?.length ?? 0} conversaciones (total=${r?.total})`;
});
// ── MENSAJES ──────────────────────────────────────────────────────────────
let msgId = "";
await probe("GET /conversations/{id}/messages — mensajes del hilo", async () => {
if (!convId) throw new Error("sin id de conversación");
const r = await crmRequest<any>("GET", `/conversations/${convId}/messages`, { token: CTX.token,
query: { limit: 5 },
});
const lista = r?.messages?.messages ?? r?.messages ?? [];
msgId = lista[0]?.id ?? "";
return `${lista.length} mensajes · paginación: ${claves(r?.messages)} · campos del mensaje: ${claves(lista[0])}`;
});
await probe("GET /conversations/messages/{id} — traer UN mensaje por su id", async () => {
if (!msgId) throw new Error("sin id de mensaje");
const r = await crmRequest<any>("GET", `/conversations/messages/${msgId}`, { token: CTX.token });
return `campos: ${claves(r?.message ?? r)}`;
});
// ── CALENDARIOS Y CITAS ───────────────────────────────────────────────────
let calId = "";
await probe("GET /calendars/ — calendarios de la subcuenta", async () => {
const r = await crmRequest<any>("GET", "/calendars/", { token: CTX.token,
query: { locationId: LOC },
version: VERSION_CALENDARS,
});
const cals: any[] = r?.calendars ?? [];
calId = cals[0]?.id ?? "";
return `${cals.length} calendarios: ${cals.map((c) => `${c.name}(${c.id})`).join(", ").slice(0, 260)}`;
});
await probe("GET /calendars/events — citas en un rango de fechas", async () => {
if (!calId) throw new Error("sin calendario");
const ahora = Date.now();
const r = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
query: {
locationId: LOC,
calendarId: calId,
startTime: String(ahora - 90 * 86400000),
endTime: String(ahora + 90 * 86400000),
},
version: VERSION_CALENDARS,
});
const ev: any[] = r?.events ?? [];
return `${ev.length} eventos en ±90 días · campos: ${claves(ev[0])}`;
});
await probe("GET /calendars/events/appointments/{id} — traer UNA cita por id", async () => {
const ahora = Date.now();
if (!calId) throw new Error("sin calendario");
const lista = await crmRequest<any>("GET", "/calendars/events", { token: CTX.token,
query: {
locationId: LOC,
calendarId: calId,
startTime: String(ahora - 365 * 86400000),
endTime: String(ahora + 365 * 86400000),
},
version: VERSION_CALENDARS,
});
const id = lista?.events?.[0]?.id;
if (!id) throw new Error("no hay ninguna cita en ±365 días con la que probar");
const r = await crmRequest<any>("GET", `/calendars/events/appointments/${id}`, { token: CTX.token,
version: VERSION_CALENDARS,
});
return `cita ${id} · campos: ${claves(r?.appointment ?? r)}`;
});
// ── SERVICIOS ─────────────────────────────────────────────────────────────
await probe("GET /calendars/services/catalog — catálogo de servicios", async () => {
const r = await crmRequest<any>("GET", "/calendars/services/catalog", { token: CTX.token,
query: { locationId: LOC },
version: VERSION_CALENDARS,
});
const s: any[] = r?.services ?? [];
return `${s.length} servicios${s.length ? ` · campos: ${claves(s[0])}` : " (vacío, confirma el hallazgo 6)"}`;
});
await probe("GET /calendars/groups — agrupaciones de calendarios", async () => {
const r = await crmRequest<any>("GET", "/calendars/groups", { token: CTX.token,
query: { locationId: LOC },
version: VERSION_CALENDARS,
});
return `${r?.groups?.length ?? 0} grupos · ${claves(r?.groups?.[0])}`;
});
// ── CONTACTO POR ID (ya medido; se reconfirma para tener la forma) ────────
await probe("GET /contacts/{id} — traer UN contacto por id", async () => {
const b = await crmRequest<any>("POST", "/contacts/search", { token: CTX.token,
body: { locationId: LOC, pageLimit: 1 },
});
const id = b?.contacts?.[0]?.id;
if (!id) throw new Error("el buscador no devolvió contactos");
const r = await crmRequest<any>("GET", `/contacts/${id}`, { token: CTX.token });
return `contacto ${id} · campos: ${claves(r?.contact, 20)}`;
});
console.log(`\n────────────\n${ok} rutas responden · ${fail} fallan`);
}
main().catch((e) => {
console.error("\nEl sondeo se detuvo:", e?.message ?? e);
process.exit(1);
});
+117
View File
@@ -0,0 +1,117 @@
/**
* La pregunta que decide el mapeo cita → oportunidad:
*
* MEDIDO: `POST /opportunities/` devuelve 400 OPPORTUNITY_NO_DUPLICATE con
* `meta.existingId` cuando el contacto ya tiene una.
*
* ¿El bloqueo es sobre CUALQUIER oportunidad, o solo sobre las abiertas? De eso
* depende todo: una clienta de spa vuelve muchas veces, y si el CRM solo admite
* una oportunidad por contacto en toda su vida, entonces "una cita = una
* oportunidad" es un modelo imposible y hay que reciclar la misma fila.
*/
import { loadEnv } from "../lib/env.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";
loadEnv();
const LOC = process.env.CRM_LOCATION_ID!;
const CTX = ctxDesdeEnv();
const PIPELINE = "Mrclt4VzRZV1DI4Vbt5c";
const ETAPA_PRIMERA = "8063839c-fa73-419a-8b16-9606fe8e64c1";
const ETAPA_GANADO = "b91c1653-e785-43a8-8b54-f493205c9b5a";
const CONTACTO = "WzBTBaHkNnpmjMb1Avx3"; // el de prueba, ya creado
const OPP = "IMkYdAkBowggN9aKVbfc";
const ok = (s: string) => console.log(` ✔ ${s}`);
const fail = (s: string) => console.log(` ✗ ${s}`);
async function crearOtra(nombre: string) {
try {
const r: any = await crmRequest("POST", "/opportunities/", { token: CTX.token,
body: {
pipelineId: PIPELINE,
locationId: LOC,
name: nombre,
pipelineStageId: ETAPA_PRIMERA,
status: "open",
contactId: CONTACTO,
monetaryValue: 400,
},
});
return { creada: r?.opportunity?.id as string, error: null as any };
} catch (e: any) {
return { creada: null, error: e };
}
}
async function main() {
console.log("── 1. Cerrar la oportunidad existente como «won» (cita completada)");
await crmRequest("PUT", `/opportunities/${OPP}/status`, { token: CTX.token, body: { status: "won" } });
let r: any = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
if (r?.opportunity?.status === "won") ok(`releído status=won, etapa=${r.opportunity.pipelineStageId}`);
else fail(`al releer status=${r?.opportunity?.status}`);
console.log("\n── 2. ¿El PUT general mueve la etapa a «Ganado»?");
try {
await crmRequest("PUT", `/opportunities/${OPP}`, { token: CTX.token,
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_GANADO },
});
r = await crmRequest("GET", `/opportunities/${OPP}`, { token: CTX.token });
if (r?.opportunity?.pipelineStageId === ETAPA_GANADO)
ok(`etapa movida a Ganado, status sigue en «${r.opportunity.status}»`);
else fail(`al releer etapa=${r?.opportunity?.pipelineStageId}`);
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 250)}`);
}
console.log("\n── 3. LA PREGUNTA: ¿deja crear otra con la anterior ya cerrada?");
const segunda = await crearOtra("Manicura — Prueba AgendaMax (segunda visita)");
if (segunda.creada) {
ok(`SÍ — creada id=${segunda.creada}`);
console.log(" >> El bloqueo es solo sobre oportunidades ABIERTAS.");
console.log(" >> Modelo viable: una cita = una oportunidad, cerrando la previa.");
console.log("\n── 4. Y con esta abierta, ¿rechaza una tercera?");
const tercera = await crearOtra("Pedicura — Prueba AgendaMax (tercera)");
if (tercera.creada) {
console.log(` ✔ también la creó (id=${tercera.creada})`);
console.log(" >> Entonces el rechazo anterior fue por nombre/importe idénticos.");
console.log(` Limpieza extra: DELETE /opportunities/${tercera.creada}`);
} else {
ok(`rechazada como se esperaba: ${tercera.error?.body?.code ?? tercera.error?.message}`);
console.log(" >> CONFIRMADO: máximo UNA oportunidad abierta por contacto.");
}
console.log(`\n Limpieza: DELETE /opportunities/${segunda.creada}`);
} else {
fail(`NO — ${segunda.error?.body?.code ?? segunda.error?.message}`);
console.log(" >> Grave: un contacto solo puede tener UNA oportunidad en toda su vida.");
console.log(" >> Entonces la oportunidad no puede representar una cita, sino la");
console.log(" relación con la clienta, y se recicla en cada visita.");
}
console.log("\n── 5. Oportunidades del contacto, tal como las ve el CRM");
try {
const l: any = await crmRequest("GET", `/contacts/${CONTACTO}/opportunities`, { token: CTX.token });
console.log(
JSON.stringify(
(l?.opportunities ?? []).map((o: any) => ({
id: o.id,
name: o.name,
status: o.status,
monetaryValue: o.monetaryValue,
})),
null,
2
)
);
} catch (e: any) {
fail(e.message);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+138
View File
@@ -0,0 +1,138 @@
/**
* ¿Tiene el token permiso de ESCRITURA sobre calendarios y servicios?
*
* Sin crear nada. El truco: se manda un POST deliberadamente incompleto y se
* mira QUÉ error vuelve.
*
* 401 «not authorized for this scope» → falta el permiso
* 400 / 422 sobre campos → el permiso está, lo que falla es el cuerpo
*
* Distinguir esas dos cosas es justo lo que hace falta para saber si se puede
* planificar la escritura de citas al calendario del CRM, y no cuesta un solo
* registro basura en la subcuenta del cliente.
*
* De paso lee las cabeceras X-RateLimit-*, que hoy no lee nadie: el intervalo de
* 650 ms del cliente es una estimación observada, no una cuota conocida.
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts
*/
import { requireEnv, loadEnv } from "../lib/env.ts";
loadEnv();
const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com";
const LOC = requireEnv("CRM_LOCATION_ID");
const TOKEN = requireEnv("CRM_TOKEN");
async function crudo(
method: string,
path: string,
version: string,
body?: unknown
): Promise<{ status: number; texto: string; headers: Record<string, string> }> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
authorization: `Bearer ${TOKEN}`,
version,
accept: "application/json",
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const headers: Record<string, string> = {};
res.headers.forEach((v, k) => {
if (k.toLowerCase().startsWith("x-ratelimit")) headers[k] = v;
});
return { status: res.status, texto: await res.text(), headers };
}
function veredicto(status: number, texto: string): string {
if (status === 401 && /not authorized for this scope/i.test(texto)) {
return "✖ FALTA EL PERMISO";
}
if (status === 401) return "✖ 401 (token rechazado o sin permiso — ambiguo)";
if (status === 400 || status === 422) return "✔ EL PERMISO ESTÁ (rechaza por el cuerpo, no por el token)";
if (status >= 200 && status < 300) return "⚠ ACEPTÓ LA PETICIÓN — revisa si creó algo";
return `? ${status}`;
}
async function main() {
console.log(`Subcuenta ${LOC}\n`);
console.log("── ¿calendars/events.write? (POST incompleto a propósito)");
{
// Falta `startTime`, que es obligatorio. Si el permiso está, la API se queja
// del campo; si no está, se queja del token antes de mirar el cuerpo.
const r = await crudo("POST", "/calendars/events/appointments", "v3", {
locationId: LOC,
});
console.log(` ${veredicto(r.status, r.texto)}`);
console.log(` ${r.status} · ${r.texto.slice(0, 320)}`);
}
console.log("\n── ¿calendars.write? (POST incompleto al catálogo de servicios)");
{
// Faltan `name`, `slug` y `staff[]`, todos obligatorios.
const r = await crudo("POST", "/calendars/services/catalog", "v3", {
locationId: LOC,
});
console.log(` ${veredicto(r.status, r.texto)}`);
console.log(` ${r.status} · ${r.texto.slice(0, 320)}`);
}
console.log("\n── ¿users.readonly? (confirma el hallazgo 14)");
{
const r = await crudo("GET", `/users/?locationId=${LOC}`, "2021-07-28");
let n = 0;
try { n = JSON.parse(r.texto || "{}")?.users?.length ?? 0; } catch { n = 0; }
console.log(
r.status === 200
? ` ✔ RESPONDE 200 con ${n} usuarios — el hallazgo 14 (401) ha quedado obsoleto`
: ` ✖ ${r.status} · ${r.texto.slice(0, 160)}`
);
if (r.status === 200 && n) {
const us = JSON.parse(r.texto).users.slice(0, 8);
for (const u of us) console.log(` ${u.id} ${u.name ?? ""}`);
}
}
console.log("\n── Citas de un contacto: GET /contacts/{id}/appointments");
{
const b = await crudo("POST", "/contacts/search", "2021-07-28", {
locationId: LOC,
pageLimit: 1,
});
let id: string | undefined;
try { id = JSON.parse(b.texto || "{}")?.contacts?.[0]?.id; } catch { id = undefined; }
if (!id) {
console.log(" (no se pudo obtener un contacto de prueba)");
} else {
const r = await crudo("GET", `/contacts/${id}/appointments`, "2021-07-28");
console.log(` contacto ${id} → ${r.status} · ${r.texto.slice(0, 200)}`);
}
}
console.log("\n── Cabeceras de límite de tasa (nadie las lee hoy)");
{
const r = await crudo("GET", `/locations/${LOC}`, "2021-07-28");
const hs = Object.entries(r.headers);
if (!hs.length) {
console.log(" la respuesta no trae ninguna cabecera X-RateLimit-*");
} else {
for (const [k, v] of hs) console.log(` ${k}: ${v}`);
const max = Number(r.headers["x-ratelimit-max"]);
const ventana = Number(r.headers["x-ratelimit-interval-milliseconds"]);
if (max && ventana) {
console.log(
` → cuota real: ${max} peticiones / ${ventana} ms = 1 cada ${Math.ceil(ventana / max)} ms`
);
console.log(` → el cliente usa 650 ms; margen sin aprovechar: ${(650 / (ventana / max)).toFixed(1)}×`);
}
}
}
}
main().catch((e) => {
console.error("Se detuvo:", e?.message ?? e);
process.exit(1);
});
+231
View File
@@ -0,0 +1,231 @@
/**
* Spike de ESCRITURA contra Bucéfalo CRM. Escribe de verdad en la subcuenta del
* cliente, así que todo lo que crea lleva el tag `agendamax:prueba` y el correo
* autorizado, y al final imprime cómo borrarlo.
*
* Regla que gobierna este archivo: **un 200 no es prueba de nada**. Cada
* escritura se vuelve a leer desde la API antes de darla por buena. El proyecto
* hermano pasó meses creyendo que escribía porque los tests estaban escritos
* desde la implementación y no contra el contrato real.
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike-write.ts
*/
import { loadEnv } from "../lib/env.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";
loadEnv();
const LOC = process.env.CRM_LOCATION_ID!;
const CTX = ctxDesdeEnv();
const CORREO = process.env.CRM_TEST_EMAIL || "[email protected]";
const PIPELINE = "Mrclt4VzRZV1DI4Vbt5c"; // "Standar", medido en el spike de lectura
const ETAPA_PRIMERA = "8063839c-fa73-419a-8b16-9606fe8e64c1"; // 1er Contacto
const ETAPA_GANADO = "b91c1653-e785-43a8-8b54-f493205c9b5a";
const ETAPA_PERDIDO = "04b28d7f-167b-4e97-af24-5a229f56b27f";
const marca = `agendamax-spike-${Date.now()}`;
function ok(s: string) {
console.log(` ✔ ${s}`);
}
function fail(s: string) {
console.log(` ✗ ${s}`);
}
async function main() {
console.log(`Subcuenta ${LOC} · correo de prueba ${CORREO}\n`);
let contactId: string | null = null;
let oppId: string | null = null;
// ── 1. Crear contacto CON atribución UTM ────────────────────────────────
console.log("── POST /contacts/ (con attributionSource)");
try {
const r: any = await crmRequest("POST", "/contacts/", { token: CTX.token,
body: {
locationId: LOC,
firstName: "Prueba",
lastName: "AgendaMax",
email: CORREO,
phone: "+524451052792",
country: "MX",
source: "AgendaMax",
tags: ["agendamax:prueba"],
attributionSource: {
sessionSource: "Referral",
utmSource: "agendamax",
utmMedium: "plataforma",
utmCampaign: marca,
campaign: marca, // hay que mandar los dos: /contacts/search descarta utmCampaign
medium: "form",
referrer: "https://agendamax.consultoriae3.com",
},
},
});
contactId = r?.contact?.id ?? null;
ok(`creado id=${contactId}`);
} catch (e: any) {
if (e.status === 400 && e.body?.meta?.contactId) {
contactId = e.body.meta.contactId;
ok(`ya existía (400 con meta) id=${contactId} · campo=${e.body?.meta?.matchingField}`);
console.log(" >> El rechazo de duplicado del CRM funciona: es idempotencia real.");
} else {
fail(e.message);
}
}
// ── 2. RELEER el contacto: ¿persistió la atribución? ────────────────────
console.log("\n── GET /contacts/{id} — relectura (¿persistió el UTM?)");
if (contactId) {
try {
const r: any = await crmRequest("GET", `/contacts/${contactId}`, { token: CTX.token });
const c = r?.contact;
console.log(
JSON.stringify(
{
id: c?.id,
email: c?.email,
phone: c?.phone,
tags: c?.tags,
source: c?.source,
attributionSource: c?.attributionSource,
},
null,
2
)
);
const utm = c?.attributionSource?.utmCampaign || c?.attributionSource?.campaign;
if (utm === marca) ok("la atribución persistió y se puede releer");
else fail(`la atribución NO coincide (esperaba ${marca}, leí ${utm})`);
} catch (e: any) {
fail(e.message);
}
}
// ── 3. Crear oportunidad ────────────────────────────────────────────────
console.log("\n── POST /opportunities/ (cita en espera → status open)");
if (contactId) {
try {
const r: any = await crmRequest("POST", "/opportunities/", { token: CTX.token,
body: {
pipelineId: PIPELINE,
locationId: LOC,
name: "Extensiones de pestañas — Prueba AgendaMax",
pipelineStageId: ETAPA_PRIMERA,
status: "open",
contactId,
monetaryValue: 850,
},
});
oppId = r?.opportunity?.id ?? null;
ok(`creada id=${oppId}`);
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
}
}
// ── 4. RELEER la oportunidad ────────────────────────────────────────────
console.log("\n── GET /opportunities/{id} — relectura");
if (oppId) {
try {
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
const o = r?.opportunity;
console.log(
JSON.stringify(
{
id: o?.id,
name: o?.name,
status: o?.status,
monetaryValue: o?.monetaryValue,
pipelineStageId: o?.pipelineStageId,
contactId: o?.contact?.id ?? o?.contactId,
},
null,
2
)
);
if (o?.monetaryValue === 850) ok("el importe persistió");
else fail(`el importe NO persistió: leí ${o?.monetaryValue}`);
} catch (e: any) {
fail(e.message);
}
}
// ── 5. Cambiar el estado a won (cita completada) ────────────────────────
// MEDIDO: /status NO acepta pipelineStageId (422 "should not exist"). Solo status.
console.log("\n── PUT /opportunities/{id}/status — cita completada → won (solo status)");
if (oppId) {
try {
await crmRequest("PUT", `/opportunities/${oppId}/status`, { token: CTX.token, body: { status: "won" } });
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
const o = r?.opportunity;
if (o?.status === "won") ok(`releído: status=${o.status}, etapa=${o.pipelineStageId}`);
else fail(`devolvió 200 pero al releer status=${o?.status}`);
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
}
}
// ── 5b. ¿Se puede mover la etapa por el PUT general? ─────────────────────
console.log("\n── PUT /opportunities/{id} — mover a la etapa «Ganado»");
if (oppId) {
try {
await crmRequest("PUT", `/opportunities/${oppId}`, { token: CTX.token,
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_GANADO },
});
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
const o = r?.opportunity;
if (o?.pipelineStageId === ETAPA_GANADO) ok(`releído: etapa movida, status=${o.status}`);
else fail(`al releer etapa=${o?.pipelineStageId}`);
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 300)}`);
}
}
// ── 6. Volver a lost (cita cancelada) ───────────────────────────────────
console.log("\n── PUT /opportunities/{id}/status — cita cancelada → lost");
if (oppId) {
try {
await crmRequest("PUT", `/opportunities/${oppId}/status`, { token: CTX.token, body: { status: "lost" } });
await crmRequest("PUT", `/opportunities/${oppId}`, { token: CTX.token,
body: { pipelineId: PIPELINE, pipelineStageId: ETAPA_PERDIDO },
});
const r: any = await crmRequest("GET", `/opportunities/${oppId}`, { token: CTX.token });
const o = r?.opportunity;
if (o?.status === "lost") ok(`releído: status=lost, etapa=${o.pipelineStageId}`);
else fail(`al releer status=${o?.status}`);
} catch (e: any) {
fail(e.message);
}
}
// ── 7. Enviar un correo ─────────────────────────────────────────────────
console.log("\n── POST /conversations/messages — correo de prueba");
if (contactId) {
try {
const r: any = await crmRequest("POST", "/conversations/messages", { token: CTX.token,
body: {
type: "Email",
contactId,
subject: "Prueba de integración AgendaMax ↔ Bucéfalo CRM",
html: `<p>Mensaje de prueba enviado desde AgendaMax.</p><p>Marca: <code>${marca}</code></p>`,
emailTo: CORREO,
},
});
ok(`aceptado: ${JSON.stringify(r).slice(0, 300)}`);
console.log(" >> Un 200 aquí NO prueba entrega. Hay que mirar la bandeja real.");
} catch (e: any) {
fail(`${e.message} — ${JSON.stringify(e.body).slice(0, 400)}`);
}
}
console.log(`\n\nLimpieza (marca ${marca}):`);
if (oppId) console.log(` DELETE /opportunities/${oppId}`);
if (contactId) console.log(` DELETE /contacts/${contactId}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+130
View File
@@ -0,0 +1,130 @@
/**
* Spike de integración contra Bucéfalo CRM. Solo lecturas.
*
* Existe porque el proyecto hermano ya pagó el precio de descubrir tarde que
* ninguna escritura funcionaba: los tests estaban escritos desde la
* implementación y no contra el contrato real de la API. Aquí no se da por
* buena ninguna capacidad sin haberla ejercido.
*
* node scripts/run-tsx.mjs platform/scripts/crm-spike.ts
*/
import { loadEnv } from "../lib/env.ts";
import { ctxDesdeEnv } from "../crm/ctx.ts";
import { crmRequest } from "../crm/client.ts";
loadEnv();
const LOC = process.env.CRM_LOCATION_ID!;
const CTX = ctxDesdeEnv();
async function probe(label: string, fn: () => Promise<unknown>) {
process.stdout.write(`\n── ${label}\n`);
try {
const out = await fn();
console.log(JSON.stringify(out, null, 2).slice(0, 1800));
return out as any;
} catch (e: any) {
console.log(` ✗ ${e.message}`);
return null;
}
}
async function main() {
console.log(`Subcuenta: ${LOC}`);
await probe("GET /locations/{id} — ¿el token ve la subcuenta?", async () => {
const r: any = await crmRequest("GET", `/locations/${LOC}`, { token: CTX.token });
return {
name: r?.location?.name,
timezone: r?.location?.timezone,
country: r?.location?.country,
};
});
await probe("POST /contacts/search — forma real de un contacto", async () => {
const r: any = await crmRequest("POST", `/contacts/search`, { token: CTX.token,
body: { locationId: LOC, page: 1, pageLimit: 2 },
});
const c = r?.contacts?.[0];
return {
total: r?.total,
devueltos: r?.contacts?.length,
claves_de_un_contacto: c ? Object.keys(c).sort() : null,
attributionSource: c?.attributionSource ?? null,
customFields_ejemplo: c?.customFields?.slice(0, 3) ?? null,
};
});
const pipes = await probe("GET /opportunities/pipelines — pipeline y etapas", async () => {
const r: any = await crmRequest("GET", `/opportunities/pipelines?locationId=${LOC}`, { token: CTX.token });
return (r?.pipelines ?? []).map((p: any) => ({
id: p.id,
name: p.name,
stages: (p.stages ?? []).map((s: any) => ({ id: s.id, name: s.name, position: s.position })),
}));
});
await probe("GET /locations/{id}/customFields — campos personalizados", async () => {
const r: any = await crmRequest("GET", `/locations/${LOC}/customFields`, { token: CTX.token });
return (r?.customFields ?? []).map((f: any) => ({
id: f.id,
name: f.name,
fieldKey: f.fieldKey,
dataType: f.dataType,
}));
});
await probe("GET /users/?locationId — personal del CRM", async () => {
const r: any = await crmRequest("GET", `/users/?locationId=${LOC}`, { token: CTX.token });
return (r?.users ?? []).map((u: any) => ({
id: u.id,
name: u.name,
email: u.email,
roles: u.roles?.role,
}));
});
await probe("GET /calendars/?locationId — ¿EXISTEN calendarios?", async () => {
const r: any = await crmRequest("GET", `/calendars/?locationId=${LOC}`, { token: CTX.token, version: "v3" });
return {
cuantos: r?.calendars?.length ?? 0,
calendarios: (r?.calendars ?? []).map((c: any) => ({
id: c.id,
name: c.name,
isActive: c.isActive,
})),
};
});
await probe("GET /calendars/services/catalog — ¿hay catálogo de servicios?", async () => {
const r: any = await crmRequest("GET", `/calendars/services/catalog?locationId=${LOC}`, { token: CTX.token,
version: "v3",
});
return r;
});
await probe("POST /conversations/search — conversaciones", async () => {
const r: any = await crmRequest("GET", `/conversations/search?locationId=${LOC}&limit=2`, { token: CTX.token });
const c = r?.conversations?.[0];
return {
total: r?.total,
claves_de_una_conversacion: c ? Object.keys(c).sort() : null,
muestra: c
? { id: c.id, contactId: c.contactId, lastMessageType: c.lastMessageType, type: c.type }
: null,
};
});
const pipeline = (pipes ?? [])[0];
if (pipeline) {
console.log(
`\n>> Pipeline por defecto: ${pipeline.name} (${pipeline.id}) con ${pipeline.stages.length} etapas`
);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+158
View File
@@ -0,0 +1,158 @@
/**
* Siembra la base de desarrollo con el spa, su personal, un catálogo de partida
* y unas citas de hoy sin resolver, para poder recorrer el cierre de día.
*
* ATENCIÓN SOBRE EL CATÁLOGO: los servicios de abajo salen del vocabulario
* medido en la muestra anotada de hilos del CRM (`extensiones`, `facial`,
* `pedicura`, `pestañas`, `uñas`, `depilación`, `masaje`) — no de la lista de
* precios de la dueña. **Las duraciones y los precios son marcadores de
* posición**, puestos para que la rejilla tenga algo que dibujar. Hay que
* sustituirlos por los reales en una sesión con ella antes de enseñar esto como
* catálogo del negocio.
*/
import { pool } from "../db/pool.ts";
import { runMigrations } from "../db/migrate.ts";
import { normalizePhone } from "../lib/phone.ts";
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,
});
// nombre, categoría, duración (min), precio — duración y precio SIN VERIFICAR.
const SERVICIOS: [string, string, number, number][] = [
["Extensiones de pestañas", "pestañas", 120, 850],
["Retoque de pestañas", "pestañas", 75, 550],
["Limpieza facial profunda", "facial", 60, 700],
["Manicura", "uñas", 45, 300],
["Pedicura", "uñas", 60, 400],
["Uñas acrílicas", "uñas", 90, 600],
["Depilación con cera", "depilación", 30, 250],
["Masaje relajante", "masaje", 60, 750],
];
const PERSONAL: [string, string][] = [
["Karla Ruiz", "[email protected]"],
["Brenda Salas", "[email protected]"],
["Paola Núñez", "[email protected]"],
];
const CLIENTAS: [string, string | null][] = [
["Mariana López", "55 8888 7777"],
["Alejandra Torres", "5544443333"],
["Gabriela Méndez", "+52 55 2222 1111"],
["Rocío Herrera", null], // sin teléfono: no contactable, y es un caso real y frecuente
["Diana Castillo", "01 55 6666 5555"],
];
async function main() {
await runMigrations();
const ya = await pool.query(`SELECT id FROM businesses WHERE slug = 'yola-franco'`);
if (ya.rows[0]) {
console.log("[seed] el negocio ya existe — no se toca nada");
await pool.end();
return;
}
const biz = await pool.query(
`INSERT INTO businesses (name, industry, slug, timezone, working_hours)
VALUES ('Yola Franco Spa','Estética y Spa','yola-franco','America/Mexico_City',$1::jsonb)
RETURNING id`,
[WORKING_HOURS]
);
const bid = biz.rows[0].id as number;
const serviceIds: number[] = [];
for (const [name, category, duration, price] of SERVICIOS) {
const r = await pool.query(
`INSERT INTO services (business_id, name, category, duration_min, price)
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
[bid, name, category, duration, price]
);
serviceIds.push(r.rows[0].id);
}
const employeeIds: number[] = [];
for (const [name, email] of PERSONAL) {
const r = await pool.query(
`INSERT INTO employees (business_id, name, email) VALUES ($1,$2,$3) RETURNING id`,
[bid, name, email]
);
employeeIds.push(r.rows[0].id);
// Todo el personal puede dar todos los servicios hasta que la dueña acote
// quién hace qué. Es una suposición, y conviene que se note.
for (const sid of serviceIds) {
await pool.query(
`INSERT INTO employee_services (employee_id, service_id) VALUES ($1,$2)`,
[r.rows[0].id, sid]
);
}
}
await pool.query(
`INSERT INTO users (business_id, email, password, name, role)
VALUES ($1,'[email protected]','demo1234','Yola Franco','owner')`,
[bid]
);
for (let i = 0; i < PERSONAL.length; i++) {
await pool.query(
`INSERT INTO users (business_id, email, password, name, role, employee_id)
VALUES ($1,$2,'demo1234',$3,'employee',$4)`,
[bid, PERSONAL[i][1], PERSONAL[i][0], employeeIds[i]]
);
}
const clientIds: number[] = [];
for (const [name, phone] of CLIENTAS) {
const r = await pool.query(
`INSERT INTO clients (business_id, name, phone, phone_e164) VALUES ($1,$2,$3,$4)
RETURNING id`,
[bid, name, phone, normalizePhone(phone)]
);
clientIds.push(r.rows[0].id);
}
// Citas de HOY, sin resolver, para que el cierre de día tenga trabajo. Las
// horas se construyen sobre el día local del proceso, que en desarrollo es el
// del spa; el servidor las acota con la zona del negocio de todas formas.
const hoy = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const dia = `${hoy.getFullYear()}-${p(hoy.getMonth() + 1)}-${p(hoy.getDate())}`;
// Hora local de México → UTC: +6 h. Se escribe explícito para no depender del
// reloj del proceso.
const citas: [number, number, number, string][] = [
[clientIds[0], employeeIds[0], serviceIds[0], `${dia}T16:00:00Z`], // 10:00 local
[clientIds[1], employeeIds[1], serviceIds[3], `${dia}T17:00:00Z`], // 11:00
[clientIds[2], employeeIds[2], serviceIds[2], `${dia}T18:30:00Z`], // 12:30
[clientIds[3], employeeIds[0], serviceIds[6], `${dia}T20:00:00Z`], // 14:00
[clientIds[4], employeeIds[1], serviceIds[7], `${dia}T22:00:00Z`], // 16:00
];
for (const [cid, eid, sid, start] of citas) {
await pool.query(
`INSERT INTO appointments (business_id, client_id, employee_id, service_id,
start_at, end_at, price)
SELECT $1,$2,$3,$4,$5::timestamptz,
$5::timestamptz + make_interval(mins => duration_min), price
FROM services WHERE id = $4`,
[bid, cid, eid, sid, start]
);
}
console.log(
`[seed] Yola Franco Spa creado: ${SERVICIOS.length} servicios, ` +
`${PERSONAL.length} especialistas, ${CLIENTAS.length} clientas, ${citas.length} citas de hoy.`
);
console.log("[seed] Entra con [email protected] / demo1234");
await pool.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});