From 257afe98c0cda074ea37d4eef71eeb7abeaff839 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 10 Aug 2026 17:23:10 -0300 Subject: [PATCH] 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 --- docs/n8n-agendamento-runbook.md | 263 ++ ...gendamento-gchat-zpro-clinica-exemplo.json | 188 + .../2026-08-10-n8n-agendamento-workflow.md | 469 +++ ...6-08-10-n8n-agendamento-workflow-design.md | 164 + gti-scheduling-mcp/.env.example | 29 + gti-scheduling-mcp/.gitignore | 4 + gti-scheduling-mcp/.prettierrc.json | 7 + gti-scheduling-mcp/README.md | 276 ++ gti-scheduling-mcp/docs/klingo-openapi.json | 3374 +++++++++++++++++ gti-scheduling-mcp/docs/zpro-api-mapping.md | 237 ++ gti-scheduling-mcp/eslint.config.js | 25 + gti-scheduling-mcp/package-lock.json | 3138 +++++++++++++++ gti-scheduling-mcp/package.json | 35 + .../scripts/diagnose-zpro-list.ts | 146 + .../src/adapters/adapter-factory.ts | 30 + .../src/adapters/klingo/klingo-http-client.ts | 151 + .../src/adapters/klingo/klingo.adapter.ts | 147 + .../src/adapters/klingo/klingo.errors.ts | 74 + .../src/adapters/klingo/klingo.mapper.ts | 217 ++ .../src/adapters/klingo/klingo.types.ts | 115 + .../adapters/scheduling-adapter.interface.ts | 32 + .../src/adapters/zpro/zpro-http-client.ts | 148 + .../src/adapters/zpro/zpro.adapter.ts | 133 + .../src/adapters/zpro/zpro.errors.ts | 72 + .../src/adapters/zpro/zpro.mapper.ts | 175 + .../src/adapters/zpro/zpro.types.ts | 56 + gti-scheduling-mcp/src/config/.gitkeep | 1 + gti-scheduling-mcp/src/config/config.types.ts | 59 + gti-scheduling-mcp/src/config/env.config.ts | 122 + gti-scheduling-mcp/src/diagnostics/analyze.ts | 86 + gti-scheduling-mcp/src/diagnostics/client.ts | 101 + .../src/diagnostics/response.ts | 56 + .../src/diagnostics/sanitize.ts | 105 + gti-scheduling-mcp/src/index.ts | 37 + gti-scheduling-mcp/src/server/mcp.server.ts | 65 + gti-scheduling-mcp/src/tools/agendar.tool.ts | 85 + .../src/tools/consultar-horarios.tool.ts | 64 + gti-scheduling-mcp/src/tools/mcp-response.ts | 69 + .../src/types/scheduling.types.ts | 150 + .../test/adapter-factory.test.ts | 52 + gti-scheduling-mcp/test/analyze.test.ts | 106 + gti-scheduling-mcp/test/config.test.ts | 172 + .../test/diagnose-client.test.ts | 184 + .../test/klingo-adapter.test.ts | 431 +++ gti-scheduling-mcp/test/sanitize.test.ts | 129 + gti-scheduling-mcp/test/tools.test.ts | 100 + gti-scheduling-mcp/test/zpro-adapter.test.ts | 570 +++ gti-scheduling-mcp/tsconfig.json | 16 + 48 files changed, 12465 insertions(+) create mode 100644 docs/n8n-agendamento-runbook.md create mode 100644 docs/n8n-workflows/agendamento-gchat-zpro-clinica-exemplo.json create mode 100644 docs/superpowers/plans/2026-08-10-n8n-agendamento-workflow.md create mode 100644 docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md create mode 100644 gti-scheduling-mcp/.env.example create mode 100644 gti-scheduling-mcp/.gitignore create mode 100644 gti-scheduling-mcp/.prettierrc.json create mode 100644 gti-scheduling-mcp/README.md create mode 100644 gti-scheduling-mcp/docs/klingo-openapi.json create mode 100644 gti-scheduling-mcp/docs/zpro-api-mapping.md create mode 100644 gti-scheduling-mcp/eslint.config.js create mode 100644 gti-scheduling-mcp/package-lock.json create mode 100644 gti-scheduling-mcp/package.json create mode 100644 gti-scheduling-mcp/scripts/diagnose-zpro-list.ts create mode 100644 gti-scheduling-mcp/src/adapters/adapter-factory.ts create mode 100644 gti-scheduling-mcp/src/adapters/klingo/klingo-http-client.ts create mode 100644 gti-scheduling-mcp/src/adapters/klingo/klingo.adapter.ts create mode 100644 gti-scheduling-mcp/src/adapters/klingo/klingo.errors.ts create mode 100644 gti-scheduling-mcp/src/adapters/klingo/klingo.mapper.ts create mode 100644 gti-scheduling-mcp/src/adapters/klingo/klingo.types.ts create mode 100644 gti-scheduling-mcp/src/adapters/scheduling-adapter.interface.ts create mode 100644 gti-scheduling-mcp/src/adapters/zpro/zpro-http-client.ts create mode 100644 gti-scheduling-mcp/src/adapters/zpro/zpro.adapter.ts create mode 100644 gti-scheduling-mcp/src/adapters/zpro/zpro.errors.ts create mode 100644 gti-scheduling-mcp/src/adapters/zpro/zpro.mapper.ts create mode 100644 gti-scheduling-mcp/src/adapters/zpro/zpro.types.ts create mode 100644 gti-scheduling-mcp/src/config/.gitkeep create mode 100644 gti-scheduling-mcp/src/config/config.types.ts create mode 100644 gti-scheduling-mcp/src/config/env.config.ts create mode 100644 gti-scheduling-mcp/src/diagnostics/analyze.ts create mode 100644 gti-scheduling-mcp/src/diagnostics/client.ts create mode 100644 gti-scheduling-mcp/src/diagnostics/response.ts create mode 100644 gti-scheduling-mcp/src/diagnostics/sanitize.ts create mode 100644 gti-scheduling-mcp/src/index.ts create mode 100644 gti-scheduling-mcp/src/server/mcp.server.ts create mode 100644 gti-scheduling-mcp/src/tools/agendar.tool.ts create mode 100644 gti-scheduling-mcp/src/tools/consultar-horarios.tool.ts create mode 100644 gti-scheduling-mcp/src/tools/mcp-response.ts create mode 100644 gti-scheduling-mcp/src/types/scheduling.types.ts create mode 100644 gti-scheduling-mcp/test/adapter-factory.test.ts create mode 100644 gti-scheduling-mcp/test/analyze.test.ts create mode 100644 gti-scheduling-mcp/test/config.test.ts create mode 100644 gti-scheduling-mcp/test/diagnose-client.test.ts create mode 100644 gti-scheduling-mcp/test/klingo-adapter.test.ts create mode 100644 gti-scheduling-mcp/test/sanitize.test.ts create mode 100644 gti-scheduling-mcp/test/tools.test.ts create mode 100644 gti-scheduling-mcp/test/zpro-adapter.test.ts create mode 100644 gti-scheduling-mcp/tsconfig.json diff --git a/docs/n8n-agendamento-runbook.md b/docs/n8n-agendamento-runbook.md new file mode 100644 index 0000000..aefeb30 --- /dev/null +++ b/docs/n8n-agendamento-runbook.md @@ -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 `` 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: " \ + -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. diff --git a/docs/n8n-workflows/agendamento-gchat-zpro-clinica-exemplo.json b/docs/n8n-workflows/agendamento-gchat-zpro-clinica-exemplo.json new file mode 100644 index 0000000..474252d --- /dev/null +++ b/docs/n8n-workflows/agendamento-gchat-zpro-clinica-exemplo.json @@ -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" + } +} diff --git a/docs/superpowers/plans/2026-08-10-n8n-agendamento-workflow.md b/docs/superpowers/plans/2026-08-10-n8n-agendamento-workflow.md new file mode 100644 index 0000000..7dc4bcb --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-n8n-agendamento-workflow.md @@ -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= + ZPRO_API_TOKEN= + ZPRO_API_ID= + ``` + +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 `` no restante deste plano. + +- [ ] **Step 2: Validar o workflow** + +Chame `n8n_validate_workflow` com `{"id": ""}`. 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 `` via `n8n_update_partial_workflow`. + +**Interfaces:** +- Consumes: `` 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": ""}`. 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 ``. + +**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": "", "name": "OpenAI account" } + } + } +} +``` + +Substitua `` 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": ""}`. 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 ``. + +**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": "", "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": "", "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": "", "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. diff --git a/docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md b/docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md new file mode 100644 index 0000000..14f087d --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-n8n-agendamento-workflow-design.md @@ -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. diff --git a/gti-scheduling-mcp/.env.example b/gti-scheduling-mcp/.env.example new file mode 100644 index 0000000..0de0a13 --- /dev/null +++ b/gti-scheduling-mcp/.env.example @@ -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= diff --git a/gti-scheduling-mcp/.gitignore b/gti-scheduling-mcp/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/gti-scheduling-mcp/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/gti-scheduling-mcp/.prettierrc.json b/gti-scheduling-mcp/.prettierrc.json new file mode 100644 index 0000000..17d2b4e --- /dev/null +++ b/gti-scheduling-mcp/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2 +} diff --git a/gti-scheduling-mcp/README.md b/gti-scheduling-mcp/README.md new file mode 100644 index 0000000..e7efd31 --- /dev/null +++ b/gti-scheduling-mcp/README.md @@ -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 ` 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//` + (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 +``` diff --git a/gti-scheduling-mcp/docs/klingo-openapi.json b/gti-scheduling-mcp/docs/klingo-openapi.json new file mode 100644 index 0000000..5b8d7d8 --- /dev/null +++ b/gti-scheduling-mcp/docs/klingo-openapi.json @@ -0,0 +1,3374 @@ +{ + "swagger": "2.0", + "info": { + "description": "Klingo API externa", + "version": "1.0.0", + "title": "Klingo API Externa", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "suporte@klingo.com.br" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "tags": [ + { + "name": "teste", + "description": "Teste da API." + }, + { + "name": "sessao", + "description": "Autenticação do usuário." + }, + { + "name": "cadastros", + "description": "Obtenção de dados cadastrais." + }, + { + "name": "paciente", + "description": "Integração do cadastro de paciente." + }, + { + "name": "agenda", + "description": "Funções da agenda." + }, + { + "name": "voucher", + "description": "Gestão das agendas marcadas" + }, + { + "name": "check-in", + "description": "Funções para realização do check-in do paciente" + }, + { + "name": "webhook", + "description": "Documentação do webhook de retorno dos status das marcações" + }, + { + "name": "atendimentos", + "description": "Consulta dos Atendimentos de uma Data" + } + ], + "schemes": ["https"], + "paths": { + "/live": { + "get": { + "tags": ["teste"], + "summary": "Verifica se a API está no ar.", + "description": "Verifica se a API está no ar e exibe a versão.", + "operationId": "versao", + "responses": { + "200": { + "description": "OK" + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/externo/register": { + "post": { + "tags": ["sessao"], + "summary": "Realiza o pré-cadastro do paciente.", + "security": [ + { + "app_token": [] + } + ], + "description": "Pré-Cadastro do Paciente", + "operationId": "register", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados do Paciente.", + "required": true, + "schema": { + "type": "object", + "properties": { + "paciente": { + "$ref": "#/definitions/paciente" + } + } + } + } + ], + "responses": { + "200": { + "description": "Id do paciente criado.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": "123", + "description": "Id do novo paciente registrado." + } + } + } + }, + "403": { + "description": "dados inválidos." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/externo/login": { + "post": { + "tags": ["sessao"], + "summary": "Realiza a autenticação da aplicação, identificando o paciente.", + "security": [ + { + "app_token": [] + } + ], + "description": "Autenticação da Aplicação. Para logar com o id do paciente, deve-se preceder o id com a letra P (Ex.: P123)", + "operationId": "login", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados para Autenticação.", + "required": true, + "schema": { + "$ref": "#/definitions/credencialLogin" + } + } + ], + "responses": { + "200": { + "description": "Token da autenticação que deve ser enviado no header dos demais endpoints.", + "schema": { + "$ref": "#/definitions/sessao" + } + }, + "403": { + "description": "dados inválidos." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/externo/portal/register": { + "post": { + "tags": ["sessao"], + "summary": "Realiza o pré-cadastro do paciente, como um usuário do portal.", + "security": [ + { + "app_token": [] + } + ], + "description": "Cadastro do Paciente", + "operationId": "registerPortal", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados do Paciente.", + "required": true, + "schema": { + "type": "object", + "properties": { + "senha": { + "type": "string", + "example": "1234", + "description": "Senha do usuário no portal." + }, + "paciente": { + "$ref": "#/definitions/paciente" + } + } + } + } + ], + "responses": { + "200": { + "description": "Id do paciente criado.", + "schema": { + "$ref": "#/definitions/sessao" + } + }, + "403": { + "description": "dados inválidos." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/externo/portal/login": { + "post": { + "tags": ["sessao"], + "summary": "Realiza a autenticação da aplicação, identificando o paciente, baseado no login/senha do portal de agendamentos do klingo.", + "security": [ + { + "app_token": [] + } + ], + "description": "Autenticação da Aplicação.", + "operationId": "loginPortal", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados para Autenticação.", + "required": true, + "schema": { + "$ref": "#/definitions/credencialLoginPortal" + } + } + ], + "responses": { + "200": { + "description": "Token da autenticação que deve ser enviado no header dos demais endpoints.", + "schema": { + "$ref": "#/definitions/sessao" + } + }, + "403": { + "description": "dados inválidos." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/externo/portal/recuperar": { + "post": { + "tags": ["sessao"], + "summary": "Envio de e-mail para o usuário para recuperação de senha.", + "security": [ + { + "app_token": [] + } + ], + "description": "Autenticação da Aplicação.", + "operationId": "recuperarPortal", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados para Autenticação.", + "required": true, + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "teste@teste.com.br", + "description": "email do usuário para recuperação de senha." + } + } + } + } + ], + "responses": { + "200": { + "description": "Token da autenticação que deve ser enviado no header dos demais endpoints.", + "schema": { + "type": "object", + "properties": { + "level": { + "type": "string", + "example": "success", + "description": "status de sucesso: success ou error." + }, + "msg": { + "type": "string", + "example": "texto da mensagem", + "description": "mensagem de retorno." + } + } + } + }, + "403": { + "description": "dados inválidos." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/paciente": { + "put": { + "tags": ["paciente"], + "summary": "Atualização dos dados do paciente", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza a atualização dos dados do paciente", + "operationId": "updatePac", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados do paciente.", + "required": true, + "schema": { + "$ref": "#/definitions/paciente" + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/paciente" + } + }, + "403": { + "description": "credenciais inválidas." + }, + "405": { + "description": "Invalid input" + } + } + }, + "get": { + "tags": ["paciente"], + "summary": "Obter dados do paciente", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza a consulta do paciente", + "operationId": "getPac", + "produces": ["application/json"], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/paciente" + } + }, + "403": { + "description": "credenciais inválidas." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/paciente/identificar": { + "post": { + "tags": ["paciente"], + "summary": "Realiza a identificação do do paciente pelo telefone, para integração via Whatsapp.", + "security": [ + { + "app_token": [] + } + ], + "description": "Realiza a identificação do do paciente pelo telefone. Deve ser enviado adicional ao telefone, o CPF ou a data de nascimento (ou passando o parâmetro apenas_telefone = true).", + "operationId": "identificar paciente por telefone", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados do paciente.", + "required": true, + "schema": { + "type": "object", + "properties": { + "telefone": { + "type": "string", + "example": "1199999999" + }, + "dt_nascimento": { + "type": "string", + "example": "1980-01-01" + }, + "cpf": { + "type": "string", + "example": "enviar alternativa a data de nascimento" + }, + "apenas_telefone": { + "type": "boolean", + "example": false + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/sessao" + } + }, + "403": { + "description": "credenciais inválidas." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/paciente/cpf": { + "get": { + "tags": ["paciente"], + "summary": "Realiza a identificação do do paciente pelo CPF", + "security": [ + { + "app_token": [] + } + ], + "description": "Realiza a identificação do do paciente pelo CPF.", + "operationId": "identificar paciente por CPF", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "cpf", + "description": "CPF do paciente (apenas números com 11 dígitos).", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/paciente" + } + }, + "403": { + "description": "credenciais inválidas." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/atendimentos": { + "get": { + "tags": ["atendimentos"], + "summary": "Obter a lista de atendimentos de uma determinada data", + "security": [ + { + "app_token": [] + } + ], + "description": "Realiza a consulta dos atendimentos de uma determinada data.", + "operationId": "getAtendimentos", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "data", + "description": "Data desejada no formato YYYY-MM-DD. Se não informada, busca-se os atendimentos do dia.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/atendimento" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/resultado/{id_marcacao}": { + "get": { + "tags": ["resultados"], + "summary": "Obter resultados de um determinado voucher (marcação)", + "security": [ + { + "app_token": [] + } + ], + "description": "Realiza a consulta dos resultados de um determinado voucher.", + "operationId": "getResultado", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "path", + "name": "id_marcacao", + "required": true, + "type": "number", + "description": "Número da Marcação" + } + ], + "responses": { + "200": { + "description": "Lista de resultados enviada com sucesso.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/resultado" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/resultado/pdf/{id_marcacao}": { + "get": { + "tags": ["resultados"], + "summary": "Obter resultado laboratorial (PDF) de um determinado voucher (marcação)", + "security": [ + { + "app_token": [] + } + ], + "description": "Realiza a consulta do resultado de um determinado voucher.", + "operationId": "getResultadoPdf", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "path", + "name": "id_marcacao", + "required": true, + "type": "number", + "description": "Número da Marcação" + } + ], + "responses": { + "200": { + "description": "Resultado enviada com sucesso.", + "schema": { + "type": "object", + "properties": { + "id_marcacao": { + "type": "string" + }, + "id_atendimento": { + "type": "string" + }, + "pdf_base64": { + "type": "string" + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/resultado/pdf/an/{accession_number}": { + "get": { + "tags": ["resultados"], + "summary": "Obter resultado de exame de imagem (PDF) de um determinado accession number", + "security": [ + { + "app_token": [] + } + ], + "description": "Realiza a consulta do resultado de um determinado accession number.", + "operationId": "getResultadoPdfAn", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "path", + "name": "accession_number", + "required": true, + "type": "string", + "description": "Accession Number" + }, + { + "in": "body", + "name": "body", + "description": "Data de Nascimento (Opcional) ou Id do Paciente pra validar a consulta.", + "required": true, + "schema": { + "type": "object", + "properties": { + "dtnasc": { + "type": "string", + "example": "2000-01-01" + }, + "patientid": { + "type": "string", + "example": "1234" + } + } + } + } + ], + "responses": { + "200": { + "description": "Resultado enviada com sucesso.", + "schema": { + "type": "object", + "properties": { + "id_marcacao": { + "type": "string" + }, + "id_atendimento": { + "type": "string" + }, + "pdf_base64": { + "type": "string" + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/vouchers": { + "get": { + "tags": ["voucher"], + "summary": "Obter os vouchers do paciente (agendamentos)", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza a consulta de agendamentos.", + "operationId": "getVouchers", + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/voucher" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/voucher": { + "delete": { + "tags": ["voucher"], + "summary": "Exclusão do voucher (agendamento)", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza a exclusão do voucher.", + "operationId": "delVoucher", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Id do voucher a ser cancelado.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "type": "string", + "example": "Voucher cancelado" + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/requisicao": { + "get": { + "tags": ["voucher"], + "summary": "Obter os dados da requisição de exames do paciente", + "security": [ + { + "bearer": [] + } + ], + "description": "Obter dados da requisição de exames do paciente.", + "operationId": "getRequisicao", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "id", + "description": "Id da requisição lida no QRCode da requisicão. RQ-ORIGEM-ID-TOKEN", + "required": true, + "type": "number" + }, + { + "in": "query", + "name": "origem", + "description": "Origem da requisição lida no QRCode da requisicão. RQ-ORIGEM-ID-TOKEN", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "token", + "description": "Token da requisição lida no QRCode da requisicão. RQ-ORIGEM-ID-TOKEN", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "pdf", + "description": "Se esse parâmetro for passado igual a 1, é retornado um PDF no lugar do json", + "required": false, + "type": "number" + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/requisicao" + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/horarios": { + "get": { + "tags": ["agenda"], + "summary": "Consultar horários disponíveis para marcação.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Consulta dos horários disponíveis para marcação.", + "operationId": "getTimes", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "especialidade", + "description": "CBOS da especialidade. Obrigatório somente se marcação de consulta.", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "exame", + "description": "Código do exame. Obrigatório somente se marcação de exame.", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "inicio", + "description": "Data inicial da busca. Se não informado, busca-se a partir da data atual.", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "fim", + "description": "Data final da busca. Se não informado, busca-se até 10 dias em relação a data inicial.", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "crm", + "description": "Filtra o médico pelo CRM informado. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "cnes", + "description": "Filtra a unidade pelo CNES informado. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "classe_unidade", + "description": "Filtra a unidade pela classificação da unidade. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "plano", + "description": "Filtra operadora/plano. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "revisao", + "description": "Indica se a consulta é de revisão. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "adicao", + "description": "Indica se é uma busca adicional, na qual as reservas do paciente não devem ser liberadas. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "remarcacao", + "description": "Indica se é uma busca para remarcação do procedimento. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "subespecialidade", + "description": "Indica a subespecialidade do profissional. (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "sexo", + "description": "Sexo do Paciente, quando buscado sem o bearer token do paciente (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "nascimento", + "description": "Data de nascimento do paciente (YYYY-MM-DD), quando buscado sem o bearer token do paciente (opcional)", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "id_regiao_cd", + "description": "Id da Região de Coleta Domiciliar (Necessário apenas para agendamentos de coleta domiciliar)", + "required": false, + "type": "number" + }, + { + "in": "query", + "name": "telemed", + "description": "Indica se a consulta é apenas de agendas de telemedicina. (preencher com 1 ou 0) - (opcional)", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/horariosAgenda" + } + }, + "403": { + "description": "procedimento não identificado. Vai ocorrer se o procedimento de consulta não for identificado." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/horario": { + "post": { + "tags": ["agenda"], + "summary": "Confirmar a marcação do horário..", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza a marcação do horário na agenda do médico. O solicitante pode ser informado opcionalmente para marcações de exames.", + "operationId": "schedule", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados da marcação.", + "required": true, + "schema": { + "$ref": "#/definitions/horarioMarcar" + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/voucher" + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/horario_procedimentos": { + "get": { + "tags": ["agenda"], + "summary": "Consultar os procedimentos disponíveis para um determinado horário.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Consultar os procedimentos disponíveis para um determinado horário.", + "operationId": "getHorarioProcs", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "horario", + "description": "Chave do horário obtido na lista de horários. (Ex.: 2023-08-28|3|2|1|08:00)", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Lista de Procedimentos.", + "schema": { + "$ref": "#/definitions/horarioProcedimentos" + } + }, + "403": { + "description": "horário não identificado." + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/reservar": { + "post": { + "tags": ["agenda"], + "summary": "Realizar a reserva do horário.", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza a reserva do horário para posterior confirmação da marcação. Essa ação não REALIZA a marcação, mas apenas segura o horário durante 10 minutos, caso ainda tenha alguma etapa para realizar a confirmação final.", + "operationId": "reserve", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados da marcação.", + "required": true, + "schema": { + "$ref": "#/definitions/horarioMarcar" + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/reserva" + } + }, + "403": { + "description": "Invalid input" + } + } + }, + "delete": { + "tags": ["agenda"], + "summary": "Cancelar a reserva de horário.", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza o cancelamento da reserva do horário, passando o id da reserva realizada.", + "operationId": "reserveDelete", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados da marcação.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + } + }, + "403": { + "description": "Invalid input" + } + } + } + }, + "/agenda/procedimento": { + "put": { + "tags": ["agenda"], + "summary": "Alterar procedimento de uma marcação.", + "security": [ + { + "bearer": [] + } + ], + "description": "Alterar procedimento de uma marcação.", + "operationId": "alterProc", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados da marcação.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id_marcacao": { + "type": "number", + "example": 1234, + "description": "Id da marcação." + }, + "id_procedimento": { + "type": "number", + "example": 1000, + "description": "Id do novo procedimento." + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/voucher" + } + }, + "403": { + "description": "Invalid input" + } + } + } + }, + "/agenda/verificar_horario": { + "get": { + "tags": ["agenda"], + "summary": "Verificar operadoras e procedimentos específicos para um horário.", + "security": [ + { + "app_token": [] + } + ], + "description": "Verificar operadoras e procedimentos específicos para um horário.", + "operationId": "verificarHorario", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados da marcação.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "2021-07-19|71|2|1|18:30", + "description": "Id da horário selecionado." + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/regraHorario" + } + }, + "403": { + "description": "Invalid input" + } + } + } + }, + "/agenda/consultas": { + "get": { + "tags": ["cadastros"], + "summary": "lista os procedimentos de consulta disponíveis na base. Pode ser enviado o CBOS ou o id da especialidade para filtrar as consultas.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista os procedimentos de consulta.", + "operationId": "getConsultas", + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "cbos", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "id_especialidade", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "cnes", + "type": "string", + "required": false + } + ], + "responses": { + "200": { + "description": "lista de consultas.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/procedimento" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/especialidades": { + "get": { + "tags": ["cadastros"], + "summary": "lista as especialidades disponíveis na base.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista as especialidades.", + "operationId": "getEspecialidades", + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "cbos", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "cnes", + "type": "string", + "required": false + }, + { + "in": "query", + "name": "id_plano", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "consultas", + "type": "boolean", + "required": false + } + ], + "responses": { + "200": { + "description": "lista de especialidades.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/especialidade" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/exames": { + "get": { + "tags": ["cadastros"], + "summary": "lista os exames disponíveis na base.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista os exames.", + "operationId": "getExames", + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "id", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "busca", + "type": "string", + "required": false + } + ], + "responses": { + "200": { + "description": "lista de exames.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/procedimento" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/agenda/profissionais": { + "get": { + "tags": ["cadastros"], + "summary": "lista os profissionais disponíveis para um determinado procedimento.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista os profissionais.", + "operationId": "getProfissionais", + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "id_procedimento", + "type": "number", + "required": true + }, + { + "in": "query", + "name": "id_plano", + "type": "number", + "required": true + }, + { + "in": "query", + "name": "cnes", + "type": "number", + "required": false + } + ], + "responses": { + "200": { + "description": "lista de profissionais.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/profissional" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/checkin": { + "post": { + "tags": ["check-in"], + "summary": "realiza o check-in de atendimento do paciente.", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza o check-in de atendimento do paciente.", + "operationId": "checkIn", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Parâmetros para o checkin.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123, + "description": "Id do voucher" + }, + "expresso": { + "type": "boolean", + "example": false, + "description": "Indica se deve ser realizado check-in expresso para o atendimento." + }, + "preferencial": { + "type": "boolean", + "example": false, + "description": "Indica se é atendimento preferencial. Parâmetro opcional." + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "$ref": "#/definitions/checkinrealizado" + } + }, + "405": { + "description": "Dados inválidos" + } + } + }, + "delete": { + "tags": ["check-in"], + "summary": "realiza o cancelamento do check-in de atendimento do paciente.", + "security": [ + { + "bearer": [] + } + ], + "description": "Realiza o cancelamento do check-in de atendimento do paciente. Somente se o atendimento não tiver sido iniciado.", + "operationId": "checkInCancel", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Parâmetros para o cancelamento do checkin.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123, + "description": "Id do voucher" + } + } + } + } + ], + "responses": { + "200": { + "description": "requisição executada com sucesso.", + "schema": { + "type": "string", + "example": "Checkin cancelado" + } + }, + "403": { + "description": "Dados inválidos" + } + } + } + }, + "/webhook-configurado": { + "post": { + "tags": ["webhook"], + "summary": "Webhook disparado quando ocorre uma atualização no status da marcação.", + "description": "Essa não é uma rota da API, mas a descrição da requisição enviada quando uma atualização de status da agenda ocorre.", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "header", + "name": "X-APP-TOKEN", + "type": "string", + "required": true + }, + { + "in": "header", + "name": "X-DOMAIN", + "type": "string", + "required": true + }, + { + "in": "header", + "name": "X-EVENT", + "type": "string", + "required": true, + "description": "Possíveis Eventos: STATUS-MARCACAO, REMARCACAO, CHAMADA ou FIM-ATENDIMENTO" + }, + { + "in": "body", + "name": "body", + "description": "Informações da Atualização de Status. A informação {nova} é enviada quando o evento é de REMARCACAO.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id_marcacao": { + "type": "number", + "example": 123, + "description": "Id do marcação atualizada." + }, + "status": { + "type": "string", + "example": "C", + "description": "Status atualizado: A - Aberto, B - Bloqueado ou C - Cancelado." + }, + "motivo": { + "type": "string", + "example": "Motivo do cancelamento." + }, + "checkin": { + "type": "string", + "example": "2020-01-01 10:00", + "description": "Quando o paciente realiza o check-in na clínica, é enviado o status A, com a data/hora do check-in." + }, + "evento": { + "type": "object", + "description": "Evento de Chamada e Fim de Atendimento", + "properties": { + "nome": { + "type": "string", + "example": "chamada", + "description": "Evento Especial: chamada e fim-atendimento" + }, + "local": { + "type": "string", + "example": "Consultório 10", + "description": "Local da chamada. Específico para o evento chamada" + } + } + }, + "nova": { + "$ref": "#/definitions/voucher" + } + } + } + } + ], + "responses": { + "200": { + "description": "Chamada com sucesso." + } + } + } + }, + "/convenios": { + "get": { + "tags": ["cadastros"], + "summary": "lista de convênios.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista de convênios.", + "operationId": "getConvenios", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "cnes", + "type": "string", + "required": false + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "$ref": "#/definitions/convenios" + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/convenio/{cnpj}": { + "get": { + "tags": ["cadastros"], + "summary": "lista de convênios por CNPJ.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista de convênios por CNPJ.", + "operationId": "getConvenioCnpj", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "path", + "name": "cnpj", + "type": "string", + "required": true + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "$ref": "#/definitions/convenios" + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/unidades": { + "get": { + "tags": ["cadastros"], + "summary": "lista de unidades.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista de unidades.", + "operationId": "getUnidades", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "classe_unidade", + "type": "string", + "description": "Filtra pela classificação da unidade. (opcional)", + "required": false + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/unidade" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/recepcoes": { + "get": { + "tags": ["cadastros"], + "summary": "lista de recepções.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista de recepções.", + "operationId": "getRecepcoes", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "cnes", + "type": "string", + "description": "Identificação da unidade.", + "required": true + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/recepcao" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/profissionais": { + "get": { + "tags": ["cadastros"], + "summary": "lista de profissionais.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista de profissionais. agenda_compartilhada=1, trás além dos profissionais, as agendas compartilhadas. Pode ser enviado o CBOS no parâmetro especialidade ou o id, no parâmetro id_especialidade.", + "operationId": "getMedicos", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "especialidade", + "type": "string", + "required": false + }, + { + "in": "query", + "name": "id_especialidade", + "type": "string", + "required": false + }, + { + "in": "query", + "name": "agenda_compartilhada", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "vinculos", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "cnes", + "type": "string", + "required": false + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/profissional" + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/profissional": { + "get": { + "tags": ["cadastros"], + "summary": "informação do profissional.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Informações do Profissional.", + "operationId": "getMedico", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "id_medico", + "type": "number", + "required": false + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "$ref": "#/definitions/profissional" + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/preco": { + "get": { + "tags": ["cadastros"], + "summary": "preço de procedimento.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Preço de Procedimento.", + "operationId": "getPreco", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "id_procedimento", + "type": "string", + "required": true + }, + { + "in": "query", + "name": "id_plano", + "type": "number", + "required": true + }, + { + "in": "query", + "name": "id_medico", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "id_unidade", + "type": "number", + "required": false + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "id_item_tabela": { + "type": "number", + "description": "Id", + "example": 123 + }, + "valor": { + "type": "number", + "description": "Valor", + "example": 100 + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/precos": { + "get": { + "tags": ["cadastros"], + "summary": "preços de procedimento.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Preços de Procedimento. Resultado Paginado.", + "operationId": "getPrecos", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "id_plano", + "type": "number", + "required": true + }, + { + "in": "query", + "name": "id_medico", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "id_unidade", + "type": "number", + "required": false + }, + { + "in": "query", + "name": "page", + "type": "number", + "required": false + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id_item_tabela": { + "type": "number", + "description": "Id", + "example": 123 + }, + "valor": { + "type": "number", + "description": "Valor", + "example": 100 + } + } + } + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/regiao_cd": { + "get": { + "tags": ["cadastros"], + "summary": "Regiões de Coleta Domiciliar.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Lista as regiões de coleta domiciliar de uma unidade de coleta domiciliar.", + "operationId": "getRegioes", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "query", + "name": "cnes", + "type": "string", + "required": true + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "lista": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Id", + "example": 123 + }, + "nome": { + "type": "string", + "description": "Nome", + "example": "Pituba" + } + } + } + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/formas_de_pagamento": { + "get": { + "tags": ["adiantamentos"], + "summary": "Lista de formas de pagamento para o registro do adiantamento.", + "security": [ + { + "app_token": [] + }, + { + "bearer": [] + } + ], + "description": "Formas de Pagamento.", + "operationId": "getFormas", + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "lista": { + "type": "array", + "items": { + "$ref": "#/definitions/formaPag" + } + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/adiantamentos": { + "get": { + "tags": ["adiantamentos"], + "summary": "Lista de os adiantamentos registrados do paciente.", + "security": [ + { + "bearer": [] + } + ], + "description": "Lista os Adiantamentos Registros do Paciente.", + "operationId": "getAdiantamentos", + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "lista": { + "type": "array", + "items": { + "$ref": "#/definitions/adiantamento" + } + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/adiantamento": { + "post": { + "tags": ["adiantamentos"], + "summary": "Registrar adiantamento do paciente.", + "security": [ + { + "bearer": [] + } + ], + "description": "Registrar Adiantamento do Paciente.", + "operationId": "saveAdiantamento", + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Dados do Adiantamento.", + "required": true, + "schema": { + "type": "object", + "properties": { + "id_externo": { + "type": "string", + "example": "Id no sistema de origem" + }, + "data": { + "type": "string", + "example": "2025-01-01" + }, + "cnes": { + "type": "string", + "example": "123456" + }, + "valor_total": { + "type": "number", + "example": 50 + }, + "obs": { + "type": "string", + "example": "Lorem Ipsum" + }, + "responsavel_financeiro": { + "type": "string", + "example": "Nome, somente quando for outro responável financeiro" + }, + "responsavel_cpf_cnpj": { + "type": "string", + "example": "12345678901 - CPF/CNPJ somente números" + }, + "responsavel_endereco": { + "type": "string", + "example": "Endereço" + }, + "responsavel_email": { + "type": "string", + "example": "teste@teste.com" + }, + "formas_de_pagamento": { + "type": "array", + "items": { + "properties": { + "id_forma_pagamento": { + "type": "number", + "example": 1 + }, + "id_bandeira_cartao": { + "type": "number", + "example": 1 + }, + "valor": { + "type": "number", + "example": 50 + }, + "parcelas": { + "type": "number", + "example": 2 + }, + "num_autorizacao": { + "type": "string", + "example": "1234" + } + } + } + } + } + } + } + ], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "lista": { + "type": "array", + "items": { + "$ref": "#/definitions/adiantamento" + } + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/autorizacoes": { + "get": { + "tags": ["autorizacoes"], + "summary": "Lista as autorizações registradas do paciente.", + "security": [ + { + "bearer": [] + } + ], + "description": "Lista as Autorizações Registradas do Paciente.", + "operationId": "getAutorizacoes", + "consumes": ["application/json"], + "produces": ["application/json"], + "responses": { + "200": { + "description": "lista com sucesso.", + "schema": { + "type": "object", + "properties": { + "lista": { + "type": "array", + "items": { + "$ref": "#/definitions/autorizacao" + } + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + } + }, + "securityDefinitions": { + "bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "app_token": { + "type": "apiKey", + "name": "X-APP-TOKEN", + "in": "header" + } + }, + "definitions": { + "plano": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "id do plano", + "example": "01" + }, + "nome": { + "type": "string", + "description": "nome do plano", + "example": "Básico" + }, + "codigo": { + "type": "string", + "description": "código do plano", + "example": "123" + } + } + }, + "addconvenio": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "id do convenio", + "example": "01" + }, + "reg_ans": { + "type": "string", + "description": "registro na ANS", + "example": "12345" + }, + "matricula": { + "type": "string", + "description": "matrícula no convênio", + "example": "12345" + }, + "validade": { + "type": "string", + "description": "validade da matrícula no formato yyyy-mm-dd", + "example": "1980-06-01" + }, + "id_plano": { + "type": "string", + "description": "Id do Plano", + "example": "01" + } + } + }, + "convenio": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "id do convenio", + "example": "01" + }, + "nome": { + "type": "string", + "description": "nome do convenio", + "example": "UNIMED" + }, + "reg_ans": { + "type": "string", + "description": "registro na ANS", + "example": "12345" + }, + "planos": { + "type": "array", + "items": { + "$ref": "#/definitions/plano" + } + } + } + }, + "convenios": { + "type": "array", + "items": { + "$ref": "#/definitions/convenio" + } + }, + "sessao": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "description": "Token de autenticação. Deve enviado nas demais requisições como Bearer Token", + "example": "ABCDE12345" + }, + "token_type": { + "type": "string", + "description": "bearer", + "example": "bearer" + }, + "expires_in": { + "type": "number", + "description": "Tempo para expiração do token.", + "example": 360000 + }, + "user": { + "type": "object", + "description": "Dados do Paciente", + "properties": { + "id": { + "type": "number", + "example": 1, + "description": "Id do Paciente" + }, + "nome": { + "type": "string", + "example": "FULANO DA SILVA", + "description": "Nome do Paciente" + }, + "id_paciente": { + "type": "number", + "example": 1, + "description": "ID do cadastro definitivo do paciente." + } + } + }, + "unidade": { + "type": "object", + "description": "Dados da Unidade", + "properties": { + "nome": { + "type": "string", + "example": "Clinica Modelo", + "description": "Nome da Unidade Acessada" + } + } + } + } + }, + "credencialLogin": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id do paciente que será tratado na sessão.", + "example": "123" + } + } + }, + "credencialLoginPortal": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "e-mail da conta de acesso ao portal de agendamento.", + "example": "teste@teste.com.br" + }, + "senha": { + "type": "string", + "description": "senha da conta de acesso ao portal de agendamento.", + "example": "1234" + } + } + }, + "docs": { + "type": "object", + "properties": { + "cpf": { + "type": "string", + "example": "11111111111", + "description": "CPF do paciente." + }, + "rg": { + "type": "string", + "example": "2222222222", + "description": "RG do paciente." + } + } + }, + "contatos": { + "type": "object", + "properties": { + "celular": { + "type": "string", + "example": "11988884444" + }, + "telefone": { + "type": "string", + "example": "112345678" + }, + "email": { + "type": "string", + "example": "a@a.com.br" + } + } + }, + "endereco": { + "type": "object", + "properties": { + "cep": { + "type": "string" + }, + "endereco": { + "type": "string" + }, + "numero": { + "type": "string" + }, + "complemento": { + "type": "string" + }, + "bairro": { + "type": "string" + }, + "cidade": { + "type": "string", + "example": "São Paulo" + }, + "uf": { + "type": "string", + "example": "SP" + } + } + }, + "paciente": { + "type": "object", + "properties": { + "id_origem": { + "type": "integer", + "description": "Id do paciente no sistema de origem.", + "example": "1234" + }, + "nome": { + "type": "string", + "description": "Nome do Paciente", + "example": "João da Silva" + }, + "sexo": { + "type": "string", + "enum": ["M", "F"] + }, + "dt_nasc": { + "type": "string", + "description": "yyyy-mm-dd", + "example": "1980-06-01" + }, + "mae": { + "type": "string", + "description": "Nome da Mãe", + "example": "Maria das Graças" + }, + "docs": { + "$ref": "#/definitions/docs" + }, + "contatos": { + "$ref": "#/definitions/contatos" + }, + "endereco": { + "$ref": "#/definitions/endereco" + }, + "convenio": { + "$ref": "#/definitions/addconvenio" + } + } + }, + "profissional": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id do Profissional" + }, + "tratamento": { + "type": "string", + "description": "Tratamento", + "example": "Dr(a)." + }, + "numero": { + "type": "string", + "description": "Número no Conselho", + "example": "1234" + }, + "uf": { + "type": "string", + "description": "UF do Conselho do Médico", + "example": "SP" + }, + "conselho": { + "type": "string", + "description": "Conselho do Médico", + "example": "CRM" + }, + "nome": { + "type": "string", + "description": "Nome do Profissional", + "example": "Fulano da Silva" + }, + "subespecialidade": { + "type": "string", + "description": "Subespecialidade do profissional.", + "example": "Catarata" + }, + "foto": { + "type": "string", + "description": "URL da Foto do Profissional", + "example": "https://endereco-da-foto/" + }, + "selecionado": { + "type": "boolean", + "example": false + } + } + }, + "unidade": { + "type": "object", + "properties": { + "nome": { + "type": "string" + }, + "cep": { + "type": "string" + }, + "cnes": { + "type": "string" + }, + "local": { + "type": "string" + } + } + }, + "recepcao": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 12 + }, + "nome": { + "type": "string", + "example": "Principal" + }, + "sigla": { + "type": "string", + "example": "PC" + }, + "id_externo": { + "type": "string", + "example": "123" + }, + "senhas": { + "type": "array", + "description": "Senhas que podem ser retiradas nessa recepção", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "nome": { + "type": "string", + "example": "Atendimento" + }, + "preferencial": { + "type": "boolean", + "example": false + }, + "link": { + "type": "string", + "example": "Link para Geração da Senha (POST)" + } + } + } + } + } + }, + "horarioMarcar": { + "type": "object", + "properties": { + "procedimento": { + "type": "string", + "description": "Código do Procedimento", + "example": "1000" + }, + "id": { + "type": "string", + "description": "Id do horário a ser marcado" + }, + "plano": { + "type": "number", + "description": "Id do plano sendo marcado.", + "example": 1 + }, + "email": { + "type": "boolean", + "description": "Indica se o e-mail será enviado ao paciente ao realizar a marcação.", + "example": true + }, + "teleatendimento": { + "type": "boolean", + "description": "Indica se o atendimento será no formato de teleatendimento.", + "example": false + }, + "revisao": { + "type": "boolean", + "description": "Para para indicar se a consulta deve ser marcada como revisão. Se não for passado o parâmetro como true, será marcado de acordo com o cálculo do sistema.", + "example": false + }, + "remarcacao": { + "type": "number", + "description": "Id da marcação de origem, quando é um processo de remarcação.", + "example": 3333 + }, + "ordem_chegada": { + "type": "boolean", + "description": "Quando o horário consultado for de ordem de chegada, deve-se enviar esse parâmetro como true, para realizar a marcação", + "example": false + }, + "lista": { + "type": "array", + "description": "Id dos procedimentos associados, para realização no mesmo horário (ex.: Procedimentos laboratoriais).", + "items": { + "type": "number", + "example": 123, + "description": "Id do procedimento associado." + } + }, + "solicitante": { + "$ref": "#/definitions/solicitante" + }, + "confirmado": { + "type": "string", + "description": "Se for passado um texto, a marcação já irá ser feita como confirmada." + }, + "id_externo": { + "type": "string", + "description": "Id do sistema de origem." + }, + "obs": { + "type": "string", + "description": "Observação da Marcação." + }, + "duracao": { + "type": "number", + "example": 10, + "description": "Apenas para reserva de horário. Duração em minutos da expiração da reserva." + }, + "id_ampliar": { + "type": "number", + "description": "Apenas para reserva de horário. Id da reserva anterior, a qual se deseja ampliar a expiração." + }, + "anexos": { + "type": "array", + "items": { + "type": "string", + "example": "URL do anexo" + } + } + } + }, + "procedimento": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "descr": { + "type": "string" + }, + "abrev": { + "type": "string" + }, + "tuss": { + "type": "string" + }, + "sus": { + "type": "string" + }, + "especialidade": { + "$ref": "#/definitions/especialidade" + } + } + }, + "voucher": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id do Voucher", + "example": 123 + }, + "status": { + "type": "string", + "description": "Status do Voucher.", + "enum": ["agendado", "atendido", "checkin"] + }, + "tipo": { + "type": "string", + "description": "Tipo de Voucher.", + "enum": ["A"] + }, + "data": { + "type": "string", + "example": "2019-01-01" + }, + "hora": { + "type": "string", + "example": "09:00" + }, + "solicitante": { + "$ref": "#/definitions/profissional" + }, + "realizador": { + "$ref": "#/definitions/profissional" + }, + "unidade": { + "$ref": "#/definitions/unidade" + }, + "procedimento": { + "$ref": "#/definitions/procedimento" + }, + "especialidade": { + "$ref": "#/definitions/especialidade" + }, + "id_externo": { + "type": "string", + "example": "1234" + } + } + }, + "atendimento": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id do Atendimento", + "example": 123 + }, + "paciente": { + "$ref": "#/definitions/paciente" + }, + "medico": { + "$ref": "#/definitions/profissional" + }, + "plano": { + "$ref": "#/definitions/plano" + }, + "procedimento": { + "type": "array", + "items": { + "$ref": "#/definitions/procedimento" + } + } + } + }, + "horariosAgenda": { + "type": "object", + "properties": { + "procedimento": { + "type": "string", + "example": "CONS" + }, + "horarios": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id da Data." + }, + "data": { + "type": "string", + "description": "data do horário disponível.", + "example": "2019-01-01" + }, + "procedimento": { + "type": "string", + "description": "procedimento para o qual o horário está disponível", + "example": "CONS" + }, + "ordem_chegada": { + "type": "boolean", + "example": false, + "description": "Indica se a agenda é por ordem de chegada." + }, + "faixa_ordem_chegada": { + "type": "string", + "description": "Preenchido quando é agenda por ordem de chegada." + }, + "horarios": { + "type": "object", + "description": "Lista de Horários com ID e horário." + }, + "profissional": { + "$ref": "#/definitions/profissional" + }, + "unidade": { + "$ref": "#/definitions/unidade" + } + } + } + } + } + }, + "horarioProcedimentos": { + "type": "object", + "properties": { + "chave": { + "type": "string", + "example": "2023-08-28|3|2|1|08:00" + }, + "data": { + "type": "string", + "example": "2023-08-28" + }, + "hora": { + "type": "string", + "example": "08:00" + }, + "medico": { + "$ref": "#/definitions/profissional" + }, + "unidade": { + "$ref": "#/definitions/unidade" + }, + "procedimentos": { + "type": "array", + "items": { + "$ref": "#/definitions/procedimento" + } + } + } + }, + "especialidade": { + "type": "object", + "properties": { + "codigo": { + "type": "string", + "example": "OFT" + }, + "nome": { + "type": "string", + "example": "OFTALMOLOGIA" + }, + "cbos": { + "type": "string", + "example": "225265" + }, + "subespecialidade": { + "type": "array", + "items": { + "type": "string", + "example": "Especial" + } + } + } + }, + "requisicao": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123 + }, + "origem": { + "type": "string", + "example": "origem" + }, + "tipo": { + "type": "string", + "example": "requisicao" + }, + "paciente": { + "type": "string", + "example": "FULANO DA SILVA" + }, + "data": { + "type": "string", + "example": "01/01/2000" + }, + "medico": { + "$ref": "#/definitions/solicitante" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/procedimento" + } + } + } + }, + "solicitante": { + "type": "object", + "properties": { + "conselho": { + "type": "string", + "example": "CRM" + }, + "uf": { + "type": "string", + "example": "BA" + }, + "numero": { + "type": "number", + "example": 1234 + }, + "nome": { + "type": "string", + "example": "João da Silva" + }, + "cbos": { + "type": "string", + "example": "225265" + } + } + }, + "reserva": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": "123" + }, + "expira": { + "type": "string", + "example": "2020-01-01 14:10", + "description": "Data/Hora da expiração da reserva." + }, + "data": { + "type": "string", + "example": "2020-01-01" + }, + "hora": { + "type": "string", + "example": "10:00" + }, + "profissional": { + "$ref": "#/definitions/profissional" + }, + "instrucao": { + "type": "object", + "properties": { + "procedimento": { + "type": "string", + "example": "instrução do procedimento" + }, + "profissional": { + "type": "string", + "example": "instrução do profissional" + } + } + }, + "validacao": { + "type": "array", + "items": { + "type": "object", + "properties": { + "procedimento": { + "type": "number", + "example": 123 + }, + "validado": { + "type": "boolean" + }, + "id_requisicao": { + "type": "number", + "example": 456 + }, + "solicitante": { + "$ref": "#/definitions/profissional" + } + } + } + } + } + }, + "laudo": { + "type": "object", + "properties": { + "id_laudo": { + "type": "number", + "example": 123 + }, + "id_marcacao": { + "type": "number", + "example": 123 + }, + "id_atendimento": { + "type": "number", + "example": 123 + }, + "id_atendimento_procedimento": { + "type": "number", + "example": 123 + }, + "dt_atendimento": { + "type": "string", + "example": "1900-01-01 00:00" + }, + "procedimento": { + "$ref": "#/definitions/procedimento" + }, + "status": { + "type": "string", + "example": "Status do Laudo" + }, + "pronto": { + "type": "boolean", + "example": true + }, + "medico": { + "$ref": "#/definitions/profissional" + }, + "unidade": { + "$ref": "#/definitions/unidade" + }, + "resultado_html": { + "type": "string", + "example": "Resultado em HTML" + }, + "resultado_pdf": { + "type": "string", + "example": "Base 64 do PDF" + }, + "anexos": { + "type": "array", + "items": { + "type": "string", + "example": "URL do anexo" + } + } + } + }, + "resultado": { + "type": "object", + "properties": { + "lista": { + "type": "array", + "items": { + "$ref": "#/definitions/laudo" + } + } + } + }, + "regraHorario": { + "type": "object", + "properties": { + "id_agenda": { + "type": "number", + "example": 123 + }, + "filtro_operadora": { + "type": "string", + "example": "T", + "description": "T - Todas, A - Apenas ou E - Exceto" + }, + "operadoras": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "nome": { + "type": "string" + } + } + } + }, + "procedimentos": { + "type": "array", + "items": { + "$ref": "#/definitions/procedimento" + } + } + } + }, + "checkinrealizado": { + "type": "object", + "properties": { + "id": { + "type": "array", + "items": { + "type": "string", + "example": "Check-in Realizado", + "description": "Mensagem de Retorno" + } + }, + "id_marcacao": { + "type": "number", + "example": 1234, + "description": "Id da marcação" + }, + "preferencial": { + "type": "boolean", + "example": false + }, + "expresso": { + "type": "string", + "example": "Check-in Expresso. Dirija-se ao consultório X.", + "description": "Mensagem para o paciente, no caso de check-in expresso." + }, + "local": { + "type": "string", + "example": "consultório X", + "description": "Local de encaminhamento em caso de check-in expresso." + }, + "recepcao": { + "type": "string", + "example": "Recepção Principal", + "description": "Recepção na qual o paciente fez o check-in. Exibir para o paciente, no caso de check-in não expresso." + } + } + }, + "formaPag": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": "2", + "description": "Id da Forma de Pagamento" + }, + "nome": { + "type": "string", + "example": "Cartão de Crédito", + "description": "Descrição da Forma de Pagamento" + }, + "tipo_cartao": { + "type": "string", + "example": "C", + "description": "Quando cartão, tipo do cartão (C - Crédito ou D - Débito)" + }, + "bandeiras": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "number", + "example": 1, + "description": "Id da Bandeira do Cartão" + }, + "nome": { + "type": "string", + "example": "AMEX", + "description": "Nome da Bandeira do Cartão" + } + } + } + } + } + }, + "adiantamento": { + "type": "object", + "properties": { + "id_pagamento_atendimento": { + "type": "number", + "example": "1", + "description": "Id do Adiantamento" + }, + "dt_data": { + "type": "string", + "example": "2025-01-01", + "description": "Data do Adiantamento" + }, + "dc_valor_adiantado": { + "type": "number", + "example": 50, + "description": "Valor do Adiantamento" + }, + "st_observacoes": { + "type": "string", + "example": "Lorem Ipsum", + "description": "Observação do Adiantamento" + }, + "id_unidade_operacao": { + "type": "number", + "example": 1, + "description": "Id da Unidade do Adiantamento" + }, + "item_pagamento_atendimentos": { + "type": "array", + "description": "Formas de Pagamento", + "items": { + "properties": { + "id_item_pagamento_atendimento": { + "type": "number", + "example": 1, + "description": "Id do Item da Forma de Pagamento" + }, + "id_forma_pagamento": { + "type": "number", + "example": 1, + "description": "Id da Forma de Pagamento" + }, + "id_bandeira_cartao": { + "type": "number", + "example": 1, + "description": "Id da Bandeira do Cartão" + }, + "dc_valor": { + "type": "number", + "example": 50, + "description": "Valor da Forma de Pagamento" + } + } + } + } + } + }, + "autorizacao": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": "1", + "description": "Id da Autorização" + }, + "data": { + "type": "string", + "example": "2025-01-01", + "description": "Data da Autorização" + }, + "procedimento": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 123, + "description": "Id do Procedimento" + }, + "nome": { + "type": "string", + "example": "EXAME ABC", + "description": "Nome do Procedimento" + }, + "sigla": { + "type": "string", + "example": "ABC", + "description": "Sigla do Procedimento" + } + } + }, + "quantidade": { + "type": "number", + "example": 1, + "description": "Quantidade" + }, + "status_id": { + "type": "number", + "example": 0, + "description": "Status do Procedimento" + }, + "status_nome": { + "type": "number", + "example": "Aberto", + "description": "Status do Procedimento" + }, + "observacoes": { + "type": "string", + "example": "Lorem Ipsum", + "description": "Observação do Adiantamento" + }, + "unidade": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1, + "description": "Id da Unidade" + }, + "nome": { + "type": "string", + "example": "Central", + "description": "Nome da Unidade" + }, + "sigla": { + "type": "string", + "example": "CEN", + "description": "Sigla da Unidade" + } + } + }, + "autorizacao": { + "type": "object", + "properties": { + "codigo": { + "type": "string", + "example": "1234", + "description": "Código da Autorização (Senha)" + }, + "data": { + "type": "string", + "example": "2000-01-01", + "description": "Data da Autorização" + }, + "validade_inicio": { + "type": "string", + "example": "2000-01-01", + "description": "Validade Início" + }, + "validade_termino": { + "type": "string", + "example": "2000-01-01", + "description": "Validade Término" + } + } + } + } + } + }, + "externalDocs": { + "description": "klingo", + "url": "https://www.klingo.com.br/" + }, + "host": "api-externa.klingo.app", + "basePath": "/api" +} diff --git a/gti-scheduling-mcp/docs/zpro-api-mapping.md b/gti-scheduling-mcp/docs/zpro-api-mapping.md new file mode 100644 index 0000000..dfe6bae --- /dev/null +++ b/gti-scheduling-mcp/docs/zpro-api-mapping.md @@ -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 `. +- 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. diff --git a/gti-scheduling-mcp/eslint.config.js b/gti-scheduling-mcp/eslint.config.js new file mode 100644 index 0000000..8d48827 --- /dev/null +++ b/gti-scheduling-mcp/eslint.config.js @@ -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: "^_" }, + ], + }, + }, +); diff --git a/gti-scheduling-mcp/package-lock.json b/gti-scheduling-mcp/package-lock.json new file mode 100644 index 0000000..fa100f1 --- /dev/null +++ b/gti-scheduling-mcp/package-lock.json @@ -0,0 +1,3138 @@ +{ + "name": "gti-scheduling-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gti-scheduling-mcp", + "version": "0.1.0", + "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" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.23.8", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.8.tgz", + "integrity": "sha512-8W675THjbzfFmLOQzjDBIBna+WjqMGIxmSZ1mMc1+o9qoVsEuAgQu5j5ueLhau8inOkDu9OslVg0FmfBs1RIHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/gti-scheduling-mcp/package.json b/gti-scheduling-mcp/package.json new file mode 100644 index 0000000..1e953d8 --- /dev/null +++ b/gti-scheduling-mcp/package.json @@ -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" + } +} diff --git a/gti-scheduling-mcp/scripts/diagnose-zpro-list.ts b/gti-scheduling-mcp/scripts/diagnose-zpro-list.ts new file mode 100644 index 0000000..9d98391 --- /dev/null +++ b/gti-scheduling-mcp/scripts/diagnose-zpro-list.ts @@ -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 { + 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); + }); diff --git a/gti-scheduling-mcp/src/adapters/adapter-factory.ts b/gti-scheduling-mcp/src/adapters/adapter-factory.ts new file mode 100644 index 0000000..1da0640 --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/adapter-factory.ts @@ -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}".`, + ); +} diff --git a/gti-scheduling-mcp/src/adapters/klingo/klingo-http-client.ts b/gti-scheduling-mcp/src/adapters/klingo/klingo-http-client.ts new file mode 100644 index 0000000..fe00f3a --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/klingo/klingo-http-client.ts @@ -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, 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 { + 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, + body: { telefone: string; cpf?: string; apenas_telefone?: boolean }, + options: KlingoHttpOptions = {}, +): Promise { + 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, + query: KlingoHorariosQuery, + options: KlingoHttpOptions = {}, +): Promise { + 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, + accessToken: string, + body: Record, + options: KlingoHttpOptions = {}, +): Promise { + 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", + ); +} diff --git a/gti-scheduling-mcp/src/adapters/klingo/klingo.adapter.ts b/gti-scheduling-mcp/src/adapters/klingo/klingo.adapter.ts new file mode 100644 index 0000000..173db05 --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/klingo/klingo.adapter.ts @@ -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 { + 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 { + // 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, + 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); + } +} diff --git a/gti-scheduling-mcp/src/adapters/klingo/klingo.errors.ts b/gti-scheduling-mcp/src/adapters/klingo/klingo.errors.ts new file mode 100644 index 0000000..ab0dad8 --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/klingo/klingo.errors.ts @@ -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}).`, + ); +} diff --git a/gti-scheduling-mcp/src/adapters/klingo/klingo.mapper.ts b/gti-scheduling-mcp/src/adapters/klingo/klingo.mapper.ts new file mode 100644 index 0000000..6c2548a --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/klingo/klingo.mapper.ts @@ -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; +} diff --git a/gti-scheduling-mcp/src/adapters/klingo/klingo.types.ts b/gti-scheduling-mcp/src/adapters/klingo/klingo.types.ts new file mode 100644 index 0000000..a20ac28 --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/klingo/klingo.types.ts @@ -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; +} diff --git a/gti-scheduling-mcp/src/adapters/scheduling-adapter.interface.ts b/gti-scheduling-mcp/src/adapters/scheduling-adapter.interface.ts new file mode 100644 index 0000000..24975e7 --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/scheduling-adapter.interface.ts @@ -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; + + /** + * Realiza um agendamento em um horário disponível. + */ + agendar(input: CreateAppointmentInput): Promise; +} diff --git a/gti-scheduling-mcp/src/adapters/zpro/zpro-http-client.ts b/gti-scheduling-mcp/src/adapters/zpro/zpro-http-client.ts new file mode 100644 index 0000000..94cc43e --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/zpro/zpro-http-client.ts @@ -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, +): 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 { + 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); + } +} diff --git a/gti-scheduling-mcp/src/adapters/zpro/zpro.adapter.ts b/gti-scheduling-mcp/src/adapters/zpro/zpro.adapter.ts new file mode 100644 index 0000000..2434e8a --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/zpro/zpro.adapter.ts @@ -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 { + throw new ZProApiError( + "NOT_SUPPORTED", + "O provider Z-PRO não oferece consulta de disponibilidade de horários.", + ); + } + + async agendar(input: CreateAppointmentInput): Promise { + 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); + } +} diff --git a/gti-scheduling-mcp/src/adapters/zpro/zpro.errors.ts b/gti-scheduling-mcp/src/adapters/zpro/zpro.errors.ts new file mode 100644 index 0000000..aad932e --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/zpro/zpro.errors.ts @@ -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}).`, + ); +} diff --git a/gti-scheduling-mcp/src/adapters/zpro/zpro.mapper.ts b/gti-scheduling-mcp/src/adapters/zpro/zpro.mapper.ts new file mode 100644 index 0000000..a861e39 --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/zpro/zpro.mapper.ts @@ -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; + 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, + input: CreateAppointmentInput, +): Appointment { + return { + id: String(data.id), + slot: { + startDateTime: data.startAt, + endDateTime: data.endAt, + }, + patient: input.patient, + status: mapZProStatus(data.status), + }; +} diff --git a/gti-scheduling-mcp/src/adapters/zpro/zpro.types.ts b/gti-scheduling-mcp/src/adapters/zpro/zpro.types.ts new file mode 100644 index 0000000..331499d --- /dev/null +++ b/gti-scheduling-mcp/src/adapters/zpro/zpro.types.ts @@ -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; + }; +} diff --git a/gti-scheduling-mcp/src/config/.gitkeep b/gti-scheduling-mcp/src/config/.gitkeep new file mode 100644 index 0000000..edc1430 --- /dev/null +++ b/gti-scheduling-mcp/src/config/.gitkeep @@ -0,0 +1 @@ +Pasta reservada para configurações futuras (variáveis de ambiente, constantes de configuração, etc.). Vazia propositalmente neste esqueleto inicial. diff --git a/gti-scheduling-mcp/src/config/config.types.ts b/gti-scheduling-mcp/src/config/config.types.ts new file mode 100644 index 0000000..ace2acb --- /dev/null +++ b/gti-scheduling-mcp/src/config/config.types.ts @@ -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; diff --git a/gti-scheduling-mcp/src/config/env.config.ts b/gti-scheduling-mcp/src/config/env.config.ts new file mode 100644 index 0000000..b72362c --- /dev/null +++ b/gti-scheduling-mcp/src/config/env.config.ts @@ -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, + 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; +} diff --git a/gti-scheduling-mcp/src/diagnostics/analyze.ts b/gti-scheduling-mcp/src/diagnostics/analyze.ts new file mode 100644 index 0000000..5145556 --- /dev/null +++ b/gti-scheduling-mcp/src/diagnostics/analyze.ts @@ -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; + /** 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 { + 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 = {}; + 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), + ), + }; +} diff --git a/gti-scheduling-mcp/src/diagnostics/client.ts b/gti-scheduling-mcp/src/diagnostics/client.ts new file mode 100644 index 0000000..a08eeed --- /dev/null +++ b/gti-scheduling-mcp/src/diagnostics/client.ts @@ -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, +): 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 { + 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); + } +} diff --git a/gti-scheduling-mcp/src/diagnostics/response.ts b/gti-scheduling-mcp/src/diagnostics/response.ts new file mode 100644 index 0000000..773ee41 --- /dev/null +++ b/gti-scheduling-mcp/src/diagnostics/response.ts @@ -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), + }; +} diff --git a/gti-scheduling-mcp/src/diagnostics/sanitize.ts b/gti-scheduling-mcp/src/diagnostics/sanitize.ts new file mode 100644 index 0000000..f56c947 --- /dev/null +++ b/gti-scheduling-mcp/src/diagnostics/sanitize.ts @@ -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 = 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 = {}; + 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); +} diff --git a/gti-scheduling-mcp/src/index.ts b/gti-scheduling-mcp/src/index.ts new file mode 100644 index 0000000..a01c72f --- /dev/null +++ b/gti-scheduling-mcp/src/index.ts @@ -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(); diff --git a/gti-scheduling-mcp/src/server/mcp.server.ts b/gti-scheduling-mcp/src/server/mcp.server.ts new file mode 100644 index 0000000..1ca9bfe --- /dev/null +++ b/gti-scheduling-mcp/src/server/mcp.server.ts @@ -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 { + const server = createMcpServer(adapter); + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/gti-scheduling-mcp/src/tools/agendar.tool.ts b/gti-scheduling-mcp/src/tools/agendar.tool.ts new file mode 100644 index 0000000..e535d12 --- /dev/null +++ b/gti-scheduling-mcp/src/tools/agendar.tool.ts @@ -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); + } + }; +} diff --git a/gti-scheduling-mcp/src/tools/consultar-horarios.tool.ts b/gti-scheduling-mcp/src/tools/consultar-horarios.tool.ts new file mode 100644 index 0000000..03ed8c4 --- /dev/null +++ b/gti-scheduling-mcp/src/tools/consultar-horarios.tool.ts @@ -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); + } + }; +} diff --git a/gti-scheduling-mcp/src/tools/mcp-response.ts b/gti-scheduling-mcp/src/tools/mcp-response.ts new file mode 100644 index 0000000..ab09b1b --- /dev/null +++ b/gti-scheduling-mcp/src/tools/mcp-response.ts @@ -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, + }; +} diff --git a/gti-scheduling-mcp/src/types/scheduling.types.ts b/gti-scheduling-mcp/src/types/scheduling.types.ts new file mode 100644 index 0000000..7102a9b --- /dev/null +++ b/gti-scheduling-mcp/src/types/scheduling.types.ts @@ -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; +} diff --git a/gti-scheduling-mcp/test/adapter-factory.test.ts b/gti-scheduling-mcp/test/adapter-factory.test.ts new file mode 100644 index 0000000..e3d0cda --- /dev/null +++ b/gti-scheduling-mcp/test/adapter-factory.test.ts @@ -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; + }, + ); + }); +}); diff --git a/gti-scheduling-mcp/test/analyze.test.ts b/gti-scheduling-mcp/test/analyze.test.ts new file mode 100644 index 0000000..b8207e2 --- /dev/null +++ b/gti-scheduling-mcp/test/analyze.test.ts @@ -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"]); + }); +}); diff --git a/gti-scheduling-mcp/test/config.test.ts b/gti-scheduling-mcp/test/config.test.ts new file mode 100644 index 0000000..6619d33 --- /dev/null +++ b/gti-scheduling-mcp/test/config.test.ts @@ -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 = {}, +): 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"); + }); +}); diff --git a/gti-scheduling-mcp/test/diagnose-client.test.ts b/gti-scheduling-mcp/test/diagnose-client.test.ts new file mode 100644 index 0000000..104b1ca --- /dev/null +++ b/gti-scheduling-mcp/test/diagnose-client.test.ts @@ -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 = {}; + + const fakeFetch = async ( + url: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + 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 = {}; + const fakeFetch = async ( + _url: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + 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 => { + 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 => { + 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("erro"); + + 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 = `${"x".repeat(1000)}`; + + const report = buildResponseReport(longHtml, []); + + assert.equal(report.isJson, false); + assert.equal(report.structure, undefined); + assert.ok(report.sanitizedText.length <= 500); + }); +}); diff --git a/gti-scheduling-mcp/test/klingo-adapter.test.ts b/gti-scheduling-mcp/test/klingo-adapter.test.ts new file mode 100644 index 0000000..ae97206 --- /dev/null +++ b/gti-scheduling-mcp/test/klingo-adapter.test.ts @@ -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; + 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; + 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; + 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); + }); + } +}); diff --git a/gti-scheduling-mcp/test/sanitize.test.ts b/gti-scheduling-mcp/test/sanitize.test.ts new file mode 100644 index 0000000..e89f177 --- /dev/null +++ b/gti-scheduling-mcp/test/sanitize.test.ts @@ -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)); + }); +}); diff --git a/gti-scheduling-mcp/test/tools.test.ts b/gti-scheduling-mcp/test/tools.test.ts new file mode 100644 index 0000000..28d8481 --- /dev/null +++ b/gti-scheduling-mcp/test/tools.test.ts @@ -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 => { + 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 => + 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); + }); +}); diff --git a/gti-scheduling-mcp/test/zpro-adapter.test.ts b/gti-scheduling-mcp/test/zpro-adapter.test.ts new file mode 100644 index 0000000..3cf74c9 --- /dev/null +++ b/gti-scheduling-mcp/test/zpro-adapter.test.ts @@ -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, + code: string, +): Promise { + 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 => { + 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 = {}; + let capturedBody: unknown; + + const fetchImpl = async ( + url: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + 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 => { + 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 => { + 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 = {}; + const fetchImpl = async ( + _url: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + 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 => + 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 => { + 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 => { + 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> = [ + 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 => + // 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 => + 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 => + 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 => + 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 => + 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 => + 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 => + // 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` 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 => + 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 => + 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 => + 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 => + jsonResponse(201, { id: "x" }); + const adapter = new ZProAdapter(config, { fetchImpl }); + + const appointment = await adapter.agendar(baseInput); + + assert.equal(appointment.responseMappingPending, true); + }); +}); diff --git a/gti-scheduling-mcp/tsconfig.json b/gti-scheduling-mcp/tsconfig.json new file mode 100644 index 0000000..b2d0b5e --- /dev/null +++ b/gti-scheduling-mcp/tsconfig.json @@ -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"] +}