Files
KLINGO-CLINICAL-SYSLIFE-GCHAT/gti-scheduling-mcp/src/adapters/zpro/zpro.mapper.ts
T
GustavoandClaude Sonnet 5 257afe98c0 Snapshot: gti-scheduling-mcp (Z-PRO + Klingo adapters) e workflow n8n de agendamento funcionando de ponta a ponta
- gti-scheduling-mcp: MCP server com ZProAdapter e KlingoAdapter (agendar() completo pros dois)
- Workflow n8n 'Agendamento - GChat - Z-PRO': webhook -> AI Agent -> MCP -> Z-PRO, testado com sucesso real
- Documentacao: specs, plano, runbook de problemas reais e export do workflow

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 17:23:10 -03:00

176 lines
6.1 KiB
TypeScript

/**
* 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<ZProAppointmentCreateResponse>;
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<ZProAppointmentCreateResponse["data"]>,
input: CreateAppointmentInput,
): Appointment {
return {
id: String(data.id),
slot: {
startDateTime: data.startAt,
endDateTime: data.endAt,
},
patient: input.patient,
status: mapZProStatus(data.status),
};
}