import { crmRequest, CrmError, VERSION_CALENDARS } from "./client.ts"; import type { CrmCtx } from "./ctx.ts"; export interface CrmCalendar { id: string; name: string; isActive?: boolean; calendarType?: string; } export interface CrmEvent { id: string; calendarId: string; contactId?: string; title?: string; appointmentStatus?: string; assignedUserId?: string; startTime?: string; endTime?: string; } export interface AltaCita { calendarId: string; contactId: string; /** ISO con desplazamiento, no epoch. Ver `isoConDesplazamiento`. */ startTime: string; endTime: string; title: string; assignedUserId?: string; appointmentStatus?: string; } /** * ISO con el desplazamiento horario del NEGOCIO. * * El CRM acepta `2026-09-03T11:00:00-06:00` en el alta de citas, y **no** * milisegundos — al revés que el filtro de rango de `/calendars/events`, que sí * los exige. Esa asimetría es de la API, no nuestra. * * Y no vale `toISOString()`: devuelve UTC con `Z`, y aunque el instante sea el * mismo, la hora de pared que el CRM enseña en su interfaz sale de lo que se * escribe aquí. Se construye con `Intl` y nunca con `new Date(y, m, d, …)`, que * resuelve el reloj en la zona del proceso — el error que ya costó un fallo de * producción en este repo (ver la sección de zonas horarias de CLAUDE.md). */ export function isoConDesplazamiento(d: Date, tz: string): string { const zona = tz || "America/Mexico_City"; const p = new Intl.DateTimeFormat("en-CA", { timeZone: zona, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).formatToParts(d); const g = (t: string) => p.find((x) => x.type === t)!.value; const off = new Intl.DateTimeFormat("en-US", { timeZone: zona, timeZoneName: "longOffset" }) .formatToParts(d) .find((x) => x.type === "timeZoneName")!.value; const m = off.match(/GMT([+-])(\d{2}):(\d{2})/); const desp = m ? `${m[1]}${m[2]}:${m[3]}` : "+00:00"; // `en-CA` con hour12:false puede rendir la medianoche como 24; el CRM espera 00. const hora = g("hour") === "24" ? "00" : g("hour"); return `${g("year")}-${g("month")}-${g("day")}T${hora}:${g("minute")}:${g("second")}${desp}`; } /** * Estado de la cita de la plataforma → estado del CRM. * * En la PETICIÓN el enum admite `new|confirmed|cancelled|showed|noshow|invalid`. * En la respuesta hay dos más (`active`, `completed`) que el CRM asigna por su * cuenta y no se pueden escribir. */ export function estadoCitaCrm(estado: string): string { switch (estado) { case "completed": return "showed"; case "no_show": return "noshow"; case "cancelled": return "cancelled"; default: return "confirmed"; } } export async function listarCalendarios(ctx: CrmCtx): Promise { const r = await crmRequest("GET", "/calendars/", { token: ctx.token, query: { locationId: ctx.locationId }, version: VERSION_CALENDARS, }); return r?.calendars ?? []; } /** * El personal de la subcuenta. * * MEDIDO (hallazgo 35): esta ruta devolvía `401` cuando se midió por primera vez * y hoy responde `200` con 6 usuarios. Da los ids que `staff[]` exige al crear * servicios y `assignedUserId` al crear citas. Si vuelve a dar 401, quien llame * debe poder seguir sin ella, no romperse. */ export async function listarPersonal(ctx: CrmCtx): Promise<{ id: string; name: string }[]> { const r = await crmRequest("GET", "/users/", { token: ctx.token, query: { locationId: ctx.locationId }, }); return (r?.users ?? []).map((u: any) => ({ id: u.id, name: u.name ?? "" })); } export async function obtenerCita(ctx: CrmCtx, eventId: string): Promise { try { const r = await crmRequest("GET", `/calendars/events/appointments/${eventId}`, { token: ctx.token, version: VERSION_CALENDARS, }); return (r?.event ?? r?.appointment ?? r) as CrmEvent; } catch (e) { if (e instanceof CrmError && e.status === 404) return null; throw e; } } /** * Citas de un calendario en un rango. * * MEDIDO (hallazgo 28): sin `calendarId`, `userId` o `groupId` la API responde * `422 Either of userId, calendarId or groupId is required`. No existe «dame * todas las citas de la subcuenta»: hay que iterar los calendarios. * * El rango va en **milisegundos epoch**, al revés que el alta. */ export async function citasEnRango( ctx: CrmCtx, calendarId: string, desdeMs: number, hastaMs: number ): Promise { const r = await crmRequest("GET", "/calendars/events", { token: ctx.token, version: VERSION_CALENDARS, query: { locationId: ctx.locationId, calendarId, startTime: String(desdeMs), endTime: String(hastaMs), }, }); return r?.events ?? []; } export async function crearCita(ctx: CrmCtx, a: AltaCita): Promise<{ id: string }> { const r = await crmRequest("POST", "/calendars/events/appointments", { token: ctx.token, version: VERSION_CALENDARS, body: { // `locationId` va en el POST y ROMPE el PUT con 422. No reciclar el cuerpo // del alta para actualizar: es la misma trampa ya medida en contactos. locationId: ctx.locationId, calendarId: a.calendarId, contactId: a.contactId, startTime: a.startTime, endTime: a.endTime, title: a.title, appointmentStatus: a.appointmentStatus ?? "confirmed", ...(a.assignedUserId ? { assignedUserId: a.assignedUserId } : {}), // La plataforma ya avisó a la clienta: que el CRM no dispare además sus // automatizaciones y le llegue el mismo aviso dos veces. toNotify: false, // AgendaMax es la fuente de verdad del horario, y su base ya impide el // solape con una restricción de exclusión. Que el CRM no rechace por su // propia idea de disponibilidad, que no conoce la agenda real. ignoreFreeSlotValidation: true, }, }); const id = r?.id ?? r?.event?.id ?? r?.appointment?.id; if (!id) throw new Error("El CRM aceptó la cita pero no devolvió su identificador"); return { id }; } /** Actualiza una cita ya escrita. Sin `locationId` ni `contactId`: el PUT los rechaza. */ export async function actualizarCita( ctx: CrmCtx, eventId: string, cambios: Partial> ): Promise { await crmRequest("PUT", `/calendars/events/appointments/${eventId}`, { token: ctx.token, version: VERSION_CALENDARS, body: { ...(cambios.calendarId ? { calendarId: cambios.calendarId } : {}), ...(cambios.startTime ? { startTime: cambios.startTime } : {}), ...(cambios.endTime ? { endTime: cambios.endTime } : {}), ...(cambios.title ? { title: cambios.title } : {}), ...(cambios.appointmentStatus ? { appointmentStatus: cambios.appointmentStatus } : {}), toNotify: false, }, }); }