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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Pasta reservada para configurações futuras (variáveis de ambiente, constantes de configuração, etc.). Vazia propositalmente neste esqueleto inicial.
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* config/config.types.ts
|
||||
*
|
||||
* Tipos de configuração do agendamento.
|
||||
*
|
||||
* A configuração genérica (SchedulingProviderConfig) é uma união
|
||||
* discriminada pela propriedade `provider`, permitindo que futuramente
|
||||
* novos sistemas sejam adicionados sem alterar o contrato dos Adapters.
|
||||
*
|
||||
* Campos presentes aqui refletem apenas o que já foi verificado/definido
|
||||
* para cada provider. Não devem existir campos imaginários para sistemas
|
||||
* ainda não integrados (SysLife, ClinicAll, etc.).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuração específica do sistema Z-PRO.
|
||||
*/
|
||||
export interface ZProConfig {
|
||||
/** Nome do provider, usado para selecionar o Adapter correto. */
|
||||
provider: "zpro";
|
||||
/** URL base da API do Z-PRO. */
|
||||
baseUrl: string;
|
||||
/** Token de autenticação da API do Z-PRO. */
|
||||
apiToken: string;
|
||||
/** Identificador da API (ApiID) usado nas rotas externas do Z-PRO. */
|
||||
apiId: string;
|
||||
/**
|
||||
* Identificador da instância no Z-PRO.
|
||||
* Mantido temporariamente por compatibilidade; não é utilizado pelo
|
||||
* diagnóstico.
|
||||
*/
|
||||
instanceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuração específica do sistema Klingo.
|
||||
*
|
||||
* Reflete apenas o que está documentado no Swagger público da API
|
||||
* (`docs/klingo-openapi.json`): autenticação por `X-APP-TOKEN` (token de
|
||||
* aplicação, estático). O token de sessão do paciente (`Authorization:
|
||||
* Bearer`, obtido via `/externo/login` ou `/paciente/identificar`) NÃO é
|
||||
* uma variável de ambiente — ele é obtido em tempo de execução, por
|
||||
* paciente, e não deve ser um segredo fixo de configuração.
|
||||
*/
|
||||
export interface KlingoConfig {
|
||||
/** Nome do provider, usado para selecionar o Adapter correto. */
|
||||
provider: "klingo";
|
||||
/** URL base da API do Klingo. */
|
||||
baseUrl: string;
|
||||
/** Token de aplicação do Klingo, enviado no header `X-APP-TOKEN`. */
|
||||
appToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuração genérica do provider de agendamento.
|
||||
*
|
||||
* Novos providers devem adicionar seu próprio tipo à união.
|
||||
*/
|
||||
export type SchedulingProviderConfig = ZProConfig | KlingoConfig;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* config/env.config.ts
|
||||
*
|
||||
* Módulo responsável por ler e validar a configuração da aplicação por
|
||||
* variáveis de ambiente.
|
||||
*
|
||||
* A leitura de `process.env` acontece EXCLUSIVAMENTE neste módulo.
|
||||
*
|
||||
* Variáveis lidas:
|
||||
* - SCHEDULING_PROVIDER (padrão: "zpro"; valores: "zpro" | "klingo")
|
||||
*
|
||||
* Quando SCHEDULING_PROVIDER=zpro:
|
||||
* - ZPRO_BASE_URL (obrigatória)
|
||||
* - ZPRO_API_TOKEN (obrigatória)
|
||||
* - ZPRO_API_ID (obrigatória)
|
||||
* - ZPRO_INSTANCE_ID (opcional, mantida por compatibilidade)
|
||||
*
|
||||
* Quando SCHEDULING_PROVIDER=klingo:
|
||||
* - KLINGO_BASE_URL (obrigatória)
|
||||
* - KLINGO_APP_TOKEN (obrigatória) — token de aplicação (X-APP-TOKEN)
|
||||
*
|
||||
* As variáveis de um provider não selecionado NÃO são exigidas (ex.: não
|
||||
* é preciso definir ZPRO_* quando SCHEDULING_PROVIDER=klingo).
|
||||
*
|
||||
* A configuração NUNCA deve expor nenhum token em mensagens de erro ou logs.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SchedulingProviderConfig } from "./config.types.js";
|
||||
|
||||
/** Converte valores vazios em `undefined`, permitindo tratar variáveis
|
||||
* opcionais deixadas em branco como ausentes. */
|
||||
function emptyToUndefined(value: unknown): unknown {
|
||||
return typeof value === "string" && value.trim() === "" ? undefined : value;
|
||||
}
|
||||
|
||||
/** Schema para uma variável textual opcional (validação condicional de
|
||||
* obrigatoriedade acontece em `superRefine`, conforme o provider). */
|
||||
const optionalString = z.preprocess(
|
||||
emptyToUndefined,
|
||||
z.string().trim().optional(),
|
||||
);
|
||||
|
||||
const rawEnvSchema = z.object({
|
||||
SCHEDULING_PROVIDER: z.preprocess(
|
||||
emptyToUndefined,
|
||||
z
|
||||
.enum(["zpro", "klingo"], {
|
||||
errorMap: () => ({
|
||||
message: 'SCHEDULING_PROVIDER deve ser "zpro" ou "klingo".',
|
||||
}),
|
||||
})
|
||||
.default("zpro"),
|
||||
),
|
||||
ZPRO_BASE_URL: optionalString,
|
||||
ZPRO_API_TOKEN: optionalString,
|
||||
ZPRO_API_ID: optionalString,
|
||||
ZPRO_INSTANCE_ID: optionalString,
|
||||
KLINGO_BASE_URL: optionalString,
|
||||
KLINGO_APP_TOKEN: optionalString,
|
||||
});
|
||||
|
||||
/** Adiciona um issue de "campo obrigatório" na variável informada. */
|
||||
function requireField(
|
||||
ctx: z.RefinementCtx,
|
||||
fieldName: keyof z.infer<typeof rawEnvSchema>,
|
||||
value: string | undefined,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${fieldName} é obrigatória.`,
|
||||
path: [fieldName],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const envSchema = rawEnvSchema
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.SCHEDULING_PROVIDER === "zpro") {
|
||||
requireField(ctx, "ZPRO_BASE_URL", env.ZPRO_BASE_URL);
|
||||
requireField(ctx, "ZPRO_API_TOKEN", env.ZPRO_API_TOKEN);
|
||||
requireField(ctx, "ZPRO_API_ID", env.ZPRO_API_ID);
|
||||
} else if (env.SCHEDULING_PROVIDER === "klingo") {
|
||||
requireField(ctx, "KLINGO_BASE_URL", env.KLINGO_BASE_URL);
|
||||
requireField(ctx, "KLINGO_APP_TOKEN", env.KLINGO_APP_TOKEN);
|
||||
}
|
||||
})
|
||||
.transform((env): SchedulingProviderConfig => {
|
||||
if (env.SCHEDULING_PROVIDER === "klingo") {
|
||||
return {
|
||||
provider: "klingo",
|
||||
baseUrl: env.KLINGO_BASE_URL as string,
|
||||
appToken: env.KLINGO_APP_TOKEN as string,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: "zpro",
|
||||
baseUrl: env.ZPRO_BASE_URL as string,
|
||||
apiToken: env.ZPRO_API_TOKEN as string,
|
||||
apiId: env.ZPRO_API_ID as string,
|
||||
instanceId: env.ZPRO_INSTANCE_ID,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Lê e valida a configuração a partir das variáveis de ambiente.
|
||||
*
|
||||
* Lança um `Error` com mensagem clara (e sem segredos) quando uma
|
||||
* variável obrigatória estiver ausente ou inválida.
|
||||
*/
|
||||
export function loadSchedulingConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SchedulingProviderConfig {
|
||||
const result = envSchema.safeParse(env);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((issue) => issue.message).join(" ");
|
||||
throw new Error(`Falha ao carregar a configuração: ${issues}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* diagnostics/analyze.ts
|
||||
*
|
||||
* Identificação da estrutura de uma resposta JSON, sem inventar
|
||||
* interpretações: apenas relata o que realmente existe na resposta
|
||||
* (tipo da raiz, chaves, tipos dos campos e possíveis campos de
|
||||
* paginação/identificação/data/status, com base em heurísticas simples
|
||||
* de nomeação).
|
||||
*/
|
||||
|
||||
/** Resumo estrutural de uma resposta JSON. */
|
||||
export interface StructureSummary {
|
||||
/** Tipo da raiz (object, array, string, number, boolean, null). */
|
||||
rootType: string;
|
||||
/** Chaves existentes na raiz, quando ela é um objeto. */
|
||||
rootKeys: string[];
|
||||
/** Quantidade de itens, quando a raiz é um array. */
|
||||
arrayItemCount?: number;
|
||||
/** Chaves do primeiro item (ou da raiz, quando ela é um objeto). */
|
||||
firstItemKeys: string[];
|
||||
/** Tipos dos campos do primeiro item (ou da raiz, quando objeto). */
|
||||
fieldTypes: Record<string, string>;
|
||||
/** Possíveis campos de paginação encontrados. */
|
||||
possiblePaginationFields: string[];
|
||||
/** Possíveis campos de identificação encontrados. */
|
||||
possibleIdFields: string[];
|
||||
/** Possíveis campos de data encontrados. */
|
||||
possibleDateFields: string[];
|
||||
/** Possíveis campos de status encontrados. */
|
||||
possibleStatusFields: string[];
|
||||
}
|
||||
|
||||
const PAGINATION_PATTERN = /page|per.?page|limit|offset|total|count/i;
|
||||
const ID_PATTERN = /(^|_)(id|uuid)$|(^|_)(identifier|identificador)$|Id$/;
|
||||
const DATE_PATTERN = /date|time|at$|timestamp|created|updated|scheduled/i;
|
||||
const STATUS_PATTERN = /status|state|situa/i;
|
||||
|
||||
function typeOf(value: unknown): string {
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (value === null) return "null";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analisa a estrutura de um valor JSON já interpretado.
|
||||
*/
|
||||
export function analyzeStructure(value: unknown): StructureSummary {
|
||||
const rootType = Array.isArray(value) ? "array" : typeOf(value);
|
||||
const rootKeys = isRecord(value) ? Object.keys(value) : [];
|
||||
|
||||
let sample: unknown = value;
|
||||
let arrayItemCount: number | undefined;
|
||||
if (Array.isArray(value)) {
|
||||
arrayItemCount = value.length;
|
||||
sample = value[0];
|
||||
}
|
||||
|
||||
const firstItemKeys = isRecord(sample) ? Object.keys(sample) : [];
|
||||
|
||||
const fieldTypes: Record<string, string> = {};
|
||||
if (isRecord(sample)) {
|
||||
for (const [key, entry] of Object.entries(sample)) {
|
||||
fieldTypes[key] = typeOf(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rootType,
|
||||
rootKeys,
|
||||
arrayItemCount,
|
||||
firstItemKeys,
|
||||
fieldTypes,
|
||||
possiblePaginationFields: firstItemKeys.filter((key) =>
|
||||
PAGINATION_PATTERN.test(key),
|
||||
),
|
||||
possibleIdFields: firstItemKeys.filter((key) => ID_PATTERN.test(key)),
|
||||
possibleDateFields: firstItemKeys.filter((key) => DATE_PATTERN.test(key)),
|
||||
possibleStatusFields: firstItemKeys.filter((key) =>
|
||||
STATUS_PATTERN.test(key),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* diagnostics/client.ts
|
||||
*
|
||||
* Cliente HTTP mínimo para o script de diagnóstico do Z-PRO.
|
||||
*
|
||||
* Faz apenas uma requisição GET (leitura) em
|
||||
* `/v2/api/external/{ApiID}/appointment/list`, sem retry, com timeout
|
||||
* configurável. Nenhuma mensagem de erro expõe o token.
|
||||
*/
|
||||
|
||||
import type { ZProConfig } from "../config/config.types.js";
|
||||
|
||||
/** Timeout padrão da requisição de diagnóstico (15 segundos). */
|
||||
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Resultado bruto da requisição. */
|
||||
export interface ZProListResponse {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
bodyText: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Erro lançado quando a requisição excede o timeout. */
|
||||
export class RequestTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Timeout ao consultar a API do Z-PRO.");
|
||||
this.name = "RequestTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Erro lançado em falhas de rede (sem detalhes que exponham segredos). */
|
||||
export class NetworkRequestError extends Error {
|
||||
constructor() {
|
||||
super("Falha de rede ao consultar a API do Z-PRO.");
|
||||
this.name = "NetworkRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta a URL do endpoint de listagem com os parâmetros mais
|
||||
* conservadores: page=1 e limit=5.
|
||||
*/
|
||||
export function buildAppointmentListUrl(
|
||||
config: Pick<ZProConfig, "baseUrl" | "apiId">,
|
||||
): string {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
const apiId = encodeURIComponent(config.apiId);
|
||||
return `${baseUrl}/v2/api/external/${apiId}/appointment/list?page=1&limit=5`;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa a requisição GET de listagem de agendamentos.
|
||||
*
|
||||
* `fetchImpl` permite injetar um fetch fake em testes; `timeoutMs`
|
||||
* controla o tempo máximo da requisição.
|
||||
*/
|
||||
export async function fetchAppointmentList(
|
||||
config: ZProConfig,
|
||||
options: {
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
} = {},
|
||||
): Promise<ZProListResponse> {
|
||||
const { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = options;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(buildAppointmentListUrl(config), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const bodyText = await response.text();
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type"),
|
||||
bodyText,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw new RequestTimeoutError();
|
||||
}
|
||||
throw new NetworkRequestError();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* diagnostics/response.ts
|
||||
*
|
||||
* Tratamento da resposta recebida pelo script de diagnóstico: decide se
|
||||
* o corpo é JSON e, em caso afirmativo, produz uma versão sanitizada e o
|
||||
* resumo estrutural. Respostas não JSON são tratadas como texto (limitado
|
||||
* e sanitizado).
|
||||
*/
|
||||
|
||||
import { analyzeStructure, type StructureSummary } from "./analyze.js";
|
||||
import { sanitizeRecursively, sanitizeText } from "./sanitize.js";
|
||||
|
||||
/** Relatório gerado a partir do corpo da resposta. */
|
||||
export interface ResponseReport {
|
||||
/** Se o corpo foi interpretado como JSON válido. */
|
||||
isJson: boolean;
|
||||
/** Versão sanitizada (e limitada, no caso de texto) do corpo. */
|
||||
sanitizedText: string;
|
||||
/** Resumo estrutural, presente apenas quando a resposta é JSON. */
|
||||
structure?: StructureSummary;
|
||||
}
|
||||
|
||||
/** Tenta interpretar o texto como JSON; retorna null se não for JSON. */
|
||||
export function tryParseJson(text: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói o relatório a partir do corpo bruto da resposta, sanitizando
|
||||
* campos sensíveis e redigindo os segredos informados (ex.: o token).
|
||||
*/
|
||||
export function buildResponseReport(
|
||||
bodyText: string,
|
||||
secrets: string[] = [],
|
||||
): ResponseReport {
|
||||
const parsed = tryParseJson(bodyText);
|
||||
|
||||
if (parsed === null) {
|
||||
return {
|
||||
isJson: false,
|
||||
sanitizedText: sanitizeText(bodyText, secrets),
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized = sanitizeRecursively(parsed, secrets);
|
||||
|
||||
return {
|
||||
isJson: true,
|
||||
sanitizedText: JSON.stringify(sanitized, null, 2),
|
||||
structure: analyzeStructure(parsed),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* diagnostics/sanitize.ts
|
||||
*
|
||||
* Sanitização recursiva de respostas para o script de diagnóstico.
|
||||
*
|
||||
* Nenhuma informação sensível (nomes, telefones, e-mails, documentos,
|
||||
* tokens, senhas, endereços) deve ser impressa. A sanitização mascara
|
||||
* valores de campos cujos nomes contenham termos sensíveis e também
|
||||
* redige ocorrências de segredos conhecidos (ex.: o token da API) dentro
|
||||
* de qualquer texto.
|
||||
*/
|
||||
|
||||
/** Valor que substitui conteúdo sensível na saída. */
|
||||
export const REDACTED = "[REDACTED]";
|
||||
|
||||
/** Substrings de nomes de campo consideradas sensíveis. */
|
||||
export const SENSITIVE_FIELD_NAMES = [
|
||||
"name",
|
||||
"nome",
|
||||
"phone",
|
||||
"telefone",
|
||||
"email",
|
||||
"cpf",
|
||||
"document",
|
||||
"documento",
|
||||
"token",
|
||||
"authorization",
|
||||
"password",
|
||||
"senha",
|
||||
"address",
|
||||
"endereco",
|
||||
] as const;
|
||||
|
||||
/** Profundidade máxima de recursão, como proteção extra. */
|
||||
const MAX_DEPTH = 20;
|
||||
|
||||
/** Verifica se um nome de campo é sensível (ignorando maiúsculas/minúsculas). */
|
||||
export function isSensitiveField(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return SENSITIVE_FIELD_NAMES.some((term) => lower.includes(term));
|
||||
}
|
||||
|
||||
/** Substitui todas as ocorrências de cada segredo pelo valor REDACTED. */
|
||||
function redact(text: string, secrets: string[]): string {
|
||||
let result = text;
|
||||
for (const secret of secrets) {
|
||||
if (!secret) continue;
|
||||
result = result.split(secret).join(REDACTED);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produz uma cópia sanitizada de um valor JSON (objeto, array ou primitivo).
|
||||
*
|
||||
* `secrets` são valores adicionais a redigir (ex.: o token da API), para
|
||||
* que não apareçam nem mesmo em campos não sensíveis.
|
||||
*/
|
||||
export function sanitizeRecursively(
|
||||
value: unknown,
|
||||
secrets: string[] = [],
|
||||
seen: WeakSet<object> = new WeakSet(),
|
||||
depth = 0,
|
||||
): unknown {
|
||||
if (value === null || typeof value !== "object") {
|
||||
if (typeof value === "string") {
|
||||
return redact(value, secrets);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (depth > MAX_DEPTH || seen.has(value)) {
|
||||
return REDACTED;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const result = value.map((item) =>
|
||||
sanitizeRecursively(item, secrets, seen, depth + 1),
|
||||
);
|
||||
seen.delete(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = isSensitiveField(key)
|
||||
? REDACTED
|
||||
: sanitizeRecursively(entry, secrets, seen, depth + 1);
|
||||
}
|
||||
seen.delete(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiza um texto não JSON: redige segredos e limita o comprimento
|
||||
* a `maxLength` caracteres (padrão: 500).
|
||||
*/
|
||||
export function sanitizeText(
|
||||
text: string,
|
||||
secrets: string[] = [],
|
||||
maxLength = 500,
|
||||
): string {
|
||||
return redact(text, secrets).slice(0, maxLength);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* index.ts
|
||||
*
|
||||
* Ponto de entrada da aplicação. Responsável por carregar e validar a
|
||||
* configuração, criar o SchedulingAdapter via AdapterFactory e iniciar
|
||||
* o servidor MCP.
|
||||
*
|
||||
* Com configuração inválida, a aplicação encerra com mensagem clara e
|
||||
* sem revelar segredos.
|
||||
*/
|
||||
|
||||
import { loadSchedulingConfig } from "./config/env.config.js";
|
||||
import { createSchedulingAdapter } from "./adapters/adapter-factory.js";
|
||||
import type { SchedulingAdapter } from "./adapters/scheduling-adapter.interface.js";
|
||||
import { startMcpServer } from "./server/mcp.server.js";
|
||||
|
||||
function bootstrap(): void {
|
||||
let adapter: SchedulingAdapter;
|
||||
try {
|
||||
const config = loadSchedulingConfig();
|
||||
adapter = createSchedulingAdapter(config);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Falha ao carregar a configuração.";
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
startMcpServer(adapter).catch((error) => {
|
||||
console.error("Falha ao iniciar o servidor MCP:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* server/mcp.server.ts
|
||||
*
|
||||
* Configuração do servidor MCP.
|
||||
*
|
||||
* Responsável por instanciar o servidor e registrar as ferramentas
|
||||
* disponíveis, que recebem o SchedulingAdapter por injeção de
|
||||
* dependência. Nenhuma regra de negócio é implementada aqui.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CONSULTAR_HORARIOS_TOOL_NAME,
|
||||
CONSULTAR_HORARIOS_TOOL_DESCRIPTION,
|
||||
consultarHorariosInputSchema,
|
||||
createConsultarHorariosHandler,
|
||||
} from "../tools/consultar-horarios.tool.js";
|
||||
import {
|
||||
AGENDAR_TOOL_NAME,
|
||||
AGENDAR_TOOL_DESCRIPTION,
|
||||
agendarInputSchema,
|
||||
createAgendarHandler,
|
||||
} from "../tools/agendar.tool.js";
|
||||
import type { SchedulingAdapter } from "../adapters/scheduling-adapter.interface.js";
|
||||
|
||||
/**
|
||||
* Cria e configura a instância do servidor MCP, registrando
|
||||
* exclusivamente as ferramentas de agendamento suportadas por este MVP.
|
||||
*
|
||||
* O Adapter é injetado nas ferramentas e nunca é criado aqui.
|
||||
*/
|
||||
export function createMcpServer(adapter: SchedulingAdapter): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "gti-scheduling-mcp",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
CONSULTAR_HORARIOS_TOOL_NAME,
|
||||
CONSULTAR_HORARIOS_TOOL_DESCRIPTION,
|
||||
consultarHorariosInputSchema,
|
||||
createConsultarHorariosHandler(adapter),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
AGENDAR_TOOL_NAME,
|
||||
AGENDAR_TOOL_DESCRIPTION,
|
||||
agendarInputSchema,
|
||||
createAgendarHandler(adapter),
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa o servidor MCP utilizando o transporte stdio.
|
||||
*/
|
||||
export async function startMcpServer(
|
||||
adapter: SchedulingAdapter,
|
||||
): Promise<void> {
|
||||
const server = createMcpServer(adapter);
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* tools/agendar.tool.ts
|
||||
*
|
||||
* Definição da ferramenta MCP "agendar".
|
||||
*
|
||||
* Esta ferramenta expõe, via MCP, a criação de um agendamento.
|
||||
* Nenhuma regra de negócio é implementada aqui: o handler apenas define
|
||||
* o contrato (nome, descrição e schema de entrada) e delega a execução
|
||||
* ao Adapter, recebido por injeção de dependência.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SchedulingAdapter } from "../adapters/scheduling-adapter.interface.js";
|
||||
import type { CreateAppointmentInput } from "../types/scheduling.types.js";
|
||||
import { toTextResponse, toErrorResponse } from "./mcp-response.js";
|
||||
|
||||
/** Nome da ferramenta, utilizado no registro do servidor MCP. */
|
||||
export const AGENDAR_TOOL_NAME = "agendar";
|
||||
|
||||
/** Descrição da ferramenta, exibida ao cliente MCP. */
|
||||
export const AGENDAR_TOOL_DESCRIPTION =
|
||||
"Realiza um agendamento em um horário disponível de uma clínica.";
|
||||
|
||||
/** Schema de entrada da ferramenta. */
|
||||
export const agendarInputSchema = {
|
||||
clinicId: z.string().describe("Identificador da clínica"),
|
||||
slotId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Identificador do horário pré-selecionado (apenas para providers que suportam consulta de disponibilidade)",
|
||||
),
|
||||
specialtyId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Identificador da especialidade/procedimento do horário pré-selecionado (copiado do resultado de consultar_horarios, quando o provider exigir)",
|
||||
),
|
||||
startAt: z
|
||||
.string()
|
||||
.describe("Data e hora de início do agendamento (ISO 8601)"),
|
||||
endAt: z
|
||||
.string()
|
||||
.describe("Data e hora de término do agendamento (ISO 8601)"),
|
||||
title: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Título/assunto do agendamento (usado apenas por providers que exigem um resumo textual)",
|
||||
),
|
||||
patient: z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Identificador do paciente, se existente"),
|
||||
name: z.string().describe("Nome completo do paciente"),
|
||||
document: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Documento de identificação do paciente"),
|
||||
phone: z.string().optional().describe("Telefone de contato do paciente"),
|
||||
email: z.string().optional().describe("E-mail de contato do paciente"),
|
||||
})
|
||||
.describe("Dados do paciente a ser agendado"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Cria o handler da ferramenta "agendar", delegando o agendamento ao
|
||||
* Adapter recebido por injeção de dependência.
|
||||
*
|
||||
* Erros lançados pelo Adapter são capturados e convertidos em uma
|
||||
* resposta MCP estruturada, sem encerrar o servidor.
|
||||
*/
|
||||
export function createAgendarHandler(adapter: SchedulingAdapter) {
|
||||
return async function agendarHandler(args: CreateAppointmentInput) {
|
||||
try {
|
||||
const appointment = await adapter.agendar(args);
|
||||
return toTextResponse(appointment);
|
||||
} catch (error) {
|
||||
return toErrorResponse(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* tools/consultar-horarios.tool.ts
|
||||
*
|
||||
* Definição da ferramenta MCP "consultar_horarios".
|
||||
*
|
||||
* Esta ferramenta expõe, via MCP, a consulta de horários disponíveis.
|
||||
* Nenhuma regra de negócio é implementada aqui: o handler apenas define
|
||||
* o contrato (nome, descrição e schema de entrada) e delega a execução
|
||||
* ao Adapter, recebido por injeção de dependência.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SchedulingAdapter } from "../adapters/scheduling-adapter.interface.js";
|
||||
import type { QueryAvailableSlotsInput } from "../types/scheduling.types.js";
|
||||
import { toTextResponse, toErrorResponse } from "./mcp-response.js";
|
||||
|
||||
/** Nome da ferramenta, utilizado no registro do servidor MCP. */
|
||||
export const CONSULTAR_HORARIOS_TOOL_NAME = "consultar_horarios";
|
||||
|
||||
/** Descrição da ferramenta, exibida ao cliente MCP. */
|
||||
export const CONSULTAR_HORARIOS_TOOL_DESCRIPTION =
|
||||
"Consulta horários disponíveis para agendamento em uma clínica.";
|
||||
|
||||
/** Schema de entrada da ferramenta. */
|
||||
export const consultarHorariosInputSchema = {
|
||||
clinicId: z.string().describe("Identificador da clínica"),
|
||||
professionalId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Identificador do profissional (opcional)"),
|
||||
specialtyId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Identificador da especialidade/serviço (opcional)"),
|
||||
startDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Data inicial do período de busca (ISO 8601)"),
|
||||
endDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Data final do período de busca (ISO 8601)"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Cria o handler da ferramenta "consultar_horarios", delegando a
|
||||
* consulta ao Adapter recebido por injeção de dependência.
|
||||
*
|
||||
* Erros lançados pelo Adapter são capturados e convertidos em uma
|
||||
* resposta MCP estruturada, sem encerrar o servidor.
|
||||
*/
|
||||
export function createConsultarHorariosHandler(adapter: SchedulingAdapter) {
|
||||
return async function consultarHorariosHandler(
|
||||
args: QueryAvailableSlotsInput,
|
||||
) {
|
||||
try {
|
||||
const slots = await adapter.consultarHorarios(args);
|
||||
return toTextResponse(slots);
|
||||
} catch (error) {
|
||||
return toErrorResponse(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* tools/mcp-response.ts
|
||||
*
|
||||
* Helpers para construir as respostas MCP das ferramentas de forma
|
||||
* consistente, tanto de sucesso quanto de erro.
|
||||
*
|
||||
* Erros nunca expõem stack trace: retornam um payload estruturado
|
||||
* com `success: false`, `code` e `message`.
|
||||
*/
|
||||
|
||||
/** Payload de erro estruturado devolvido ao cliente MCP. */
|
||||
export interface ErrorPayload {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o payload de erro estruturado a partir de uma exceção.
|
||||
* Erros "Not implemented" são mapeados para o código NOT_IMPLEMENTED;
|
||||
* demais erros são tratados como erro interno genérico, sem vazar
|
||||
* detalhes de implementação.
|
||||
*/
|
||||
function buildErrorPayload(error: unknown): ErrorPayload {
|
||||
if (error instanceof Error && error.message === "Not implemented") {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_IMPLEMENTED",
|
||||
message: "Not implemented",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Erro interno ao processar a solicitação.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resposta de sucesso de uma ferramenta MCP. */
|
||||
export function toTextResponse(data: unknown) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ success: true, data }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Resposta de erro de uma ferramenta MCP. */
|
||||
export function toErrorResponse(error: unknown) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(buildErrorPayload(error)),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* types/scheduling.types.ts
|
||||
*
|
||||
* Tipos genéricos de domínio para agendamento em clínicas.
|
||||
*
|
||||
* Estes tipos NUNCA devem conter nomes ou conceitos específicos de um
|
||||
* sistema de clínica em particular (ex.: Z-Pro, Klingo, SysLife, ClinicAll).
|
||||
* Eles representam o "contrato" comum que todos os Adapters devem respeitar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Representa um horário disponível para agendamento.
|
||||
*/
|
||||
export interface AvailableSlot {
|
||||
/**
|
||||
* Identificador único do horário no sistema de origem.
|
||||
* Opcional: um provider pode confirmar a criação de um agendamento
|
||||
* (sucesso HTTP) sem que o adapter consiga determinar o identificador
|
||||
* do horário — por exemplo, quando o schema de resposta do provider
|
||||
* ainda não foi verificado. Ver `Appointment.responseMappingPending`.
|
||||
*/
|
||||
id?: string;
|
||||
/** Data e hora de início do horário disponível (ISO 8601). */
|
||||
startDateTime: string;
|
||||
/** Data e hora de término do horário disponível (ISO 8601). */
|
||||
endDateTime: string;
|
||||
/**
|
||||
* Identificador do profissional relacionado a este horário.
|
||||
* Opcional porque nem todo provider informa um profissional associado
|
||||
* ao agendamento (ex.: Z-PRO não expõe esse conceito no fluxo de
|
||||
* criação); providers que o suportam devem sempre preenchê-lo.
|
||||
*/
|
||||
professionalId?: string;
|
||||
/** Nome do profissional relacionado a este horário. */
|
||||
professionalName?: string;
|
||||
/** Identificador da especialidade/serviço relacionado a este horário. */
|
||||
specialtyId?: string;
|
||||
/** Nome da especialidade/serviço relacionado a este horário. */
|
||||
specialtyName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Representa um paciente a ser vinculado a um agendamento.
|
||||
*/
|
||||
export interface Patient {
|
||||
/** Identificador único do paciente no sistema de origem, quando existente. */
|
||||
id?: string;
|
||||
/** Nome completo do paciente. */
|
||||
name: string;
|
||||
/** Documento de identificação do paciente (ex.: CPF). */
|
||||
document?: string;
|
||||
/** Telefone de contato do paciente. */
|
||||
phone?: string;
|
||||
/** E-mail de contato do paciente. */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Representa um agendamento aceito pelo provider.
|
||||
*/
|
||||
export interface Appointment {
|
||||
/**
|
||||
* Identificador único do agendamento no sistema de origem.
|
||||
* Opcional: ausente quando o provider confirma a operação (sucesso
|
||||
* HTTP), mas o adapter não pode extrair o identificador real da
|
||||
* resposta sem inventá-lo (ver `responseMappingPending`). Nesse caso, a
|
||||
* AUSÊNCIA de `id` não significa que a operação falhou.
|
||||
*/
|
||||
id?: string;
|
||||
/** Horário utilizado no agendamento. */
|
||||
slot: AvailableSlot;
|
||||
/** Paciente vinculado ao agendamento. */
|
||||
patient: Patient;
|
||||
/** Situação atual do agendamento. */
|
||||
status: AppointmentStatus;
|
||||
/**
|
||||
* `true` quando o provider confirmou a operação (resposta de sucesso),
|
||||
* mas o adapter ainda não consegue mapear o corpo da resposta para este
|
||||
* tipo genérico (schema de resposta do provider não verificado). Nesse
|
||||
* caso os demais campos (em especial `id` e `slot.id`) refletem apenas
|
||||
* o que foi enviado na requisição, não o que o provider de fato
|
||||
* retornou, e não devem ser tratados como definitivos.
|
||||
*
|
||||
* Consumidores (agente/n8n) NÃO devem reenviar/repetir a operação só
|
||||
* porque este campo está `true` ou porque `id` está ausente — a
|
||||
* operação já foi aceita pelo provider.
|
||||
*/
|
||||
responseMappingPending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Situações possíveis de um agendamento.
|
||||
*/
|
||||
export type AppointmentStatus = "confirmed" | "pending" | "cancelled";
|
||||
|
||||
/**
|
||||
* Dados de entrada para consultar horários disponíveis.
|
||||
*/
|
||||
export interface QueryAvailableSlotsInput {
|
||||
/** Identificador da clínica a ser consultada. */
|
||||
clinicId: string;
|
||||
/** Identificador do profissional (opcional, filtro). */
|
||||
professionalId?: string;
|
||||
/** Identificador da especialidade/serviço (opcional, filtro). */
|
||||
specialtyId?: string;
|
||||
/** Data inicial do período de busca (ISO 8601). */
|
||||
startDate?: string;
|
||||
/** Data final do período de busca (ISO 8601). */
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dados de entrada para realizar um agendamento.
|
||||
*
|
||||
* O agendamento é sempre definido por uma data/hora de início e término
|
||||
* explícitas (`startAt`/`endAt`). `slotId` é opcional e existe apenas para
|
||||
* providers que expõem uma consulta de disponibilidade e trabalham com a
|
||||
* seleção de um horário pré-existente; providers que criam o agendamento
|
||||
* diretamente por data/hora (ex.: Z-PRO) não o utilizam.
|
||||
*/
|
||||
export interface CreateAppointmentInput {
|
||||
/** Identificador da clínica onde o agendamento será realizado. */
|
||||
clinicId: string;
|
||||
/**
|
||||
* Identificador do horário pré-selecionado, quando o provider expõe uma
|
||||
* consulta de disponibilidade (`consultarHorarios()`). Ausente quando o
|
||||
* provider não suporta essa consulta.
|
||||
*/
|
||||
slotId?: string;
|
||||
/**
|
||||
* Identificador da especialidade/procedimento associado ao horário
|
||||
* pré-selecionado (normalmente copiado de `AvailableSlot.specialtyId`,
|
||||
* retornado por `consultarHorarios()`). Opcional: só é usado por
|
||||
* providers cujo agendamento exige explicitamente qual procedimento está
|
||||
* sendo marcado, além do horário em si.
|
||||
*/
|
||||
specialtyId?: string;
|
||||
/** Data e hora de início do agendamento (ISO 8601). */
|
||||
startAt: string;
|
||||
/** Data e hora de término do agendamento (ISO 8601). */
|
||||
endAt: string;
|
||||
/**
|
||||
* Título/assunto do agendamento. Genérico e opcional: destina-se apenas
|
||||
* a providers cuja API exige um resumo textual do agendamento (ex.:
|
||||
* Z-PRO). Providers que não usam esse conceito devem ignorá-lo.
|
||||
*/
|
||||
title?: string;
|
||||
/** Dados do paciente a ser agendado. */
|
||||
patient: Patient;
|
||||
}
|
||||
Reference in New Issue
Block a user