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,215 @@
|
||||
import { crmRequest, CrmError } from "./client.ts";
|
||||
import type { CrmCtx } from "./ctx.ts";
|
||||
|
||||
/** El enum de la API. La cita en espera es `open`, completada `won`, cancelada `lost`. */
|
||||
export type CrmOppStatus = "open" | "won" | "lost" | "abandoned";
|
||||
|
||||
export interface CrmOpportunity {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: CrmOppStatus;
|
||||
monetaryValue?: number;
|
||||
pipelineId?: string;
|
||||
pipelineStageId?: string;
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
/** Estado de la cita en AgendaMax → estado de la oportunidad en el CRM. */
|
||||
export function estadoOportunidad(estadoCita: string): CrmOppStatus {
|
||||
switch (estadoCita) {
|
||||
case "completed":
|
||||
return "won";
|
||||
case "cancelled":
|
||||
case "no_show":
|
||||
// Una cita a la que no vino la clienta tampoco produjo ingreso: para el
|
||||
// embudo del CRM es una pérdida. El matiz de POR QUÉ se perdió (no vino,
|
||||
// canceló ella, canceló el spa) vive en AgendaMax, que sí lo distingue.
|
||||
return "lost";
|
||||
default:
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* El nombre de la oportunidad, con el formato acordado: SERVICIO + NOMBRE.
|
||||
* Se recorta a 255 porque el CRM no documenta el límite y un nombre largo
|
||||
* es la clase de cosa que falla en producción y no en pruebas.
|
||||
*/
|
||||
export function nombreOportunidad(servicio: string, cliente: string): string {
|
||||
return `${servicio} — ${cliente}`.slice(0, 255);
|
||||
}
|
||||
|
||||
export interface EtapasPipeline {
|
||||
pipelineId: string;
|
||||
open?: string | null;
|
||||
won?: string | null;
|
||||
lost?: string | null;
|
||||
}
|
||||
|
||||
function etapaPara(estado: CrmOppStatus, etapas: EtapasPipeline): string | null {
|
||||
if (estado === "won") return etapas.won ?? null;
|
||||
if (estado === "lost") return etapas.lost ?? null;
|
||||
return etapas.open ?? null;
|
||||
}
|
||||
|
||||
export async function obtenerOportunidad(
|
||||
ctx: CrmCtx,
|
||||
id: string
|
||||
): Promise<CrmOpportunity | null> {
|
||||
try {
|
||||
const r = await crmRequest<any>("GET", `/opportunities/${id}`, { token: ctx.token });
|
||||
return r?.opportunity ?? null;
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Las oportunidades de un contacto. `GET /contacts/{id}/opportunities` NO existe (404). */
|
||||
export async function oportunidadesDeContacto(
|
||||
ctx: CrmCtx,
|
||||
contactId: string
|
||||
): Promise<CrmOpportunity[]> {
|
||||
const r = await crmRequest<any>("GET", "/opportunities/search", {
|
||||
token: ctx.token,
|
||||
query: { location_id: ctx.locationId, contact_id: contactId, limit: 20 },
|
||||
});
|
||||
return r?.opportunities ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambia el estado y la etapa.
|
||||
*
|
||||
* Tres cosas medidas gobiernan esta función, y las tres son contraintuitivas:
|
||||
*
|
||||
* 1. Son **dos llamadas**, no una: `PUT /opportunities/{id}/status` rechaza
|
||||
* `pipelineStageId` con `422 property pipelineStageId should not exist`.
|
||||
* 2. **El orden importa**: el `/status` mueve la etapa por su cuenta, así que
|
||||
* va primero y la etapa deseada se escribe después. Al revés, el `/status`
|
||||
* pisa la etapa recién puesta (medido: de «Ganado» a «Cotización Aceptada»).
|
||||
* 3. En `won`, la subcuenta acaba imponiendo **su** etapa igualmente: se probó
|
||||
* reescribir y releer tres veces y el CRM la devuelve a «Cotización
|
||||
* Aceptada» de forma asíncrona, después de que la relectura ya confirmó la
|
||||
* nuestra. Hay una regla del lado del CRM que gobierna eso, y pelearse con
|
||||
* ella sería un bucle que nunca gana. En `lost` sí respeta «Perdido».
|
||||
*
|
||||
* Se escribe la etapa una vez y no se insiste. Lo que el negocio pidió mapear
|
||||
* es el **estado** —`open` / `won` / `lost`—, y ese sí queda estable y
|
||||
* verificado; la etapa es presentación y la manda el CRM.
|
||||
*/
|
||||
async function aplicarEstado(
|
||||
ctx: CrmCtx,
|
||||
id: string,
|
||||
estado: CrmOppStatus,
|
||||
etapas: EtapasPipeline
|
||||
): Promise<void> {
|
||||
await crmRequest("PUT", `/opportunities/${id}/status`, {
|
||||
token: ctx.token,
|
||||
body: { status: estado },
|
||||
});
|
||||
|
||||
const etapa = etapaPara(estado, etapas);
|
||||
if (!etapa) return;
|
||||
|
||||
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, pipelineStageId: etapa },
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResultadoOportunidad {
|
||||
opportunity: CrmOpportunity;
|
||||
via: "creada" | "reciclada" | "actualizada";
|
||||
}
|
||||
|
||||
/**
|
||||
* Proyecta una cita al CRM como oportunidad.
|
||||
*
|
||||
* Dos caminos, y la plataforma elige sola sin configuración:
|
||||
*
|
||||
* 1. **Crear.** Si la subcuenta permite duplicados, cada cita estrena su
|
||||
* oportunidad — que es el modelo pedido.
|
||||
* 2. **Reciclar.** Si responde `400 OPPORTUNITY_NO_DUPLICATE`, el CRM entrega
|
||||
* en `meta.existingId` la que ya existe, y se le pone el nombre, el importe
|
||||
* y el estado de esta cita.
|
||||
*
|
||||
* El camino 2 es el que corre hoy en Yola: la subcuenta tiene
|
||||
* `allowDuplicateOpportunity: false`. Ahí la oportunidad representa *la cita
|
||||
* vigente de la clienta*, y el histórico completo vive en AgendaMax. Si alguien
|
||||
* activa el ajuste en el CRM, esta misma función pasa al camino 1 sin cambios.
|
||||
*/
|
||||
export async function upsertOportunidad(
|
||||
ctx: CrmCtx,
|
||||
args: {
|
||||
contactId: string;
|
||||
nombre: string;
|
||||
importe: number;
|
||||
estado: CrmOppStatus;
|
||||
etapas: EtapasPipeline;
|
||||
/** Si ya la teníamos anclada, se actualiza directamente. */
|
||||
oportunidadId?: string | null;
|
||||
}
|
||||
): Promise<ResultadoOportunidad> {
|
||||
const { contactId, nombre, importe, estado, etapas } = args;
|
||||
|
||||
// Ya anclada: actualizar en sitio.
|
||||
if (args.oportunidadId) {
|
||||
const existente = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||
if (existente) {
|
||||
await crmRequest("PUT", `/opportunities/${args.oportunidadId}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||
});
|
||||
await aplicarEstado(ctx, args.oportunidadId, estado, etapas);
|
||||
const releida = await obtenerOportunidad(ctx, args.oportunidadId);
|
||||
return { opportunity: releida ?? existente, via: "actualizada" };
|
||||
}
|
||||
// El id guardado ya no resuelve; se sigue por el camino normal.
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
pipelineId: etapas.pipelineId,
|
||||
locationId: ctx.locationId,
|
||||
name: nombre,
|
||||
status: estado,
|
||||
contactId,
|
||||
monetaryValue: importe,
|
||||
};
|
||||
const etapa = etapaPara(estado, etapas);
|
||||
if (etapa) body.pipelineStageId = etapa;
|
||||
|
||||
try {
|
||||
const r = await crmRequest<any>("POST", "/opportunities/", { token: ctx.token, body });
|
||||
const creada = r?.opportunity;
|
||||
// Se RELEE antes de dar el id por bueno: la respuesta de creación de esta
|
||||
// API refleja lo que mandaste, no necesariamente lo que persistió.
|
||||
const releida = creada?.id ? await obtenerOportunidad(ctx, creada.id) : null;
|
||||
return { opportunity: releida ?? creada, via: "creada" };
|
||||
} catch (e) {
|
||||
if (e instanceof CrmError && e.status === 400) {
|
||||
const cuerpo = e.body as any;
|
||||
const existenteId =
|
||||
cuerpo?.meta?.existingId ??
|
||||
(cuerpo?.code === "OPPORTUNITY_NO_DUPLICATE" ? cuerpo?.meta?.id : null);
|
||||
|
||||
let id: string | null = existenteId ?? null;
|
||||
if (!id) {
|
||||
// El 400 no trajo el id: se busca. Es el tercer mecanismo, el más débil,
|
||||
// pero aquí la llave natural (contacto + subcuenta) es exacta.
|
||||
const previas = await oportunidadesDeContacto(ctx, contactId);
|
||||
id = previas[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await crmRequest("PUT", `/opportunities/${id}`, {
|
||||
token: ctx.token,
|
||||
body: { pipelineId: etapas.pipelineId, name: nombre, monetaryValue: importe },
|
||||
});
|
||||
await aplicarEstado(ctx, id, estado, etapas);
|
||||
const releida = await obtenerOportunidad(ctx, id);
|
||||
if (releida) return { opportunity: releida, via: "reciclada" };
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user