import { pool } from "../db/pool.ts"; import { crmRequest, VERSION_CALENDARS } from "./client.ts"; import { ctxDe, type CrmCtx } from "./ctx.ts"; import { listarPersonal } from "./calendars.ts"; import { slugify } from "../lib/businessDefaults.ts"; export interface CrmService { id: string; name: string; slug: string; serviceDuration?: number; serviceDurationUnit?: string; } /** * El catálogo de servicios de la subcuenta. * * MEDIDO dos veces (hallazgos 6 y 32): devuelve `services: []`. El catálogo del * CRM está VACÍO, no ausente — el modelo existe y admite duración, precio, * categoría y variaciones. Simplemente nadie lo ha poblado. * * De ahí la dirección: «sincronizar servicios» no puede significar traerlos. La * duración y el precio los define el negocio en la plataforma, y lo único con * sentido es publicarlos hacia allá. */ export async function catalogoDelCrm(ctx: CrmCtx): Promise { const r = await crmRequest("GET", "/calendars/services/catalog", { token: ctx.token, query: { locationId: ctx.locationId }, version: VERSION_CALENDARS, }); return r?.services ?? []; } export interface ResultadoPublicacion { crmServiceId: string; nombre: string; yaEstaba: boolean; } /** * Publica un servicio de la plataforma en el catálogo del CRM. * * MEDIDO (hallazgo 34): `calendars.write` está y `staff[]` con al menos un * miembro es obligatorio — el `422` lo dice literalmente. Los ids de personal * salen de `GET /users/`, que volvió a estar disponible (hallazgo 35). */ export async function publicarServicio( businessId: number, serviceId: number ): Promise { const ctx = await ctxDe(businessId); const { rows } = await pool.query( `SELECT id, name, description, duration_min, price, color, crm_service_id FROM services WHERE id = $1 AND business_id = $2 AND active = true`, [serviceId, businessId] ); const s = rows[0]; if (!s) throw { status: 404, error: "Ese servicio no existe en este negocio, o está inactivo" }; if (s.crm_service_id) { // Ya publicado. Se comprueba que siga existiendo antes de darlo por bueno: // alguien pudo borrarlo desde la interfaz del CRM. const catalogo = await catalogoDelCrm(ctx); if (catalogo.some((x) => x.id === s.crm_service_id)) { return { crmServiceId: s.crm_service_id, nombre: s.name, yaEstaba: true }; } } let personal: { id: string; name: string }[] = []; try { personal = await listarPersonal(ctx); } catch (e: any) { // El permiso de personal se ha visto ir y venir (hallazgo 14 → 35). Si no // está, se dice qué falta en vez de fallar con el 422 del catálogo. throw { status: 409, error: "No se pudo leer el personal de la subcuenta, y el catálogo exige al menos una persona por servicio. Revisa que el token tenga permiso de usuarios.", }; } if (!personal.length) { throw { status: 409, error: "La subcuenta de Bucéfalo CRM no tiene personal, y el catálogo exige al menos una persona por servicio", }; } const cuerpo = { locationId: ctx.locationId, name: s.name, slug: slugify(s.name), ...(s.description ? { description: s.description } : {}), ...(s.color ? { eventColor: s.color } : {}), serviceDuration: Number(s.duration_min), serviceDurationUnit: "mins", staff: personal.slice(0, 1).map((p) => ({ id: p.id })), variations: [], }; let r: any; try { r = await crmRequest("POST", "/calendars/services/catalog", { token: ctx.token, version: VERSION_CALENDARS, body: cuerpo, }); } catch (e: any) { // MEDIDO: la PRIMERA publicación en una subcuenta que nunca ha tenido // catálogo falla con `400 No default service category found for this // location` — y ese mismo intento hace que el CRM cree la categoría por // defecto (`isSystemGenerated: true`). El reintento sí entra. // // Se reintenta UNA vez y solo ante ese mensaje concreto: reintentar a ciegas // un POST es fabricar duplicados. const msg = String(e?.message ?? ""); if (e?.status === 400 && /default service category/i.test(msg)) { r = await crmRequest("POST", "/calendars/services/catalog", { token: ctx.token, version: VERSION_CALENDARS, body: cuerpo, }); } else { throw e; } } const crmServiceId = r?.service?.id ?? r?.id; if (!crmServiceId) { throw new Error("El CRM aceptó el servicio pero no devolvió su identificador"); } // No se acepta el 200 como prueba: se relee el catálogo y se busca. const catalogo = await catalogoDelCrm(ctx); if (!catalogo.some((x) => x.id === crmServiceId)) { throw new Error( "El servicio no aparece al releer el catálogo del CRM: la escritura no persistió" ); } await pool.query( `UPDATE services SET crm_service_id = $2, crm_synced_at = now() WHERE id = $1`, [serviceId, crmServiceId] ); return { crmServiceId, nombre: s.name, yaEstaba: false }; }