- 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>
176 lines
6.1 KiB
TypeScript
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),
|
|
};
|
|
}
|