/** * adapters/zpro/zpro.mapper.ts * * Mapeamento entre os tipos genéricos do projeto e o contrato específico * da API Z-PRO. Isolado do cliente HTTP (zpro-http-client.ts) e do * adapter (zpro.adapter.ts). * * Contém o mapeamento de REQUEST (genérico -> Z-PRO) e dois caminhos de * mapeamento de RESPOSTA de sucesso: * * 1. `tryParseAppointmentCreateResponse` + `buildAppointmentFromResponse` * — quando o corpo bate com o envelope real verificado * (`{success: true, data: {id, startAt, endAt, status}}`, ver * `docs/zpro-api-mapping.md`), o `Appointment` é montado com os * dados REAIS da resposta (id numérico real, etc.). * 2. `buildPendingMappingAppointment` — quando o corpo não bate com esse * envelope (formato mudou, campo ausente, não é JSON, etc.), constrói * um `Appointment` "aceito, mapeamento pendente" usando apenas o * INPUT. Nunca inventa um `id` a partir de um corpo que não segue o * formato verificado — essa é a proteção contra duplicidade mantida * mesmo após a resposta real ter sido capturada. */ import type { Appointment, AppointmentStatus, CreateAppointmentInput, } from "../../types/scheduling.types.js"; import type { ZProAppointmentCreateBody, ZProAppointmentCreateResponse, } from "./zpro.types.js"; /** * Monta o `title` enviado ao Z-PRO: usa o valor informado pelo chamador * quando existir; caso contrário, gera um valor padrão a partir do nome * do paciente. Essa lógica é específica do Z-PRO (que exige um título) e * fica isolada aqui, sem contaminar o tipo genérico com uma convenção de * um provider só. */ function buildTitle(input: CreateAppointmentInput): string { return input.title?.trim() ? input.title.trim() : `Consulta - ${input.patient.name}`; } /** * Converte o input genérico de criação de agendamento no corpo esperado * por `POST /v2/api/external/{ApiID}/appointment/create`. * * Só inclui campos do Z-PRO para os quais existe uma fonte de dados no * tipo genérico (`contactId` a partir de `patient.id`, `contactPhone` a * partir de `patient.phone`). Campos do Z-PRO sem fonte no domínio * genérico (`description`, `whatsappId`, `notes`) não são enviados. */ export function buildAppointmentCreateBody( input: CreateAppointmentInput, ): ZProAppointmentCreateBody { const body: ZProAppointmentCreateBody = { title: buildTitle(input), contactName: input.patient.name, startAt: input.startAt, endAt: input.endAt, status: "pending", }; if (input.patient.id) { body.contactId = input.patient.id; } if (input.patient.phone) { body.contactPhone = input.patient.phone; } return body; } /** * Constrói o `Appointment` retornado quando a API do Z-PRO confirma * sucesso (HTTP 2xx) mas o schema de resposta ainda não foi verificado. * * Usa EXCLUSIVAMENTE dados já conhecidos e confiáveis do próprio `input` * (o que foi enviado e aceito) — nunca lê `parsed`/o corpo da resposta. * Por isso `id` e `slot.id` ficam ausentes (não inventados) e * `responseMappingPending` é sinalizado como `true`, deixando explícito * que o provider aceitou a operação e que ela não deve ser repetida. */ export function buildPendingMappingAppointment( input: CreateAppointmentInput, ): Appointment { return { slot: { startDateTime: input.startAt, endDateTime: input.endAt, }, patient: input.patient, status: "pending", responseMappingPending: true, }; } /** * Mapeia o `status` textual retornado pelo Z-PRO para o tipo genérico * `AppointmentStatus`. Apenas `"pending"` foi observado em uma resposta * real (é o único valor que este adapter envia hoje); `"confirmed"` e * `"cancelled"` são mapeados por analogia com os valores documentados no * filtro de `AppointmentList` (`pending | confirmed | cancelled | * completed`), não por observação direta. Qualquer outro valor (incluindo * `"completed"`, que não tem correspondente genérico) cai em `"pending"`, * a opção mais conservadora — nunca reporta `"confirmed"` sem uma * confirmação textual explícita da API. */ function mapZProStatus(status: string): AppointmentStatus { if (status === "confirmed") return "confirmed"; if (status === "cancelled") return "cancelled"; return "pending"; } /** * Tenta interpretar o corpo de `appointment/create` como o envelope de * sucesso REAL verificado (`{success: true, data: {id, startAt, endAt, * status}}`). Retorna `undefined` (nunca lança) quando o corpo não é JSON * válido ou não bate com esse formato — nesses casos o chamador deve usar * `buildPendingMappingAppointment`, nunca inventar os campos faltantes. */ export function tryParseAppointmentCreateResponse( bodyText: string, ): ZProAppointmentCreateResponse["data"] | undefined { let parsed: unknown; try { parsed = JSON.parse(bodyText); } catch { return undefined; } if (typeof parsed !== "object" || parsed === null) return undefined; const envelope = parsed as Partial; if (envelope.success !== true) return undefined; const data = envelope.data; if ( typeof data !== "object" || data === null || typeof data.id !== "number" || typeof data.startAt !== "string" || typeof data.endAt !== "string" || typeof data.status !== "string" ) { return undefined; } return data; } /** * Constrói o `Appointment` a partir do envelope de sucesso REAL da * resposta (já validado por `tryParseAppointmentCreateResponse`). Usa o * `id`, `startAt`, `endAt` e `status` retornados pela API — dados * observados de verdade, não inventados. `patient` continua vindo do * `input`: a resposta ecoa `contactName`/`contactPhone`, mas não traz * nenhum dado de paciente que o `input` já não tivesse. */ export function buildAppointmentFromResponse( data: NonNullable, input: CreateAppointmentInput, ): Appointment { return { id: String(data.id), slot: { startDateTime: data.startAt, endDateTime: data.endAt, }, patient: input.patient, status: mapZProStatus(data.status), }; }