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>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user