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,30 @@
|
||||
/**
|
||||
* adapters/adapter-factory.ts
|
||||
*
|
||||
* Fábrica responsável por fornecer a instância correta de SchedulingAdapter
|
||||
* com base na configuração de provider validada.
|
||||
*
|
||||
* Providers suportados: "zpro" e "klingo". Qualquer outro valor é
|
||||
* rejeitado. Esta função não lê `process.env` diretamente: a configuração
|
||||
* é recebida já validada pelo módulo de configuração.
|
||||
*/
|
||||
|
||||
import type { SchedulingProviderConfig } from "../config/config.types.js";
|
||||
import type { SchedulingAdapter } from "./scheduling-adapter.interface.js";
|
||||
import { ZProAdapter } from "./zpro/zpro.adapter.js";
|
||||
import { KlingoAdapter } from "./klingo/klingo.adapter.js";
|
||||
|
||||
export function createSchedulingAdapter(
|
||||
config: SchedulingProviderConfig,
|
||||
): SchedulingAdapter {
|
||||
if (config.provider === "zpro") {
|
||||
return new ZProAdapter(config);
|
||||
}
|
||||
if (config.provider === "klingo") {
|
||||
return new KlingoAdapter(config);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider de agendamento não suportado: "${(config as { provider: string }).provider}".`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* adapters/klingo/klingo-http-client.ts
|
||||
*
|
||||
* Cliente HTTP mínimo do KlingoAdapter para os três endpoints usados:
|
||||
* - POST /paciente/identificar (X-APP-TOKEN)
|
||||
* - GET /agenda/horarios (X-APP-TOKEN)
|
||||
* - POST /agenda/reservar (Authorization: Bearer, sessão do paciente)
|
||||
*
|
||||
* Usa o `fetch` nativo do Node.js. Timeout via AbortController. Nunca inclui
|
||||
* token/CPF/telefone em mensagens de erro. Não faz retry. Mesmo padrão já
|
||||
* usado em `src/adapters/zpro/zpro-http-client.ts`.
|
||||
*/
|
||||
|
||||
import type { KlingoConfig } from "../../config/config.types.js";
|
||||
import type { KlingoHorariosQuery } from "./klingo.types.js";
|
||||
import { KlingoApiError } from "./klingo.errors.js";
|
||||
|
||||
/** Timeout padrão das requisições ao Klingo (15 segundos). */
|
||||
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Resultado bruto de uma chamada HTTP ao Klingo. */
|
||||
export interface KlingoHttpResponse {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
export interface KlingoHttpOptions {
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
function buildUrl(config: Pick<KlingoConfig, "baseUrl">, path: string): string {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/** Executa uma requisição HTTP ao Klingo, normalizando timeout e erro de rede. */
|
||||
async function executeRequest(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
options: KlingoHttpOptions,
|
||||
context: string,
|
||||
): Promise<KlingoHttpResponse> {
|
||||
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = options;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const bodyText = await response.text();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type"),
|
||||
bodyText,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw new KlingoApiError(
|
||||
"KLINGO_TIMEOUT",
|
||||
`Tempo limite excedido ao ${context}.`,
|
||||
);
|
||||
}
|
||||
throw new KlingoApiError(
|
||||
"KLINGO_API_ERROR",
|
||||
`Falha de rede ao ${context}.`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** `POST /paciente/identificar` — autenticado por X-APP-TOKEN. */
|
||||
export async function identificarPaciente(
|
||||
config: Pick<KlingoConfig, "baseUrl" | "appToken">,
|
||||
body: { telefone: string; cpf?: string; apenas_telefone?: boolean },
|
||||
options: KlingoHttpOptions = {},
|
||||
): Promise<KlingoHttpResponse> {
|
||||
return executeRequest(
|
||||
buildUrl(config, "/paciente/identificar"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-APP-TOKEN": config.appToken,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
options,
|
||||
"identificar o paciente no Klingo",
|
||||
);
|
||||
}
|
||||
|
||||
/** `GET /agenda/horarios` — autenticado por X-APP-TOKEN. */
|
||||
export async function getHorarios(
|
||||
config: Pick<KlingoConfig, "baseUrl" | "appToken">,
|
||||
query: KlingoHorariosQuery,
|
||||
options: KlingoHttpOptions = {},
|
||||
): Promise<KlingoHttpResponse> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) params.set(key, value);
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
|
||||
return executeRequest(
|
||||
buildUrl(config, `/agenda/horarios${suffix}`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-APP-TOKEN": config.appToken,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
options,
|
||||
"consultar horários disponíveis no Klingo",
|
||||
);
|
||||
}
|
||||
|
||||
/** `POST /agenda/reservar` — autenticado pelo Bearer de sessão do paciente. */
|
||||
export async function reservarHorario(
|
||||
config: Pick<KlingoConfig, "baseUrl">,
|
||||
accessToken: string,
|
||||
body: Record<string, unknown>,
|
||||
options: KlingoHttpOptions = {},
|
||||
): Promise<KlingoHttpResponse> {
|
||||
return executeRequest(
|
||||
buildUrl(config, "/agenda/reservar"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
options,
|
||||
"reservar o horário no Klingo",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* adapters/klingo/klingo.adapter.ts
|
||||
*
|
||||
* Adapter responsável pela integração com o sistema Klingo. Baseado
|
||||
* inteiramente em `docs/klingo-openapi.json` (Swagger público da API).
|
||||
*
|
||||
* `consultarHorarios()`: chama `GET /agenda/horarios` de verdade
|
||||
* (autenticado por `X-APP-TOKEN`). A API confirma a estrutura externa da
|
||||
* resposta (`procedimento`, `horarios[]` com `id`/`data`/`profissional`/
|
||||
* `unidade`), mas o formato interno de cada horário específico (a lista de
|
||||
* horas dentro de cada bloco) NÃO está documentado no Swagger — é descrito
|
||||
* apenas como um objeto genérico, sem `properties`/`items`. Sem essa
|
||||
* informação não é possível montar `AvailableSlot.startDateTime`/
|
||||
* `endDateTime` reais sem inventar nomes de campo. Por isso este método
|
||||
* lança `KlingoApiError` com código `SLOT_MAPPING_UNAVAILABLE` sempre que a
|
||||
* API responde com sucesso — é uma lacuna de contrato conhecida, não uma
|
||||
* falha técnica. Ver `zpro-api-mapping.md`/discussão equivalente: assim que
|
||||
* uma resposta real de `GET /agenda/horarios` for capturada, este método
|
||||
* deve ganhar o mapeamento completo.
|
||||
*
|
||||
* `agendar()`: identifica o paciente por telefone
|
||||
* (`POST /paciente/identificar`, também por `X-APP-TOKEN`) para obter um
|
||||
* token de sessão, e então reserva o horário
|
||||
* (`POST /agenda/reservar`, autenticado pelo Bearer de sessão), enviando o
|
||||
* campo `confirmado` para que a marcação já seja feita como confirmada
|
||||
* (comportamento documentado literalmente no Swagger). O schema de sucesso
|
||||
* de `reservar` (`reserva`) É documentado (`id`, `data`, `hora`,
|
||||
* `profissional`), então a resposta é mapeada de verdade — sem o placeholder
|
||||
* `responseMappingPending` usado no Z-PRO, exceto no caso defensivo em que a
|
||||
* API responde 2xx com um corpo que foge do schema documentado (ver
|
||||
* `klingo.mapper.ts#buildPendingMappingAppointment`), pelo mesmo motivo já
|
||||
* aplicado ao Z-PRO: nunca tratar um HTTP 2xx como erro operacional, para
|
||||
* não induzir um consumidor a repetir a operação e criar um agendamento
|
||||
* duplicado.
|
||||
*/
|
||||
|
||||
import type { SchedulingAdapter } from "../scheduling-adapter.interface.js";
|
||||
import type {
|
||||
AvailableSlot,
|
||||
Appointment,
|
||||
QueryAvailableSlotsInput,
|
||||
CreateAppointmentInput,
|
||||
} from "../../types/scheduling.types.js";
|
||||
import type { KlingoConfig } from "../../config/config.types.js";
|
||||
import { KlingoApiError, classifyHttpErrorStatus } from "./klingo.errors.js";
|
||||
import {
|
||||
buildHorariosQuery,
|
||||
buildIdentificarPacienteBody,
|
||||
buildPendingMappingAppointment,
|
||||
buildReservarBody,
|
||||
buildAppointmentFromReserva,
|
||||
extractAccessToken,
|
||||
tryParseReservaResponse,
|
||||
} from "./klingo.mapper.js";
|
||||
import {
|
||||
getHorarios,
|
||||
identificarPaciente,
|
||||
reservarHorario,
|
||||
type KlingoHttpOptions,
|
||||
} from "./klingo-http-client.js";
|
||||
|
||||
export class KlingoAdapter implements SchedulingAdapter {
|
||||
private readonly config: KlingoConfig;
|
||||
private readonly httpOptions: KlingoHttpOptions;
|
||||
|
||||
/**
|
||||
* `options.fetchImpl`/`options.timeoutMs` permitem injetar um fetch fake
|
||||
* e um timeout curto em testes, sem alterar o contrato usado pela
|
||||
* AdapterFactory (`new KlingoAdapter(config)` continua válido).
|
||||
*/
|
||||
constructor(
|
||||
config: KlingoConfig,
|
||||
options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
|
||||
) {
|
||||
this.config = config;
|
||||
this.httpOptions = options;
|
||||
}
|
||||
|
||||
async consultarHorarios(
|
||||
input: QueryAvailableSlotsInput,
|
||||
): Promise<AvailableSlot[]> {
|
||||
const query = buildHorariosQuery(input);
|
||||
const response = await getHorarios(this.config, query, this.httpOptions);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw classifyHttpErrorStatus(
|
||||
response.status,
|
||||
"consultar horários disponíveis no Klingo",
|
||||
);
|
||||
}
|
||||
|
||||
// A API respondeu com sucesso, mas o schema documentado não detalha o
|
||||
// formato interno de cada horário (ver docblock desta classe). Não
|
||||
// inventamos nomes de campo — reportamos a lacuna de forma explícita.
|
||||
throw new KlingoApiError(
|
||||
"SLOT_MAPPING_UNAVAILABLE",
|
||||
"A API do Klingo respondeu com sucesso, mas o formato dos horários individuais (data/hora de cada opção) não está documentado no Swagger público. É necessário capturar uma resposta real de GET /agenda/horarios para completar este mapeamento.",
|
||||
);
|
||||
}
|
||||
|
||||
async agendar(input: CreateAppointmentInput): Promise<Appointment> {
|
||||
// 1) Identifica o paciente por telefone para obter um token de sessão.
|
||||
// Esta etapa não cria nenhum dado no Klingo — sem risco de
|
||||
// duplicidade — então é seguro lançar erro se algo sair fora do
|
||||
// esperado, em vez de seguir adiante.
|
||||
const identificarBody = buildIdentificarPacienteBody(input.patient);
|
||||
const identificarResponse = await identificarPaciente(
|
||||
this.config,
|
||||
identificarBody,
|
||||
this.httpOptions,
|
||||
);
|
||||
|
||||
if (identificarResponse.status < 200 || identificarResponse.status >= 300) {
|
||||
throw classifyHttpErrorStatus(
|
||||
identificarResponse.status,
|
||||
"identificar o paciente no Klingo",
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = extractAccessToken(identificarResponse.bodyText);
|
||||
|
||||
// 2) Reserva (e confirma) o horário. A partir daqui, HTTP 2xx é SEMPRE
|
||||
// tratado como sucesso operacional, independentemente do corpo —
|
||||
// mesma proteção contra duplicidade já usada no ZProAdapter.
|
||||
const reservarBody = buildReservarBody(input);
|
||||
const reservarResponse = await reservarHorario(
|
||||
this.config,
|
||||
accessToken,
|
||||
reservarBody as unknown as Record<string, unknown>,
|
||||
this.httpOptions,
|
||||
);
|
||||
|
||||
if (reservarResponse.status < 200 || reservarResponse.status >= 300) {
|
||||
throw classifyHttpErrorStatus(
|
||||
reservarResponse.status,
|
||||
"reservar o horário no Klingo",
|
||||
);
|
||||
}
|
||||
|
||||
const reserva = tryParseReservaResponse(reservarResponse.bodyText);
|
||||
if (reserva) {
|
||||
return buildAppointmentFromReserva(reserva, input);
|
||||
}
|
||||
// Corpo fora do schema documentado, mas HTTP 2xx: não é um erro.
|
||||
return buildPendingMappingAppointment(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* adapters/klingo/klingo.errors.ts
|
||||
*
|
||||
* Erros internos, previsíveis e tipados do KlingoAdapter.
|
||||
*
|
||||
* Nenhuma mensagem de erro expõe token, CPF, telefone, stack trace ou corpo
|
||||
* bruto da resposta da API.
|
||||
*
|
||||
* - VALIDATION_ERROR -> HTTP 400 / 422, ou dado obrigatório
|
||||
* ausente no input antes mesmo da chamada
|
||||
* (ex.: telefone do paciente ausente)
|
||||
* - AUTHENTICATION_ERROR -> HTTP 401 / 403 (inclui falha ao
|
||||
* identificar/logar o paciente)
|
||||
* - NOT_FOUND -> HTTP 404
|
||||
* - KLINGO_API_ERROR -> demais erros HTTP (ex.: 500) e falha de rede
|
||||
* - KLINGO_TIMEOUT -> requisição excedeu o tempo limite
|
||||
* - SLOT_MAPPING_UNAVAILABLE -> a API respondeu com sucesso, mas o
|
||||
* schema de resposta necessário para
|
||||
* montar um AvailableSlot completo (data
|
||||
* + horário) não está documentado no
|
||||
* Swagger público (ver
|
||||
* docs/klingo-openapi.json, definição
|
||||
* `horariosAgenda.horarios[].horarios`).
|
||||
* Não é um erro de rede/negócio: é uma
|
||||
* lacuna de contrato conhecida. Não deve
|
||||
* ser tratado como motivo para retry.
|
||||
*/
|
||||
export type KlingoErrorCode =
|
||||
| "VALIDATION_ERROR"
|
||||
| "AUTHENTICATION_ERROR"
|
||||
| "NOT_FOUND"
|
||||
| "KLINGO_API_ERROR"
|
||||
| "KLINGO_TIMEOUT"
|
||||
| "SLOT_MAPPING_UNAVAILABLE";
|
||||
|
||||
/** Erro tipado do KlingoAdapter, com um código previsível e mensagem curta. */
|
||||
export class KlingoApiError extends Error {
|
||||
readonly code: KlingoErrorCode;
|
||||
|
||||
constructor(code: KlingoErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "KlingoApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Classifica um status HTTP de erro (fora do intervalo 2xx) em um KlingoApiError. */
|
||||
export function classifyHttpErrorStatus(
|
||||
status: number,
|
||||
context: string,
|
||||
): KlingoApiError {
|
||||
if (status === 401 || status === 403) {
|
||||
return new KlingoApiError(
|
||||
"AUTHENTICATION_ERROR",
|
||||
`Falha de autenticação na API do Klingo ao ${context} (status ${status}).`,
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
return new KlingoApiError(
|
||||
"NOT_FOUND",
|
||||
`Recurso não encontrado na API do Klingo ao ${context} (status ${status}).`,
|
||||
);
|
||||
}
|
||||
if (status === 400 || status === 422) {
|
||||
return new KlingoApiError(
|
||||
"VALIDATION_ERROR",
|
||||
`A API do Klingo rejeitou os dados enviados ao ${context} (status ${status}).`,
|
||||
);
|
||||
}
|
||||
return new KlingoApiError(
|
||||
"KLINGO_API_ERROR",
|
||||
`A API do Klingo retornou um erro inesperado ao ${context} (status ${status}).`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* adapters/klingo/klingo.mapper.ts
|
||||
*
|
||||
* Tradução entre os tipos genéricos de domínio (`types/scheduling.types.ts`)
|
||||
* e os tipos internos do Klingo (`klingo.types.ts`), verificados contra
|
||||
* `docs/klingo-openapi.json`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Appointment,
|
||||
CreateAppointmentInput,
|
||||
Patient,
|
||||
QueryAvailableSlotsInput,
|
||||
} from "../../types/scheduling.types.js";
|
||||
import type {
|
||||
KlingoHorariosQuery,
|
||||
KlingoHorarioMarcarBody,
|
||||
KlingoIdentificarPacienteBody,
|
||||
KlingoReservaResponse,
|
||||
} from "./klingo.types.js";
|
||||
import { KlingoApiError } from "./klingo.errors.js";
|
||||
|
||||
/**
|
||||
* Texto fixo enviado no campo `confirmado` de `horarioMarcar` para indicar
|
||||
* que a marcação deve ser feita como confirmada (não apenas uma reserva
|
||||
* temporária de 10 minutos). Comportamento documentado literalmente no
|
||||
* Swagger: "Se for passado um texto, a marcação já irá ser feita como
|
||||
* confirmada." Não há um valor específico exigido pela documentação, então
|
||||
* usamos um marcador identificável para fins de auditoria.
|
||||
*/
|
||||
export const KLINGO_CONFIRMADO_MARKER = "Confirmado via gti-scheduling-mcp";
|
||||
|
||||
/**
|
||||
* Monta o corpo de `POST /paciente/identificar` a partir do paciente.
|
||||
*
|
||||
* Exige `patient.phone` (obrigatório pela API). Quando `patient.document`
|
||||
* está presente, é enviado como `cpf` (o tipo genérico já documenta
|
||||
* `document` como "ex.: CPF"). Na ausência de `document`, usa
|
||||
* `apenas_telefone: true`, conforme permitido pela API.
|
||||
*
|
||||
* Lança `VALIDATION_ERROR` se `patient.phone` estiver ausente — isso é
|
||||
* verificado ANTES de qualquer chamada de rede.
|
||||
*/
|
||||
export function buildIdentificarPacienteBody(
|
||||
patient: Patient,
|
||||
): KlingoIdentificarPacienteBody {
|
||||
if (!patient.phone?.trim()) {
|
||||
throw new KlingoApiError(
|
||||
"VALIDATION_ERROR",
|
||||
"O telefone do paciente é obrigatório para identificação no Klingo.",
|
||||
);
|
||||
}
|
||||
|
||||
const body: KlingoIdentificarPacienteBody = { telefone: patient.phone };
|
||||
if (patient.document?.trim()) {
|
||||
body.cpf = patient.document;
|
||||
} else {
|
||||
body.apenas_telefone = true;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Monta os query params de `GET /agenda/horarios` a partir do input genérico. */
|
||||
export function buildHorariosQuery(
|
||||
input: QueryAvailableSlotsInput,
|
||||
): KlingoHorariosQuery {
|
||||
const query: KlingoHorariosQuery = {};
|
||||
if (input.specialtyId) query.especialidade = input.specialtyId;
|
||||
if (input.professionalId) query.crm = input.professionalId;
|
||||
if (input.clinicId) query.cnes = input.clinicId;
|
||||
if (input.startDate) query.inicio = input.startDate;
|
||||
if (input.endDate) query.fim = input.endDate;
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o corpo de `POST /agenda/reservar` a partir do input de agendamento.
|
||||
*
|
||||
* Exige `input.slotId` (o `id` do horário, obtido via `consultarHorarios()`)
|
||||
* e `input.specialtyId` (o código do procedimento associado a esse
|
||||
* horário) — ambos obrigatórios pela API do Klingo (`horarioMarcar.id` e
|
||||
* `horarioMarcar.procedimento`). Lança `VALIDATION_ERROR` se ausentes,
|
||||
* ANTES de qualquer chamada de rede.
|
||||
*/
|
||||
export function buildReservarBody(
|
||||
input: CreateAppointmentInput,
|
||||
): KlingoHorarioMarcarBody {
|
||||
if (!input.slotId?.trim()) {
|
||||
throw new KlingoApiError(
|
||||
"VALIDATION_ERROR",
|
||||
"slotId é obrigatório para agendar no Klingo (obtido via consultarHorarios()).",
|
||||
);
|
||||
}
|
||||
if (!input.specialtyId?.trim()) {
|
||||
throw new KlingoApiError(
|
||||
"VALIDATION_ERROR",
|
||||
"specialtyId é obrigatório para agendar no Klingo (código do procedimento do horário selecionado).",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
procedimento: input.specialtyId,
|
||||
id: input.slotId,
|
||||
confirmado: KLINGO_CONFIRMADO_MARKER,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói o `Appointment` genérico a partir da resposta de
|
||||
* `POST /agenda/reservar` (definição `reserva`, totalmente documentada:
|
||||
* `id`, `data`, `hora`, `profissional`). Diferente do Z-PRO, aqui o
|
||||
* mapeamento de sucesso É verificado — nenhum campo é inventado.
|
||||
*/
|
||||
export function buildAppointmentFromReserva(
|
||||
reserva: KlingoReservaResponse,
|
||||
input: CreateAppointmentInput,
|
||||
): Appointment {
|
||||
return {
|
||||
id: String(reserva.id),
|
||||
slot: {
|
||||
id: input.slotId,
|
||||
startDateTime: input.startAt,
|
||||
endDateTime: input.endAt,
|
||||
professionalId: reserva.profissional?.id
|
||||
? String(reserva.profissional.id)
|
||||
: undefined,
|
||||
professionalName: reserva.profissional?.nome,
|
||||
specialtyId: input.specialtyId,
|
||||
},
|
||||
patient: input.patient,
|
||||
status: "confirmed",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói um `Appointment` "aceito, mapeamento pendente" apenas a partir
|
||||
* do input — usado quando `POST /agenda/reservar` responde com HTTP 2xx,
|
||||
* mas o corpo não pode ser interpretado como o `reserva` documentado (ex.:
|
||||
* corpo vazio/não-JSON, ou sem um `id` numérico válido).
|
||||
*
|
||||
* Mesmo princípio de segurança já aplicado no ZProAdapter: reportar essa
|
||||
* situação como erro induziria um consumidor a repetir a operação, com
|
||||
* risco de agendamento duplicado. Nunca lê nenhum campo do corpo da
|
||||
* resposta.
|
||||
*/
|
||||
export function buildPendingMappingAppointment(
|
||||
input: CreateAppointmentInput,
|
||||
): Appointment {
|
||||
return {
|
||||
slot: {
|
||||
id: input.slotId,
|
||||
startDateTime: input.startAt,
|
||||
endDateTime: input.endAt,
|
||||
specialtyId: input.specialtyId,
|
||||
},
|
||||
patient: input.patient,
|
||||
status: "pending",
|
||||
responseMappingPending: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta interpretar o corpo de `POST /agenda/reservar` como o `reserva`
|
||||
* documentado. Retorna `undefined` (nunca lança) quando o corpo não é JSON
|
||||
* válido ou não contém um `id` numérico — nesse caso o chamador deve usar
|
||||
* `buildPendingMappingAppointment`, nunca inventar os campos faltantes.
|
||||
*/
|
||||
export function tryParseReservaResponse(
|
||||
bodyText: string,
|
||||
): KlingoReservaResponse | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bodyText);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
"id" in parsed &&
|
||||
typeof (parsed as { id: unknown }).id === "number"
|
||||
) {
|
||||
return parsed as KlingoReservaResponse;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai `access_token` do corpo de `POST /paciente/identificar` (definição
|
||||
* `sessao`). Diferente da resposta de `reservar`, esta chamada NÃO cria
|
||||
* nenhum dado no Klingo — ainda não há risco de duplicidade — então, ao
|
||||
* contrário de `tryParseReservaResponse`, aqui é seguro lançar um erro
|
||||
* claro (`AUTHENTICATION_ERROR`) quando o corpo não traz um token utilizável,
|
||||
* em vez de seguir adiante com um valor inventado.
|
||||
*/
|
||||
export function extractAccessToken(bodyText: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bodyText);
|
||||
} catch {
|
||||
throw new KlingoApiError(
|
||||
"AUTHENTICATION_ERROR",
|
||||
"A resposta de identificação do paciente no Klingo não é um JSON válido.",
|
||||
);
|
||||
}
|
||||
const token =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as { access_token?: unknown }).access_token
|
||||
: undefined;
|
||||
if (typeof token !== "string" || token.trim() === "") {
|
||||
throw new KlingoApiError(
|
||||
"AUTHENTICATION_ERROR",
|
||||
"A resposta de identificação do paciente no Klingo não trouxe um access_token válido.",
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* adapters/klingo/klingo.types.ts
|
||||
*
|
||||
* Tipos internos do KlingoAdapter, refletindo exclusivamente o que está
|
||||
* documentado em `docs/klingo-openapi.json` (Swagger público da API do
|
||||
* Klingo). Nenhum campo aqui foi inventado.
|
||||
*/
|
||||
|
||||
/** Corpo de `POST /paciente/identificar`. */
|
||||
export interface KlingoIdentificarPacienteBody {
|
||||
telefone: string;
|
||||
cpf?: string;
|
||||
dt_nascimento?: string;
|
||||
apenas_telefone?: boolean;
|
||||
}
|
||||
|
||||
/** Corpo de `POST /externo/login`. */
|
||||
export interface KlingoLoginBody {
|
||||
/** Id do paciente, precedido de "P" (ex.: "P123"). */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resposta de `POST /paciente/identificar` e `POST /externo/login`
|
||||
* (definição `sessao`).
|
||||
*/
|
||||
export interface KlingoSessaoResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
user?: {
|
||||
id?: number;
|
||||
nome?: string;
|
||||
id_paciente?: number;
|
||||
};
|
||||
unidade?: {
|
||||
nome?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Query params de `GET /agenda/horarios`. */
|
||||
export interface KlingoHorariosQuery {
|
||||
especialidade?: string;
|
||||
exame?: string;
|
||||
inicio?: string;
|
||||
fim?: string;
|
||||
crm?: string;
|
||||
cnes?: string;
|
||||
}
|
||||
|
||||
/** Um profissional (definição `profissional`, campos documentados usados aqui). */
|
||||
export interface KlingoProfissional {
|
||||
id?: number;
|
||||
nome?: string;
|
||||
crm?: string;
|
||||
}
|
||||
|
||||
/** Uma unidade (definição `unidade`, campos documentados usados aqui). */
|
||||
export interface KlingoUnidade {
|
||||
nome?: string;
|
||||
cnes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Um bloco de horários por data/procedimento (item de
|
||||
* `horariosAgenda.horarios[]`).
|
||||
*
|
||||
* IMPORTANTE: o campo `horarios` (lista de horários com id e hora, por
|
||||
* dentro deste bloco) está documentado no Swagger apenas como
|
||||
* `{"type": "object", "description": "Lista de Horários com ID e horário."}`,
|
||||
* sem `properties`/`items`. Não há como determinar os nomes reais dos
|
||||
* campos internos (id do horário específico, hora) sem uma resposta real
|
||||
* capturada da API — por isso ele é tipado como `unknown` aqui, e o
|
||||
* adapter NÃO tenta interpretá-lo (ver `KlingoApiError` código
|
||||
* `SLOT_MAPPING_UNAVAILABLE`).
|
||||
*/
|
||||
export interface KlingoHorarioBloco {
|
||||
id?: string;
|
||||
data?: string;
|
||||
procedimento?: string;
|
||||
ordem_chegada?: boolean;
|
||||
faixa_ordem_chegada?: string;
|
||||
/** Estrutura interna não documentada — nunca interpretado pelo adapter. */
|
||||
horarios?: unknown;
|
||||
profissional?: KlingoProfissional;
|
||||
unidade?: KlingoUnidade;
|
||||
}
|
||||
|
||||
/** Resposta de `GET /agenda/horarios` (definição `horariosAgenda`). */
|
||||
export interface KlingoHorariosAgendaResponse {
|
||||
procedimento?: string;
|
||||
horarios?: KlingoHorarioBloco[];
|
||||
}
|
||||
|
||||
/** Corpo de `POST /agenda/reservar` (definição `horarioMarcar`). */
|
||||
export interface KlingoHorarioMarcarBody {
|
||||
procedimento: string;
|
||||
id: string;
|
||||
plano?: number;
|
||||
email?: boolean;
|
||||
teleatendimento?: boolean;
|
||||
revisao?: boolean;
|
||||
confirmado?: string;
|
||||
id_externo?: string;
|
||||
obs?: string;
|
||||
}
|
||||
|
||||
/** Resposta de `POST /agenda/reservar` (definição `reserva`). */
|
||||
export interface KlingoReservaResponse {
|
||||
id: number;
|
||||
expira?: string;
|
||||
data?: string;
|
||||
hora?: string;
|
||||
profissional?: KlingoProfissional;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* adapters/scheduling-adapter.interface.ts
|
||||
*
|
||||
* Contrato que todo Adapter de agendamento deve implementar.
|
||||
*
|
||||
* O MCP nunca conhece detalhes de nenhum sistema de clínica específico.
|
||||
* Toda a responsabilidade de integração (chamadas HTTP, autenticação,
|
||||
* regras próprias de cada sistema, etc.) pertence exclusivamente ao Adapter
|
||||
* que implementa esta interface.
|
||||
*
|
||||
* Novos sistemas (ex.: KlingoAdapter, SysLifeAdapter, ClinicAllAdapter)
|
||||
* devem implementar exatamente esta mesma interface.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AvailableSlot,
|
||||
Appointment,
|
||||
QueryAvailableSlotsInput,
|
||||
CreateAppointmentInput,
|
||||
} from "../types/scheduling.types.js";
|
||||
|
||||
export interface SchedulingAdapter {
|
||||
/**
|
||||
* Consulta os horários disponíveis para agendamento.
|
||||
*/
|
||||
consultarHorarios(input: QueryAvailableSlotsInput): Promise<AvailableSlot[]>;
|
||||
|
||||
/**
|
||||
* Realiza um agendamento em um horário disponível.
|
||||
*/
|
||||
agendar(input: CreateAppointmentInput): Promise<Appointment>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* adapters/zpro/zpro-http-client.ts
|
||||
*
|
||||
* Cliente HTTP mínimo do ZProAdapter para
|
||||
* `POST /v2/api/external/{ApiID}/appointment/create`.
|
||||
*
|
||||
* Usa o `fetch` nativo do Node.js (sem Axios). Aplica autenticação Bearer,
|
||||
* timeout via AbortController, envia/recebe JSON e nunca inclui o token
|
||||
* em mensagens de erro. Não faz retry.
|
||||
*
|
||||
* Segue o mesmo padrão já usado em `src/diagnostics/client.ts` (mantido
|
||||
* separado porque um é infraestrutura de produção do adapter e o outro é
|
||||
* um script de diagnóstico somente leitura).
|
||||
*/
|
||||
|
||||
import type { ZProConfig } from "../../config/config.types.js";
|
||||
import type { ZProAppointmentCreateBody } from "./zpro.types.js";
|
||||
import { ZProApiError } from "./zpro.errors.js";
|
||||
|
||||
/** Timeout padrão da requisição de criação de agendamento (15 segundos). */
|
||||
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Resultado bruto de uma chamada HTTP ao Z-PRO. */
|
||||
export interface ZProHttpResponse {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadados seguros de diagnóstico sobre uma resposta HTTP do Z-PRO.
|
||||
*
|
||||
* Contém apenas informações de transporte (status, content-type, formato
|
||||
* aparente do corpo) — NUNCA o corpo em si, headers de autenticação, nem
|
||||
* qualquer dado do paciente. Seguro para logar/registrar.
|
||||
*/
|
||||
export interface ZProResponseMetadata {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
hasBody: boolean;
|
||||
/** Formato aparente do corpo, sem interpretar nenhum campo dele. */
|
||||
bodyFormat: "empty" | "json" | "non-json";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva metadados seguros de diagnóstico a partir de uma resposta.
|
||||
* Não lê nenhum campo do corpo — só verifica se ele é JSON sintaticamente
|
||||
* válido, para classificar o formato aparente.
|
||||
*/
|
||||
export function describeResponseMetadata(
|
||||
response: ZProHttpResponse,
|
||||
): ZProResponseMetadata {
|
||||
const trimmed = response.bodyText.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.contentType,
|
||||
hasBody: false,
|
||||
bodyFormat: "empty",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.contentType,
|
||||
hasBody: true,
|
||||
bodyFormat: "json",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.contentType,
|
||||
hasBody: true,
|
||||
bodyFormat: "non-json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Monta a URL de `appointment/create` a partir da configuração. */
|
||||
export function buildAppointmentCreateUrl(
|
||||
config: Pick<ZProConfig, "baseUrl" | "apiId">,
|
||||
): string {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
const apiId = encodeURIComponent(config.apiId);
|
||||
return `${baseUrl}/v2/api/external/${apiId}/appointment/create`;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa a requisição `POST appointment/create`.
|
||||
*
|
||||
* `fetchImpl` permite injetar um fetch fake em testes; `timeoutMs`
|
||||
* controla o tempo máximo da requisição. Erros de timeout e de rede são
|
||||
* normalizados em `ZProApiError`, sem nunca incluir o token na mensagem.
|
||||
*/
|
||||
export async function postAppointmentCreate(
|
||||
config: ZProConfig,
|
||||
body: ZProAppointmentCreateBody,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
} = {},
|
||||
): Promise<ZProHttpResponse> {
|
||||
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = options;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(buildAppointmentCreateUrl(config), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type"),
|
||||
bodyText,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw new ZProApiError(
|
||||
"ZPRO_TIMEOUT",
|
||||
"Tempo limite excedido ao criar o agendamento no Z-PRO.",
|
||||
);
|
||||
}
|
||||
throw new ZProApiError(
|
||||
"ZPRO_API_ERROR",
|
||||
"Falha de rede ao criar o agendamento no Z-PRO.",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* adapters/zpro/zpro.adapter.ts
|
||||
*
|
||||
* Adapter responsável pela integração com o sistema Z-Pro.
|
||||
*
|
||||
* `agendar()`: chama de fato `POST appointment/create`. A requisição
|
||||
* (autenticação, URL, corpo) é 100% baseada em contrato verificado na
|
||||
* coleção pública do Postman (ver `docs/zpro-api-mapping.md`).
|
||||
*
|
||||
* O mapeamento da resposta de SUCESSO foi verificado por um teste manual
|
||||
* real em 09/08/2026 (ver `docs/zpro-api-mapping.md`, seção "Atualização
|
||||
* — resposta real de sucesso capturada"): quando o corpo bate com o
|
||||
* envelope observado (`{success: true, data: {id, startAt, endAt,
|
||||
* status}}`), o `Appointment` é construído com o `id` real retornado pela
|
||||
* API (`zpro.mapper.ts#buildAppointmentFromResponse`).
|
||||
*
|
||||
* Mesmo assim, QUALQUER HTTP 2xx continua sendo tratado como sucesso
|
||||
* operacional — o adapter NÃO lança erro quando o corpo foge desse
|
||||
* envelope (JSON com outro formato, texto simples, vazio, ou JSON
|
||||
* malformado). Só temos um único exemplo real capturado; reportar como
|
||||
* falha uma operação que o provider aceitou (e que pode já ter sido
|
||||
* persistida) induziria um consumidor a repeti-la (risco de agendamento
|
||||
* duplicado). Nesse caso, retorna um `Appointment` construído apenas com o
|
||||
* que já era conhecido do próprio input, com `responseMappingPending:
|
||||
* true`. Ver `zpro.mapper.ts#buildPendingMappingAppointment`.
|
||||
*
|
||||
* `consultarHorarios()`: decisão de produto — o Z-PRO não expõe nenhum
|
||||
* endpoint de disponibilidade de horários. Não há solução alternativa
|
||||
* implementada (não se tenta calcular horários livres a partir da lista
|
||||
* de agendamentos existentes).
|
||||
*/
|
||||
|
||||
import type { SchedulingAdapter } from "../scheduling-adapter.interface.js";
|
||||
import type {
|
||||
AvailableSlot,
|
||||
Appointment,
|
||||
QueryAvailableSlotsInput,
|
||||
CreateAppointmentInput,
|
||||
} from "../../types/scheduling.types.js";
|
||||
import type { ZProConfig } from "../../config/config.types.js";
|
||||
import { ZProApiError, classifyHttpErrorStatus } from "./zpro.errors.js";
|
||||
import {
|
||||
buildAppointmentCreateBody,
|
||||
buildAppointmentFromResponse,
|
||||
buildPendingMappingAppointment,
|
||||
tryParseAppointmentCreateResponse,
|
||||
} from "./zpro.mapper.js";
|
||||
import {
|
||||
describeResponseMetadata,
|
||||
postAppointmentCreate,
|
||||
type ZProResponseMetadata,
|
||||
} from "./zpro-http-client.js";
|
||||
|
||||
export class ZProAdapter implements SchedulingAdapter {
|
||||
private readonly config: ZProConfig;
|
||||
private readonly fetchImpl?: typeof fetch;
|
||||
private readonly timeoutMs?: number;
|
||||
private readonly onResponseMetadata?: (
|
||||
metadata: ZProResponseMetadata,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* `options.fetchImpl`/`options.timeoutMs` permitem injetar um fetch
|
||||
* fake e um timeout curto em testes (mesmo padrão de
|
||||
* `src/diagnostics/client.ts`), sem alterar o contrato usado pela
|
||||
* AdapterFactory (`new ZProAdapter(config)` continua válido).
|
||||
*
|
||||
* `options.onResponseMetadata`, quando fornecido, é chamado com
|
||||
* metadados seguros de diagnóstico (status, content-type, presença de
|
||||
* corpo, formato aparente) após cada resposta do Z-PRO — nunca com o
|
||||
* corpo em si, headers de autenticação, ou dados do paciente. Sem
|
||||
* efeito por padrão (não loga nada sozinho, para nunca escrever em
|
||||
* stdout e corromper o transporte stdio do MCP).
|
||||
*/
|
||||
constructor(
|
||||
config: ZProConfig,
|
||||
options: {
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
onResponseMetadata?: (metadata: ZProResponseMetadata) => void;
|
||||
} = {},
|
||||
) {
|
||||
this.config = config;
|
||||
this.fetchImpl = options.fetchImpl;
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
this.onResponseMetadata = options.onResponseMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* O Z-PRO não possui endpoint de consulta de disponibilidade de
|
||||
* horários (ver `docs/zpro-api-mapping.md`). Não implementar soluções
|
||||
* alternativas (ex.: inferir disponibilidade a partir da lista de
|
||||
* agendamentos existentes) — isso inventaria uma regra de negócio não
|
||||
* documentada pela API.
|
||||
*/
|
||||
async consultarHorarios(
|
||||
_input: QueryAvailableSlotsInput,
|
||||
): Promise<AvailableSlot[]> {
|
||||
throw new ZProApiError(
|
||||
"NOT_SUPPORTED",
|
||||
"O provider Z-PRO não oferece consulta de disponibilidade de horários.",
|
||||
);
|
||||
}
|
||||
|
||||
async agendar(input: CreateAppointmentInput): Promise<Appointment> {
|
||||
const body = buildAppointmentCreateBody(input);
|
||||
|
||||
const response = await postAppointmentCreate(this.config, body, {
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: this.timeoutMs,
|
||||
});
|
||||
|
||||
this.onResponseMetadata?.(describeResponseMetadata(response));
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw classifyHttpErrorStatus(response.status);
|
||||
}
|
||||
|
||||
// QUALQUER HTTP 2xx é sucesso operacional — o provider aceitou a
|
||||
// operação. Quando o corpo bate com o envelope real verificado
|
||||
// (ver docs/zpro-api-mapping.md), mapeamos os dados de verdade
|
||||
// (id real, etc.). Caso contrário — corpo vazio, texto simples, JSON
|
||||
// malformado ou formato diferente do único exemplo capturado — não
|
||||
// tratamos como erro (só temos um exemplo real; não arriscamos
|
||||
// reportar falha numa operação já aceita) e retornamos um Appointment
|
||||
// "aceito, mapeamento pendente" construído somente a partir do input.
|
||||
const data = tryParseAppointmentCreateResponse(response.bodyText);
|
||||
if (data) {
|
||||
return buildAppointmentFromResponse(data, input);
|
||||
}
|
||||
return buildPendingMappingAppointment(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* adapters/zpro/zpro.errors.ts
|
||||
*
|
||||
* Erros internos, previsíveis e tipados do ZProAdapter.
|
||||
*
|
||||
* Nenhuma mensagem de erro expõe token, headers sensíveis, stack trace ou
|
||||
* corpo bruto da resposta da API. Os códigos abaixo cobrem exatamente os
|
||||
* cenários verificados/possíveis com a integração real do endpoint
|
||||
* `appointment/create` do Z-PRO:
|
||||
*
|
||||
* - VALIDATION_ERROR -> HTTP 400 / 422
|
||||
* - AUTHENTICATION_ERROR -> HTTP 401 / 403
|
||||
* - NOT_FOUND -> HTTP 404
|
||||
* - ZPRO_API_ERROR -> demais erros HTTP (ex.: 500) e falha de rede
|
||||
* - ZPRO_TIMEOUT -> requisição excedeu o tempo limite
|
||||
* - NOT_SUPPORTED -> operação que este provider não oferece
|
||||
* (decisão de produto, não falha técnica)
|
||||
*
|
||||
* IMPORTANTE: QUALQUER HTTP 2xx é tratado como sucesso operacional — não
|
||||
* existe (e não deve existir) um código de erro para "resposta de sucesso
|
||||
* que não pôde ser interpretada". Corpo vazio, texto simples ou JSON
|
||||
* malformado em uma resposta 2xx NÃO são erros: o provider aceitou a
|
||||
* operação, e reportar isso como falha induziria um consumidor a
|
||||
* repeti-la (risco de agendamento duplicado). O adapter retorna
|
||||
* normalmente um `Appointment` com `responseMappingPending: true` (ver
|
||||
* `zpro.mapper.ts` e `Appointment.responseMappingPending` em
|
||||
* `scheduling.types.ts`).
|
||||
*/
|
||||
export type ZProErrorCode =
|
||||
| "VALIDATION_ERROR"
|
||||
| "AUTHENTICATION_ERROR"
|
||||
| "NOT_FOUND"
|
||||
| "ZPRO_API_ERROR"
|
||||
| "ZPRO_TIMEOUT"
|
||||
| "NOT_SUPPORTED";
|
||||
|
||||
/** Erro tipado do ZProAdapter, com um código previsível e mensagem curta. */
|
||||
export class ZProApiError extends Error {
|
||||
readonly code: ZProErrorCode;
|
||||
|
||||
constructor(code: ZProErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ZProApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Classifica um status HTTP de erro (fora do intervalo 2xx) em um ZProApiError. */
|
||||
export function classifyHttpErrorStatus(status: number): ZProApiError {
|
||||
if (status === 401 || status === 403) {
|
||||
return new ZProApiError(
|
||||
"AUTHENTICATION_ERROR",
|
||||
`Falha de autenticação na API do Z-PRO (status ${status}).`,
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
return new ZProApiError(
|
||||
"NOT_FOUND",
|
||||
`Recurso não encontrado na API do Z-PRO (status ${status}). Verifique ZPRO_API_ID.`,
|
||||
);
|
||||
}
|
||||
if (status === 400 || status === 422) {
|
||||
return new ZProApiError(
|
||||
"VALIDATION_ERROR",
|
||||
`A API do Z-PRO rejeitou os dados enviados (status ${status}).`,
|
||||
);
|
||||
}
|
||||
return new ZProApiError(
|
||||
"ZPRO_API_ERROR",
|
||||
`A API do Z-PRO retornou um erro inesperado (status ${status}).`,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* adapters/zpro/zpro.types.ts
|
||||
*
|
||||
* Tipos internos do Z-PRO. Podem usar nomes específicos da API (diferente
|
||||
* de `src/types/scheduling.types.ts`, que é genérico).
|
||||
*
|
||||
* Contém o corpo da requisição de `POST appointment/create` (verificado no
|
||||
* exemplo salvo na coleção pública do Postman) e o envelope de SUCESSO da
|
||||
* resposta, verificado em 09/08/2026 por um teste manual único e
|
||||
* controlado contra uma instância real do Z-PRO (ver
|
||||
* `docs/zpro-api-mapping.md`, seção "Atualização — resposta real de
|
||||
* sucesso capturada").
|
||||
*/
|
||||
|
||||
/**
|
||||
* Corpo da requisição de `POST /v2/api/external/{ApiID}/appointment/create`.
|
||||
*
|
||||
* Campos verificados no exemplo salvo na coleção: `title`, `description`,
|
||||
* `contactId`, `contactName`, `contactPhone`, `whatsappId`, `startAt`,
|
||||
* `endAt`, `status`, `notes`. A coleção não declara quais são obrigatórios;
|
||||
* este adapter só envia os campos para os quais existe uma fonte de dados
|
||||
* confiável no domínio genérico (ver `zpro.mapper.ts`).
|
||||
*/
|
||||
export interface ZProAppointmentCreateBody {
|
||||
title: string;
|
||||
contactId?: string;
|
||||
contactName: string;
|
||||
contactPhone?: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: "pending";
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope de SUCESSO de `POST appointment/create`, verificado por um
|
||||
* teste manual real (um único exemplo capturado). Só os campos realmente
|
||||
* observados na resposta estão tipados como obrigatórios; os demais
|
||||
* campos existem na resposta real mas não são utilizados pelo adapter
|
||||
* (`description`, `whatsappId`, `tenantId`, `notes`, etc. — todos `null`
|
||||
* no exemplo capturado).
|
||||
*
|
||||
* IMPORTANTE: só o valor `status: "pending"` foi observado na prática (é o
|
||||
* que este adapter sempre envia). Os valores `"confirmed"`/`"cancelled"`
|
||||
* não foram confirmados por uma resposta real — o mapeamento para esses
|
||||
* casos (`zpro.mapper.ts`) é uma extrapolação razoável, não uma observação
|
||||
* direta.
|
||||
*/
|
||||
export interface ZProAppointmentCreateResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
id: number;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user