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,263 @@
|
||||
# Runbook — Workflow de Agendamento no n8n (GChat/Z-PRO + gti-scheduling-mcp)
|
||||
|
||||
> Registra problemas reais encontrados ao montar o workflow
|
||||
> `Agendamento - GChat - Z-PRO (clínica exemplo)` (id `yG2mi6ECBpPv4Hmn`, servidor
|
||||
> `srvdevn8ngti` / `devn8n.gtigrupo.com.br`), e como foram resolvidos — para não
|
||||
> repetir o mesmo processo de descoberta ao duplicar este workflow para uma nova
|
||||
> clínica. Ver também `docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md`
|
||||
> (design) e `docs/superpowers/plans/2026-08-10-n8n-agendamento-workflow.md` (plano).
|
||||
|
||||
## 1. Node MCP: qual usar e como instalar
|
||||
|
||||
- O node **oficial** do n8n (`MCP Client Tool`, `@n8n/n8n-nodes-langchain`) só
|
||||
conecta em MCP remoto via HTTP Streamable/SSE — **não** roda comando local
|
||||
(stdio). Para rodar o `gti-scheduling-mcp` como subprocesso local, use o
|
||||
pacote **comunitário** `n8n-nodes-mcp` (autor `nerding-io`).
|
||||
- Instalação: **Settings → Community Nodes → Install a community node** →
|
||||
`n8n-nodes-mcp`.
|
||||
- Ao conectar como tool de um AI Agent, o **tipo do node no JSON do workflow**
|
||||
precisa ser `n8n-nodes-mcp.mcpClientTool` (não `n8n-nodes-mcp.mcpClient`) —
|
||||
é uma variante "Tool" que o n8n gera automaticamente para nodes marcados
|
||||
como usáveis em AI Agents. Usar `mcpClient` na conexão `ai_tool` falha a
|
||||
validação com "cannot output ai_tool connections".
|
||||
- É necessário setar a variável de ambiente do **processo do n8n**
|
||||
(não é configurável pela UI):
|
||||
```
|
||||
N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
|
||||
```
|
||||
Nesse servidor, o n8n roda via **systemd** (`n8n.service`), com
|
||||
`EnvironmentFile=/opt/n8n.env`. A variável foi adicionada nesse arquivo,
|
||||
seguido de `systemctl restart n8n`. (Se o n8n rodasse em Docker, seria em
|
||||
`environment:` no `docker-compose.yml`.)
|
||||
|
||||
## 2. Credencial MCP (STDIO) — dois bugs reais
|
||||
|
||||
Tipo de credencial: `mcpClientApi` ("MCP Client (STDIO) API"), campos:
|
||||
`command`, `args`, `environments`.
|
||||
|
||||
- **Bug 1 — `command: "node"` sozinho falhou** com "Connection closed". O
|
||||
serviço systemd tem um `PATH` mais restrito que um shell interativo. Fix:
|
||||
usar o **caminho absoluto** (`which node` no servidor → `/usr/bin/node`).
|
||||
|
||||
- **Bug 2 — o campo "Environments" é um `<input>` de uma linha só (mascarado
|
||||
como senha) na UI do n8n.** O código real do node
|
||||
(`nodes/McpClient/McpClient.node.ts` no pacote `n8n-nodes-mcp`) espera o
|
||||
formato `NOME=VALOR` **separado por quebra de linha real** (`split('\n')`),
|
||||
mas navegadores removem quebras de linha ao digitar OU colar em campos de
|
||||
uma linha — então múltiplas variáveis nunca conseguem ser inseridas
|
||||
corretamente pela UI (tentamos: uma por linha, vírgula, espaço — todas
|
||||
colapsam a mesma forma, e a primeira variável acaba recebendo o resto do
|
||||
texto como valor, quebrando a validação).
|
||||
|
||||
**Fix**: criar/recriar a credencial via **API REST do n8n**, que aceita o
|
||||
JSON com `\n` de verdade (não passa pelo campo quebrado da UI):
|
||||
|
||||
```bash
|
||||
# 1. Gerar uma API key: Settings → n8n API → Create an API key
|
||||
# 2. DELETE na credencial antiga (se existir), depois POST na nova:
|
||||
curl -X POST "https://devn8n.gtigrupo.com.br/api/v1/credentials" \
|
||||
-H "X-N8N-API-KEY: <chave>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Client (STDIO) account",
|
||||
"type": "mcpClientApi",
|
||||
"data": {
|
||||
"command": "/usr/bin/node",
|
||||
"args": "/opt/gti-scheduling-mcp/dist/index.js",
|
||||
"environments": "SCHEDULING_PROVIDER=zpro\nZPRO_BASE_URL=https://...\nZPRO_API_TOKEN=...\nZPRO_API_ID=..."
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Sintoma quando esse bug está presente: o log do n8n
|
||||
(`journalctl -u n8n -f`) mostra exatamente a mensagem de erro do
|
||||
`gti-scheduling-mcp` (`Falha ao carregar a configuração: SCHEDULING_PROVIDER
|
||||
deve ser "zpro" ou "klingo".`), seguida de `MCP error -32000: Connection
|
||||
closed` — o processo sobe e cai na validação de config antes de completar o
|
||||
handshake stdio.
|
||||
|
||||
## 3. Deploy do `gti-scheduling-mcp` no servidor do n8n
|
||||
|
||||
O `node dist/index.js` executado pela credencial precisa estar no **mesmo
|
||||
host** onde o n8n roda (o node comunitário spawna o processo localmente).
|
||||
Processo usado (sem repositório git configurado ainda neste projeto):
|
||||
`tar` local (excluindo `node_modules`/`dist`) → `scp` pro servidor → `npm
|
||||
install && npm run build` lá. Publicado em `/opt/gti-scheduling-mcp`.
|
||||
|
||||
## 4. Modelo de IA (OpenAI Chat Model node) — gotchas de configuração
|
||||
|
||||
Mesmo usando um provider "compatível com OpenAI" (ex.: Z.ai/GLM), o node
|
||||
`OpenAI Chat Model` (`@n8n/n8n-nodes-langchain.lmChatOpenAi`, v1.3) tem
|
||||
comportamento por padrão que quebra com endpoints não-OpenAI:
|
||||
|
||||
- **`responsesApiEnabled` é `true` por padrão** nessa versão — usa a
|
||||
"Responses API" da OpenAI (`/responses`), um endpoint proprietário que
|
||||
providers OpenAI-compatíveis geralmente não implementam (eles só têm
|
||||
`/chat/completions`). Sintoma: `404 Not Found` /
|
||||
`MODEL_NOT_FOUND`. **Fix**: setar `responsesApiEnabled: false`
|
||||
explicitamente.
|
||||
- **Campo `model`** é um `resourceLocator` — precisa de `{mode: "id", value:
|
||||
"nome-do-modelo"}` explícito. Omitir `mode` pode fazer o n8n tentar listar
|
||||
modelos via uma chamada de API (`GET /models`) que o provider alternativo
|
||||
pode não suportar do mesmo jeito, gerando erro parecido com o anterior.
|
||||
- **Base URL da credencial**: o campo correto na credencial `openAiApi` é
|
||||
`url` (aparece como "Base URL" na UI, geralmente atrás de "Add Option").
|
||||
|
||||
## 5. Expressões em sub-nodes de IA (Memory/Tools) dentro do AI Agent
|
||||
|
||||
Dentro de nodes conectados via `ai_memory`/`ai_tool`/`ai_languageModel`
|
||||
(sub-nodes do AI Agent), referenciar um node upstream explicitamente
|
||||
(`={{ $('Normalizar entrada').item.json.sessionId }}`) falhou com "Key
|
||||
parameter is empty" — mesmo com o dado presente no item que chegou no AI
|
||||
Agent. **Fix**: usar `={{ $json.sessionId }}` (referência ao item atual, sem
|
||||
nome de node) — é o padrão que o próprio n8n usa por padrão nesse campo, e
|
||||
funciona porque sub-nodes de IA herdam o item do node pai (AI Agent) na
|
||||
execução.
|
||||
|
||||
## 6. Contrato do webhook do Z-PRO/GChat (verificado, não documentado
|
||||
oficialmente em lugar nenhum)
|
||||
|
||||
- Uma execução do workflow **por mensagem** do cliente — o Z-PRO não
|
||||
enfileira mensagens seguintes se o workflow não responder imediatamente.
|
||||
- Corpo recebido: `{step, entrada, number, contactName, protocol}` — `step`
|
||||
deve ser `"process"`; `protocol` é o identificador do **atendimento**
|
||||
(não do telefone).
|
||||
- Resposta esperada: **texto puro**, nunca JSON — node `Respond to Webhook`
|
||||
em modo `text`.
|
||||
|
||||
## 7. Z.ai/GLM como modelo temporário — limitação real de tool-calling
|
||||
|
||||
Usado como substituto temporário enquanto a chave da OpenAI não chegava.
|
||||
Dois endpoints testados:
|
||||
|
||||
- **`https://api.z.ai/api/coding/paas/v4`** ("GLM Coding Plan"): funciona
|
||||
bem para chat simples, mas é **restrito a ferramentas oficialmente
|
||||
suportadas** (Claude Code, Goose, etc. — ver
|
||||
`https://docs.z.ai/devpack/faq`). Ao anexar uma tool MCP genérica ao AI
|
||||
Agent, o modelo passou a devolver respostas **vazias silenciosamente**
|
||||
(sem erro) em qualquer mensagem, mesmo sem chamar a tool. Confirmado
|
||||
desabilitando a tool: a conversa voltou a funcionar normalmente.
|
||||
- **`https://api.z.ai/api/paas/v4/`** (endpoint geral, mesma conta): suporta
|
||||
tool-calling arbitrário segundo a documentação, mas apresentou timeout
|
||||
consistente nesta conta/plano (possivelmente saldo separado do "Coding
|
||||
Plan").
|
||||
- **Conclusão**: Z.ai/GLM não é uma substituição direta e confiável da
|
||||
OpenAI para um AI Agent com tools neste projeto. Recomendado usar a chave
|
||||
real da OpenAI (ou outro provider com suporte robusto e testado de
|
||||
function-calling) assim que disponível — só trocar a credencial e o
|
||||
`model`/`responsesApiEnabled` de volta, sem tocar no resto do workflow.
|
||||
|
||||
## 8. Tratamento de erro adicionado
|
||||
|
||||
`Normalizar entrada` e `AI Agent` têm `onError: "continueErrorOutput"`,
|
||||
ambos roteados para um node `Set` ("Erro - Resposta padrão") com uma
|
||||
mensagem fixa amigável, que alimenta o `Respond to Webhook`. Isso garante
|
||||
que o Z-PRO **sempre** recebe alguma resposta, mesmo se a normalização
|
||||
lançar uma exceção ou o AI Agent falhar (timeout, erro de modelo, etc.) —
|
||||
testado com um timeout real do endpoint geral do Z.ai, confirmado
|
||||
funcionando.
|
||||
|
||||
## 9. Bug crítico — `toolParameters` do node MCP nunca era preenchido pelo modelo
|
||||
|
||||
**Sintoma**: toda chamada de `agendar` falhava com
|
||||
`MCP error -32602: Input validation error: Invalid arguments for tool
|
||||
agendar: Required at clinicId, Required at startAt, Required at endAt,
|
||||
Required at patient.` — mesmo com o prompt do sistema explicando o formato
|
||||
esperado em detalhe.
|
||||
|
||||
**Causa raiz**: o node `n8n-nodes-mcp` (operação `executeTool`) tem um
|
||||
campo próprio **`toolParameters`** (JSON, obrigatório, valor padrão
|
||||
`'{}'`) que carrega os argumentos reais enviados à tool. Ele **não é
|
||||
preenchido automaticamente** só por a tool estar conectada a um AI Agent —
|
||||
é preciso configurá-lo explicitamente com o helper `$fromAI()` do n8n, que
|
||||
é o mecanismo real usado para "deixar o modelo decidir este valor em cada
|
||||
chamada". Sem isso, o campo fica travado no `{}` padrão, e nenhum valor
|
||||
que o modelo "pensa" que está enviando chega de fato ao MCP.
|
||||
|
||||
**Fix**: configurar `toolParameters` assim (mistura valores fixos vindos
|
||||
do próprio workflow com `$fromAI()` só para o que o modelo precisa mesmo
|
||||
decidir):
|
||||
|
||||
```
|
||||
={{ JSON.stringify({
|
||||
clinicId: $('Normalizar entrada').item.json.clinicId,
|
||||
startAt: $fromAI('startAt', 'Data e hora de início, ISO 8601 completo', 'string'),
|
||||
endAt: $fromAI('endAt', 'Data e hora de término, ISO 8601 completo', 'string'),
|
||||
patient: {
|
||||
name: $fromAI('patientName', 'Nome completo confirmado do paciente', 'string'),
|
||||
phone: $('Normalizar entrada').item.json.telefone
|
||||
}
|
||||
}) }}
|
||||
```
|
||||
|
||||
## 10. Bug crítico — `systemMessage` do AI Agent nunca era avaliado como expressão
|
||||
|
||||
**Sintoma**: um agendamento de teste foi criado com o campo de telefone
|
||||
contendo literalmente o texto `{{ $('Normalizar entrada').item.json.telefone
|
||||
}}` em vez de um número de telefone real.
|
||||
|
||||
**Causa raiz**: o campo `parameters.options.systemMessage` do node AI Agent
|
||||
só é avaliado como expressão do n8n se o **valor inteiro do campo** começar
|
||||
com `=`. Sem esse prefixo, tudo — inclusive trechos `{{ ... }}` escritos
|
||||
"como exemplo" dentro do texto — é enviado ao modelo como **texto literal**,
|
||||
e o modelo, sem saber que aquilo deveria virar um valor real, às vezes
|
||||
copia o placeholder ao pé da letra.
|
||||
|
||||
**Fix**: sempre prefixar o campo inteiro com `=` quando ele precisar
|
||||
referenciar dados reais do workflow (`={{ $('Normalizar entrada')... }}`).
|
||||
|
||||
**Consequência que isso causou (transparência total)**: dois agendamentos
|
||||
de teste fictícios ficaram duplicados no Z-PRO (`Consulta - TESTE-N8N
|
||||
Paciente Final`, mesma data/hora) antes desse bug ser identificado — uma
|
||||
chamada que o agente reportou como "erro" na verdade teve sucesso
|
||||
silenciosamente no lado do Z-PRO. Os testes fictícios foram apagados
|
||||
manualmente depois. Isso reforça por que a disciplina de "uma execução por
|
||||
vez, conferir visualmente antes de tentar de novo" existe em todos os
|
||||
testes reais deste projeto — mesmo seguindo ela, um bug de configuração
|
||||
ainda pode gerar um resultado inesperado, e só a conferência visual pega
|
||||
isso.
|
||||
|
||||
## 11. Bug — modelo não sabe a data de hoje
|
||||
|
||||
**Sintoma**: pedido de agendamento para "amanhã" resultou em uma data no
|
||||
passado (2023) sendo enviada ao Z-PRO.
|
||||
|
||||
**Causa raiz**: modelos de linguagem não têm acesso à data/hora real do
|
||||
momento da conversa — sem essa informação no prompt, o modelo "chuta" uma
|
||||
data (geralmente relacionada à data de corte do treinamento).
|
||||
|
||||
**Fix**: incluir a data/hora atual no `systemMessage` via expressão do
|
||||
próprio n8n:
|
||||
```
|
||||
A data de hoje é {{ $now.setZone('America/Sao_Paulo').toFormat('yyyy-MM-dd (cccc)') }}
|
||||
```
|
||||
|
||||
## 12. Bug — horário criado 3h adiantado/atrasado (fuso horário)
|
||||
|
||||
**Sintoma**: cliente pediu "sexta às 10h", o agendamento foi criado em produção
|
||||
às 7h — exatamente 3h a menos (o offset de America/Sao_Paulo, UTC-3).
|
||||
|
||||
**Causa raiz**: o exemplo de formato dado ao modelo usava sufixo `Z` (UTC)
|
||||
— ex. `2026-04-20T18:00:00.000Z`. O modelo tratou o horário local que o
|
||||
cliente pediu (10h) como se já fosse UTC, sem converter, e o Z-PRO (que
|
||||
aparentemente armazena/exibe em UTC) mostrou 10h UTC como 7h local.
|
||||
|
||||
**Fix**: instruir o modelo a **nunca usar `Z`/UTC** e sempre escrever o
|
||||
mesmo número de hora que o cliente pediu, com offset `-03:00` explícito
|
||||
(ex.: cliente pediu 10h → `2026-08-14T10:00:00-03:00`). Isso evita
|
||||
qualquer aritmética de fuso horário pelo modelo — ele só copia o número
|
||||
que o cliente já disse, sem calcular nada. Ajustado tanto na descrição do
|
||||
`$fromAI('startAt', ...)`/`$fromAI('endAt', ...)` quanto numa regra
|
||||
explícita no `systemMessage`.
|
||||
|
||||
**Se isso acontecer nas próximas clínicas**: o offset `-03:00` só está
|
||||
certo para fuso de Brasília. Se uma clínica futura estiver em outro fuso,
|
||||
ajustar o offset (e o `$now.setZone(...)` do prompt) de acordo.
|
||||
|
||||
## Status final (10/08/2026)
|
||||
|
||||
Testado de ponta a ponta com sucesso, com a chave real da OpenAI
|
||||
(`gpt-4o-mini`): telefone correto, data correta, um único agendamento por
|
||||
teste, confirmado visualmente no Z-PRO. Workflow deixado **desativado**
|
||||
propositalmente — só ativar quando o Z-PRO de uma clínica real for
|
||||
configurado para apontar pra esse webhook.
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"name": "Agendamento - GChat - Z-PRO (clínica exemplo)",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "agendamento-zpro-clinica-exemplo",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-zpro",
|
||||
"name": "Webhook Z-PRO",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [-208, -176]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "text",
|
||||
"responseBody": "={{ $json.output }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-stub",
|
||||
"name": "Respond to Webhook",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.5,
|
||||
"position": [224, 48]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"jsCode": "const body = $input.item.json.body || {};\n\n// Configuração fixa desta cópia do workflow. Ajustar por clínica ao\n// duplicar este workflow para uma nova instância.\nconst clinicConfig = {\n provider: \"zpro\",\n clinicId: \"clinica-exemplo\",\n};\n\nif (body.step !== \"process\") {\n return {\n json: {\n earlyReply: true,\n sessionId: body.protocol || \"\",\n mensagem: \"\",\n nomeContato: \"\",\n telefone: \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n };\n}\n\nreturn {\n json: {\n earlyReply: false,\n sessionId: body.protocol || \"\",\n mensagem: String(body.entrada || \"\").trim(),\n nomeContato: body.contactName || \"\",\n telefone: body.number || \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n};"
|
||||
},
|
||||
"id": "normalizar-entrada",
|
||||
"name": "Normalizar entrada",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [-16, -176],
|
||||
"onError": "continueErrorOutput"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict",
|
||||
"version": 2
|
||||
},
|
||||
"combinator": "and",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cond-early-reply",
|
||||
"leftValue": "={{ $json.earlyReply }}",
|
||||
"rightValue": true,
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "if-early-reply",
|
||||
"name": "É step=process?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.3,
|
||||
"position": [240, -336]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": { "mode": "id", "value": "gpt-4o-mini" },
|
||||
"builtInTools": {},
|
||||
"options": {}
|
||||
},
|
||||
"id": "openai-chat-model",
|
||||
"name": "OpenAI Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.3,
|
||||
"position": [512, -64],
|
||||
"credentials": {
|
||||
"openAiApi": { "id": "PRECISA_RECRIAR_NO_SERVIDOR_NOVO", "name": "GPT" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"sessionIdType": "customKey",
|
||||
"sessionKey": "={{ $json.sessionId }}",
|
||||
"contextWindowLength": 20
|
||||
},
|
||||
"id": "memoria-sessao",
|
||||
"name": "Memória da Sessão",
|
||||
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
||||
"typeVersion": 1.4,
|
||||
"position": [672, -96]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "={{ $json.mensagem }}",
|
||||
"options": {
|
||||
"systemMessage": "=Você é o assistente de agendamento da clínica, conversando por WhatsApp. A data de hoje é {{ $now.setZone('America/Sao_Paulo').toFormat('yyyy-MM-dd (cccc)') }}, fuso America/Sao_Paulo — use isso pra calcular qualquer data relativa (\"amanhã\", \"sexta que vem\", etc.), nunca assuma outro ano. clinicId e telefone do paciente já são preenchidos automaticamente pelo sistema — você só precisa decidir data/hora e confirmar o nome completo.\n\nRegras:\n1. O nome de contato do WhatsApp é \"{{ $('Normalizar entrada').item.json.nomeContato }}\" e o telefone é \"{{ $('Normalizar entrada').item.json.telefone }}\" — mas SEMPRE confirme o nome completo do paciente com o cliente antes de agendar, mesmo que já tenha o nome de contato.\n2. Pergunte a data e o horário desejados para a consulta, e opcionalmente o motivo. O horário que o cliente disser (ex.: \"10h\") é sempre horário local de Brasília (America/Sao_Paulo, UTC-3) — use esse mesmo número de hora ao montar startAt/endAt, com offset -03:00 (nunca converta pra UTC/Z, nunca some ou subtraia horas).\n3. Nunca invente informação que o cliente não deu.\n4. Responda sempre em português, em tom cordial e objetivo, adequado para WhatsApp (mensagens curtas).\n5. Quando tiver o nome completo confirmado e a data/hora desejada, você pode agendar de verdade.\n6. NUNCA agende mais de uma vez na mesma conversa — se o cliente já confirmou um agendamento nesta conversa, não agende de novo; informe que o agendamento já foi feito.\n7. Se o agendamento falhar, explique o problema ao cliente em linguagem simples e pergunte se ele quer tentar de novo com outros dados, ou avise que vai encaminhar para um atendente."
|
||||
}
|
||||
},
|
||||
"id": "agente-agendamento",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"position": [560, -320],
|
||||
"onError": "continueErrorOutput"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeTool",
|
||||
"toolName": "agendar",
|
||||
"toolParameters": "={{ JSON.stringify({ clinicId: $('Normalizar entrada').item.json.clinicId, startAt: $fromAI('startAt', 'Data e hora de inicio da consulta, no horario local pedido pelo cliente, em ISO 8601 com offset -03:00 (America/Sao_Paulo) -- NUNCA use Z/UTC. Exemplo: cliente pediu 10h -> 2026-08-14T10:00:00-03:00', 'string'), endAt: $fromAI('endAt', 'Data e hora de termino da consulta, mesmo formato do startAt (offset -03:00, nunca Z/UTC), geralmente 30 minutos apos o startAt', 'string'), patient: { name: $fromAI('patientName', 'Nome completo do paciente, ja confirmado com o cliente na conversa', 'string'), phone: $('Normalizar entrada').item.json.telefone } }) }}"
|
||||
},
|
||||
"id": "mcp-tool-agendar",
|
||||
"name": "MCP - gti-scheduling-mcp",
|
||||
"type": "n8n-nodes-mcp.mcpClientTool",
|
||||
"typeVersion": 1,
|
||||
"position": [1024, -128],
|
||||
"credentials": {
|
||||
"mcpClientApi": { "id": "PRECISA_RECRIAR_NO_SERVIDOR_NOVO", "name": "Client (STDIO) account" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "erro-output",
|
||||
"name": "output",
|
||||
"type": "string",
|
||||
"value": "Desculpe, tive um problema técnico agora e não consegui continuar. Pode tentar de novo em alguns minutos, ou aguardar que alguém da nossa equipe vai te atender."
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "erro-resposta-padrao",
|
||||
"name": "Erro - Resposta padrão",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.5,
|
||||
"position": [240, -176]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook Z-PRO": {
|
||||
"main": [[{ "node": "Normalizar entrada", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Normalizar entrada": {
|
||||
"main": [
|
||||
[{ "node": "É step=process?", "type": "main", "index": 0 }],
|
||||
[{ "node": "Erro - Resposta padrão", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"OpenAI Chat Model": {
|
||||
"ai_languageModel": [[{ "node": "AI Agent", "type": "ai_languageModel", "index": 0 }]]
|
||||
},
|
||||
"Memória da Sessão": {
|
||||
"ai_memory": [[{ "node": "AI Agent", "type": "ai_memory", "index": 0 }]]
|
||||
},
|
||||
"É step=process?": {
|
||||
"main": [
|
||||
[],
|
||||
[{ "node": "AI Agent", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"AI Agent": {
|
||||
"main": [
|
||||
[{ "node": "Respond to Webhook", "type": "main", "index": 0 }],
|
||||
[{ "node": "Erro - Resposta padrão", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"MCP - gti-scheduling-mcp": {
|
||||
"ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]]
|
||||
},
|
||||
"Erro - Resposta padrão": {
|
||||
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
# Workflow n8n de Agendamento (GChat/Z-PRO → gti-scheduling-mcp) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Construir, no n8n (`https://devn8n.gtigrupo.com.br`), o primeiro workflow que recebe mensagens de clientes via webhook do Z-PRO/GChat, conduz a conversa com um AI Agent para capturar os dados do agendamento, e chama a tool `agendar` do `gti-scheduling-mcp` (via `ZProAdapter`) para realizar o agendamento de fato.
|
||||
|
||||
**Architecture:** Webhook (Z-PRO) → node `Code` de normalização/guarda de `step` → `AI Agent` (OpenAI + memória de sessão por `protocol` + tool MCP `agendar`) → `Respond to Webhook` (texto puro). Construído incrementalmente: cada task adiciona uma camada e é validada isoladamente antes da próxima.
|
||||
|
||||
**Tech Stack:** n8n (self-hosted, `devn8n.gtigrupo.com.br`), node `n8n-nodes-mcp` (community, transporte stdio), `gti-scheduling-mcp` (já implementado, sem alterações de código nesta etapa), OpenAI Chat Model.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Nenhuma alteração de código/arquitetura no `gti-scheduling-mcp` é necessária ou permitida nesta etapa — ele já expõe a tool `agendar` exatamente como o workflow precisa consumir.
|
||||
- O webhook precisa **sempre responder de imediato**, uma execução por mensagem — nunca segurar a resposta esperando mais mensagens (Z-PRO descarta mensagens se isso acontecer).
|
||||
- A resposta ao Z-PRO é **sempre texto puro**, nunca JSON.
|
||||
- Nenhuma credencial (token do Z-PRO, API key da OpenAI) é inserida por quem executa este plano de forma automática — todas vêm de Credentials do n8n, criadas manualmente pelo usuário na UI.
|
||||
- `sessionId` da memória do agente = `protocol` do payload do Z-PRO (nunca o telefone — o mesmo telefone pode ter vários atendimentos/protocolos).
|
||||
- Escopo: só `agendar()` via `ZProAdapter`. Nada de `consultar_horarios`, cancelamento, remarcação ou múltiplas clínicas nesta etapa (ver spec, seção "Fora de escopo").
|
||||
|
||||
Spec de referência: `docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Pré-requisitos (ação manual do usuário — fora do escopo de execução automática)
|
||||
|
||||
Estes itens **não podem ser feitos pelas tasks abaixo** (exigem acesso à infraestrutura do n8n/credenciais reais) e precisam estar prontos antes da Task 5:
|
||||
|
||||
1. **Instalar o node comunitário `n8n-nodes-mcp`** no n8n de vocês, seguindo
|
||||
`https://docs.n8n.io/integrations/community-nodes/installation/`.
|
||||
2. **Definir a variável de ambiente `N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true`**
|
||||
no processo do n8n (ex.: no `docker-compose.yml` do serviço n8n) e reiniciar o n8n — sem isso, o node comunitário não pode ser usado como tool dentro do AI Agent.
|
||||
3. **Criar uma Credential do tipo OpenAI** no n8n (Settings → Credentials → OpenAI API), com uma API key válida.
|
||||
4. **Criar uma Credential do tipo "MCP Client (Command Line) API"** (fornecida pelo node `n8n-nodes-mcp` após instalado), com:
|
||||
- **Command**: `node`
|
||||
- **Arguments**: caminho absoluto para `dist/index.js` do `gti-scheduling-mcp` no servidor onde o n8n roda (ex.: `/opt/gti-scheduling-mcp/dist/index.js`) — depende de onde o projeto for implantado, que é uma decisão de infraestrutura fora do escopo deste plano.
|
||||
- **Environment Variables** (uma por linha, formato `NOME=valor`):
|
||||
```
|
||||
SCHEDULING_PROVIDER=zpro
|
||||
ZPRO_BASE_URL=<url real da clínica>
|
||||
ZPRO_API_TOKEN=<token real da clínica>
|
||||
ZPRO_API_ID=<ApiID real da clínica — o GUID, não o número do registro>
|
||||
```
|
||||
|
||||
Sem os 4 itens acima, a Task 5 (conectar a tool MCP ao Agent) não pode ser validada de ponta a ponta — as Tasks 1 a 4 não dependem deles.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Build do `gti-scheduling-mcp` e localização do `dist/index.js`
|
||||
|
||||
**Files:**
|
||||
- Nenhum arquivo novo — apenas build do projeto existente.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: caminho absoluto de `dist/index.js`, usado nas Tasks 5 e no pré-requisito 4 acima.
|
||||
|
||||
- [ ] **Step 1: Rodar o build**
|
||||
|
||||
```bash
|
||||
cd "D:\PROJETOS\KLINGO-CLINICAL-SYSLIFE-GCHAT-AGENDAMENTO\gti-scheduling-mcp"
|
||||
npm run build
|
||||
```
|
||||
|
||||
Esperado: `tsc -p tsconfig.json` sem erros, e a pasta `dist/` criada/atualizada.
|
||||
|
||||
- [ ] **Step 2: Confirmar que `dist/index.js` existe e roda**
|
||||
|
||||
```bash
|
||||
node "D:\PROJETOS\KLINGO-CLINICAL-SYSLIFE-GCHAT-AGENDAMENTO\gti-scheduling-mcp\dist\index.js" --help 2>&1 | head -5
|
||||
```
|
||||
|
||||
Esperado: o processo inicia (mesmo que não reconheça `--help`, não deve dar erro de módulo não encontrado). Se o servidor esperar variáveis de ambiente obrigatórias e falhar por causa disso, isso confirma que o build está correto (o processo rodou, só não tinha config — comportamento esperado sem env vars).
|
||||
|
||||
Anote o caminho absoluto de `dist/index.js` — será necessário nos pré-requisitos e na Task 5.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Esqueleto base do webhook (Webhook → Respond to Webhook)
|
||||
|
||||
**Files:** Nenhum arquivo local — cria o workflow no n8n via `n8n_create_workflow`.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `workflowId` (retornado pela chamada) — usado em todas as tasks seguintes.
|
||||
|
||||
- [ ] **Step 1: Criar o workflow com dois nodes ligados diretamente**
|
||||
|
||||
Chame a tool `n8n_create_workflow` com:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Agendamento - GChat - Z-PRO (clínica exemplo)",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-zpro",
|
||||
"name": "Webhook Z-PRO",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2.1,
|
||||
"position": [0, 0],
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "agendamento-zpro-clinica-exemplo",
|
||||
"responseMode": "responseNode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "respond-stub",
|
||||
"name": "Respond to Webhook",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.5,
|
||||
"position": [300, 0],
|
||||
"parameters": {
|
||||
"respondWith": "text",
|
||||
"responseBody": "teste ok"
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook Z-PRO": {
|
||||
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Guarde o `id` do workflow retornado — chamado de `<workflowId>` no restante deste plano.
|
||||
|
||||
- [ ] **Step 2: Validar o workflow**
|
||||
|
||||
Chame `n8n_validate_workflow` com `{"id": "<workflowId>"}`. Esperado: sem erros (pode haver avisos sobre o workflow estar inativo — normal, só ativamos na Task 6).
|
||||
|
||||
- [ ] **Step 3: Ativar temporariamente e testar a resposta em texto puro**
|
||||
|
||||
Ative o workflow (`n8n_update_partial_workflow` com operação `{"type": "activateWorkflow"}`), pegue a URL de produção do webhook (visível no node Webhook Z-PRO / na resposta da criação), e chame:
|
||||
|
||||
```bash
|
||||
curl -i -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"step":"process","entrada":"oi","number":"5511999999999","contactName":"Teste","protocol":"protocolo-teste-1"}'
|
||||
```
|
||||
|
||||
Esperado: HTTP 200, corpo **exatamente** `teste ok` (texto puro, sem aspas de JSON ao redor).
|
||||
|
||||
- [ ] **Step 4: Desativar o workflow de novo**
|
||||
|
||||
`n8n_update_partial_workflow` com `{"type": "deactivateWorkflow"}` — mantemos desativado até a Task 6, para não responder de verdade a mensagens reais enquanto ainda está incompleto.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Node de normalização + guarda de `step`
|
||||
|
||||
**Files:** Nenhum arquivo local — edita o workflow `<workflowId>` via `n8n_update_partial_workflow`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `<workflowId>` da Task 2.
|
||||
- Produces: campos `sessionId`, `mensagem`, `nomeContato`, `telefone`, `provider`, `clinicId`, `earlyReply` — consumidos pela Task 4 (AI Agent) e pelo `If` desta task.
|
||||
|
||||
- [ ] **Step 1: Adicionar o node `Code` de normalização**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "normalizar-entrada",
|
||||
"name": "Normalizar entrada",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [150, -120],
|
||||
"parameters": {
|
||||
"mode": "runOnceForEachItem",
|
||||
"language": "javaScript",
|
||||
"jsCode": "const body = $input.item.json.body || {};\n\n// Configuração fixa desta cópia do workflow. Ajustar por clínica ao\n// duplicar este workflow para uma nova instância.\nconst clinicConfig = {\n provider: \"zpro\",\n clinicId: \"clinica-exemplo\",\n};\n\nif (body.step !== \"process\") {\n return {\n json: {\n earlyReply: true,\n sessionId: body.protocol || \"\",\n mensagem: \"\",\n nomeContato: \"\",\n telefone: \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n };\n}\n\nreturn {\n json: {\n earlyReply: false,\n sessionId: body.protocol || \"\",\n mensagem: String(body.entrada || \"\").trim(),\n nomeContato: body.contactName || \"\",\n telefone: body.number || \"\",\n provider: clinicConfig.provider,\n clinicId: clinicConfig.clinicId,\n },\n};"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Adicionar o node `If` que decide entre resposta antecipada e fluxo normal**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "if-early-reply",
|
||||
"name": "É step=process?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.3,
|
||||
"position": [400, -120],
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
|
||||
"combinator": "and",
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cond-early-reply",
|
||||
"leftValue": "={{ $json.earlyReply }}",
|
||||
"rightValue": true,
|
||||
"operator": { "type": "boolean", "operation": "true", "singleValue": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rewire — Webhook → Normalizar entrada → If**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "type": "removeConnection", "source": "Webhook Z-PRO", "target": "Respond to Webhook" },
|
||||
{ "type": "addConnection", "source": "Webhook Z-PRO", "target": "Normalizar entrada" },
|
||||
{ "type": "addConnection", "source": "Normalizar entrada", "target": "É step=process?" }
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ligar a saída TRUE do If (índice 0) direto ao Respond to Webhook existente, com texto vazio**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "type": "patchNodeField", "nodeName": "Respond to Webhook", "fieldPath": "parameters.responseBody", "patches": [{ "find": "teste ok", "replace": "={{ $json.mensagem || '' }}" }] },
|
||||
{ "type": "addConnection", "source": "É step=process?", "sourceOutput": 0, "target": "Respond to Webhook" }
|
||||
]
|
||||
```
|
||||
|
||||
A saída FALSE (índice 1) do `If` fica sem conexão por enquanto — será ligada ao AI Agent na Task 4.
|
||||
|
||||
- [ ] **Step 5: Validar**
|
||||
|
||||
`n8n_validate_workflow` com `{"id": "<workflowId>"}`. Esperado: sem erros. Pode haver aviso de node com saída não conectada (`É step=process?` saída FALSE) — esperado nesta task, resolvido na Task 4.
|
||||
|
||||
- [ ] **Step 6: Testar os dois caminhos**
|
||||
|
||||
Ative o workflow, chame o webhook duas vezes:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"step":"outro","entrada":"x","number":"1","contactName":"y","protocol":"p1"}'
|
||||
```
|
||||
|
||||
Esperado: corpo vazio (early reply, `step` diferente de `process`).
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"step":"process","entrada":"Olá, quero marcar uma consulta","number":"5511999999999","contactName":"Fulano","protocol":"p2"}'
|
||||
```
|
||||
|
||||
Esperado: corpo = `Olá, quero marcar uma consulta` (o `Respond to Webhook` está ecoando `mensagem` só para confirmar que os campos normalizados chegaram certos — será substituído pela resposta do Agent na Task 4).
|
||||
|
||||
Desative o workflow ao final (`deactivateWorkflow`), como na Task 2.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: AI Agent com memória de sessão (sem tool ainda)
|
||||
|
||||
**Files:** Nenhum arquivo local — edita `<workflowId>`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `earlyReply` (falso), `sessionId`, `mensagem`, `nomeContato`, `telefone` da Task 3.
|
||||
- Produces: texto de resposta do Agent, consumido pelo `Respond to Webhook`.
|
||||
|
||||
- [ ] **Step 1: Adicionar o node OpenAI Chat Model**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "openai-chat-model",
|
||||
"name": "OpenAI Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1,
|
||||
"position": [650, 100],
|
||||
"parameters": {
|
||||
"model": { "value": "gpt-4o-mini" },
|
||||
"options": {}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": { "id": "<ID da Credential OpenAI criada no pré-requisito 3>", "name": "OpenAI account" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Substitua `<ID da Credential OpenAI criada no pré-requisito 3>` pelo ID real da credencial (visível na UI do n8n, em Settings → Credentials, ou via `n8n_manage_credentials` se disponível). Se preferir, este campo pode ser deixado para o usuário selecionar manualmente na UI do n8n após a criação do node — não bloqueia os steps seguintes.
|
||||
|
||||
- [ ] **Step 2: Adicionar o node Simple Memory**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "memoria-sessao",
|
||||
"name": "Memória da Sessão",
|
||||
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
||||
"typeVersion": 1.4,
|
||||
"position": [650, 250],
|
||||
"parameters": {
|
||||
"sessionIdType": "customKey",
|
||||
"sessionKey": "={{ $('Normalizar entrada').item.json.sessionId }}",
|
||||
"contextWindowLength": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Adicionar o node AI Agent**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "agente-agendamento",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"position": [650, -120],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "={{ $json.mensagem }}",
|
||||
"options": {
|
||||
"systemMessage": "Você é o assistente de agendamento da clínica, conversando por WhatsApp. Regras:\n1. O nome de contato do WhatsApp é \"{{ $('Normalizar entrada').item.json.nomeContato }}\" e o telefone é \"{{ $('Normalizar entrada').item.json.telefone }}\" — mas SEMPRE confirme o nome completo do paciente com o cliente antes de agendar, mesmo que já tenha o nome de contato.\n2. Pergunte a data e o horário desejados para a consulta, e opcionalmente o motivo.\n3. Nunca invente informação que o cliente não deu.\n4. Responda sempre em português, em tom cordial e objetivo, adequado para WhatsApp (mensagens curtas).\n5. Quando tiver o nome completo confirmado e a data/hora desejada, você pode agendar de verdade.\n6. NUNCA agende mais de uma vez na mesma conversa — se o cliente já confirmou um agendamento nesta conversa, não agende de novo; informe que o agendamento já foi feito.\n7. Se o agendamento falhar, explique o problema ao cliente em linguagem simples e pergunte se ele quer tentar de novo com outros dados, ou avise que vai encaminhar para um atendente."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Conectar Chat Model e Memory ao Agent, e a saída FALSE do If ao Agent**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "type": "addConnection", "source": "OpenAI Chat Model", "sourceOutput": "ai_languageModel", "target": "AI Agent", "targetInput": "ai_languageModel" },
|
||||
{ "type": "addConnection", "source": "Memória da Sessão", "sourceOutput": "ai_memory", "target": "AI Agent", "targetInput": "ai_memory" },
|
||||
{ "type": "addConnection", "source": "É step=process?", "sourceOutput": 1, "target": "AI Agent" },
|
||||
{ "type": "addConnection", "source": "AI Agent", "target": "Respond to Webhook" }
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Ajustar o Respond to Webhook para usar a saída do Agent**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "patchNodeField",
|
||||
"nodeName": "Respond to Webhook",
|
||||
"fieldPath": "parameters.responseBody",
|
||||
"patches": [{ "find": "={{ $json.mensagem || '' }}", "replace": "={{ $json.output }}" }]
|
||||
}
|
||||
```
|
||||
|
||||
(`$json.output` é o campo de texto padrão que o node AI Agent produz.)
|
||||
|
||||
- [ ] **Step 6: Validar**
|
||||
|
||||
`n8n_validate_workflow` com `{"id": "<workflowId>"}`. Esperado: sem erros. Se aparecer erro sobre a credencial OpenAI ausente/inválida, ajuste o `credentials.openAiApi.id` (Step 1) ou peça para o usuário selecionar a credencial manualmente na UI antes de prosseguir.
|
||||
|
||||
- [ ] **Step 7: Testar a memória de sessão**
|
||||
|
||||
Ative o workflow. Envie duas mensagens com o **mesmo** `protocol`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"step":"process","entrada":"Meu nome completo é João da Silva Teste","number":"5511999999999","contactName":"João","protocol":"p-memoria-1"}'
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"step":"process","entrada":"Qual é o meu nome completo que eu te falei?","number":"5511999999999","contactName":"João","protocol":"p-memoria-1"}'
|
||||
```
|
||||
|
||||
Esperado: a segunda resposta menciona "João da Silva Teste" — confirma que a memória por `sessionId=protocol` está funcionando entre execuções separadas do webhook.
|
||||
|
||||
Desative o workflow ao final.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Conectar a tool MCP `agendar` ao Agent
|
||||
|
||||
**Pré-requisito:** os 4 itens da seção "Pré-requisitos" no topo deste plano precisam estar prontos (node comunitário instalado, env var setada e n8n reiniciado, credenciais OpenAI e MCP criadas).
|
||||
|
||||
**Files:** Nenhum arquivo local — edita `<workflowId>`.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: credencial MCP stdio criada no pré-requisito 4.
|
||||
- Produces: capacidade do Agent de chamar `agendar` de verdade.
|
||||
|
||||
- [ ] **Step 1: Adicionar o node MCP Client Tool**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "mcp-tool-agendar",
|
||||
"name": "MCP - gti-scheduling-mcp",
|
||||
"type": "n8n-nodes-mcp.mcpClientTool",
|
||||
"typeVersion": 1,
|
||||
"position": [900, 250],
|
||||
"parameters": {
|
||||
"connectionType": "cmd",
|
||||
"include": "selected",
|
||||
"includeTools": ["agendar"]
|
||||
},
|
||||
"credentials": {
|
||||
"mcpClientCmdApi": { "id": "<ID da Credential MCP stdio criada no pré-requisito 4>", "name": "gti-scheduling-mcp (stdio)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Os nomes exatos do tipo de credencial (`mcpClientCmdApi`) e da propriedade `connectionType` podem variar conforme a versão instalada do pacote `n8n-nodes-mcp` — se a validação do Step 3 apontar um nome de campo diferente, ajuste conforme o que a própria UI do n8n mostrar ao adicionar o node manualmente (Settings do node → aba Credentials).
|
||||
|
||||
- [ ] **Step 2: Conectar o node como tool do Agent**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "MCP - gti-scheduling-mcp",
|
||||
"sourceOutput": "ai_tool",
|
||||
"target": "AI Agent",
|
||||
"targetInput": "ai_tool"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Validar**
|
||||
|
||||
`n8n_validate_workflow` com `{"id": "<workflowId>", "options": {"profile": "ai-friendly"}}`. Corrija qualquer erro de nome de campo/credencial apontado (ver nota no Step 1).
|
||||
|
||||
- [ ] **Step 4: Teste manual controlado, com dados fictícios, seguindo a mesma disciplina de segurança contra duplicidade do teste do `ZProAdapter`**
|
||||
|
||||
Ative o workflow. Envie, com um `protocol` novo e único:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://devn8n.gtigrupo.com.br/webhook/agendamento-zpro-clinica-exemplo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"step":"process","entrada":"Oi, quero marcar uma consulta pra amanhã às 14h, meu nome completo é TESTE-N8N Paciente","number":"5511900000001","contactName":"Teste N8N","protocol":"p-e2e-1"}'
|
||||
```
|
||||
|
||||
Continue a conversa (repetindo a chamada com o mesmo `protocol`, mudando só `entrada`) até o Agent confirmar o agendamento. **Antes de cada nova tentativa, se algo parecer ter falhado, confira visualmente no Z-PRO se o agendamento já foi criado** (mesma regra usada no teste manual do `ZProAdapter`) — não repita mensagens de confirmação "às cegas".
|
||||
|
||||
Confirme visualmente no Z-PRO que o agendamento fictício foi criado.
|
||||
|
||||
- [ ] **Step 5: Desativar o workflow ao final do teste**
|
||||
|
||||
`n8n_update_partial_workflow` com `{"type": "deactivateWorkflow"}` — só reativar definitivamente depois que o usuário revisar o resultado (Task 6).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Revisão final e ativação
|
||||
|
||||
**Files:** Nenhum arquivo local.
|
||||
|
||||
- [ ] **Step 1: Validação final completa**
|
||||
|
||||
`n8n_validate_workflow` com `{"id": "<workflowId>", "options": {"profile": "strict"}}`. Resolver qualquer erro/aviso restante.
|
||||
|
||||
- [ ] **Step 2: Apresentar o resumo ao usuário**
|
||||
|
||||
Antes de ativar definitivamente, mostrar ao usuário: URL do webhook de produção, nome do workflow, e pedir confirmação de que o Z-PRO já está configurado para apontar o fluxo de agendamento desta clínica para essa URL.
|
||||
|
||||
- [ ] **Step 3: Ativar**
|
||||
|
||||
Só ativar (`activateWorkflow`) depois da confirmação explícita do usuário no Step 2 — ativar aqui significa que mensagens reais de clientes passam a ser processadas.
|
||||
|
||||
---
|
||||
|
||||
## Observação sobre "commit"
|
||||
|
||||
Nem `D:\PROJETOS\KLINGO-CLINICAL-SYSLIFE-GCHAT-AGENDAMENTO` nem `gti-scheduling-mcp` são repositórios git — não há passo de `git commit` neste plano. O estado do workflow vive inteiramente no n8n; cada task é validada via `n8n_validate_workflow` e teste funcional (chamada real ao webhook), no lugar de testes automatizados de código.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Design — Workflow n8n de Agendamento via GChat/Z-PRO + gti-scheduling-mcp
|
||||
|
||||
**Data:** 2026-08-10
|
||||
**Status:** Aprovado pelo usuário, aguardando plano de implementação.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Construir o primeiro workflow n8n que recebe mensagens de clientes via
|
||||
GChat (webhook do Z-PRO), conduz a conversa com um agente de IA para
|
||||
capturar os dados necessários e realiza o agendamento de fato, chamando a
|
||||
tool `agendar` do `gti-scheduling-mcp` (que por sua vez delega ao Adapter
|
||||
do sistema da clínica — Z-PRO neste MVP).
|
||||
|
||||
Escopo do MVP: **só agendamento** (`agendar`). Consulta de disponibilidade,
|
||||
cancelamento e remarcação ficam fora desta etapa.
|
||||
|
||||
## Contexto verificado (não inventado)
|
||||
|
||||
Estes fatos vêm de um workflow real já em produção no n8n de referência
|
||||
("Enforcer - GLPI"/fluxo de suporte via Z-PRO, código do node `1 - Decide`
|
||||
compartilhado pelo usuário) e de consultas diretas ao n8n
|
||||
(`devn8n.gtigrupo.com.br`) e ao registro de nodes via `n8n-mcp`:
|
||||
|
||||
- O Z-PRO chama o webhook do n8n **uma vez por mensagem do cliente** — não
|
||||
agrupa/enfileira mensagens.
|
||||
- O corpo da requisição (`req = $json.body`) tem os campos: `step`
|
||||
(esperado `"process"`), `entrada` (texto da mensagem do cliente),
|
||||
`number` (telefone), `contactName`, `protocol` (identificador único do
|
||||
**atendimento**, não do telefone — o mesmo cliente pode ter vários
|
||||
atendimentos/protocolos ao longo do tempo).
|
||||
- **Se o workflow segurar a resposta** (esperando mais mensagens antes de
|
||||
responder), o Z-PRO **descarta** as mensagens seguintes do cliente
|
||||
(confirmado em teste real: 5 mensagens em sequência → 1 execução no
|
||||
n8n). O workflow precisa responder a cada execução, sempre.
|
||||
- A resposta ao Z-PRO é **texto puro** (não JSON), via node `Respond to
|
||||
Webhook` em modo texto.
|
||||
- O node oficial `MCP Client Tool` (`@n8n/n8n-nodes-langchain`) só suporta
|
||||
transporte HTTP Streamable/SSE — **não** suporta rodar um comando local
|
||||
via stdio.
|
||||
- O node comunitário `n8n-nodes-mcp` (`mcpClient`/`mcpClientTool`) suporta
|
||||
conexão via linha de comando (stdio) — é o caminho escolhido para não
|
||||
precisar alterar o transporte do `gti-scheduling-mcp` (que hoje só
|
||||
implementa `StdioServerTransport`).
|
||||
- O `gti-scheduling-mcp` (`ZProAdapter`/`KlingoAdapter`) já está pronto e
|
||||
testado para `agendar()` — nenhuma mudança de arquitetura nele é
|
||||
necessária para este workflow.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
```
|
||||
[Cliente WhatsApp]
|
||||
│
|
||||
▼
|
||||
[Z-PRO/GChat] ──webhook──▶ [n8n: Webhook] → [Normalizar entrada] → [AI Agent] → [Respond to Webhook]
|
||||
│ ▲
|
||||
(tool call) │ │ (memória de sessão)
|
||||
▼ │
|
||||
[MCP Client Tool] [Simple Memory]
|
||||
(stdio, agendar) (sessionId = protocol)
|
||||
│
|
||||
▼
|
||||
[gti-scheduling-mcp: tool "agendar"]
|
||||
│
|
||||
▼
|
||||
[AdapterFactory → ZProAdapter/KlingoAdapter]
|
||||
│
|
||||
▼
|
||||
[Sistema real da clínica: Z-PRO/Klingo]
|
||||
```
|
||||
|
||||
O `gti-scheduling-mcp` não sofre nenhuma alteração de arquitetura para
|
||||
este workflow — o n8n é só mais um cliente MCP, como qualquer outro.
|
||||
|
||||
## Componentes (nós do n8n)
|
||||
|
||||
1. **Webhook (Z-PRO)** — `POST`, recebe `{step, entrada, number,
|
||||
contactName, protocol}`.
|
||||
2. **Normalizar entrada** (`Set`/`Code`) —
|
||||
- Valida `step === "process"`; caso contrário, responde com um texto
|
||||
mínimo e encerra (mesma defesa do fluxo de referência).
|
||||
- Monta as variáveis usadas a seguir: `sessionId = protocol`,
|
||||
`mensagem = entrada`, `nomeContato = contactName`, `telefone =
|
||||
number`.
|
||||
- **Nó de configuração da clínica** (fixo por cópia do workflow, não
|
||||
dinâmico em runtime): `provider` (ex.: `"zpro"`), `clinicId`, e
|
||||
referência de qual Credential do n8n / config do MCP Client Tool
|
||||
usar para esta clínica. Cada clínica nova = uma cópia deste workflow
|
||||
com esses valores ajustados e seu próprio webhook cadastrado no
|
||||
Z-PRO — não há branching dinâmico de provider dentro de uma mesma
|
||||
execução.
|
||||
3. **AI Agent** (`@n8n/n8n-nodes-langchain.agent`) —
|
||||
- Chat Model: OpenAI (credencial a configurar no n8n).
|
||||
- System prompt cobre: confirmar o nome completo do paciente (mesmo já
|
||||
tendo `contactName`), perguntar data/horário desejado, motivo
|
||||
(opcional), nunca chamar `agendar` mais de uma vez por atendimento,
|
||||
responder sempre em português, tom cordial, nunca inventar dado que
|
||||
o cliente não informou.
|
||||
- **Memória**: `Simple Memory` (`memoryBufferWindow`), `sessionId =
|
||||
protocol`. Trade-off aceito para o MVP: essa memória vive na RAM do
|
||||
processo do n8n — um restart do n8n no meio de um atendimento perde
|
||||
o histórico da conversa. Evolução futura, se necessário: Postgres ou
|
||||
Redis Chat Memory (durável, mas exige infraestrutura adicional).
|
||||
- **Tool**: `MCP Client Tool` (via `n8n-nodes-mcp`, transporte stdio),
|
||||
expondo a tool `agendar` do `gti-scheduling-mcp` (comando: `node
|
||||
dist/index.js`, variáveis de ambiente = credenciais da clínica,
|
||||
vindas de uma Credential do n8n — nunca hardcoded no node/JSON do
|
||||
workflow).
|
||||
4. **Respond to Webhook** — modo texto simples; devolve só a resposta em
|
||||
linguagem natural produzida pelo AI Agent.
|
||||
|
||||
## Tratamento de erro
|
||||
|
||||
Quando `agendar` falha (`VALIDATION_ERROR`, `AUTHENTICATION_ERROR`,
|
||||
`NOT_FOUND`, `ZPRO_API_ERROR`/`KLINGO_API_ERROR`, timeout, etc.), o MCP já
|
||||
devolve uma resposta estruturada (`success:false` + código/mensagem) — o
|
||||
AI Agent recebe isso como resultado da tool e decide, em linguagem
|
||||
natural, como comunicar isso ao cliente (pedir os dados de novo, avisar
|
||||
que vai encaminhar para um atendente humano, etc.), dentro do que o
|
||||
system prompt permitir. Não há tratamento hard-coded por código de erro
|
||||
neste workflow — isso fica deliberadamente a cargo do agente, para manter
|
||||
o MVP simples.
|
||||
|
||||
## Risco de agendamento duplicado (mitigação no nível do workflow)
|
||||
|
||||
O `ZProAdapter`/`KlingoAdapter` já trata qualquer HTTP 2xx do provider
|
||||
como sucesso operacional (sem risco de duplicidade por reinterpretação de
|
||||
resposta ambígua — ver `docs/zpro-api-mapping.md`). O risco adicional
|
||||
neste workflow é de outra natureza: o **próprio agente** decidir chamar a
|
||||
tool `agendar` mais de uma vez na mesma conversa (ex.: o cliente confirma
|
||||
duas vezes, ou o agente se confunde). Mitigação adotada para o MVP:
|
||||
instrução explícita no system prompt para nunca chamar `agendar` mais de
|
||||
uma vez por atendimento (`protocol`). Isto é uma mitigação por prompt, não
|
||||
uma garantia determinística — se isso se mostrar insuficiente em uso real,
|
||||
a evolução natural é adicionar uma marcação de "já agendado" no estado da
|
||||
sessão, verificada por código antes de permitir uma nova chamada da tool.
|
||||
|
||||
## Fora de escopo (explicitamente adiado)
|
||||
|
||||
- Consulta de disponibilidade (`consultar_horarios`) — não implementada
|
||||
para nenhum provider neste MVP (Z-PRO não suporta; Klingo tem lacuna de
|
||||
mapeamento de resposta).
|
||||
- Cancelamento/remarcação de agendamento.
|
||||
- Transferência automática de fila/atendimento humano no Z-PRO após o
|
||||
agendamento (padrão existente no fluxo de referência via
|
||||
`updateticketinfo`) — pode ser adicionado depois, não é necessário para
|
||||
o MVP funcionar.
|
||||
- Suporte a múltiplas clínicas com providers diferentes atendidas por uma
|
||||
única execução/webhook compartilhado — cada clínica é uma cópia
|
||||
dedicada do workflow.
|
||||
- Memória de conversa durável entre restarts do n8n (Postgres/Redis Chat
|
||||
Memory).
|
||||
|
||||
## Testes
|
||||
|
||||
Este workflow vive no n8n, fora do repositório `gti-scheduling-mcp` — não
|
||||
há testes automatizados de código aqui. A validação é funcional/manual:
|
||||
|
||||
- Testar o fluxo completo com uma conversa real (ou simulada via chamada
|
||||
direta ao webhook) contra o Z-PRO, com dados fictícios, seguindo a mesma
|
||||
disciplina de segurança contra duplicidade já usada nos testes manuais
|
||||
do `ZProAdapter` (execução única, verificação visual no Z-PRO antes de
|
||||
qualquer nova tentativa).
|
||||
- Confirmar que o `Respond to Webhook` devolve texto puro, sem envelope
|
||||
JSON, compatível com o que o Z-PRO espera exibir ao cliente.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Configuração do provider de agendamento
|
||||
# Valores suportados: "zpro" | "klingo"
|
||||
SCHEDULING_PROVIDER=zpro
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Z-PRO — exigidas somente quando SCHEDULING_PROVIDER=zpro
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# URL base da API do Z-PRO (obrigatória)
|
||||
ZPRO_BASE_URL=
|
||||
|
||||
# Token de autenticação da API do Z-PRO (obrigatória)
|
||||
ZPRO_API_TOKEN=
|
||||
|
||||
# Identificador da API (ApiID) usado nas rotas externas do Z-PRO (obrigatória)
|
||||
ZPRO_API_ID=
|
||||
|
||||
# Identificador da instância no Z-PRO (opcional, mantido por compatibilidade)
|
||||
ZPRO_INSTANCE_ID=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Klingo — exigidas somente quando SCHEDULING_PROVIDER=klingo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# URL base da API do Klingo (obrigatória)
|
||||
KLINGO_BASE_URL=
|
||||
|
||||
# Token de aplicação do Klingo, enviado no header X-APP-TOKEN (obrigatória)
|
||||
KLINGO_APP_TOKEN=
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
# gti-scheduling-mcp
|
||||
|
||||
## Objetivo do projeto
|
||||
|
||||
`gti-scheduling-mcp` é um **MCP Server** responsável **exclusivamente** por
|
||||
realizar agendamentos em sistemas de clínicas.
|
||||
|
||||
O MCP nunca conhece detalhes de nenhuma API específica. Toda a integração
|
||||
com um sistema de clínica (Z-Pro, Klingo, SysLife, ClinicAll, etc.) é
|
||||
responsabilidade exclusiva do respectivo **Adapter**, que implementa uma
|
||||
interface comum e genérica.
|
||||
|
||||
Neste MVP existem apenas duas ferramentas (tools) expostas via MCP:
|
||||
|
||||
- `consultar_horarios` — consulta horários disponíveis para agendamento.
|
||||
- `agendar` — realiza um agendamento em um horário disponível.
|
||||
|
||||
Nenhuma outra ferramenta, regra de negócio, integração HTTP, banco de
|
||||
dados ou IA é implementada nesta etapa. Este repositório representa
|
||||
apenas o esqueleto inicial do projeto.
|
||||
|
||||
## Estrutura das pastas
|
||||
|
||||
```
|
||||
src/
|
||||
adapters/
|
||||
scheduling-adapter.interface.ts # Contrato comum a todos os Adapters
|
||||
adapter-factory.ts # Fábrica que resolve o Adapter a usar
|
||||
zpro/
|
||||
zpro.adapter.ts # Adapter do sistema Z-Pro
|
||||
zpro.types.ts # Tipos internos (corpo da requisição verificado)
|
||||
zpro.mapper.ts # Mapeamento genérico -> corpo da requisição Z-PRO
|
||||
zpro-http-client.ts # Cliente HTTP (POST appointment/create)
|
||||
zpro.errors.ts # Erros tipados e classificação de status HTTP
|
||||
klingo/
|
||||
klingo.adapter.ts # Adapter do sistema Klingo
|
||||
klingo.types.ts # Tipos internos (schemas verificados no Swagger)
|
||||
klingo.mapper.ts # Mapeamento genérico <-> requisições/respostas Klingo
|
||||
klingo-http-client.ts # Cliente HTTP (identificar, horarios, reservar)
|
||||
klingo.errors.ts # Erros tipados e classificação de status HTTP
|
||||
|
||||
tools/
|
||||
consultar-horarios.tool.ts # Definição da tool "consultar_horarios"
|
||||
agendar.tool.ts # Definição da tool "agendar"
|
||||
mcp-response.ts # Helpers de resposta MCP (sucesso/erro)
|
||||
|
||||
server/
|
||||
mcp.server.ts # Criação/registro do servidor MCP
|
||||
|
||||
types/
|
||||
scheduling.types.ts # Tipos genéricos de domínio
|
||||
|
||||
config/
|
||||
env.config.ts # Leitura/validação de variáveis de ambiente
|
||||
config.types.ts # Tipos de configuração (Z-Pro e genérico)
|
||||
|
||||
diagnostics/
|
||||
sanitize.ts # Sanitização recursiva de respostas
|
||||
analyze.ts # Identificação da estrutura de respostas
|
||||
client.ts # Cliente HTTP mínimo (GET + timeout)
|
||||
response.ts # Tratamento de resposta JSON/não JSON
|
||||
|
||||
index.ts # Ponto de entrada da aplicação
|
||||
|
||||
scripts/
|
||||
diagnose-zpro-list.ts # CLI temporário de descoberta do contrato da API
|
||||
|
||||
test/
|
||||
config.test.ts # Testes da validação de configuração
|
||||
adapter-factory.test.ts # Testes da AdapterFactory
|
||||
tools.test.ts # Testes do tratamento de erro das ferramentas
|
||||
zpro-adapter.test.ts # Testes do ZProAdapter real (mocks de fetch)
|
||||
klingo-adapter.test.ts # Testes do KlingoAdapter real (mocks de fetch)
|
||||
sanitize.test.ts # Testes da sanitização recursiva
|
||||
analyze.test.ts # Testes da identificação de estrutura
|
||||
diagnose-client.test.ts # Testes do client de diagnóstico e respostas
|
||||
|
||||
docs/
|
||||
zpro-api-mapping.md # Contrato verificado da API do Z-PRO
|
||||
klingo-openapi.json # Swagger/OpenAPI público da API do Klingo
|
||||
```
|
||||
|
||||
## Fluxo do sistema
|
||||
|
||||
1. Um cliente MCP invoca uma das ferramentas registradas
|
||||
(`consultar_horarios` ou `agendar`).
|
||||
2. A ferramenta (`tools/`) recebe a chamada, mas **não** contém regra de
|
||||
negócio — sua responsabilidade é apenas definir o contrato de entrada
|
||||
e delegar a execução.
|
||||
3. A execução é delegada ao Adapter apropriado, obtido através da
|
||||
`adapter-factory.ts`, que recebe a configuração validada e resolve o
|
||||
Adapter correto para o provider configurado.
|
||||
4. O Adapter (ex.: `ZProAdapter`) é responsável por toda a comunicação
|
||||
com o sistema real da clínica, respeitando a interface
|
||||
`SchedulingAdapter`.
|
||||
5. O resultado retorna ao cliente MCP no formato genérico definido em
|
||||
`types/scheduling.types.ts`.
|
||||
|
||||
As ferramentas MCP capturam qualquer erro lançado pelo Adapter e devolvem
|
||||
uma resposta estruturada (`success: false` + código/mensagem), sem
|
||||
encerrar o servidor.
|
||||
|
||||
## Status do ZProAdapter
|
||||
|
||||
- **`consultarHorarios()`**: decisão de produto — o Z-PRO não expõe
|
||||
nenhum endpoint de disponibilidade de horários (ver
|
||||
`docs/zpro-api-mapping.md`). O método sempre lança um erro claro
|
||||
(`ZProApiError` com código `NOT_SUPPORTED`); não há solução
|
||||
alternativa implementada (não se tenta inferir disponibilidade a
|
||||
partir da lista de agendamentos existentes).
|
||||
- **`agendar()`**: a chamada HTTP real (`POST appointment/create`) é
|
||||
implementada de ponta a ponta — autenticação, URL, corpo da requisição
|
||||
e classificação de erros HTTP (401/403/404/400/422/500, timeout, erro
|
||||
de rede).
|
||||
|
||||
O **mapeamento da resposta de sucesso** foi verificado por um teste
|
||||
manual único e controlado em 09/08/2026, contra uma instância real do
|
||||
Z-PRO (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 — não
|
||||
é mais um placeholder.
|
||||
|
||||
Mesmo assim, **qualquer HTTP 2xx continua sendo tratado como sucesso
|
||||
operacional** — o adapter **não lança erro** quando o corpo foge desse
|
||||
envelope (formato diferente, texto simples, vazio, ou JSON malformado).
|
||||
Só existe um exemplo real capturado até agora (só cobre o caminho de
|
||||
sucesso com `status: "pending"`); reportar como falha uma operação que
|
||||
o provider aceitou (e que pode já ter sido persistida) induziria um
|
||||
consumidor (agente/n8n) a repeti-la, arriscando um agendamento
|
||||
duplicado. Nesse caso, `agendar()` retorna um `Appointment` construído
|
||||
apenas com os dados já conhecidos do próprio input, com `id` ausente e
|
||||
`responseMappingPending: true`. Consumidores devem tratar
|
||||
`responseMappingPending: true` como "aceito, detalhes incompletos" —
|
||||
**nunca** como motivo para reenviar a operação. Apenas respostas HTTP
|
||||
fora do intervalo 2xx continuam classificadas como erro
|
||||
(401/403/404/400/422/500, timeout, erro de rede).
|
||||
|
||||
Opcionalmente, `new ZProAdapter(config, { onResponseMetadata })` aceita
|
||||
um callback de diagnóstico chamado após cada resposta do Z-PRO, com
|
||||
metadados seguros (status, content-type, presença de corpo, formato
|
||||
aparente) — nunca o corpo, o token ou dados do paciente. Sem esse
|
||||
callback, nada é registrado (por padrão, para nunca escrever em stdout
|
||||
e corromper o transporte stdio do MCP).
|
||||
|
||||
## Status do KlingoAdapter
|
||||
|
||||
Baseado inteiramente em `docs/klingo-openapi.json` (Swagger público da API
|
||||
do Klingo — bem mais completo que o do Z-PRO: 41 endpoints documentados,
|
||||
incluindo consulta de disponibilidade real).
|
||||
|
||||
- **`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 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 **sempre lança** `KlingoApiError` com código
|
||||
`SLOT_MAPPING_UNAVAILABLE` quando a API responde com sucesso — é uma
|
||||
lacuna de contrato conhecida, não uma falha técnica, e não deve ser
|
||||
tratada como motivo para retry. Assim que uma resposta real de
|
||||
`GET /agenda/horarios` for capturada, `klingo.adapter.ts` deve ganhar o
|
||||
mapeamento completo.
|
||||
- **`agendar()`**: implementado de ponta a ponta.
|
||||
1. Identifica o paciente via `POST /paciente/identificar`
|
||||
(`X-APP-TOKEN`), usando `patient.phone` + `patient.document` (como
|
||||
CPF) — ou `apenas_telefone: true` quando não há `document`. Não
|
||||
cria nenhum dado no Klingo, então erros aqui (inclusive resposta 2xx
|
||||
sem `access_token` válido) são reportados normalmente, sem risco de
|
||||
duplicidade.
|
||||
2. Reserva o horário via `POST /agenda/reservar`, autenticado pelo
|
||||
Bearer de sessão obtido no passo anterior, enviando `procedimento`
|
||||
(de `input.specialtyId`) e `id` (de `input.slotId`) — ambos
|
||||
obrigatórios pela API, validados antes de qualquer chamada de rede.
|
||||
Envia também `confirmado` preenchido, para que a marcação já seja
|
||||
feita como confirmada em vez de 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").
|
||||
3. O schema de sucesso de `reservar` (`reserva`) **é documentado**
|
||||
(`id`, `data`, `hora`, `profissional`) — diferente do Z-PRO, a
|
||||
resposta é mapeada de verdade para o `Appointment` genérico, com
|
||||
`status: "confirmed"`. Mesmo assim, **qualquer HTTP 2xx continua
|
||||
sendo tratado como sucesso operacional** mesmo que o corpo fuja do
|
||||
schema documentado (JSON sem `id` numérico, corpo vazio, texto,
|
||||
JSON malformado) — mesma proteção contra duplicidade do ZProAdapter:
|
||||
nesse caso, retorna um `Appointment` com `responseMappingPending: true`
|
||||
em vez de lançar erro.
|
||||
|
||||
Variáveis de ambiente: apenas `KLINGO_BASE_URL` e `KLINGO_APP_TOKEN`
|
||||
(ver seção Configuração). O token de sessão do paciente não é uma
|
||||
variável de ambiente — é obtido em tempo de execução, por paciente, e
|
||||
descartado a cada chamada (sem cache, por simplicidade neste MVP).
|
||||
|
||||
## Configuração
|
||||
|
||||
A configuração é lida exclusivamente por variáveis de ambiente (módulo
|
||||
`src/config/env.config.ts`), conforme o arquivo de exemplo `.env.example`.
|
||||
Cada provider só exige suas próprias variáveis — as do provider não
|
||||
selecionado em `SCHEDULING_PROVIDER` não são exigidas.
|
||||
|
||||
| Variável | Obrigatória | Descrição |
|
||||
| --------------------- | ------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `SCHEDULING_PROVIDER` | Não (padrão `zpro`) | Provider de agendamento (`zpro` \| `klingo`) |
|
||||
| `ZPRO_BASE_URL` | Sim, se `SCHEDULING_PROVIDER=zpro` | URL base da API do Z-PRO |
|
||||
| `ZPRO_API_TOKEN` | Sim, se `SCHEDULING_PROVIDER=zpro` | Token de autenticação da API do Z-PRO |
|
||||
| `ZPRO_API_ID` | Sim, se `SCHEDULING_PROVIDER=zpro` | Identificador da API (ApiID) usado nas rotas externas |
|
||||
| `ZPRO_INSTANCE_ID` | Não | Identificador da instância no Z-PRO (mantido por compatibilidade) |
|
||||
| `KLINGO_BASE_URL` | Sim, se `SCHEDULING_PROVIDER=klingo` | URL base da API do Klingo |
|
||||
| `KLINGO_APP_TOKEN` | Sim, se `SCHEDULING_PROVIDER=klingo` | Token de aplicação do Klingo, enviado no header `X-APP-TOKEN` |
|
||||
|
||||
O token de **sessão do paciente** do Klingo (`Authorization: Bearer`,
|
||||
obtido via `/externo/login` ou `/paciente/identificar`) não é uma
|
||||
variável de ambiente — não é um segredo fixo de configuração, e sim algo
|
||||
obtido em tempo de execução por paciente. Ver `docs/klingo-openapi.json`.
|
||||
|
||||
Com configuração inválida, a aplicação encerra a inicialização com uma
|
||||
mensagem clara, sem expor nenhum token em mensagens de erro ou logs.
|
||||
Nunca crie um arquivo `.env` real com credenciais; o `.env` está no
|
||||
`.gitignore`.
|
||||
|
||||
## Script de diagnóstico da API Z-PRO
|
||||
|
||||
O script `scripts/diagnose-zpro-list.ts` consulta apenas o endpoint de
|
||||
**listagem** de agendamentos do Z-PRO (GET) para descobrir o contrato real
|
||||
da resposta, pois a coleção pública do Postman não possui exemplos de
|
||||
resposta. Veja `docs/zpro-api-mapping.md`.
|
||||
|
||||
```bash
|
||||
npm run diagnose:zpro:list
|
||||
```
|
||||
|
||||
O script:
|
||||
|
||||
- faz **uma única** requisição `GET` em
|
||||
`{ZPRO_BASE_URL}/v2/api/external/{ZPRO_API_ID}/appointment/list`
|
||||
com `page=1` e `limit=5` (parâmetros conservadores);
|
||||
- envia `Authorization: Bearer <token>` e `Accept: application/json`;
|
||||
- usa timeout de 15 segundos e **não** executa retry;
|
||||
- **não** chama `appointment/create` nem modifica dados;
|
||||
- imprime informações da requisição (URL sem token, status, content-type,
|
||||
duração), a resposta sanitizada e um resumo estrutural;
|
||||
- nunca imprime token, stack trace ou dados pessoais completos dos
|
||||
pacientes (valores sensíveis são mascarados com `[REDACTED]`).
|
||||
|
||||
Em caso de falha (variável ausente, autenticação, ApiID inválido, timeout,
|
||||
rede, resposta não JSON ou status HTTP fora de 2xx), o processo encerra
|
||||
com código de saída 1.
|
||||
|
||||
## Como adicionar um novo Adapter
|
||||
|
||||
1. Criar uma nova pasta em `src/adapters/<nome-do-sistema>/`
|
||||
(ex.: `src/adapters/klingo/`).
|
||||
2. Criar a classe do Adapter (ex.: `klingo.adapter.ts`) implementando a
|
||||
interface `SchedulingAdapter`, definida em
|
||||
`scheduling-adapter.interface.ts`.
|
||||
3. Implementar os métodos `consultarHorarios()` e `agendar()`, traduzindo
|
||||
os dados específicos do sistema para os tipos genéricos definidos em
|
||||
`types/scheduling.types.ts`.
|
||||
4. Atualizar `adapter-factory.ts` para que o novo Adapter possa ser
|
||||
selecionado quando necessário.
|
||||
5. **Nunca** expor detalhes do sistema específico (nomes de campos,
|
||||
endpoints, formatos próprios, etc.) fora do próprio Adapter.
|
||||
|
||||
## Scripts disponíveis
|
||||
|
||||
```bash
|
||||
npm run dev # Executa o servidor em modo desenvolvimento (tsx)
|
||||
npm run build # Compila o projeto TypeScript para dist/
|
||||
npm run start # Executa o projeto já compilado
|
||||
npm run test # Executa os testes unitários (node:test via tsx)
|
||||
npm run diagnose:zpro:list # Consulta o endpoint de listagem do Z-PRO (leitura)
|
||||
npm run lint # Executa o ESLint
|
||||
npm run format # Formata o código com Prettier
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
# Mapeamento da API Z-PRO — pasta "Agendamentos e Lembretes"
|
||||
|
||||
> Este documento contém **apenas** informações verificadas diretamente na
|
||||
> coleção pública do Postman em
|
||||
> https://www.postman.com/comunidade-zdg/z-pro/folder/ktt2uon/agendamentos-e-lembretes
|
||||
> (coleção "Z-PRO v4.x.x.x", pasta "📅 Agendamentos e Lembretes").
|
||||
> Nenhum valor de token, header sensível ou segredo real foi copiado.
|
||||
>
|
||||
> **Status: análise incompleta.** A integração real (`ZProAdapter`) **não foi
|
||||
> implementada** nesta etapa porque partes essenciais do contrato não estão
|
||||
> disponíveis na coleção. Veja a seção "O que não foi possível verificar".
|
||||
|
||||
## Itens existentes na pasta
|
||||
|
||||
A pasta contém 10 requests. Apenas os relacionados ao escopo atual
|
||||
(consultar agendamentos / criar agendamento) foram analisados em detalhe;
|
||||
os demais foram apenas identificados para não serem confundidos com o
|
||||
escopo.
|
||||
|
||||
| Nome | Método | Path | No escopo atual? |
|
||||
| ---------------------- | ------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| AppointmentCreate | POST | `/v2/api/external/{{ApiID}}/appointment/create` | ✅ sim |
|
||||
| AppointmentList | GET | `/v2/api/external/{{ApiID}}/appointment/list` | ✅ sim (única consulta existente — ver ressalva abaixo) |
|
||||
| AppointmentShow | GET | `/v2/api/external/{{ApiID}}/appointment/show/{{id}}` | fora do escopo pedido (não é "consultar horários/agendamentos disponíveis" em lista) |
|
||||
| AppointmentUpdate | POST | `/v2/api/external/{{ApiID}}/appointment/update/{{id}}` | ❌ não (remarcação — explicitamente fora do escopo) |
|
||||
| AppointmentDelete | POST | `/v2/api/external/{{ApiID}}/appointment/delete/{{id}}` | ❌ não (cancelamento — explicitamente fora do escopo) |
|
||||
| ScheduleReminderCreate | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/create` | ❌ não (lembretes — explicitamente fora do escopo) |
|
||||
| ScheduleReminderList | GET | `/v2/api/external/{{ApiID}}/scheduleReminder/list` | ❌ não |
|
||||
| ScheduleReminderToggle | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/toggle/{{id}}` | ❌ não |
|
||||
| ScheduleReminderUpdate | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/update/{{id}}` | ❌ não |
|
||||
| ScheduleReminderDelete | POST | `/v2/api/external/{{ApiID}}/scheduleReminder/delete/{{id}}` | ❌ não |
|
||||
|
||||
**Ressalva importante:** a pasta **não possui nenhum endpoint de "horários
|
||||
disponíveis"** (busca de vagas livres/slots). O único endpoint de consulta
|
||||
relacionado a agendamentos é `AppointmentList`, que **lista agendamentos já
|
||||
existentes** (filtráveis por status `pending | confirmed | cancelled |
|
||||
completed`), não horários livres para agendar. Ver detalhes na seção de
|
||||
ambiguidades.
|
||||
|
||||
## Autenticação
|
||||
|
||||
- Tipo: **Bearer Token** (`auth.type = "bearer"`), definido tanto no nível
|
||||
da coleção quanto em cada request individual.
|
||||
- O token é enviado automaticamente pelo Postman no header
|
||||
`Authorization: Bearer <token>`.
|
||||
- Variável de coleção que armazena o token: `BearerToken` (valor de exemplo
|
||||
na coleção: `your_token_here` — placeholder, não é um token real).
|
||||
- Não há chave de API adicional em header (ex.: `X-API-Key`) documentada
|
||||
nestes requests — apenas o Bearer token.
|
||||
|
||||
## Variáveis de coleção relevantes
|
||||
|
||||
| Variável | Uso verificado |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BaseUrl` | Host base da API (ex.: `https://{{BaseUrl}}/v2/api/external/...`) |
|
||||
| `ApiID` | Segmento obrigatório da URL em **todos** os endpoints desta pasta: `/v2/api/external/{{ApiID}}/...`. Não há descrição do que ele representa (ex.: id da instância/empresa) além do nome da variável. |
|
||||
| `BearerToken` | Token usado na autenticação Bearer |
|
||||
| `SuperAdminToken` | Não utilizado em nenhum request desta pasta |
|
||||
|
||||
**Achado relevante para configuração:** o `ApiID` é parte obrigatória da
|
||||
URL de todo endpoint. A configuração atual do projeto
|
||||
(`src/config/config.types.ts`, `src/config/env.config.ts`) já prevê
|
||||
`ZPRO_INSTANCE_ID` como **opcional**. Se `ApiID` corresponder a esse campo,
|
||||
ele precisa passar a ser **obrigatório** — mas isso não deve ser alterado
|
||||
sem confirmação, pois não há descrição na coleção que confirme a
|
||||
equivalência semântica entre `ApiID` e "instância".
|
||||
|
||||
## AppointmentCreate (criar agendamento)
|
||||
|
||||
- Método: `POST`
|
||||
- URL: `https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/create`
|
||||
- Auth: Bearer (herdado da coleção)
|
||||
- Headers explícitos no request: nenhum além dos automáticos do Postman
|
||||
(Bearer + `Content-Type: application/json`, implícito pelo modo de body
|
||||
`raw`/`json`)
|
||||
- Query params: nenhum
|
||||
- Corpo da requisição (JSON, `dataMode: raw`, exemplo salvo na coleção):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Reunião de Vendas",
|
||||
"description": "Reunião sobre proposta comercial",
|
||||
"contactId": "{{contactId}}",
|
||||
"contactName": "João Silva",
|
||||
"contactPhone": "5511999999999",
|
||||
"whatsappId": "{{whatsappId}}",
|
||||
"startAt": "2026-04-15T14:00:00.000Z",
|
||||
"endAt": "2026-04-15T15:00:00.000Z",
|
||||
"status": "pending",
|
||||
"notes": "Levar proposta impressa"
|
||||
}
|
||||
```
|
||||
|
||||
- Campos identificados no exemplo: `title`, `description`, `contactId`,
|
||||
`contactName`, `contactPhone`, `whatsappId`, `startAt`, `endAt`,
|
||||
`status`, `notes`.
|
||||
- **Não há indicação, na coleção, de quais desses campos são obrigatórios
|
||||
versus opcionais** (o Postman não declara isso fora do exemplo).
|
||||
- Formato de sucesso, formato de erro e o campo que identifica o
|
||||
agendamento criado (`id`, `_id`, `appointment.id`, etc.): **não
|
||||
verificados** — não há nenhuma resposta de exemplo salva neste request na
|
||||
coleção (`responses_order: []`).
|
||||
|
||||
## AppointmentList (única consulta existente no escopo)
|
||||
|
||||
- Método: `GET`
|
||||
- URL: `https://{{BaseUrl}}/v2/api/external/{{ApiID}}/appointment/list`
|
||||
- Auth: Bearer (herdado da coleção)
|
||||
- Query params verificados:
|
||||
|
||||
| Parâmetro | Exemplo | Habilitado por padrão | Descrição na coleção |
|
||||
| ----------- | -------------------------- | --------------------- | --------------------------------------------------- |
|
||||
| `page` | `1` | sim | — |
|
||||
| `limit` | `20` | sim | — |
|
||||
| `status` | `pending` | sim | `pending \| confirmed \| cancelled \| completed` |
|
||||
| `startFrom` | `2026-04-01T00:00:00.000Z` | **não** (disabled) | Filtrar agendamentos a partir desta data (ISO 8601) |
|
||||
| `startTo` | `2026-04-30T23:59:59.000Z` | **não** (disabled) | Filtrar agendamentos até esta data (ISO 8601) |
|
||||
| `search` | `""` | **não** (disabled) | Busca por título ou nome do contato |
|
||||
|
||||
- Corpo da requisição: nenhum (GET).
|
||||
- Formato de sucesso (envelope, paginação, forma de cada item da lista) e
|
||||
formato de erro: **não verificados** — sem resposta de exemplo salva.
|
||||
|
||||
## O que não foi possível verificar
|
||||
|
||||
1. **Formato de sucesso de qualquer endpoint** (envelope da resposta,
|
||||
nomes de campos, se há paginação e como ela é representada).
|
||||
2. **Formato de erro de qualquer endpoint** (estrutura do corpo de erro,
|
||||
nomes de campos como `message`/`error`/`code`).
|
||||
3. **Identificador retornado após a criação** do agendamento (nome do
|
||||
campo, ex.: `id` vs `_id` vs `appointment.id`).
|
||||
4. **Conceito de "horário disponível"**: a pasta não expõe nenhum endpoint
|
||||
de disponibilidade/slots livres. `AppointmentList` lista agendamentos
|
||||
já existentes (ocupados), não horários livres. Não é seguro tratar a
|
||||
ausência de um agendamento como "horário disponível" — isso seria
|
||||
inventar uma regra de negócio não documentada.
|
||||
5. **Relação entre `slotId` (tipo genérico do projeto,
|
||||
`CreateAppointmentInput.slotId`) e a API Z-PRO**: `AppointmentCreate`
|
||||
não recebe um identificador de horário/slot — o agendamento é criado
|
||||
diretamente por `startAt`/`endAt`. Não há, na coleção, nenhum conceito
|
||||
de "slot" com identificador próprio.
|
||||
6. **Campos obrigatórios vs. opcionais** no corpo de `AppointmentCreate`
|
||||
(a coleção só mostra um exemplo, sem schema declarado).
|
||||
7. **Semântica exata de `ApiID`** (é o identificador da instância? da
|
||||
empresa? do WhatsApp conectado?) — não há descrição na coleção.
|
||||
|
||||
## O que é necessário para prosseguir com a integração real
|
||||
|
||||
Qualquer um dos itens abaixo destravaria a implementação:
|
||||
|
||||
- Um **exemplo de resposta salvo** (Postman "Save Response" / "Add
|
||||
Example") para `AppointmentCreate` (sucesso e, se possível, erro) e para
|
||||
`AppointmentList` (sucesso).
|
||||
- Confirmação explícita de que **não existe** endpoint de horários/slots
|
||||
disponíveis na API Z-PRO (para então documentar oficialmente que
|
||||
`consultarHorarios()` não tem correspondência 1:1 e decidir, com o
|
||||
responsável do produto, qual comportamento é aceitável — por exemplo,
|
||||
reinterpretar o escopo, ou aguardar um endpoint futuro).
|
||||
- Documentação (Swagger/OpenAPI, README da API, ou outra coleção) que
|
||||
descreva o schema de resposta dos dois endpoints.
|
||||
- Confirmação do significado e da obrigatoriedade de `ApiID`.
|
||||
|
||||
## Decisão tomada nesta etapa
|
||||
|
||||
Como os contratos de resposta não puderam ser verificados, o
|
||||
`ZProAdapter` **não foi alterado** — permanece lançando `Not implemented`,
|
||||
exatamente como estava antes desta análise, para não implementar por
|
||||
suposição. `npm run build`, `npm run lint` e `npm test` continuam
|
||||
passando sem alterações de código.
|
||||
|
||||
## Atualização — resposta real de sucesso capturada (teste manual controlado)
|
||||
|
||||
Em 09/08/2026, foi executado um teste manual único e controlado de
|
||||
`POST /v2/api/external/{ApiID}/appointment/create`, contra uma instância
|
||||
real do Z-PRO, com dados fictícios (`TESTE-MCP-2026-04-20-001`). O
|
||||
agendamento foi criado de fato (confirmado visualmente na interface do
|
||||
Z-PRO) e a resposta HTTP foi capturada:
|
||||
|
||||
- **Content-Type**: `application/json; charset=utf-8`
|
||||
- **Corpo bruto** (dados fictícios, sem nenhuma credencial):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"source": "manual",
|
||||
"checkInStatus": "none",
|
||||
"id": 2,
|
||||
"title": "TESTE-MCP-2026-04-20-001",
|
||||
"description": null,
|
||||
"contactId": null,
|
||||
"contactName": "Paciente Teste MCP",
|
||||
"contactPhone": "5511900000000",
|
||||
"whatsappId": null,
|
||||
"tenantId": 4,
|
||||
"startAt": "2026-04-20T14:00:00.000Z",
|
||||
"endAt": "2026-04-20T14:30:00.000Z",
|
||||
"status": "pending",
|
||||
"notes": null,
|
||||
"updatedAt": "2026-08-09T20:32:06.426Z",
|
||||
"createdAt": "2026-08-09T20:32:06.426Z",
|
||||
"bookingPageId": null,
|
||||
"bookingEventTypeId": null,
|
||||
"bookingProfessionalId": null,
|
||||
"userId": null,
|
||||
"location": null,
|
||||
"meetingLink": null,
|
||||
"formAnswers": null,
|
||||
"cancelToken": null,
|
||||
"googleEventId": null,
|
||||
"checkInSentAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**O que isso confirma:**
|
||||
|
||||
- Envelope de sucesso: `{ success: boolean, data: {...} }`.
|
||||
- Identificador do agendamento criado: `data.id`, **numérico** (não
|
||||
`_id`, não `appointment.id`).
|
||||
- `data.status` reflete o mesmo valor enviado na requisição (`"pending"`)
|
||||
— **não temos, ainda, um exemplo real de `"confirmed"` ou
|
||||
`"cancelled"` retornados pela API**; o mapeamento desses dois valores
|
||||
no `ZProAdapter` é feito por analogia com o valor documentado no
|
||||
request (`AppointmentList`'s filtro de `status`), não por observação
|
||||
direta.
|
||||
- `data.startAt`/`data.endAt` são ecoados exatamente como enviados.
|
||||
- `data.contactId` continua `null` quando não enviado no request (não
|
||||
inventa um contato).
|
||||
|
||||
**O que continua não verificado:** o formato de erro (4xx/5xx) do
|
||||
endpoint (esse teste só cobriu o caminho de sucesso), e os valores reais
|
||||
de `data.status` para `"confirmed"`/`"cancelled"`. O `ZProAdapter`
|
||||
continua tratando **qualquer HTTP 2xx** como sucesso mesmo que o corpo
|
||||
não bata com este envelope exato (ver `zpro.mapper.ts`), como proteção
|
||||
contra duplicidade caso a API mude de formato em algum caso não coberto
|
||||
por este teste único.
|
||||
@@ -0,0 +1,25 @@
|
||||
// eslint.config.js
|
||||
//
|
||||
// Configuração do ESLint para o projeto, utilizando as regras
|
||||
// recomendadas para TypeScript.
|
||||
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ["dist/**"],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// Permite prefixar parâmetros intencionalmente não utilizados com "_"
|
||||
// (útil em stubs de Adapter que ainda não implementam a lógica real).
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+3138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "gti-scheduling-mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP Server responsável por realizar agendamentos em sistemas de clínicas, através de Adapters específicos para cada sistema.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"test": "tsx --test \"test/**/*.test.ts\"",
|
||||
"diagnose:zpro:list": "tsx scripts/diagnose-zpro-list.ts",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"scheduling",
|
||||
"clinics"
|
||||
],
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@types/node": "^22.13.4",
|
||||
"eslint": "^9.20.0",
|
||||
"prettier": "^3.5.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* scripts/diagnose-zpro-list.ts
|
||||
*
|
||||
* Script CLI temporário e seguro para descobrir o contrato real do
|
||||
* endpoint:
|
||||
*
|
||||
* GET /v2/api/external/{ApiID}/appointment/list
|
||||
*
|
||||
* Faz apenas UMA requisição GET de leitura (page=1, limit=5), com timeout
|
||||
* de 15s, sem retry e sem modificar nenhum dado. Imprime a resposta
|
||||
* sanitizada e um resumo estrutural.
|
||||
*
|
||||
* O script nunca imprime token, header Authorization, stack trace ou
|
||||
* dados pessoais completos dos pacientes.
|
||||
*/
|
||||
|
||||
import { loadSchedulingConfig } from "../src/config/env.config.js";
|
||||
import {
|
||||
buildAppointmentListUrl,
|
||||
fetchAppointmentList,
|
||||
NetworkRequestError,
|
||||
RequestTimeoutError,
|
||||
} from "../src/diagnostics/client.js";
|
||||
import { buildResponseReport } from "../src/diagnostics/response.js";
|
||||
import { sanitizeText } from "../src/diagnostics/sanitize.js";
|
||||
import type { StructureSummary } from "../src/diagnostics/analyze.js";
|
||||
|
||||
function list(values: string[]): string {
|
||||
return values.length > 0 ? values.join(", ") : "(nenhum)";
|
||||
}
|
||||
|
||||
function classifyHttpStatus(status: number): string {
|
||||
if (status === 401 || status === 403) {
|
||||
return `Erro de autenticação (status ${status}). Verifique ZPRO_API_TOKEN.`;
|
||||
}
|
||||
if (status === 404) {
|
||||
return `ApiID inválido ou recurso não encontrado (status ${status}). Verifique ZPRO_API_ID.`;
|
||||
}
|
||||
return `Status HTTP ${status} (esperado 2xx).`;
|
||||
}
|
||||
|
||||
function printStructureSummary(summary: StructureSummary): void {
|
||||
console.log(`Tipo da raiz: ${summary.rootType}`);
|
||||
|
||||
if (summary.rootType === "array") {
|
||||
console.log(`Quantidade de itens no array: ${summary.arrayItemCount}`);
|
||||
console.log(`Chaves do primeiro item: ${list(summary.firstItemKeys)}`);
|
||||
} else if (summary.rootType === "object") {
|
||||
console.log(`Chaves na raiz: ${list(summary.rootKeys)}`);
|
||||
}
|
||||
|
||||
console.log("Tipos dos campos:");
|
||||
for (const [key, type] of Object.entries(summary.fieldTypes)) {
|
||||
console.log(` ${key}: ${type}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Possíveis campos de paginação: ${list(summary.possiblePaginationFields)}`,
|
||||
);
|
||||
console.log(
|
||||
`Possíveis campos de identificação: ${list(summary.possibleIdFields)}`,
|
||||
);
|
||||
console.log(`Possíveis campos de data: ${list(summary.possibleDateFields)}`);
|
||||
console.log(
|
||||
`Possíveis campos de status: ${list(summary.possibleStatusFields)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
let config;
|
||||
try {
|
||||
config = loadSchedulingConfig();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Falha ao carregar a configuração.";
|
||||
console.error(message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const secrets = [config.apiToken];
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchAppointmentList(config);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTimeoutError) {
|
||||
console.error("Timeout ao consultar a API do Z-PRO (15s).");
|
||||
} else if (error instanceof NetworkRequestError) {
|
||||
console.error("Falha de rede ao consultar a API do Z-PRO.");
|
||||
} else {
|
||||
console.error("Erro inesperado ao consultar a API do Z-PRO.");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log("=== Informações da requisição ===");
|
||||
console.log("Método: GET");
|
||||
console.log(`URL: ${buildAppointmentListUrl(config)}`);
|
||||
console.log(`Status: ${response.status}`);
|
||||
console.log(`Content-Type: ${response.contentType ?? "(ausente)"}`);
|
||||
console.log(`Duração: ${response.durationMs}ms`);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
console.log("");
|
||||
console.log("=== Erro HTTP ===");
|
||||
console.log(classifyHttpStatus(response.status));
|
||||
if (response.bodyText) {
|
||||
console.log("");
|
||||
console.log("Corpo da resposta (primeiros 500 caracteres, sanitizado):");
|
||||
console.log(sanitizeText(response.bodyText, secrets));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const report = buildResponseReport(response.bodyText, secrets);
|
||||
|
||||
if (!report.isJson) {
|
||||
console.log("");
|
||||
console.log("=== A resposta não é JSON ===");
|
||||
console.log("Conteúdo (primeiros 500 caracteres, sanitizado):");
|
||||
console.log(report.sanitizedText);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("=== Estrutura da resposta ===");
|
||||
console.log(report.sanitizedText);
|
||||
|
||||
if (report.structure) {
|
||||
console.log("");
|
||||
console.log("=== Resumo estrutural ===");
|
||||
printStructureSummary(report.structure);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exit(code);
|
||||
})
|
||||
.catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* test/adapter-factory.test.ts
|
||||
*
|
||||
* Testes unitários para a AdapterFactory.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createSchedulingAdapter } from "../src/adapters/adapter-factory.js";
|
||||
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
|
||||
import { KlingoAdapter } from "../src/adapters/klingo/klingo.adapter.js";
|
||||
import type { SchedulingProviderConfig } from "../src/config/config.types.js";
|
||||
|
||||
describe("createSchedulingAdapter", () => {
|
||||
it("cria um ZProAdapter quando o provider é 'zpro'", () => {
|
||||
const adapter = createSchedulingAdapter({
|
||||
provider: "zpro",
|
||||
baseUrl: "https://zpro.example.com",
|
||||
apiToken: "token-secreto",
|
||||
apiId: "api-1",
|
||||
});
|
||||
|
||||
assert.ok(adapter instanceof ZProAdapter);
|
||||
});
|
||||
|
||||
it("cria um KlingoAdapter quando o provider é 'klingo'", () => {
|
||||
const adapter = createSchedulingAdapter({
|
||||
provider: "klingo",
|
||||
baseUrl: "https://klingo.example.com",
|
||||
appToken: "app-token-secreto",
|
||||
});
|
||||
|
||||
assert.ok(adapter instanceof KlingoAdapter);
|
||||
});
|
||||
|
||||
it("rejeita um provider desconhecido", () => {
|
||||
const unsupported = {
|
||||
provider: "syslife",
|
||||
baseUrl: "https://syslife.example.com",
|
||||
} as unknown as SchedulingProviderConfig;
|
||||
|
||||
assert.throws(
|
||||
() => createSchedulingAdapter(unsupported),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /não suportado/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* test/analyze.test.ts
|
||||
*
|
||||
* Testes unitários para a identificação da estrutura de uma resposta JSON.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { analyzeStructure } from "../src/diagnostics/analyze.js";
|
||||
|
||||
describe("analyzeStructure", () => {
|
||||
it("descreve a raiz quando é um objeto", () => {
|
||||
const summary = analyzeStructure({
|
||||
data: [],
|
||||
message: "ok",
|
||||
});
|
||||
|
||||
assert.equal(summary.rootType, "object");
|
||||
assert.deepEqual(summary.rootKeys.sort(), ["data", "message"]);
|
||||
assert.equal(summary.arrayItemCount, undefined);
|
||||
});
|
||||
|
||||
it("descreve um array: quantidade de itens, chaves e tipos do primeiro item", () => {
|
||||
const summary = analyzeStructure([
|
||||
{
|
||||
id: "app-1",
|
||||
status: "confirmed",
|
||||
patient: { name: "Maria" },
|
||||
startAt: "2026-04-15T14:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(summary.rootType, "array");
|
||||
assert.equal(summary.arrayItemCount, 1);
|
||||
assert.deepEqual(summary.firstItemKeys.sort(), [
|
||||
"id",
|
||||
"patient",
|
||||
"startAt",
|
||||
"status",
|
||||
]);
|
||||
assert.deepEqual(summary.fieldTypes, {
|
||||
id: "string",
|
||||
status: "string",
|
||||
patient: "object",
|
||||
startAt: "string",
|
||||
});
|
||||
});
|
||||
|
||||
it("detecta possíveis campos de paginação", () => {
|
||||
const summary = analyzeStructure({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalCount: 42,
|
||||
items: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(summary.possiblePaginationFields.sort(), [
|
||||
"limit",
|
||||
"page",
|
||||
"totalCount",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detecta possíveis campos de identificação", () => {
|
||||
const summary = analyzeStructure({
|
||||
id: "x",
|
||||
appointmentId: "app-1",
|
||||
uuid: "abc",
|
||||
createdAt: "2026-04-15T14:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.deepEqual(summary.possibleIdFields.sort(), [
|
||||
"appointmentId",
|
||||
"id",
|
||||
"uuid",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detecta possíveis campos de data e de status", () => {
|
||||
const summary = analyzeStructure({
|
||||
createdAt: "2026-04-15T14:00:00.000Z",
|
||||
startAt: "2026-04-15T14:00:00.000Z",
|
||||
status: "pending",
|
||||
state: "active",
|
||||
});
|
||||
|
||||
assert.deepEqual(summary.possibleDateFields.sort(), [
|
||||
"createdAt",
|
||||
"startAt",
|
||||
]);
|
||||
assert.deepEqual(summary.possibleStatusFields.sort(), ["state", "status"]);
|
||||
});
|
||||
|
||||
it("não inventa campos que não existem na resposta", () => {
|
||||
const summary = analyzeStructure({
|
||||
id: "x",
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
assert.deepEqual(summary.possiblePaginationFields, []);
|
||||
assert.deepEqual(summary.possibleDateFields, []);
|
||||
assert.equal(summary.arrayItemCount, undefined);
|
||||
assert.deepEqual(summary.firstItemKeys.sort(), ["id", "status"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* test/config.test.ts
|
||||
*
|
||||
* Testes unitários para a validação da configuração carregada por
|
||||
* variáveis de ambiente (src/config/env.config.ts).
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { loadSchedulingConfig } from "../src/config/env.config.js";
|
||||
|
||||
function env(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
SCHEDULING_PROVIDER: "zpro",
|
||||
ZPRO_BASE_URL: "https://zpro.example.com",
|
||||
ZPRO_API_TOKEN: "token-secreto",
|
||||
ZPRO_API_ID: "api-1",
|
||||
ZPRO_INSTANCE_ID: "",
|
||||
...overrides,
|
||||
} as NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
describe("loadSchedulingConfig", () => {
|
||||
it("carrega uma configuração válida do Z-PRO", () => {
|
||||
const config = loadSchedulingConfig(
|
||||
env({ ZPRO_INSTANCE_ID: "instancia-1" }),
|
||||
);
|
||||
|
||||
assert.deepEqual(config, {
|
||||
provider: "zpro",
|
||||
baseUrl: "https://zpro.example.com",
|
||||
apiToken: "token-secreto",
|
||||
apiId: "api-1",
|
||||
instanceId: "instancia-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("define o provider como 'zpro' por padrão", () => {
|
||||
const withoutProvider = env();
|
||||
delete withoutProvider.SCHEDULING_PROVIDER;
|
||||
|
||||
const config = loadSchedulingConfig(withoutProvider);
|
||||
|
||||
assert.equal(config.provider, "zpro");
|
||||
});
|
||||
|
||||
it("trata ZPRO_INSTANCE_ID vazio como ausente", () => {
|
||||
const config = loadSchedulingConfig(env());
|
||||
|
||||
assert.equal(config.instanceId, undefined);
|
||||
});
|
||||
|
||||
it("falha quando ZPRO_BASE_URL está ausente", () => {
|
||||
const missingBaseUrl = env();
|
||||
delete missingBaseUrl.ZPRO_BASE_URL;
|
||||
|
||||
assert.throws(
|
||||
() => loadSchedulingConfig(missingBaseUrl),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /ZPRO_BASE_URL/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falha quando ZPRO_API_TOKEN está ausente", () => {
|
||||
const missingToken = env();
|
||||
delete missingToken.ZPRO_API_TOKEN;
|
||||
|
||||
assert.throws(
|
||||
() => loadSchedulingConfig(missingToken),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /ZPRO_API_TOKEN/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falha quando ZPRO_API_ID está ausente", () => {
|
||||
const missingApiId = env();
|
||||
delete missingApiId.ZPRO_API_ID;
|
||||
|
||||
assert.throws(
|
||||
() => loadSchedulingConfig(missingApiId),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /ZPRO_API_ID/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("não expõe o token em mensagens de erro", () => {
|
||||
const missingBaseUrl = env();
|
||||
delete missingBaseUrl.ZPRO_BASE_URL;
|
||||
|
||||
assert.throws(
|
||||
() => loadSchedulingConfig(missingBaseUrl),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(!error.message.includes("token-secreto"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejeita um provider desconhecido", () => {
|
||||
assert.throws(
|
||||
() => loadSchedulingConfig(env({ SCHEDULING_PROVIDER: "syslife" })),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /SCHEDULING_PROVIDER/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("não exige variáveis ZPRO_* quando o provider é 'klingo'", () => {
|
||||
const config = loadSchedulingConfig({
|
||||
SCHEDULING_PROVIDER: "klingo",
|
||||
KLINGO_BASE_URL: "https://klingo.example.com",
|
||||
KLINGO_APP_TOKEN: "app-token-secreto",
|
||||
} as NodeJS.ProcessEnv);
|
||||
|
||||
assert.deepEqual(config, {
|
||||
provider: "klingo",
|
||||
baseUrl: "https://klingo.example.com",
|
||||
appToken: "app-token-secreto",
|
||||
});
|
||||
});
|
||||
|
||||
it("falha quando KLINGO_BASE_URL está ausente com provider 'klingo'", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
loadSchedulingConfig({
|
||||
SCHEDULING_PROVIDER: "klingo",
|
||||
KLINGO_APP_TOKEN: "app-token-secreto",
|
||||
} as NodeJS.ProcessEnv),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /KLINGO_BASE_URL/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falha quando KLINGO_APP_TOKEN está ausente com provider 'klingo'", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
loadSchedulingConfig({
|
||||
SCHEDULING_PROVIDER: "klingo",
|
||||
KLINGO_BASE_URL: "https://klingo.example.com",
|
||||
} as NodeJS.ProcessEnv),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /KLINGO_APP_TOKEN/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("não exige variáveis KLINGO_* quando o provider é 'zpro'", () => {
|
||||
const config = loadSchedulingConfig(env());
|
||||
|
||||
assert.equal(config.provider, "zpro");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* test/diagnose-client.test.ts
|
||||
*
|
||||
* Testes unitários do cliente HTTP de diagnóstico e do tratamento de
|
||||
* respostas. Nenhum teste chama a API real — todos usam um fetch fake.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
RequestTimeoutError,
|
||||
NetworkRequestError,
|
||||
buildAppointmentListUrl,
|
||||
fetchAppointmentList,
|
||||
} from "../src/diagnostics/client.js";
|
||||
import {
|
||||
tryParseJson,
|
||||
buildResponseReport,
|
||||
} from "../src/diagnostics/response.js";
|
||||
import type { ZProConfig } from "../src/config/config.types.js";
|
||||
|
||||
const config: ZProConfig = {
|
||||
provider: "zpro",
|
||||
baseUrl: "https://zpro.example.com",
|
||||
apiToken: "token-super-secreto",
|
||||
apiId: "api-1",
|
||||
};
|
||||
|
||||
describe("buildAppointmentListUrl", () => {
|
||||
it("monta a URL com ApiID e parâmetros conservadores", () => {
|
||||
const url = buildAppointmentListUrl({
|
||||
baseUrl: "https://zpro.example.com/",
|
||||
apiId: "api-1",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
url,
|
||||
"https://zpro.example.com/v2/api/external/api-1/appointment/list?page=1&limit=5",
|
||||
);
|
||||
});
|
||||
|
||||
it("não inclui o token na URL", () => {
|
||||
const url = buildAppointmentListUrl(config);
|
||||
|
||||
assert.ok(!url.includes(config.apiToken));
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAppointmentList", () => {
|
||||
it("envia GET com Bearer token e Accept, e retorna os dados da resposta", async () => {
|
||||
let capturedUrl = "";
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
|
||||
const fakeFetch = async (
|
||||
url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
capturedUrl = String(url);
|
||||
capturedHeaders = Object.fromEntries(
|
||||
new Headers(init?.headers).entries(),
|
||||
);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await fetchAppointmentList(config, {
|
||||
fetchImpl: fakeFetch,
|
||||
});
|
||||
|
||||
assert.equal(capturedUrl, buildAppointmentListUrl(config));
|
||||
assert.equal(capturedHeaders.authorization, "Bearer token-super-secreto");
|
||||
assert.equal(capturedHeaders.accept, "application/json");
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.contentType, "application/json");
|
||||
assert.deepEqual(JSON.parse(response.bodyText), { ok: true });
|
||||
assert.equal(typeof response.durationMs, "number");
|
||||
});
|
||||
|
||||
it("não envia o token em outro header", async () => {
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
const fakeFetch = async (
|
||||
_url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = Object.fromEntries(
|
||||
new Headers(init?.headers).entries(),
|
||||
);
|
||||
return new Response("", { status: 200 });
|
||||
};
|
||||
|
||||
await fetchAppointmentList(config, { fetchImpl: fakeFetch });
|
||||
|
||||
assert.deepEqual(capturedHeaders, {
|
||||
authorization: `Bearer ${config.apiToken}`,
|
||||
accept: "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("lança RequestTimeoutError quando a requisição excede o timeout", async () => {
|
||||
const fakeFetch = async (
|
||||
_url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchAppointmentList(config, {
|
||||
timeoutMs: 50,
|
||||
fetchImpl: fakeFetch,
|
||||
}),
|
||||
(error: unknown) => error instanceof RequestTimeoutError,
|
||||
);
|
||||
});
|
||||
|
||||
it("lança NetworkRequestError em erro de rede, sem expor o token", async () => {
|
||||
const fakeFetch = async (): Promise<Response> => {
|
||||
throw new TypeError("fetch failed");
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchAppointmentList(config, { fetchImpl: fakeFetch }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof NetworkRequestError);
|
||||
assert.ok(!error.message.includes(config.apiToken));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tratamento de resposta", () => {
|
||||
it("identifica uma resposta JSON", () => {
|
||||
const parsed = tryParseJson('{"data": [1, 2]}');
|
||||
|
||||
assert.deepEqual(parsed, { data: [1, 2] });
|
||||
});
|
||||
|
||||
it("identifica uma resposta não JSON", () => {
|
||||
const parsed = tryParseJson("<html><body>erro</body></html>");
|
||||
|
||||
assert.equal(parsed, null);
|
||||
});
|
||||
|
||||
it("monta relatório para resposta JSON com conteúdo sanitizado e estrutura", () => {
|
||||
const report = buildResponseReport(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "app-1",
|
||||
patient: { name: "Maria dos Santos", phone: "75999999999" },
|
||||
status: "confirmed",
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
}),
|
||||
["token-super-secreto"],
|
||||
);
|
||||
|
||||
assert.equal(report.isJson, true);
|
||||
assert.ok(report.structure);
|
||||
assert.equal(report.structure.rootType, "object");
|
||||
assert.equal(report.structure.arrayItemCount, undefined);
|
||||
assert.ok(!report.sanitizedText.includes("Maria dos Santos"));
|
||||
assert.ok(!report.sanitizedText.includes("75999999999"));
|
||||
});
|
||||
|
||||
it("monta relatório para resposta não JSON limitado a 500 caracteres", () => {
|
||||
const longHtml = `<!DOCTYPE html>${"x".repeat(1000)}`;
|
||||
|
||||
const report = buildResponseReport(longHtml, []);
|
||||
|
||||
assert.equal(report.isJson, false);
|
||||
assert.equal(report.structure, undefined);
|
||||
assert.ok(report.sanitizedText.length <= 500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* test/klingo-adapter.test.ts
|
||||
*
|
||||
* Testes unitários do KlingoAdapter, com mocks de `fetch` (nenhuma chamada
|
||||
* de rede real). Espelha o padrão de `test/zpro-adapter.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { KlingoAdapter } from "../src/adapters/klingo/klingo.adapter.js";
|
||||
import type { KlingoConfig } from "../src/config/config.types.js";
|
||||
import type {
|
||||
CreateAppointmentInput,
|
||||
QueryAvailableSlotsInput,
|
||||
} from "../src/types/scheduling.types.js";
|
||||
|
||||
const config: KlingoConfig = {
|
||||
provider: "klingo",
|
||||
baseUrl: "https://klingo.example.com",
|
||||
appToken: "app-token-secreto",
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function rawResponse(
|
||||
status: number,
|
||||
body: string | null,
|
||||
contentType?: string,
|
||||
): Response {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: contentType ? { "content-type": contentType } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const validPatient = {
|
||||
name: "Paciente Teste",
|
||||
phone: "11999999999",
|
||||
document: "12345678900",
|
||||
};
|
||||
|
||||
const baseAgendarInput: CreateAppointmentInput = {
|
||||
clinicId: "clinica-1",
|
||||
slotId: "2026-04-20|71|2|1|18:30",
|
||||
specialtyId: "CONS",
|
||||
startAt: "2026-04-20T18:30:00.000Z",
|
||||
endAt: "2026-04-20T19:00:00.000Z",
|
||||
patient: validPatient,
|
||||
};
|
||||
|
||||
const sessaoBody = {
|
||||
access_token: "sessao-token-abc",
|
||||
token_type: "bearer",
|
||||
expires_in: 360000,
|
||||
};
|
||||
|
||||
describe("KlingoAdapter.consultarHorarios", () => {
|
||||
it("lança SLOT_MAPPING_UNAVAILABLE quando a API responde com sucesso", async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchImpl = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, { procedimento: "CONS", horarios: [] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
const input: QueryAvailableSlotsInput = {
|
||||
clinicId: "clinica-1",
|
||||
specialtyId: "CONS",
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.consultarHorarios(input),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(
|
||||
(error as { code?: string }).code,
|
||||
"SLOT_MAPPING_UNAVAILABLE",
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /\/agenda\/horarios\?especialidade=CONS/);
|
||||
const headers = calls[0].init.headers as Record<string, string>;
|
||||
assert.equal(headers["X-APP-TOKEN"], "app-token-secreto");
|
||||
});
|
||||
|
||||
it("mapeia HTTP 401 para AUTHENTICATION_ERROR sem tentar interpretar o corpo", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
rawResponse(401, "unauthorized")) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.consultarHorarios({ clinicId: "clinica-1" }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal((error as { code?: string }).code, "AUTHENTICATION_ERROR");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("monta os query params a partir do input genérico", async () => {
|
||||
let capturedUrl = "";
|
||||
const fetchImpl = (async (url: string) => {
|
||||
capturedUrl = String(url);
|
||||
return jsonResponse(200, { horarios: [] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
await assert.rejects(() =>
|
||||
adapter.consultarHorarios({
|
||||
clinicId: "12345",
|
||||
professionalId: "CRM-1",
|
||||
specialtyId: "CONS",
|
||||
startDate: "2026-04-20",
|
||||
endDate: "2026-04-25",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(capturedUrl, /especialidade=CONS/);
|
||||
assert.match(capturedUrl, /crm=CRM-1/);
|
||||
assert.match(capturedUrl, /cnes=12345/);
|
||||
assert.match(capturedUrl, /inicio=2026-04-20/);
|
||||
assert.match(capturedUrl, /fim=2026-04-25/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KlingoAdapter.agendar — validação local (sem chamada de rede)", () => {
|
||||
it("lança VALIDATION_ERROR quando patient.phone está ausente", async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error("não deveria chamar a rede");
|
||||
}) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adapter.agendar({
|
||||
...baseAgendarInput,
|
||||
patient: { name: "Sem Telefone" },
|
||||
}),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
|
||||
assert.match(error.message, /telefone/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lança VALIDATION_ERROR quando slotId está ausente (após identificar)", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
jsonResponse(200, sessaoBody)) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.agendar({ ...baseAgendarInput, slotId: undefined }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
|
||||
assert.match(error.message, /slotId/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lança VALIDATION_ERROR quando specialtyId está ausente (após identificar)", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
jsonResponse(200, sessaoBody)) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.agendar({ ...baseAgendarInput, specialtyId: undefined }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal((error as { code?: string }).code, "VALIDATION_ERROR");
|
||||
assert.match(error.message, /specialtyId/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KlingoAdapter.agendar — identificação do paciente", () => {
|
||||
it("envia telefone + cpf (X-APP-TOKEN) e usa o Bearer retornado na reserva", async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchImpl = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes("/paciente/identificar")) {
|
||||
return jsonResponse(200, sessaoBody);
|
||||
}
|
||||
return jsonResponse(201, {
|
||||
id: 555,
|
||||
data: "2026-04-20",
|
||||
hora: "18:30",
|
||||
profissional: { id: 9, nome: "Dr. Teste" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
await adapter.agendar(baseAgendarInput);
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/paciente\/identificar$/);
|
||||
const identHeaders = calls[0].init.headers as Record<string, string>;
|
||||
assert.equal(identHeaders["X-APP-TOKEN"], "app-token-secreto");
|
||||
const identBody = JSON.parse(calls[0].init.body as string);
|
||||
assert.equal(identBody.telefone, "11999999999");
|
||||
assert.equal(identBody.cpf, "12345678900");
|
||||
assert.equal(identBody.apenas_telefone, undefined);
|
||||
|
||||
assert.match(calls[1].url, /\/agenda\/reservar$/);
|
||||
const reservarHeaders = calls[1].init.headers as Record<string, string>;
|
||||
assert.equal(reservarHeaders.Authorization, "Bearer sessao-token-abc");
|
||||
});
|
||||
|
||||
it("usa apenas_telefone quando document está ausente", async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchImpl = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes("/paciente/identificar")) {
|
||||
return jsonResponse(200, sessaoBody);
|
||||
}
|
||||
return jsonResponse(201, { id: 1 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
await adapter.agendar({
|
||||
...baseAgendarInput,
|
||||
patient: { name: "Sem Documento", phone: "11988887777" },
|
||||
});
|
||||
|
||||
const identBody = JSON.parse(calls[0].init.body as string);
|
||||
assert.equal(identBody.cpf, undefined);
|
||||
assert.equal(identBody.apenas_telefone, true);
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 404, 400, 422, 500]) {
|
||||
it(`mapeia HTTP ${status} de identificar() para o código correto`, async () => {
|
||||
const fetchImpl = (async () =>
|
||||
rawResponse(status, "erro")) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.agendar(baseAgendarInput),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
const code = (error as { code?: string }).code;
|
||||
if (status === 401 || status === 403)
|
||||
assert.equal(code, "AUTHENTICATION_ERROR");
|
||||
else if (status === 404) assert.equal(code, "NOT_FOUND");
|
||||
else if (status === 400 || status === 422)
|
||||
assert.equal(code, "VALIDATION_ERROR");
|
||||
else assert.equal(code, "KLINGO_API_ERROR");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("lança AUTHENTICATION_ERROR quando identificar responde 2xx sem access_token", async () => {
|
||||
const fetchImpl = (async () =>
|
||||
jsonResponse(200, { foo: "bar" })) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.agendar(baseAgendarInput),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal((error as { code?: string }).code, "AUTHENTICATION_ERROR");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("não vaza o app token nem o access_token em mensagens de erro", async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error("network down");
|
||||
}) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.agendar(baseAgendarInput),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(!error.message.includes("app-token-secreto"));
|
||||
assert.ok(!error.message.includes("sessao-token-abc"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KlingoAdapter.agendar — reserva (mapeamento de sucesso verificado)", () => {
|
||||
it("constrói o Appointment a partir da resposta documentada de reserva", async () => {
|
||||
const fetchImpl = (async (url: string) => {
|
||||
if (String(url).includes("/paciente/identificar")) {
|
||||
return jsonResponse(200, sessaoBody);
|
||||
}
|
||||
return jsonResponse(201, {
|
||||
id: 777,
|
||||
expira: "2026-04-20 18:40",
|
||||
data: "2026-04-20",
|
||||
hora: "18:30",
|
||||
profissional: { id: 42, nome: "Dra. Exemplo" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
const appointment = await adapter.agendar(baseAgendarInput);
|
||||
|
||||
assert.equal(appointment.id, "777");
|
||||
assert.equal(appointment.status, "confirmed");
|
||||
assert.equal(appointment.responseMappingPending, undefined);
|
||||
assert.equal(appointment.slot.professionalId, "42");
|
||||
assert.equal(appointment.slot.professionalName, "Dra. Exemplo");
|
||||
assert.equal(appointment.slot.startDateTime, baseAgendarInput.startAt);
|
||||
assert.equal(appointment.slot.endDateTime, baseAgendarInput.endAt);
|
||||
assert.deepEqual(appointment.patient, validPatient);
|
||||
});
|
||||
|
||||
it("envia confirmado preenchido (marcação definitiva, não só reserva temporária)", async () => {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchImpl = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).includes("/paciente/identificar")) {
|
||||
return jsonResponse(200, sessaoBody);
|
||||
}
|
||||
return jsonResponse(201, { id: 1 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
await adapter.agendar(baseAgendarInput);
|
||||
|
||||
const reservarBody = JSON.parse(calls[1].init.body as string);
|
||||
assert.equal(reservarBody.procedimento, "CONS");
|
||||
assert.equal(reservarBody.id, baseAgendarInput.slotId);
|
||||
assert.ok(
|
||||
typeof reservarBody.confirmado === "string" &&
|
||||
reservarBody.confirmado.length > 0,
|
||||
);
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 404, 400, 422, 500]) {
|
||||
it(`mapeia HTTP ${status} de reservar() para o código correto`, async () => {
|
||||
const fetchImpl = (async (url: string) => {
|
||||
if (String(url).includes("/paciente/identificar")) {
|
||||
return jsonResponse(200, sessaoBody);
|
||||
}
|
||||
return rawResponse(status, "erro");
|
||||
}) as unknown as typeof fetch;
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.agendar(baseAgendarInput),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
const code = (error as { code?: string }).code;
|
||||
if (status === 401 || status === 403)
|
||||
assert.equal(code, "AUTHENTICATION_ERROR");
|
||||
else if (status === 404) assert.equal(code, "NOT_FOUND");
|
||||
else if (status === 400 || status === 422)
|
||||
assert.equal(code, "VALIDATION_ERROR");
|
||||
else assert.equal(code, "KLINGO_API_ERROR");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("KlingoAdapter.agendar — qualquer HTTP 2xx de reservar é sucesso", () => {
|
||||
const scenarios: {
|
||||
name: string;
|
||||
status: number;
|
||||
body: string | null;
|
||||
contentType?: string;
|
||||
}[] = [
|
||||
{
|
||||
name: "200 com JSON sem id numérico",
|
||||
status: 200,
|
||||
body: '{"status":"ok"}',
|
||||
contentType: "application/json",
|
||||
},
|
||||
{
|
||||
name: "200 com texto simples",
|
||||
status: 200,
|
||||
body: "OK",
|
||||
contentType: "text/plain",
|
||||
},
|
||||
{ name: "200 com corpo vazio", status: 200, body: "" },
|
||||
{ name: "204 sem corpo", status: 204, body: null },
|
||||
{
|
||||
name: "2xx com JSON malformado",
|
||||
status: 200,
|
||||
body: "{invalid",
|
||||
contentType: "application/json",
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
it(`${scenario.name}: NÃO lança erro, retorna Appointment com responseMappingPending`, async () => {
|
||||
const fetchImpl = (async (url: string) => {
|
||||
if (String(url).includes("/paciente/identificar")) {
|
||||
return jsonResponse(200, sessaoBody);
|
||||
}
|
||||
return rawResponse(
|
||||
scenario.status,
|
||||
scenario.body,
|
||||
scenario.contentType,
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const adapter = new KlingoAdapter(config, { fetchImpl });
|
||||
const appointment = await adapter.agendar(baseAgendarInput);
|
||||
|
||||
assert.equal(appointment.responseMappingPending, true);
|
||||
assert.equal(appointment.status, "pending");
|
||||
assert.equal(appointment.id, undefined);
|
||||
assert.equal(appointment.slot.startDateTime, baseAgendarInput.startAt);
|
||||
assert.deepEqual(appointment.patient, validPatient);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* test/sanitize.test.ts
|
||||
*
|
||||
* Testes unitários para a sanitização recursiva de respostas.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
REDACTED,
|
||||
isSensitiveField,
|
||||
sanitizeRecursively,
|
||||
sanitizeText,
|
||||
} from "../src/diagnostics/sanitize.js";
|
||||
|
||||
describe("isSensitiveField", () => {
|
||||
it("reconhece nomes de campos sensíveis ignorando maiúsculas/minúsculas", () => {
|
||||
assert.equal(isSensitiveField("name"), true);
|
||||
assert.equal(isSensitiveField("patientName"), true);
|
||||
assert.equal(isSensitiveField("NOME"), true);
|
||||
assert.equal(isSensitiveField("phone"), true);
|
||||
assert.equal(isSensitiveField("telefone"), true);
|
||||
assert.equal(isSensitiveField("email"), true);
|
||||
assert.equal(isSensitiveField("cpf"), true);
|
||||
assert.equal(isSensitiveField("document"), true);
|
||||
assert.equal(isSensitiveField("documento"), true);
|
||||
assert.equal(isSensitiveField("token"), true);
|
||||
assert.equal(isSensitiveField("authorization"), true);
|
||||
assert.equal(isSensitiveField("password"), true);
|
||||
assert.equal(isSensitiveField("senha"), true);
|
||||
assert.equal(isSensitiveField("address"), true);
|
||||
assert.equal(isSensitiveField("endereco"), true);
|
||||
});
|
||||
|
||||
it("não reconhece campos não sensíveis", () => {
|
||||
assert.equal(isSensitiveField("id"), false);
|
||||
assert.equal(isSensitiveField("appointmentId"), false);
|
||||
assert.equal(isSensitiveField("startAt"), false);
|
||||
assert.equal(isSensitiveField("status"), false);
|
||||
assert.equal(isSensitiveField("page"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRecursively", () => {
|
||||
it("mascara valores de campos sensíveis", () => {
|
||||
const sanitized = sanitizeRecursively({
|
||||
name: "Maria dos Santos",
|
||||
phone: "75999999999",
|
||||
email: "maria@example.com",
|
||||
});
|
||||
|
||||
assert.equal(sanitized.name, REDACTED);
|
||||
assert.equal(sanitized.phone, REDACTED);
|
||||
assert.equal(sanitized.email, REDACTED);
|
||||
});
|
||||
|
||||
it("percorre objetos e arrays aninhados", () => {
|
||||
const sanitized = sanitizeRecursively({
|
||||
data: [
|
||||
{
|
||||
patient: { name: "João", cpf: "12345678900" },
|
||||
appointmentId: "abc-123",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(sanitized.data[0].patient.name, REDACTED);
|
||||
assert.equal(sanitized.data[0].patient.cpf, REDACTED);
|
||||
assert.equal(sanitized.data[0].appointmentId, "abc-123");
|
||||
});
|
||||
|
||||
it("mantém campos não sensíveis intactos", () => {
|
||||
const sanitized = sanitizeRecursively({
|
||||
id: "app-1",
|
||||
status: "confirmed",
|
||||
page: 1,
|
||||
});
|
||||
|
||||
assert.equal(sanitized.id, "app-1");
|
||||
assert.equal(sanitized.status, "confirmed");
|
||||
assert.equal(sanitized.page, 1);
|
||||
});
|
||||
|
||||
it("redige ocorrências de segredos mesmo em campos não sensíveis", () => {
|
||||
const token = "segredo-super-secreto";
|
||||
const sanitized = sanitizeRecursively(
|
||||
{
|
||||
message: `token=${token}`,
|
||||
data: "x",
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
assert.ok(!JSON.stringify(sanitized).includes(token));
|
||||
assert.ok(JSON.stringify(sanitized).includes(REDACTED));
|
||||
});
|
||||
|
||||
it("não vaza o token na saída sanitizada", () => {
|
||||
const token = "token-real-xyz";
|
||||
const sanitized = sanitizeRecursively(
|
||||
{
|
||||
data: [{ patient: { name: "Maria", phone: "75999999999" }, token }],
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
assert.ok(!JSON.stringify(sanitized).includes(token));
|
||||
assert.ok(!JSON.stringify(sanitized).includes("75999999999"));
|
||||
assert.ok(!JSON.stringify(sanitized).includes("Maria"));
|
||||
});
|
||||
|
||||
it("lida com valores primitivos e null", () => {
|
||||
assert.equal(sanitizeRecursively(null), null);
|
||||
assert.equal(sanitizeRecursively("texto", ["zzz"]), "texto");
|
||||
assert.equal(sanitizeRecursively(42), 42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeText", () => {
|
||||
it("redige segredos e limita o comprimento", () => {
|
||||
const long = "a".repeat(700);
|
||||
const result = sanitizeText(`token=${long}`, [long]);
|
||||
|
||||
assert.ok(!result.includes(long));
|
||||
assert.ok(result.length <= 500);
|
||||
assert.ok(result.includes(REDACTED));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* test/tools.test.ts
|
||||
*
|
||||
* Testes unitários para o tratamento de erros das ferramentas MCP.
|
||||
*
|
||||
* Os handlers devem capturar qualquer erro lançado pelo Adapter e devolver
|
||||
* uma resposta MCP estruturada, sem encerrar o servidor e sem vazar
|
||||
* detalhes de implementação (stack trace, nomes de classe internos,
|
||||
* token). Testes de comportamento específico do ZProAdapter (mapeamento
|
||||
* de requisição, classificação de erros HTTP) ficam em
|
||||
* `test/zpro-adapter.test.ts`; aqui testamos apenas a camada da tool.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
|
||||
import { createConsultarHorariosHandler } from "../src/tools/consultar-horarios.tool.js";
|
||||
import { createAgendarHandler } from "../src/tools/agendar.tool.js";
|
||||
import type { ZProConfig } from "../src/config/config.types.js";
|
||||
|
||||
const config: ZProConfig = {
|
||||
provider: "zpro",
|
||||
baseUrl: "https://zpro.example.com",
|
||||
apiToken: "token-secreto",
|
||||
apiId: "api-1",
|
||||
};
|
||||
|
||||
describe("consultar_horarios", () => {
|
||||
it("retorna resposta de erro estruturada quando o Z-PRO não suporta a consulta", async () => {
|
||||
const adapter = new ZProAdapter(config);
|
||||
const handler = createConsultarHorariosHandler(adapter);
|
||||
|
||||
const result = await handler({ clinicId: "clinic-1" });
|
||||
const payload = JSON.parse(result.content[0].text);
|
||||
|
||||
assert.equal(payload.success, false);
|
||||
assert.equal(payload.error.code, "INTERNAL_ERROR");
|
||||
});
|
||||
|
||||
it("não retorna stack trace nem nomes de classe internos na resposta", async () => {
|
||||
const adapter = new ZProAdapter(config);
|
||||
const handler = createConsultarHorariosHandler(adapter);
|
||||
|
||||
const result = await handler({ clinicId: "clinic-1" });
|
||||
|
||||
assert.ok(!result.content[0].text.includes("at "));
|
||||
assert.ok(!result.content[0].text.includes("ZProAdapter"));
|
||||
assert.ok(!result.content[0].text.includes("ZProApiError"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("agendar", () => {
|
||||
const failingFetch = async (): Promise<Response> => {
|
||||
throw new TypeError("fetch failed");
|
||||
};
|
||||
const agendarInput = {
|
||||
clinicId: "clinic-1",
|
||||
startAt: "2026-04-15T14:00:00.000Z",
|
||||
endAt: "2026-04-15T15:00:00.000Z",
|
||||
patient: { name: "Paciente Exemplo" },
|
||||
};
|
||||
|
||||
it("retorna resposta de erro estruturada quando a chamada ao Z-PRO falha", async () => {
|
||||
const adapter = new ZProAdapter(config, { fetchImpl: failingFetch });
|
||||
const handler = createAgendarHandler(adapter);
|
||||
|
||||
const result = await handler(agendarInput);
|
||||
const payload = JSON.parse(result.content[0].text);
|
||||
|
||||
assert.equal(payload.success, false);
|
||||
assert.equal(payload.error.code, "INTERNAL_ERROR");
|
||||
});
|
||||
|
||||
it("não vaza o token do Z-PRO na resposta de erro", async () => {
|
||||
const adapter = new ZProAdapter(config, { fetchImpl: failingFetch });
|
||||
const handler = createAgendarHandler(adapter);
|
||||
|
||||
const result = await handler(agendarInput);
|
||||
|
||||
assert.ok(!result.content[0].text.includes(config.apiToken));
|
||||
});
|
||||
|
||||
it("retorna success:true quando o Z-PRO confirma a criação (HTTP 2xx) — não deve parecer falha", async () => {
|
||||
const acceptingFetch = async (): Promise<Response> =>
|
||||
new Response(JSON.stringify({ id: "algo" }), {
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl: acceptingFetch });
|
||||
const handler = createAgendarHandler(adapter);
|
||||
|
||||
const result = await handler(agendarInput);
|
||||
const payload = JSON.parse(result.content[0].text);
|
||||
|
||||
assert.equal(payload.success, true);
|
||||
assert.equal(payload.data.responseMappingPending, true);
|
||||
assert.notEqual(result.isError, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* test/zpro-adapter.test.ts
|
||||
*
|
||||
* Testes unitários do ZProAdapter real (agendar/consultarHorarios).
|
||||
* Todos usam um fetch fake injetado via `fetchImpl` — nenhum teste chama
|
||||
* a API real do Z-PRO.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ZProAdapter } from "../src/adapters/zpro/zpro.adapter.js";
|
||||
import {
|
||||
buildAppointmentCreateUrl,
|
||||
describeResponseMetadata,
|
||||
} from "../src/adapters/zpro/zpro-http-client.js";
|
||||
import { ZProApiError } from "../src/adapters/zpro/zpro.errors.js";
|
||||
import type { ZProConfig } from "../src/config/config.types.js";
|
||||
import type { CreateAppointmentInput } from "../src/types/scheduling.types.js";
|
||||
|
||||
const config: ZProConfig = {
|
||||
provider: "zpro",
|
||||
baseUrl: "https://zpro.example.com",
|
||||
apiToken: "token-super-secreto",
|
||||
apiId: "api-1",
|
||||
};
|
||||
|
||||
const baseInput: CreateAppointmentInput = {
|
||||
clinicId: "clinic-1",
|
||||
startAt: "2026-04-15T14:00:00.000Z",
|
||||
endAt: "2026-04-15T15:00:00.000Z",
|
||||
patient: { name: "Paciente Exemplo" },
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCode(
|
||||
promise: Promise<unknown>,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
await assert.rejects(promise, (error: unknown) => {
|
||||
assert.ok(error instanceof ZProApiError);
|
||||
assert.equal(error.code, code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
describe("ZProAdapter.consultarHorarios", () => {
|
||||
it("lança ZProApiError com código NOT_SUPPORTED, sem chamar a rede", async () => {
|
||||
let called = false;
|
||||
const fetchImpl = async (): Promise<Response> => {
|
||||
called = true;
|
||||
return jsonResponse(200, {});
|
||||
};
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await expectCode(
|
||||
adapter.consultarHorarios({ clinicId: "clinic-1" }),
|
||||
"NOT_SUPPORTED",
|
||||
);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZProAdapter.agendar — montagem da requisição", () => {
|
||||
it("envia POST para a URL correta, com Bearer token e corpo mapeado", async () => {
|
||||
let capturedUrl = "";
|
||||
let capturedMethod = "";
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
let capturedBody: unknown;
|
||||
|
||||
const fetchImpl = async (
|
||||
url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
capturedUrl = String(url);
|
||||
capturedMethod = init?.method ?? "";
|
||||
capturedHeaders = Object.fromEntries(
|
||||
new Headers(init?.headers).entries(),
|
||||
);
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return jsonResponse(201, { ok: true });
|
||||
};
|
||||
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await adapter.agendar({
|
||||
...baseInput,
|
||||
patient: { id: "contact-1", name: "Maria Souza", phone: "5511999999999" },
|
||||
});
|
||||
|
||||
assert.equal(capturedUrl, buildAppointmentCreateUrl(config));
|
||||
assert.equal(capturedMethod, "POST");
|
||||
assert.equal(capturedHeaders.authorization, "Bearer token-super-secreto");
|
||||
assert.equal(capturedHeaders["content-type"], "application/json");
|
||||
assert.deepEqual(capturedBody, {
|
||||
title: "Consulta - Maria Souza",
|
||||
contactId: "contact-1",
|
||||
contactName: "Maria Souza",
|
||||
contactPhone: "5511999999999",
|
||||
startAt: baseInput.startAt,
|
||||
endAt: baseInput.endAt,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("usa o título informado pelo chamador quando presente", async () => {
|
||||
let capturedBody: unknown;
|
||||
const fetchImpl = async (
|
||||
_url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return jsonResponse(201, { ok: true });
|
||||
};
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await adapter.agendar({ ...baseInput, title: "Retorno - Dr. Alex" });
|
||||
|
||||
assert.equal(
|
||||
(capturedBody as { title: string }).title,
|
||||
"Retorno - Dr. Alex",
|
||||
);
|
||||
});
|
||||
|
||||
it("omite contactId e contactPhone quando o paciente não os informa", async () => {
|
||||
let capturedBody: unknown;
|
||||
const fetchImpl = async (
|
||||
_url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
capturedBody = JSON.parse(String(init?.body));
|
||||
return jsonResponse(201, { ok: true });
|
||||
};
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await adapter.agendar(baseInput);
|
||||
|
||||
assert.ok(!("contactId" in (capturedBody as object)));
|
||||
assert.ok(!("contactPhone" in (capturedBody as object)));
|
||||
});
|
||||
|
||||
it("não inclui o token em nenhum outro header", async () => {
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
const fetchImpl = async (
|
||||
_url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
capturedHeaders = Object.fromEntries(
|
||||
new Headers(init?.headers).entries(),
|
||||
);
|
||||
return jsonResponse(201, { ok: true });
|
||||
};
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await adapter.agendar(baseInput);
|
||||
|
||||
assert.deepEqual(Object.keys(capturedHeaders).sort(), [
|
||||
"accept",
|
||||
"authorization",
|
||||
"content-type",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZProAdapter.agendar — classificação de erros HTTP", () => {
|
||||
const cases: Array<{ status: number; code: string }> = [
|
||||
{ status: 401, code: "AUTHENTICATION_ERROR" },
|
||||
{ status: 403, code: "AUTHENTICATION_ERROR" },
|
||||
{ status: 404, code: "NOT_FOUND" },
|
||||
{ status: 400, code: "VALIDATION_ERROR" },
|
||||
{ status: 422, code: "VALIDATION_ERROR" },
|
||||
{ status: 500, code: "ZPRO_API_ERROR" },
|
||||
];
|
||||
|
||||
for (const { status, code } of cases) {
|
||||
it(`mapeia HTTP ${status} para ${code}`, async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(status, { message: "erro" });
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await expectCode(adapter.agendar(baseInput), code);
|
||||
});
|
||||
}
|
||||
|
||||
it("mapeia timeout para ZPRO_TIMEOUT", async () => {
|
||||
const fetchImpl = async (
|
||||
_url: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
};
|
||||
const adapter = new ZProAdapter(config, { fetchImpl, timeoutMs: 50 });
|
||||
|
||||
await expectCode(adapter.agendar(baseInput), "ZPRO_TIMEOUT");
|
||||
});
|
||||
|
||||
it("mapeia erro de rede para ZPRO_API_ERROR, sem vazar o token", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => {
|
||||
throw new TypeError("fetch failed");
|
||||
};
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await assert.rejects(adapter.agendar(baseInput), (error: unknown) => {
|
||||
assert.ok(error instanceof ZProApiError);
|
||||
assert.equal(error.code, "ZPRO_API_ERROR");
|
||||
assert.ok(!error.message.includes(config.apiToken));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("não inclui o token em nenhuma mensagem de erro (todos os cenários)", async () => {
|
||||
const scenarios: Array<() => Promise<Response>> = [
|
||||
async () => jsonResponse(401, {}),
|
||||
async () => jsonResponse(500, {}),
|
||||
async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
},
|
||||
];
|
||||
|
||||
for (const fetchImpl of scenarios) {
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
await assert.rejects(adapter.agendar(baseInput), (error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(!error.message.includes(config.apiToken));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZProAdapter.agendar — sucesso confirmado pela API (HTTP 2xx)", () => {
|
||||
it("sinaliza responseMappingPending, sem extrair nenhum campo da resposta", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
// Corpo da resposta deliberadamente com campos que poderiam parecer
|
||||
// um id/status "óbvios" — o teste garante que NADA disso é lido.
|
||||
jsonResponse(201, {
|
||||
id: "id-do-zpro-nao-deve-aparecer",
|
||||
status: "confirmed",
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
|
||||
assert.equal(appointment.responseMappingPending, true);
|
||||
assert.equal(appointment.id, undefined);
|
||||
assert.equal(appointment.slot.id, undefined);
|
||||
// O status é o que FOI ENVIADO ("pending"), não o que a API retornou
|
||||
// ("confirmed") — não lemos o corpo da resposta.
|
||||
assert.equal(appointment.status, "pending");
|
||||
});
|
||||
|
||||
it("constrói slot/patient apenas a partir do input (nunca da resposta)", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(201, { anything: true });
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
const input: CreateAppointmentInput = {
|
||||
...baseInput,
|
||||
patient: { id: "contact-1", name: "Maria Souza", phone: "5511999999999" },
|
||||
};
|
||||
|
||||
const appointment = await adapter.agendar(input);
|
||||
|
||||
assert.deepEqual(appointment.slot, {
|
||||
startDateTime: input.startAt,
|
||||
endDateTime: input.endAt,
|
||||
});
|
||||
assert.deepEqual(appointment.patient, input.patient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZProAdapter.agendar — mapeamento real da resposta (envelope verificado)", () => {
|
||||
it("mapeia id/startAt/endAt/status reais quando o corpo bate com o envelope verificado", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(201, {
|
||||
success: true,
|
||||
data: {
|
||||
id: 2,
|
||||
title: "TESTE-MCP-2026-04-20-001",
|
||||
contactName: "Paciente Teste MCP",
|
||||
startAt: "2026-04-20T14:00:00.000Z",
|
||||
endAt: "2026-04-20T14:30:00.000Z",
|
||||
status: "pending",
|
||||
createdAt: "2026-08-09T20:32:06.426Z",
|
||||
},
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
|
||||
assert.equal(appointment.id, "2");
|
||||
assert.equal(appointment.responseMappingPending, undefined);
|
||||
assert.equal(appointment.status, "pending");
|
||||
assert.deepEqual(appointment.slot, {
|
||||
startDateTime: "2026-04-20T14:00:00.000Z",
|
||||
endDateTime: "2026-04-20T14:30:00.000Z",
|
||||
});
|
||||
// patient continua vindo do input, nunca do corpo da resposta.
|
||||
assert.deepEqual(appointment.patient, baseInput.patient);
|
||||
});
|
||||
|
||||
it("mapeia status 'confirmed' e 'cancelled' quando retornados", async () => {
|
||||
for (const status of ["confirmed", "cancelled"] as const) {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(200, {
|
||||
success: true,
|
||||
data: {
|
||||
id: 9,
|
||||
startAt: baseInput.startAt,
|
||||
endAt: baseInput.endAt,
|
||||
status,
|
||||
},
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
assert.equal(appointment.status, status);
|
||||
}
|
||||
});
|
||||
|
||||
it("cai em 'pending' para um status desconhecido (nunca inventa 'confirmed')", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(200, {
|
||||
success: true,
|
||||
data: {
|
||||
id: 9,
|
||||
startAt: baseInput.startAt,
|
||||
endAt: baseInput.endAt,
|
||||
status: "completed",
|
||||
},
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
assert.equal(appointment.status, "pending");
|
||||
});
|
||||
|
||||
it("cai no mapeamento pendente quando success:true mas data.id não é numérico", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(200, {
|
||||
success: true,
|
||||
data: {
|
||||
id: "não-numérico",
|
||||
startAt: baseInput.startAt,
|
||||
endAt: baseInput.endAt,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
assert.equal(appointment.responseMappingPending, true);
|
||||
assert.equal(appointment.id, undefined);
|
||||
});
|
||||
|
||||
it("cai no mapeamento pendente quando success:false", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
// HTTP 2xx com success:false no corpo é incomum, mas o status HTTP
|
||||
// continua sendo a fonte da verdade sobre erro/sucesso operacional
|
||||
// (ver docblock do adapter) — o corpo só é usado para enriquecer o
|
||||
// mapeamento quando bate exatamente com o envelope esperado.
|
||||
jsonResponse(200, { success: false, error: "algo não documentado" });
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
assert.equal(appointment.responseMappingPending, true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Cobertura explícita pedida: qualquer HTTP 2xx deve ser tratado como
|
||||
* sucesso operacional, independentemente do formato/ausência do corpo, e
|
||||
* um erro não-2xx deve continuar sendo tratado como erro.
|
||||
*/
|
||||
describe("ZProAdapter.agendar — qualquer HTTP 2xx é sucesso, seja qual for o corpo", () => {
|
||||
const scenarios: Array<{
|
||||
label: string;
|
||||
status: number;
|
||||
contentType: string | undefined;
|
||||
body: string;
|
||||
}> = [
|
||||
{
|
||||
label: "200 com JSON",
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "x", status: "confirmed" }),
|
||||
},
|
||||
{
|
||||
label: "200 com texto simples",
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: "OK",
|
||||
},
|
||||
{
|
||||
label: "200 com corpo vazio",
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "",
|
||||
},
|
||||
{
|
||||
label: "201 com JSON",
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ appointmentId: 123 }),
|
||||
},
|
||||
{
|
||||
label: "204 sem corpo",
|
||||
status: 204,
|
||||
contentType: undefined,
|
||||
body: "",
|
||||
},
|
||||
{
|
||||
label: "2xx com Content-Type application/json e JSON malformado",
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: "{ isto não é json válido",
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
it(`${scenario.label}: NÃO lança erro e retorna Appointment com responseMappingPending`, async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
// `Response` nativo não aceita corpo (nem string vazia) em status
|
||||
// "null body" como 204 — usar `null` nesses casos.
|
||||
new Response(scenario.body === "" ? null : scenario.body, {
|
||||
status: scenario.status,
|
||||
headers: scenario.contentType
|
||||
? { "content-type": scenario.contentType }
|
||||
: {},
|
||||
});
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
// Se agendar() lançar aqui, o teste falha — é o comportamento antigo
|
||||
// que este requisito elimina.
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
|
||||
assert.equal(appointment.responseMappingPending, true);
|
||||
assert.equal(appointment.id, undefined);
|
||||
assert.equal(appointment.status, "pending");
|
||||
assert.deepEqual(appointment.slot, {
|
||||
startDateTime: baseInput.startAt,
|
||||
endDateTime: baseInput.endAt,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("erro não-2xx continua sendo tratado como erro (sem regressão)", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(500, { message: "erro interno do Z-PRO" });
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
await expectCode(adapter.agendar(baseInput), "ZPRO_API_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeResponseMetadata — metadados seguros de diagnóstico", () => {
|
||||
it("classifica corpo vazio", () => {
|
||||
const metadata = describeResponseMetadata({
|
||||
status: 204,
|
||||
contentType: null,
|
||||
bodyText: "",
|
||||
});
|
||||
|
||||
assert.deepEqual(metadata, {
|
||||
status: 204,
|
||||
contentType: null,
|
||||
hasBody: false,
|
||||
bodyFormat: "empty",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifica corpo JSON válido", () => {
|
||||
const metadata = describeResponseMetadata({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
bodyText: JSON.stringify({ patient: { name: "Nome Sensível" } }),
|
||||
});
|
||||
|
||||
assert.equal(metadata.bodyFormat, "json");
|
||||
assert.equal(metadata.hasBody, true);
|
||||
// Nenhum dado do corpo (ex.: nome do paciente) vaza para os metadados.
|
||||
assert.ok(!JSON.stringify(metadata).includes("Nome Sensível"));
|
||||
});
|
||||
|
||||
it("classifica corpo não JSON (texto simples ou JSON malformado)", () => {
|
||||
assert.equal(
|
||||
describeResponseMetadata({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
bodyText: "OK",
|
||||
}).bodyFormat,
|
||||
"non-json",
|
||||
);
|
||||
assert.equal(
|
||||
describeResponseMetadata({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
bodyText: "{ inválido",
|
||||
}).bodyFormat,
|
||||
"non-json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZProAdapter — hook onResponseMetadata (diagnóstico opcional)", () => {
|
||||
it("recebe apenas metadados seguros, nunca o corpo, token ou dados do paciente", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
new Response(
|
||||
JSON.stringify({ id: "x", patient: { name: "Nome Sensível" } }),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
let received: unknown;
|
||||
const adapter = new ZProAdapter(config, {
|
||||
fetchImpl,
|
||||
onResponseMetadata: (metadata) => {
|
||||
received = metadata;
|
||||
},
|
||||
});
|
||||
|
||||
await adapter.agendar(baseInput);
|
||||
|
||||
assert.deepEqual(received, {
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
hasBody: true,
|
||||
bodyFormat: "json",
|
||||
});
|
||||
assert.ok(!JSON.stringify(received).includes("Nome Sensível"));
|
||||
assert.ok(!JSON.stringify(received).includes(config.apiToken));
|
||||
});
|
||||
|
||||
it("também é chamado em respostas de erro (não só em sucesso)", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(500, { message: "erro" });
|
||||
|
||||
let called = false;
|
||||
const adapter = new ZProAdapter(config, {
|
||||
fetchImpl,
|
||||
onResponseMetadata: () => {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(adapter.agendar(baseInput));
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
it("por padrão (sem o hook), não afeta o comportamento normal", async () => {
|
||||
const fetchImpl = async (): Promise<Response> =>
|
||||
jsonResponse(201, { id: "x" });
|
||||
const adapter = new ZProAdapter(config, { fetchImpl });
|
||||
|
||||
const appointment = await adapter.agendar(baseInput);
|
||||
|
||||
assert.equal(appointment.responseMappingPending, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user