Compare commits

...

110 Commits

Author SHA1 Message Date
ruicesar 7a5f5cf0fa Merge pull request 'fix(newwhats): proxy.js/server.js do auto-provisionamento (corrige 502 em prod)' (#3) from fix/newwhats-proxy-exports into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 54s
2026-07-06 16:35:08 +02:00
VPS 4 Builder f806835ae3 fix(newwhats): commita proxy.js/server.js do auto-provisionamento (main inconsistente)
O index.js (já no main) importa createAccountsRoute/provisionNewwhatsAccount do
proxy.js, mas esses exports nunca foram commitados — só existiam no working tree.
Isso quebrava o boot do backend na imagem de produção (SyntaxError: proxy.js does
not provide an export named 'createAccountsRoute') → 502. Commita proxy.js (exports
createAccountsRoute/provisionNewwhatsAccount/operatorSignature/createWsTunnel) e
server.js (usa operatorSignature + parser 12mb em /api/nw/v1), deixando o main
consistente com o backend que já roda em dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:34:07 +02:00
ruicesar 30ea2f0e10 Merge pull request 'feat(secretaria): agente/cérebro por número na aba Números' (#2) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
2026-07-06 16:07:07 +02:00
VPS 4 Builder ec76dfeec2 feat(newwhats): checklist trata telefone compartilhado e profissional não cadastrado
Fecha furos de identificação relatados:
- Telefone compartilhado na família: nova pergunta telefone_compartilhado +
  cautelas IDENTIDADE/TELEFONE COMPARTILHADO — a IA nunca assume quem fala pelo
  número (ex.: cadastrado no filho, mas quem fala é a mãe; ou pergunta do marido)
  e confirma nome + para quem é antes de agendar; se não bater, encaminha ao humano.
- Profissional não cadastrado: cautela para citações de dentista fora da lista
  (ex.: prótese com Dr. de fora) — encaminha ao humano e pede no resumo que o
  dono cadastre o profissional.
- Pendência de config quando telefone_compartilhado=Sim (completar cadastro dos familiares).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:51:09 +02:00
VPS 4 Builder 40bed96515 feat(newwhats): agenda inteligente — especialidades, situações, dúvidas, checklist do dono e pendências
Satélite da Secretária IA:
- /contexto (dentistas+especialidades+situações+cautelas) e /duvida (encaminhar
  ao humano sem agendar); agenda_pedidos ganha tipo=duvida + resumo.
- Situações da clínica: tabela agenda_situacoes + config (dono) + aba na UI.
- Checklist do dono (checklist-defs.js + agenda_checklist): perguntas Sim/Não
  conferidas contra o banco (dentistas/pacientes) → pendências de config +
  cautelas p/ a IA não chutar.
- Área de PENDÊNCIAS DO DONO (PendenciasDono.tsx): engloba config + fila
  operacional (horários/dúvidas); botão+badge no /wa-secretaria (só o dono).
- Fila humana (PedidosPendentes): cards de dúvida + resolver; /resolver.
- Botão DESLIGAR global da Secretária no ThinNav (secPowerApi, visível a todos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:40:51 +02:00
VPS 4 Builder f4c2c6861a feat(agenda): GET /dentista-contato — resolve WhatsApp do dentista (Parte A)
Endpoint para a Secretária IA acionar o dentista responsável num encaixe sub-hora.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:31:33 +02:00
VPS 4 Builder 668d672531 feat(agenda): 1h de espaçamento + filtro manhã/tarde no /slots
- Espaçamento/duração padrão do slot passa de 30 para 60 min (1h entre atendimentos).
- /slots aceita ?periodo=manha|tarde (a Secretária pergunta o período antes de oferecer
  e busca só aquele período).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 05:59:32 +02:00
VPS 4 Builder ca2cfa1f35 feat(agenda): janela IA-livre vs estendida (zona cinza) + fila da secretária humana
- Duplicar horário para todos os dias (botão na grade).
- Janela do dentista com DOIS limites: "IA agenda livre" [inicio,fim] e "aceito
  confirmar também" (mais cedo/mais tarde) — colunas hora_inicio_flex/hora_fim_flex.
- Zona cinza: quando o cliente pede um horário fora da janela livre, a Secretária IA
  NÃO agenda — registra um pedido (POST /pedido) e diz "vou confirmar e retorno".
- Fila da secretária humana: tabela agenda_pedidos + endpoints (listar/confirmar/
  recusar); tela PedidosPendentes (confirmar → vira agendamento real, com escolha do
  dentista); botão com BADGE de contagem na Agenda (poll a cada 30 min) para staff.

Testado em dev: dentista configura flex; IA registra pedido de 8h30 e responde "vou
verificar com a secretária"; humana confirma → agendamento criado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 05:29:25 +02:00
VPS 4 Builder 773e8d4bf2 fix(agenda): fuso automático (America/Sao_Paulo) + dentista configura seus horários
- TZ=America/Sao_Paulo no backend (compose): a agenda deixa de rodar em UTC —
  horários/slots passam a ser Brasília automaticamente (corrige deslocamento de 3h).
- Acesso do DENTISTA: novo botão "Meus Horários" na agenda do dentista, abrindo
  a config direto nos horários/folgas DELE (HorariosConfig com initialTab/soDentista,
  restrito ao próprio registro). Antes só admin/donoclinica alcançavam a config.
- Config da clínica: gate passa a incluir donoconsultorio (dono de consultório).

Obs.: AgendaView.tsx também traz trabalho acumulado da agenda que estava no
working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 04:44:38 +02:00
VPS 4 Builder 0a324bf8c8 feat(secretaria): folga do dentista por data (A) + salas alugadas na agenda (B)
A — Folga/férias do dentista por DATA: tabela dentistas_folgas; o dentista (ou o
dono) marca dias em que não atende; o /slots pula o dentista nessas datas.
Endpoints /dentista/:id/folgas (GET/POST/DELETE) + UI na aba Dentistas.

B — Salas alugadas: o dentista com sala_reserva aprovada passa a ter horários
oferecidos pela Secretária no quarto (disponibilidade = janela da reserva,
casada pelo usuario_id). /slots ganha passada de salas (slot com sala_id +
local); /book grava agendamentos.sala_id (coluna aditiva). Geração passa a usar
CANDIDATE_CAP e ordena por horário (clínica e salas competem).

Testado em dev: folga pula os dias do dentista; reserva de sala gera slots com o
nome da sala.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 02:12:50 +02:00
VPS 4 Builder 77dec56dd0 feat(secretaria): horários de funcionamento + feriados na agenda/Secretária
A Secretária passa a saber quando a clínica está aberta e a NÃO oferecer
horário/agendar em dia fechado (domingo, fora do horário, feriado).

- feriados.js: feriados nacionais BR (fixos + móveis via Páscoa) + resolver
  com exceções da clínica.
- agenda-schema.js: tabelas clinicas_horarios (horário do dono) e feriados
  (exceções/fechamentos), idempotentes.
- agenda-config.js: CRUD com ownership (horário/feriado da clínica = dono;
  horário do dentista = ele mesmo ou o dono), em /api/nw/agenda-config.
- agenda-bridge.js: GET /status (aberto hoje?, motivo, próximo aberto, grade,
  feriados) + /slots agora = clínica ∩ dentista − feriado.
- HorariosConfig.tsx + botão no AgendaSettingsModal: telas de configuração
  (abas Clínica e Dentistas).

Testado em dev: a Secretária ofereceu "amanhã" ao pedirem "hoje" num domingo,
com os horários configurados da clínica.

Obs.: index.js também traz trabalho acumulado do satélite (proxy de mídia via
Wasabi/storage) que estava no working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 01:23:27 +02:00
VPS 4 Builder 03711e6280 feat(secretaria): agente/cérebro por número na aba Números
Adiciona o seletor "Agente / Cérebro deste número" no NumbersPanel: cada
número de WhatsApp passa a apontar para um agente (sec_numbers.agent_id),
tornando a Secretária, os nós do cérebro e a agenda separados POR SESSÃO.
Mostra o cérebro vinculado em cada card; tipos SecNumber.agent_id e
CalendarSlot.instance_id + param instance_id no calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:48:29 +02:00
VPS 4 Builder 09edeb9558 feat(newwhats): prévia via thumbnail Wasabi + guard de mídia irrecuperável
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m42s
- MessageBubble: carrega <url>.thumb.webp na prévia (fallback ao full via onError)
- mediaUnavailable proativo via flag media_recoverable (evita 400 em mídia legada)
- useNewInboxBridge: mapeia mediaRecoverable -> media_recoverable

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:05:42 +02:00
VPS 4 Builder 8b1801bd9c feat(agenda): layout em 2 colunas no desktop + sidebar recolhível
- AgendaView: controles migrados do header horizontal para uma coluna lateral
  esquerda (AGENDAR, ícones de ação em faixa horizontal de tamanho igual,
  presença, legenda de dentistas); calendário em flex-1 ocupando a altura toda
- Mobile revisto: cabeçalho compacto (título + AGENDAR) e calendário flex-1
- Sidebar do sistema recolhível no desktop (App: estado persistido + botão
  flutuante para reexibir); toggle fica ao lado do título "AGENDA CLÍNICA"
- Toolbar do FullCalendar compactada (título/botões/cabeçalhos menores)
- Título da agenda responsivo (não gera scroll horizontal na coluna estreita)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:32:54 +02:00
VPS 4 Builder c2a1f8f701 fix(newwhats): resolve conversa por 9º dígito e nome do paciente no slot da agenda
- WhatsChatDrawer: busca o chat pelos últimos 8 dígitos (o WhatsApp grava o jid BR
  sem o 9º dígito, ex. 556799591687), evitando falso "sem conversa"
- AgendaView: slot usa pacienteNome || pacientenome (backend retorna minúsculo),
  corrigindo o card que não exibia o nome do paciente

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:29:02 +02:00
VPS 4 Builder 21bebd3256 feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda
- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:26:31 +02:00
VPS 4 Builder 372431c0a1 style(newwhats): painel de agentes +20% no desktop e tela cheia no mobile
Painel do meio (selecionar agente) passa de 280px para 336px (md:) e, no mobile,
ocupa a tela (flex-1) com o painel principal oculto (hidden md:flex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:20:03 +02:00
VPS 4 Builder ecf52d961a feat(newwhats): envia x-nw-clinica do workspace ativo (modelo de canal)
nwClient inclui o header x-nw-clinica (id do SCOREODONTO_ACTIVE_WORKSPACE) e o
proxy REST o encaminha ao motor — assim as ações manuais escopam a ponte de
agenda para a clínica do workspace ativo (por-requisição).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:19:44 +02:00
VPS 4 Builder 3e1001b7c7 style(newwhats): /wa-sessions com sidebar do scoreodonto + tema branco
- wa-sessions sai do isStandaloneView → renderiza com a sidebar (área de
  administração); wa-inbox/wa-secretaria seguem em tela cheia.
- SessionsView convertida do dark para o tema branco/profissional (bg-gray-50/
  white, text-gray-*, badges claros, botão Conectar em brand); backdrops de
  modal e botões coloridos preservados.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 03:55:16 +02:00
VPS 4 Builder 821ae591d4 style(newwhats): tema branco/profissional na Secretária (/wa-secretaria)
Converte a SecretariaView do dark para o tema claro do scoreodonto
(bg-gray-50/white, text-gray-*, border-gray-*), preservando botões brand,
backdrops de modal e acentos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:42:34 +02:00
VPS 4 Builder 9babc69172 feat(newwhats): ponte de agenda real (slots/book) para a Secretária
Expõe /api/nw/agenda/* (server-to-server, segredo compartilhado x-nw-agenda-secret,
preso ao clinica_id) para o motor operar na agenda REAL do scoreodonto:
- GET /slots — horários livres (dentistas_horarios ou fallback comercial seg–sex
  08–18/30min) menos os agendamentos existentes e o passado.
- POST /book — cria agendamento real com anti-overbooking (mesma regra do server)
  e find-or-create de paciente pelo telefone do WhatsApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:42:33 +02:00
VPS 4 Builder 092635be5a fix(realtime): guarda cleanup de WebSocket em CONNECTING (StrictMode)
Fechar o socket ainda em CONNECTING (double-mount do StrictMode em dev) gerava
"WebSocket is closed before the connection is established". closeSocket() adia o
close para o onopen e neutraliza handlers; reconexão por queda inesperada intacta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00
VPS 4 Builder 3042ddca38 feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:54:24 +02:00
VPS 4 Builder 6593993b3d feat(newwhats): backend do satélite — proxy REST + túnel WS com chave mestra
- proxy REST resolve a conta SEMPRE por x-nw-email (email do JWT verificado);
  remove o fallback "sem email" (a chave mestra sem email dá 400 no motor).
- túnel WS (/api/nw/v1/stream) injeta x-nw-email no handshake e valida o JWT
  por ?token=; server.js roteia o upgrade (/api/ws do app + /stream do plugin).
- testMotorKey usa email-sonda (404 = chave mestra válida).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:52:18 +02:00
VPS 4 Builder 872acd800a fix(sidebar): itens WHATSAPP/SECRETÁRIA usam isPluginActive (ALWAYS_ON)
getActivePlugins() não inclui ALWAYS_ON_PLUGINS; o gate dos itens checava
activePluginIds.includes('newwhats') (sempre falso). Troca para isPluginActive
('newwhats'), que considera ALWAYS_ON — agora os menus aparecem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:14:59 +02:00
VPS 4 Builder 13af039555 feat(newwhats): pareamento por TOKEN — cole 1 token e auto-configura
- Backend POST /api/nw/config/token (superadmin): decodifica nwpair_<base64url({u,k,s})>,
  VALIDA no motor (/api/ext/v1/sessions) e salva URL+chave+webhook. Token inválido/
  sem conexão é rejeitado antes de salvar.
- Frontend: campo 'Token de pareamento' como caminho principal (textarea + Aplicar);
  estado da conexão; clínica no dropdown (local); campos manuais em 'configuração
  manual' (avançado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:05:13 +02:00
VPS 4 Builder 9874eb4345 docs(newwhats): ponte de config concluída (DB-backed + UI dropdown + testar)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:57:30 +02:00
VPS 4 Builder abbd9374b4 feat(newwhats): config DB-backed + UI dedicada (dropdown de clínicas, testar conexão)
Backend:
- config.js: config lida da tabela settings (id=newwhats_config) com fallback ENV;
  loadConfigFromDb/saveConfigToDb + cache.
- index.js: endpoints superadmin GET/PUT /api/nw/config (secrets nunca em claro;
  branco mantém valor), GET /api/nw/clinicas (dropdown), GET /api/nw/status (testar
  conexão). server.js passa { superadminGuard }.
Frontend:
- PluginsView: NewwhatsConfigModal dedicado (carrega/salva no backend, dropdown de
  clínicas em vez de ID cru, botão Testar conexão). Substitui o form genérico p/ newwhats.

Resolve UX ruim: nada de digitar ID de clínica; config passa a funcionar de fato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:55:54 +02:00
VPS 4 Builder 3751d84da8 feat(nginx): rota /nw/* -> backend (Secretária do motor consulta a clínica)
O motor NewWhats chama /nw/* no satélite (dados da clínica). nginx só roteava
/api/* ao backend; adiciona location /nw/ -> scoreodonto-backend:8018.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:15:03 +02:00
VPS 4 Builder 776c25c3de docs(newwhats): frontend (plugin/views) + pendências (proxy auth, ponte de config)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:59:08 +02:00
VPS 4 Builder b031117925 feat(frontend): plugin NewWhats — Inbox + Secretária (gating por papel)
- PluginsView: registra o plugin 'newwhats' (config só no catálogo, exclusivo do
  superadmin via view 'plugins' — já gateada em isViewAllowed).
- pluginRegistry: 'newwhats' em ALWAYS_ON (ativação por plugin é local/localStorage
  e não propaga; ALWAYS_ON garante Inbox/Secretária para TODOS os usuários).
- App.tsx: views wa-inbox/wa-secretaria (ViewKey, rotas, render); gating
  isPluginActive('newwhats') libera a qualquer usuário.
- Sidebar: itens WHATSAPP + SECRETÁRIA IA quando o plugin está ativo.
- Novas views consomem o proxy /api/nw/v1/* (motor ext-api). Estágio 1: funcional
  (lista/thread/envio; ask da secretária), visual aproximado do motor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:57:46 +02:00
VPS 4 Builder b21fb0f159 feat(newwhats): satélite WhatsApp/Secretária IA adaptado ao odonto
Clona o satélite do mercado adaptado ao scoreodonto (ESM, sem PluginManager):
- backend/newwhats/: config (ENV), auth (x-nw-key), routes /nw/* (clinica,
  especialidades, dentistas, procedimentos, planos — multi-tenant por clinica_id),
  sync-knowledge (base de conhecimento odonto → motor), webhook-receiver (HMAC,
  enxuto), proxy REST /api/nw/v1/* → motor /api/ext/v1/*, manifest, INTEGRACAO-MOTOR.md.
- server.js: registerNewwhats(app, pool) antes do listen.
- Removidos (não úteis): media-transcribe (motor já transcreve), smart-router,
  personas, follow-up-detector, context-builder.
- Config por ENV: NEWWHATS_URL/INTEGRATION_KEY/WEBHOOK_SECRET/CLINICA_ID.
- ativo::int=1 normaliza boolean vs smallint entre tabelas.
- Validado: buildKnowledgeText gera 1195 chars de dados reais; registro ok no express 5.2.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:38:36 +02:00
VPS 4 Builder bfba577080 feat(agenda): isolamento por setor (opt-in) — fecha vazamento horizontal intra-clínica
build-and-promote / build (push) Successful in 1m5s
build-and-promote / promote (push) Has been skipped
A agenda só isolava entre clínicas; dentro da mesma clínica, qualquer membro
via TUDO (todos os setores/equipes). Agora o setor passa a valer:

- agendamentos.setor_id + dentistas.setor_id (migration) + índice
  (clinica_id, setor_id, start_time).
- POST carimba o setor: override em dentistas.setor_id, senão o setor do
  VÍNCULO do próprio dentista (Gestão de Equipe, UI existente), senão o setor
  de quem agenda. NULL = "geral".
- GET filtra (resolverSetorUsuario): dono/admin e membro SEM setor veem tudo
  (compat — clínica que não usa setores não muda); membro COM setor vê só o
  seu setor + os gerais (NULL). Salas não filtram.

Equipe de protético/biomédico = setor próprio, herda a mesma regra. O
anti-overbooking continua GLOBAL e acima do isolamento (isola a visão, mantém
a integridade). Validado em dev: dono/sem-setor veem 3/3; setor A vê só A+geral;
setor B vê só B+geral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:51:30 +02:00
VPS 4 Builder facf7b48c2 fix(agenda): blindagem multi-tenant — overbooking sem conta, race no PUT, privacidade e índices
build-and-promote / build (push) Successful in 1m2s
build-and-promote / promote (push) Has been skipped
Auditoria da agenda (clínicas/setores, profissional multi-local):

- anti-overbooking: agrupa registros do mesmo profissional por EMAIL quando
  usuario_id é NULL (dentista sem conta que atende fixo numa clínica e avulso
  em outras) — antes só agrupava por usuario_id e deixava passar duplo-agendamento.
- PUT (reagendar): checagem de conflito + UPDATE + auditoria agora numa única
  transação (FOR UPDATE efetivo) — fecha a race de dois reagendamentos no mesmo slot.
- privacidade entre tenants: resposta 409 deixa de expor id/clinica_id do
  compromisso alheio (helper slotOcupadoPublico) — só o horário.
- performance: índices (clinica_id,start_time) e (dentistaid,start_time) +
  janela ?from&to opcional no GET (retrocompatível).
- cadastro de dentista: e-mail obrigatório no backend (todo dentista precisa de
  conta/identidade global; protético interno sem conta usa outro fluxo).

Validado em dev.scoreodonto.com (pgdev). Produção intocada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:45:01 +02:00
VPS 4 Builder d83e0c82b2 feat(ui): dropdowns viram modal com busca (SearchSelect) na Avaliação/GTO e Nova OS
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Mesmo padrão do seletor de protético, generalizado para os demais dropdowns:

- Componente SearchSelect (modal genérico: trigger + busca + lista filtrável por rótulo/hint).
- LancarGTO: Especialidade/Área, Produto/Prótese, Procedimento, Dentista e Convênio/Credenciada
  — todos os <select> convertidos (0 selects restantes).
- Nova OS (ProteseView): Produto e Dentista convertidos.
- Cotação (RFQ): input de busca na lista de laboratórios a convidar (continua multi-seleção).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:29:49 +02:00
VPS 4 Builder e6d8b532e3 feat(protese): seletor de protético em modal (busca, favorito/padrão, vincular por e-mail)
O dropdown só listava protéticos internos (vinculados) ou do diretório público — um protético com
conta fora do diretório (ex.: claudemir) não aparecia para a clínica. Reformulado:

- Componente ProteticoPicker (modal): busca por nome/e-mail; estrela de FAVORITO; marcar PADRÃO
  (pré-selecionado na próxima OS/GTO, só um por clínica); e "Adicionar por e-mail", que VINCULA um
  protético existente como interno — resolve o caso de não estar no diretório, sem expô-lo publicamente.
- Backend: tabela protese_protetico_pref (favorito/padrão por clínica); GET /protese/laboratorios
  passa a retornar favorito/padrao e ordena padrão→favorito→interno→nota→nome; POST .../:id/pref e
  POST /protese/laboratorios/vincular.
- Picker aplicado no LancarGTO (avaliação/GTO) e na NovaOSModal; ProteticoInternoInline (protético
  SEM conta) mantido ao lado.

Validado em DEV na CONSULTT CLINIC: claudemir ausente → vinculado por e-mail → aparece interno e,
como favorito+padrão, no topo da lista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:18:32 +02:00
VPS 4 Builder 8353a9d897 feat(protese): identificação por Nome+Sobrenome com desambiguação de homônimos
O protético identifica o trabalho pelo nome do paciente (etiqueta, sem CPF — ele não atende
paciente). Quando dois trabalhos têm o mesmo nome+sobrenome, desambigua sem expor dado sensível:

- util pacienteLabel.ts (rotulosPaciente / homonimoAtivo): comparação normalizada
  (case/acento/espaço-insensitive). Em colisão: 1) diferencia pela ORIGEM (dentista, senão
  clínica) → "João Silva · Dr. Carlos"; 2) se mesma origem, nº sequencial estável por data.
- Bancada (ProteseView) e Painel do lab (LabHomeView/Próximas entregas): nome exibido já
  desambiguado em toda a fila — vale para OS nascidas na clínica ou no lab.
- Modal "Dar entrada": campo único trocado por Nome + Sobrenome(s); aviso em tempo real quando
  há homônimo ativo ("acrescente outro sobrenome para diferenciar"), informando a origem do
  conflito.

Validado em DEV (3 homônimos "João Silva": origem distinta e mesma origem → sequencial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:03:24 +02:00
VPS 4 Builder 7c8c8fc0d2 feat(protese): fluxo do lado do protético — entrada de OS, baixa/cobrança e importar OS indicada
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
Fecha o ciclo operacional do laboratório, antes só "receptor passivo" das OS da clínica.

1. Dar entrada em OS pelo lab — POST /api/protese/lab/os: o dono do laboratório registra um
   trabalho recebido fora do app, escolhendo cliente existente OU avulso (clínica/consultório/
   dentista, materializado como registro mínimo) + produto do catálogo ou tipo livre. A OS já
   entra com laboratorio_id e custódia no laboratório. UI: botão "Dar entrada" + modal na LabHome.
2. Dar baixa em pagamento — já existia (lado 'lab'); mantido na PagamentosSecao.
3. Solicitar pagamento — POST /api/protese/os/:id/cobrar: calcula o saldo devedor (custo − pago)
   e notifica o cliente, registrando no histórico. UI: botão "Solicitar pagamento (saldo)" na
   PagamentosSecao do modo bancada.
4. Importar/vincular OS indicada — GET /api/protese/lab/os-indicadas (OS com protetico_id=me e
   laboratorio_id NULL) + POST /api/protese/os/:id/vincular-lab (seta laboratorio_id, status→recebido,
   notifica a clínica). Sem isso a OS só aparecia ao dono via fallback; importando, a equipe/técnicos
   e o financeiro do lab passam a vê-la. UI: seção "OS indicadas a você" na LabHome.

origemDe agora reconhece cliente avulso tipo 'dentista'. Validado em DEV (todos os 4 endpoints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:43:35 +02:00
VPS 4 Builder 7566c01159 fix(protese): painel do lab usa fechamento próprio em vez do financeiro de pacientes (corrige 403)
LabHomeView chamava GET /api/financeiro (capability 'financeiro', negada ao protético) só para o
KPI "faturamento do mês" → 403 no console ao abrir o painel do laboratório. Passa a usar
GET /api/protese/lab/fechamento (custo das OS entregues no mês), que é do escopo do lab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:22:45 +02:00
VPS 4 Builder 87411e1566 feat(protese): CRM financeiro do laboratório (origem, contato, ticket, fechamento mensal)
Atende ao pedido de CRM financeiro do protético — antes as telas Clientes/Financeiro existiam mas
não distinguiam origem nem traziam relacionamento/fechamento.

- lab/clientes agora traz por cliente: ORIGEM (Clínica / Consultório / Dentista / Biomédico, derivada
  de clinicas.tipo + role do owner), CONTATO (telefone), TICKET MÉDIO, ÚLTIMO PEDIDO, além de
  OS/ativas/atrasadas/faturado/recebido/a-receber.
- Novo lab/fechamento: faturado x recebido por mês.
- LabClientesView reformulada: aba "Clientes (CRM)" com resumo por origem + tabela
  (origem/contato/ticket/últ. pedido/a-receber); aba "Financeiro" com KPIs + FECHAMENTO MENSAL + extrato.

Validado em DEV: cliente Clínica (a-receber 0) e Dentista avulso (a-receber 100); fechamento 06/2026
faturado 550 / recebido 450.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 04:07:11 +02:00
VPS 4 Builder 29b5b38678 fix(protese): protético acessa o Perfil/Marketplace para se publicar no diretório
Sem isso, o protético recém-cadastrado ficava invisível: o item PROFISSIONAIS não aparecia no
workspace laboratório (plugin não ativo) e o contexto laboratório bloqueava a tela — então ele não
tinha como publicar o perfil no diretório e nenhuma clínica o encontrava para enviar trabalho.

- Sidebar: PROFISSIONAIS aparece sempre para dentista/biomédico/protético (eles divulgam perfil),
  mesmo sem o plugin ativado.
- App: contexto 'laboratorio' libera marketplace-profissionais + diretorio-profissionais.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 03:56:01 +02:00
VPS 4 Builder 361f3809a7 docs(verdade-hoje): produção em v1.0.31 (4673f3c)
build-and-promote / build (push) Successful in 1m0s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:04:20 +02:00
VPS 4 Builder 4673f3c606 feat(protese): cadastro de protético interno (sem conta) + carteira no link mágico
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m2s
- Protético interno: a clínica cadastra um protético que não usa a plataforma. Cria usuário "mudo"
  (e-mail placeholder @interno.local + senha-fantasma; usuarios.email é NOT NULL/UNIQUE) + vínculo
  interno (fora do diretório). POST /protese/proteticos-internos; botão "Cadastrar protético interno"
  na Nova OS, que recarrega o dropdown e seleciona o novo.
- Carteira no link /p/TOKEN: botão "Meus trabalhos" → trabalhos em andamento/finalizados + mini
  financeiro mensal (valores pagos ao protético). GET /api/p/:token/carteira, escopo da relação
  clínica↔protético do token (não vaza outras clínicas; sem dados do paciente).

Validado em DEV: interno criado e acessível no dropdown; OS+link; carteira com andamento + R$250/2026-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:11:07 +02:00
VPS 4 Builder dc5a2d89ae feat(protese): agenda & coleta do laboratório (integrada à custódia)
Novo módulo de logística do lab (doc/AGENDA-COLETA-LAB):
- protese_coleta: agendamento de coleta (lab busca) / entrega (lab leva), com data/hora,
  endereço, responsável, status. Ligado à OS.
- POST /protese/os/:id/coleta (agendar), GET /protese/lab/agenda (cronológica),
  POST /protese/coleta/:id/status. 'realizada' MOVE a custódia automaticamente
  (coleta→laboratorio, entrega→clinica) — fonte única do movimento físico. GET detalhe traz coletas[].
- UI: tela AGENDA (lab-agenda) agrupada por dia com "marcar realizada"; seção Coleta/entrega na
  aba Monitoramento da OS (clínica e lab).

Validado em DEV: agenda coleta → realizada → custódia=laboratorio; lab vê na agenda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:11:24 +02:00
VPS 4 Builder 7671199420 docs(verdade-hoje): produção em v1.0.30 (6cd8c1f)
build-and-promote / build (push) Successful in 1m3s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:00:41 +02:00
VPS 4 Builder 6cd8c1f9e1 feat(protese): marketplace transacional fatia 4 — avaliação, satisfação na nota e selo verificado
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m5s
- A clínica avalia o laboratório (1-5★ + comentário) após a entrega (protese_avaliacao, 1 por OS,
  reavaliável). POST /protese/os/:id/avaliar; avaliação volta no GET detalhe.
- Nota ScoreOdonto passa a combinar 0.6·operacional + 0.4·satisfação (estrelas) quando há avaliações.
  Selo "Verificado" (≥5 entregas). Exposto no marketplace (★nota · nº avaliações · Verificado) e nos
  indicadores do lab (KPI Satisfação). Helper notasScorePorProtetico unificado (dropdown + marketplace).
- UI: seção "Avaliar o laboratório" na OS entregue (lado clínica); selo/avaliações no card do marketplace.
- Correção de passagem: FileText/Star importados em ProteseView (FileText faltava desde a fatia 3).

Validado em DEV: 6 entregas + 5★/3★ → nota 9.2, satisfação 4★, verificado (marketplace e indicadores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:56:35 +02:00
VPS 4 Builder c084ad553b docs(verdade-hoje): produção em v1.0.29 (cd89ca2)
build-and-promote / build (push) Successful in 1m4s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:03:13 +02:00
VPS 4 Builder cd89ca245c feat(protese): marketplace transacional fatia 3 — cotação (RFQ) multi-lab
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
Entidades protese_rfq + protese_rfq_cotacao. A clínica descreve o trabalho e convida N labs;
cada lab cota (valor/prazo/obs); a clínica compara (ordenado por preço) e escolhe → vira OS
(custo = valor da cotação) e a RFQ fecha (demais recusadas).

- Backend: POST/GET /protese/rfq, GET /protese/rfq/recebidas, POST /protese/rfq/cotacao/:id,
  POST /protese/rfq/:id/escolher (cria a OS).
- Lado lab: tela Cotações (lab-cotacoes) — RFQs recebidas + responder.
- Lado clínica: botão Cotações na tela de Prótese — pedir multi-lab + comparar + escolher.

Validado em DEV ponta a ponta: pedido → cotação R$420 → escolha → OS criada → RFQ fechada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:43:56 +02:00
VPS 4 Builder 07d798c370 docs(verdade-hoje): produção em v1.0.28 (6a88995)
build-and-promote / build (push) Successful in 50s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:00:31 +02:00
VPS 4 Builder 6a88995146 feat(protese): marketplace transacional fatia 2 — solicitar prótese cria a OS direto
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 47s
"Solicitar prótese" no card do protético abre o SolicitarProteseModal (lab pré-selecionado):
produto do catálogo (ou livre) + paciente + dentista + dentes + prazo + obs → cria a OS via
createProteseOS (que já aceita lab do diretório, sem vínculo prévio). Confirmação com CTA p/ a
Bancada. Modal auto-contido reusando getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS.

Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:56:33 +02:00
VPS 4 Builder f5a6dc7ea0 docs(verdade-hoje): produção em v1.0.27 (b5d093d)
build-and-promote / build (push) Successful in 53s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:52:30 +02:00
VPS 4 Builder b5d093d2ce feat(protese): marketplace transacional fatia 1 — vitrine de catálogo do laboratório
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
doc/MARKETPLACE-TRANSACIONAL-PROTESE: desenho do transacional (vitrine → solicitação → cotação
→ reputação), reusando catálogo/propostas/OS/score existentes.

Fatia 1: no card do protético no marketplace, "Ver catálogo de próteses" expande os produtos
(nome, preço, garantia) — reusa getLaboratorioProdutos (o catálogo já é acessível a quem está no
diretório, zero endpoint novo). Botão de contato vira "Solicitar prótese". A clínica passa a
decidir vendo serviços + preço + garantia + ★nota antes de contratar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:47:08 +02:00
VPS 4 Builder 489d2753ee docs(verdade-hoje): produção em v1.0.26 (d357f3a)
build-and-promote / build (push) Successful in 47s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:40:35 +02:00
VPS 4 Builder d357f3a2e8 feat(protese): Nota ScoreOdonto pública no marketplace de profissionais
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
GET /api/profissionais/marketplace passa a trazer a 'nota' dos protéticos (helper
notasScorePorProtetico, mesma fórmula da Fase 5 — só com volume mínimo). Card do protético
no marketplace exibe ★ nota. Ordem mantida por proximidade; a nota é comparação visível.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 2e90a2ba5f chore(protese): remove menu legado do protético (higiene)
O protético opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo).
Remove as telas clínicas legadas (agenda/lancar-gto/proteses/tratamentos/dashboard) do
fallback do protético em Sidebar.tsx e da lista de permissões em App.tsx; o fallback fora do
contexto laboratório fica mínimo (notificações/clínicas/config/diretório).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:28:39 +02:00
VPS 4 Builder 53cf2c52e6 docs(verdade-hoje): produção em v1.0.25 (8040e70)
build-and-promote / build (push) Successful in 48s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:18:54 +02:00
VPS 4 Builder 8040e70310 feat(protese): Modo 1 fatia 1c — gestão do link na UI da clínica (status/acessos/revogar)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 56s
Fecha o Modo 1 (link seguro do protético sem conta):
- GET /api/protese/os/:id/link/status: link ativo, nº de acessos, último acesso e expiração.
- UI (aba Monitoramento, modo clínica): mostra status do link com Copiar / WhatsApp / Revogar
  e os contadores de acesso/expiração; gera sob demanda.

Conversão por herança é nativa (a OS já aponta para o protetico_id do destinatário). Validado em
DEV: acessos contados, expiração 60 dias, revogação → status inativo + leitura pública 404.

Modo 1 concluído (1a leitura + 1b ação + 1c gestão).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:12:03 +02:00
VPS 4 Builder 8552e87665 docs(verdade-hoje): produção em v1.0.24 (28dc27a)
build-and-promote / build (push) Successful in 1m4s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:07:42 +02:00
VPS 4 Builder 28dc27a289 feat(protese): Modo 1 fatia 1b — ações do protético pelo link (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s
Pelo link /p/TOKEN o protético (sem conta) passa a ATUAR na OS, tudo auditado como "<nome> (link)":
- POST /api/p/:token/custodia — confirmar recebimento / devolução (clínica↔lab)
- POST /api/p/:token/status — avançar a produção (recebido…finalizado); entregue/cancelado
  seguem exclusivos da clínica (400 pelo link)
- POST /api/p/:token/foto — anexar foto do trabalho
Página pública ganha a seção "Atualizar status" (custódia/etapa/foto) enquanto a OS não está
finalizada; header alterna Acompanhamento/Somente leitura.

Validado em DEV: recebimento, avanço de etapa, foto; entregue pelo link → 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:03:52 +02:00
VPS 4 Builder f0b7a45d86 docs(verdade-hoje): produção em v1.0.23 (672c3b0)
build-and-promote / build (push) Successful in 59s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:57:18 +02:00
VPS 4 Builder 672c3b01e5 feat(protese): Modo 1 fatia 1a — leitura da OS por link seguro /p/TOKEN (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 58s
Provedor de Prótese SEM conta (doc/MODO1-LINK-SEGURO-PROTESE):

- Token por OS: tabela protese_os_link (aleatório 24B base64url, expira 60 dias, revogável,
  conta acessos). POST /protese/os/:id/link (gera/recupera) + POST .../link/revogar.
- Leitura pública GET /api/p/:token (sem auth): payload curado com a mesma blindagem soLab —
  SEM paciente_id (CPF) e SEM valor cobrado ao paciente; mostra trabalho, etapas, componentes,
  fotos, custódia, ocorrências, histórico e custo do lab.
- Página pública ProtesePublicView (mobile-first 320px+), interceptada no index.tsx antes do
  app autenticado; CTA "Criar conta grátis". Botão Gerar link / WhatsApp / Copiar na aba
  Monitoramento (modo clínica).
- Validado em DEV: leitura, blindagem (CPF/valor ocultos), token inválido→404, revogação→404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:53:02 +02:00
VPS 4 Builder 5f4460e37d feat(cadastro): card "Protético" na vitrine de onboarding + desenho do Modo 1
- OnboardingChat: adiciona o card de atalho "Protéticos" (perfil profissional → protetico),
  ao lado de Dentistas/Biomédicos. O cadastro de protético já funcionava via "Sou profissional",
  faltava o atalho na vitrine.
- doc/MODO1-LINK-SEGURO-PROTESE: desenho do "Provedor sem conta" — link seguro /p/TOKEN
  (token por OS, revogável; rotas públicas com a mesma blindagem soLab; página pública read+ação;
  CTA de conversão com herança de histórico). Evolução futura, sem refatoração.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:37:41 +02:00
VPS 4 Builder e05231663f docs(verdade-hoje): produção em v1.0.22 (84f4a79)
build-and-promote / build (push) Successful in 55s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:25:20 +02:00
VPS 4 Builder 84f4a797d2 feat(protese): Fase 5 — indicadores + Nota ScoreOdonto + ranking no marketplace
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 55s
Provedor de Prótese, Fase 5 (doc/PROVEDOR-PROTESE-DECISAO.md) — sobre a captura das fases 2-4:

- Indicadores do lab (GET /protese/lab/indicadores, só dono): entregues, tempo médio, atraso %,
  retrabalho % (garantia), ocorrências resolvidas %, componentes ausentes. Tela lab-indicadores.
- Nota ScoreOdonto (0-10) = 10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3), só com ≥3 entregas
  ("score em formação" abaixo disso). Apenas ocorrências RESOLVIDAS pesam (contraditório respeitado).
- Ranking: getProteseLaboratorios devolve a nota por protético e ordena internos→nota→nome; a
  clínica vê ★ nota no dropdown de Nova OS e Lançar GTO.

Roadmap do Provedor de Prótese concluído (Fases 1-5). Marketplace transacional e Modo 1 (link
seguro sem conta) ficam como evolução futura.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:49:32 +02:00
VPS 4 Builder ae48feaa8a feat(protese): Fase 4 — PF/PJ + CNPJ + Equipe (técnicos do laboratório)
Provedor de Prótese, Fase 4 (doc/PROVEDOR-PROTESE-DECISAO.md):

- PF/PJ: clinicas.pessoa_tipo (individual | laboratório) + documento (CPF/CNPJ) editáveis.
  Tela "Laboratório & Equipe" (lab-equipe). GET/PATCH /protese/lab.
- Equipe: novo vinculos.role='tecnico_protese'. Dono convida por e-mail (ensureUsuarioPorEmail
  → credencial temporária). GET/POST/DELETE /protese/lab/equipe (só dono).
- Acesso estendido: proteseOsAccesso reconhece o técnico (vínculo no laboratorio_id) e a bancada
  passa a usar labDoUsuario (dono OU técnico). Técnico opera a produção; menu reduzido e sem
  clientes/financeiro/equipe (endpoints retornam vazio/403). Gestão é exclusiva do dono.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:40:10 +02:00
VPS 4 Builder c8d679eb70 feat(protese): Fase 3 — Clientes e Financeiro do laboratório (operação multi-clínica)
Provedor de Prótese, Fase 3 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Clientes (lab-clientes): clínicas que enviam OS, com OS totais/ativas/atrasadas/entregues,
  faturado e a-receber por cliente. GET /protese/lab/clientes (agrega por clinica_id no escopo
  do laboratório).
- Financeiro do lab (lab-financeiro): KPIs faturado/recebido/a-receber + extrato de recebimentos
  (pagamentos lado lab) com clínica/paciente/forma/data. GET /protese/lab/pagamentos.
  Regra: faturado = custo das OS entregues (≠ garantia); a-receber = faturado − recebido.
- Menu do laboratório ganha CLIENTES e FINANCEIRO (Sidebar + App rotas/contexto + nova
  LabClientesView com abas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:09:25 +02:00
VPS 4 Builder 3ffaff9d7d feat(protese): Fase 2 — rastreabilidade de componentes + ocorrências com contraditório
Provedor de Prótese, Fase 2 (doc/PROVEDOR-PROTESE-DECISAO.md):

- Componentes: eixo 'direcao' (enviado clínica→lab | devolvido lab→clínica) + conferência
  (pendente/ok/divergente/ausente, com autor/data). Endpoint .../componentes/:id/conferir.
  UI: badges de direção e conferência + botões inline na aba Itens & Valores.
- Ocorrências (protese_os_ocorrencia): 10 categorias (parafuso substituído, peça danificada,
  retrabalho…), descrição, responsável, autor. Evidências reusam protese_os_foto (ocorrencia_id).
- Contraditório: aberta → respondida → em_analise → resolvida | contestada; 'resolvida' é final.
  Endpoints /ocorrencias, /ocorrencias/:id/resposta, /ocorrencias/:id/status. Nova aba
  "Ocorrências" (com contador) no modal da OS.
- Indicadores propositalmente NÃO implementados (só ocorrências resolvidas pesarão — Fase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:53:33 +02:00
VPS 4 Builder 7ae6993c4b feat(protese): Fase 1 — clínica de origem na OS + blindagem de CPF (LGPD)
Provedor de Prótese, Fase 1 do roadmap (doc/PROVEDOR-PROTESE-DECISAO.md):

- Origem da OS: bancada e detalhe passam a trazer clinica_nome (subquery em clinicas);
  o protético vê a clínica de origem na lista da bancada e no topo do modal. Destrava o
  cenário multi-clínica (Clínica A/B/C → mesmo laboratório).
- Blindagem de CPF (LGPD): confirmado que pacientes.id É o CPF. O provedor de prótese
  (lab que não é da clínica) deixa de receber paciente_id no detalhe e na bancada,
  mantendo paciente_nome; a clínica continua vendo o CPF para operar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:20:53 +02:00
VPS 4 Builder 9f103be397 feat(protese): etapas livres, ajustes do trabalho e privacidade de valores do paciente
- Etapas repetíveis/puláveis: cada etapa tem ação Ir/Voltar/Repetir (não só "avançar
  para a próxima"). 'entregue' segue na Linha do tempo.
- Ajustes do trabalho (aba Etapas): editar cor/material (PATCH .../os/:id); trocar o
  trabalho por outro + MOTIVO obrigatório (POST .../trocar); acréscimo/redução do valor
  cobrado do paciente com observação (POST .../ajuste-valor) — tudo auditado no histórico.
- Privacidade: o protético (lab que não é da clínica) NÃO vê o que o paciente paga/deve —
  GET detalhe omite pagamentos do lado clínica e zera o valor cobrado; a bancada também
  zera o valor. O ajuste de valor do paciente é exclusivo da clínica (403 para o lab). No
  front, o modo bancada esconde "Recebido do paciente".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:32:21 +02:00
VPS 4 Builder f4f433b14c feat(protese): avançar etapas pela clínica + recibo térmico/laser + abas reordenadas
- Aba "Linha do tempo" movida para o FINAL (entregue/cancelar continuam nela).
- Etapas agora avançáveis também pela clínica (não só pelo lab): nova lista "Etapas do
  trabalho" na 1ª aba, com botão "Avançar" por etapa. Backend: avanço da produção passa
  a aceitar lab OU clínica de origem (auditado por actor). 'entregue' segue na timeline
  (exige foto).
- Impressão por etapa em DOIS formatos: térmica (bobina 80mm) e laser (A4), com os dados
  da OS preenchidos no recibo (paciente, trabalho, dentista, lab, dentes, material/cor,
  componentes, prazo, local atual + assinaturas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:06:29 +02:00
VPS 4 Builder 8cf4a83161 feat(protese): modal da OS em abas + cupom de etapa + monitoramento de custódia
Reorganiza o gerenciamento da OS em ABAS (sem o scrollão único):
- Etapas: dados + stepper; substitui entregue/cancelar por "imprimir cupom" de cada
  etapa (comprovante imprimível que circula com o trabalho — janela de impressão).
- Linha do tempo: histórico com data/hora/autor; AQUI ficam marcar entregue / cancelar
  (e o fluxo do lab / acionar garantia).
- Itens & Valores: componentes, fotos e pagamentos.
- Monitoramento: custódia do trabalho (clínica ↔ laboratório) — "protético retirou" /
  "devolveu à clínica", com local atual em destaque e histórico de movimentações.

Backend: protese_os.local_atual + tabela protese_os_custodia + POST .../custodia
(valida transição, registra movimento e evento). GET detalhe expõe local_atual + custodia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:32:31 +02:00
VPS 4 Builder 623c9990d6 feat(protese): profissionaliza a OS — etapas, fotos, componentes, pagamentos e auditoria
Reformula o modal de detalhe da OS de prótese, fechando os gaps de gestão:

- Etapas: stepper visual do fluxo (solicitado→…→entregue) com a etapa atual e datas.
- Fotos: agora anexáveis TAMBÉM no modo clínica (antes só na bancada — beco sem saída
  para entregar), com autoria (quem anexou), data e legenda; remover foto. Toda foto
  entra no histórico.
- Componentes enviados: nova tabela protese_os_componente — item, qtd, quem enviou e
  quando (ex.: implante, pilar, modelo). Add/remover.
- Valores recebidos (dois lados): nova tabela protese_os_pagamento — lado='clinica'
  (paciente→clínica) e lado='lab' (clínica→lab), com totais, forma, autor e data.
- Auditoria: histórico com data+HORA e autor; foto/componente/pagamento viram eventos
  rastreados. Helpers carregaOSComAcesso/logEventoOS reaproveitados nos sub-recursos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:41:19 +02:00
VPS 4 Builder b80d061a6f docs(verdade-hoje): produção em v1.0.21 (064f36d)
build-and-promote / build (push) Successful in 1m1s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:21:43 +02:00
VPS 4 Builder 064f36d614 docs(protese): marca Fase 2 como implementada/validada + nota do bug pacientenome
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:35:46 +02:00
VPS 4 Builder 50e1483ad5 feat(protese): Fase 2 frontend — área do Laboratório (painel + bancada)
Workspace tipo='laboratorio' ganha menu próprio (PAINEL DO LAB + BANCADA),
isolado das telas clínicas — espelha o tratamento de tipo='sala'. lab-bancada
reaproveita ProteseView (modo bancada); lab-home é o novo LabHomeView (fila por
status, atrasadas, entregues e faturamento do mês a partir do que já existe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:17:07 +02:00
VPS 4 Builder a1c203ced5 feat(protese): Fase 2 backend — Laboratório como workspace de 1ª classe
- Workspace do protético passa a ser tipo='laboratorio' (re-tag dos existentes +
  novos nascem assim no /api/register). Área de gestão própria, como a clínica.
- Roteamento por laboratorio_id: OS nascem com o lab (POST /os e GTO→OS); a
  Bancada filtra por laboratorio_id com fallback por protetico_id (OS legadas).
  Helper labDoProtetico(). Backfill aditivo + índice.
- Receita espelhada (2.1): a entrega lança RECEITA no workspace do lab
  (clinica_id=laboratorio_id, valor=os.custo), espelho da DESPESA da clínica —
  idempotente (financeiro_lab_id), isolado em try/catch.

fix: corrige coluna `paciente_nome`→`pacientenome` no INSERT da DESPESA de prótese
(bug pré-existente da Fase 1, em produção): a entrega de OS com custo>0 abortava
com "column paciente_nome does not exist". A mesma correção vale para a RECEITA nova.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:17:07 +02:00
VPS 4 Builder 9ed8bc25bd docs(verdade-hoje): produção em v1.0.20 (8676308)
build-and-promote / build (push) Successful in 53s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:11:56 +02:00
VPS 4 Builder 8676308325 docs(salas): registra fix de menu + feature de manutenção/operador na análise da recepção
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 59s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:25:09 +02:00
VPS 4 Builder 983582f066 feat(salas): acesso operacional do funcionário à sala + bloqueio p/ manutenção
Funcionário/membro com vínculo na clínica dona da sala passa a ter a sala como
workspace de acesso OPERACIONAL (papel_sala='operador'): vê a agenda da sala e
bloqueia horário para manutenção, sem locação nem financeiro. O Painel da Sala
detecta o modo operador e esconde recebíveis/relatório/baixa, mostrando só a
agenda + ação de bloqueio.

Bloqueio de manutenção: reserva tipo='manutencao' (coluna nova, default
'locacao'), valor 0, nasce 'confirmada' (sem aprovação) e NÃO gera recebível.
Reaproveita o anti-conflito de horário da sala (409 em sobreposição) e o
cancelamento por solicitante/unidade. A agenda renderiza o bloqueio em cinza.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:23:36 +02:00
VPS 4 Builder 718a7886e1 docs(protese): modelo Fase 2 (área do protético) + análise área recepção/TC
- FASE2-PROTETICO-AREA: Laboratório como workspace de 1ª classe (tipo='laboratorio'),
  roteamento por laboratorio_id, área de gestão própria, financeiro espelhado.
- FASE2-IMPL-2.0-2.1: detalhamento de implementação (migrações, backfill, bancada,
  receita espelhada, menu da área).
- ANALISE-AREA-RECEPCAO-TC: gap de permissões da recepção fundamentado em modelos
  validados (Treatment Coordinator / RBAC / Lab Case Manager).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 9b0ba65eca fix(salas): restringe MINHAS/ALUGAR SALAS a papéis de locação
Os menus de locador (MINHAS SALAS) e locatário (ALUGAR SALAS) eram exibidos a
"todos menos superadmin", vazando para funcionário e paciente — papéis que não
são titulares de sala e não tomam decisões de locação. Passa a exibir apenas a
papéis que podem ser titulares (donosala/donoclinica/donoconsultorio/admin/
dentista/biomedico/protetico).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 553efb003b fix(rbac): expõe PRÓTESES e CONTRATOS no editor de cargos
PAGINAS_RBAC não listava 'proteses' nem 'contratos', então essas páginas não
eram concedíveis por nenhum cargo (nem via "MARCAR TODAS"/GERENTE, que derivam
de TODAS_PAGINAS = flatMap de PAGINAS_RBAC). Resultado: era impossível um dono
dar acesso à área de Prótese a um funcionário pela interface.

Adiciona ambas ao grupo ATENDIMENTO e inclui proteses/lancar-gto nos templates
RECEPÇÃO e AUXILIAR (alinhado ao papel de Treatment Coordinator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder 321ca7e127 feat(protese): Fase 1 — finalizar GTO abre OS de prótese na Bancada
Itens de GTO ligados a um produto do catálogo do protético geram, na
finalização da GTO, uma OS de prótese (status 'solicitado') na Bancada do
laboratório — idempotente por gto_item_id e isolado em try/catch para nunca
derrubar o finalize. Migrações aditivas (gto_items.produto_id/protetico_id,
protese_os.gto_item_id, re-tag de especialidades para area='protese').

No Lançar GTO, a especialidade de prótese troca o seletor de procedimentos
pelo fluxo laboratório → produto → valor → observação. Busca de pacientes
passa a ser server-side com debounce (antes filtrava só a 1ª página).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:49:36 +02:00
VPS 4 Builder d005fe77a9 feat(clinicas): permite criar unidades adicionais (multi-clínica)
- ClinicasView: botão '+ Nova clínica/consultório' + modal de criação,
  gateado pelo papel da conta (donoclinica/donoconsultorio/admin).
- Corrige applyWorkspace destrutivo no onboarding: usa refreshWorkspaces
  (acrescenta a unidade à lista, em vez de sobrescrever as existentes).

Antes não havia caminho de UI para criar uma 2ª clínica fora do
onboarding; o backend já suportava N clínicas. Validado em DEV (8020).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:46:08 +02:00
VPS 4 Builder 0ade8027f5 docs(verdade-hoje): atualiza versão corrente para v1.0.19 (715b2dc)
Alinha o documento ao git HEAD/produção atual. Mantém intactas as
menções históricas (validação em v1.0.16, colisão de tag v1.0.14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:28:35 +02:00
VPS 4 Builder 8f9177a1c1 docs(status): marca fila A concluída — produção em v1.0.19 (715b2dc)
build-and-promote / build (push) Successful in 52s
build-and-promote / promote (push) Has been skipped
Atualiza STATUS-SEGURANCA-E-PRODUCAO.md: cabeçalho (prod v1.0.19), nova
seção "Fila A concluída" (push → fix CI → build → tag/promote → backup →
verificação), seção A marcada como feita, commits agora publicados e
"Como retomar" com pendências restantes (B + A.2) e rollback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:36:50 +02:00
VPS 4 Builder 715b2dc81e ci(build): desbloqueia build — SCOREO_DB_PASS placeholder na interpolação
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m8s
O compose interpola environment: (com fail-fast `:?` em SCOREO_DB_PASS)
mesmo só no `docker compose build`, e o runner não tem .env → build abortava
(run #29, commit 8a27f3b). SCOREO_DB_PASS é runtime-only (environment:, não
build-arg), então um placeholder satisfaz a interpolação sem entrar na imagem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:00:16 +02:00
VPS 4 Builder 8a27f3bc7c docs+infra: corrige status (JWT sem commit/contagem) + versão em DEV
build-and-promote / build (push) Failing after 29s
build-and-promote / promote (push) Has been skipped
- STATUS-SEGURANCA-E-PRODUCAO.md: a rotação do JWT_SECRET deixa de ser
  atribuída ao commit fcf2fb1 (que só remove .env/adiciona .dockerignore) —
  passa a constar como ação operacional na VPS, sem commit (segredo não
  versionado). Ajusta a contagem: 13 commits da sessão (inclui este doc),
  repo ahead 14 de origin/main (o 14º, docs/LEIA, é anterior à sessão).
- dev-build.sh: builda o ambiente de DEV injetando APP_VERSION/GIT_COMMIT/
  BUILD_TIME a partir do git (git describe), espelhando o deploy-prod.sh.
  /api/version em DEV deixa de mostrar apenas "dev".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:21:59 +02:00
VPS 4 Builder 6adf353403 docs: status geral de segurança & produção (feito + pendente)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:27 +02:00
VPS 4 Builder f22e30e6aa docs(skills): completa as 62 skills (FASE 05/06/08/09/10)
Preenche as 25 restantes com conteúdo real baseado na auditoria + no RBAC:
- FASE_08 Auditoria: Permissoes (RBAC implementado), Entidades, Banco, APIs, Botoes, Performance
- FASE_09 Observabilidade: HealthChecks, VersionamentoRuntime, Logs, Diagnosticos
- FASE_06 Módulos: Agenda, CRM, Proteses, Prontuario, Radiologia, Tutoria, Documentos
- FASE_05 UI/UX: DesignSystem, Dashboard, Responsividade, Acessibilidade
- FASE_10 Roadmap: Monetizacao, Escalabilidade, Backlog, Priorizacao

Todas ancoradas em evidências reais (server.js, types.ts, documentos-unicos, relatórios).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:58:50 +02:00
VPS 4 Builder e83af044e0 docs(skills): biblioteca operacional ScoreOdonto Enterprise
Skills estruturantes preenchidas com conteúdo real (baseado no código): FASE_01
Arquitetura, FASE_02 Negócio, FASE_03 Pessoas, FASE_04 Financeiro, FASE_05 UI/UX
(regras rígidas de desktop/container/grid), FASE_07 Infra, FASE_08 Auditoria (Rotas/Fluxos).
Demais skills permanecem como scaffold para preenchimento futuro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:23:54 +02:00
VPS 4 Builder 5b99292b2d docs: auditoria funcional + auditoria estratégica de modelagem
- documentação/MAPA-FUNCIONAL-COMPLETO.md: inventário de rotas/telas/botões/fluxos,
  matriz de módulos, mapa de conexões (mermaid) e notas — baseado em evidências do código.
- documentação/AUDITORIA-ESTRATEGICA-MODELAGEM.md: análise Pessoa→Vínculo→Workspace,
  entidades mal classificadas e riscos arquiteturais.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:23:54 +02:00
VPS 4 Builder 06674563d5 security(rbac): gateia rotas /reorder + auditoria do marketplace
- Novo helper requireCapabilityFromOrders(cap, tabela): deriva o clinicaId do 1º
  item de req.body.orders, fechando o RBAC dos catálogos.
- Aplica em PUT /{especialidades,procedimentos,dentistas}/reorder (cap do catálogo).
  Antes eram authGuard puro (qualquer autenticado reordenava catálogo de qualquer clínica).
- Validado em DEV: dono 200; dentista 403; id inexistente 404; lista vazia 400.

Marketplace (salas/sala-reservas/propostas): AUDITADO — já protegido por checagem de
POSSE nos handlers (owner_usuario_id / profissional_id destinatário / vínculo p/ cancelar).
Não há gap; capability-gating seria incorreto (modelo é posse cross-clínica). Sem mudança.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:21:03 +02:00
VPS 4 Builder 0fc69bbcd3 security(rbac): sub-pass clínico — fecha o RBAC intra-clínica
Gateia 16 rotas clínicas remanescentes a 'agenda' (staff clínico; bloqueia paciente):
- coleções (requireCapability): agenda/presenca (GET+POST), agenda/pendencias,
  agenda/atividade, agenda/importar-google, interesses (GET+POST), dentistas/horarios (POST),
  dentistas/:dentistaId/horarios (GET)
- /:id (requireCapabilityRow, clinicaId da linha): agenda/pendencias/:id/resolver,
  dentistas/horarios/:id (DELETE), agendamentos/:id/{avaliacao,historico},
  pacientes/:id/{score,faltas,familia}

As /:id eram authGuard SEM validação de tenant — agora o requireCapabilityRow valida
vínculo+cargo pela clínica da linha (fecha também o gap cross-tenant).
Confirmado que telas de paciente (MeusTratamentos/Notifications) não usam nenhuma delas;
detalhes de paciente são só do PatientsView (funcionário+dono).

Validado em DEV: dono+dentista passam em tudo (200/404); row real→200, fake→404.
Residual cosmético (fora do sub-pass): rotas /reorder (sem clinicaId/:id, baixo impacto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:15:10 +02:00
VPS 4 Builder 16c13ad35d security(rbac): autorização por cargo em orçamentos e contratos
Gateia 18 rotas (tenantGuard + requireCapability), espelhando o frontend:
- orcamentos (GET/POST/PUT/DELETE) → 'orcamentos' (funcionário + dono)
- contratos (modelos CRUD+duplicar; instâncias CRUD+enviar+assinar+cancelar+
  gerar-cobranca; GETs) → 'contratos' (funcionário + dono)

'assinar' incluído: usa tenantGuard+loadContratoScoped (quem assina é membro da
clínica registrando a assinatura via ContratosView — não há fluxo de contraparte
externa). Todos os endpoints de contrato são usados só no ContratosView (funcionário+dono).
Propostas (marketplace, autorização por posse do profissional) ficam para sub-pass próprio.

Validado em DEV: dono acessa (fake→404); dentista 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:07:03 +02:00
VPS 4 Builder 5b58a8a01c security(rbac): autorização por cargo na operação clínica
Gateia 15 rotas (cap espelhando o frontend):
- leads (GET/POST/converter) → 'leads' (funcionário + dono)
- pacientes mutações (export, POST, PUT/:id) → 'agenda' (staff clínico; bloqueia
  paciente; INCLUI dentista — OrthoView cria/edita paciente)
- agendamentos (POST + ações /🆔 PUT, DELETE, confirmar, falta, remarcar, restaurar,
  reencaminhar, solicitar-avaliacao) → 'agenda' (via requireCapabilityRow nas /:id)

Escolha de 'agenda' como discriminador 'staff clínico vs paciente': é a única capability
presente em funcionário/dentista/biomédico/protético/dono e AUSENTE em paciente — casa com
quem realmente opera (inclui o dentista que mexe em paciente pelo Ortho).

GETs de operação (lista de agendamentos/pacientes, detalhes) seguem abertos (consumidos por
todas as telas clínicas). Validado em DEV: dono/dentista passam onde devem; leads nega dentista;
reads 200; negação por linha sem apagar dados.

Deferido (sub-pass): presença, pendências/resolver, importar-google, interesses,
detalhes de paciente (score/faltas/família) e horários de dentista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:59:20 +02:00
VPS 4 Builder 116200ccc6 security(rbac): autorização por cargo nos cadastros administrativos
Refatora a autorização: extrai capabilityCore; requireCapability passa a derivar o
clinicaId da requisição (param/body/query) quando não há tenantGuard; adiciona
requireCapabilityRow(cap, tabela) p/ rotas /:id cujo clinicaId vem da LINHA.

Gateia 12 rotas (cap por cargo, espelhando o frontend):
- especialidades/procedimentos: POST + PUT/:id + DELETE  (dono OU biomédico)
- planos: POST + PUT/:id + DELETE                          (dono)
- dentistas: POST + PUT/:id + DELETE/:id                   (dono)
GETs de catálogo seguem abertos (dados de referência usados em toda a app).

Corrige também risco pré-existente: POST/PUT de catálogo usavam authGuard SEM validar
vínculo (escrita cross-tenant) — agora validam vínculo + cargo.

Deferido: rotas /reorder (sem clinicaId/:id; baixo impacto) e horários de dentista (grupo agenda).
Validado em DEV: dono 404(fake)/dentista 403; GETs 200; negação por linha sem apagar dados.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:52:32 +02:00
VPS 4 Builder 9ad65c358f security(rbac): autorização por cargo na gestão de equipe/clínica
Aplica requireCapability('gestao-equipe') (dono-only por padrão; ou cargo com a
permissão) APÓS tenantGuard em 12 rotas: membros, setores e funcoes (GET + mutações).
- reset-senha: troca authGuard → tenantGuard + requireCapability (antes não validava
  o vínculo do solicitante; só hierarquia).
- PUT /clinicas/:id/color: eleva de 'tem vínculo' para 'dono/admin' (igual a /nome).
- nome, PUT /clinicas/:clinicaId e PUT /areas já tinham checagem de dono (mantidos).
- GET /clinicas/:clinicaId (branding) segue legível por qualquer membro (por design).

Checagens de hierarquia (nivelNaClinica) preservadas como defesa secundária.
Validado em DEV: dono ok; dentista 403 (inclui /color); outra clínica 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:33:17 +02:00
VPS 4 Builder f4e7fe37b1 security(rbac): autorização por cargo nas rotas financeiras (backend)
Adiciona requireCapability(cap) — espelha o isViewAllowed do frontend
(superadmin global; dono donoclinica/donoconsultorio/admin; cargo com
permissoes explícitas da funcao; senão mapa padrão por papel) — e aplica
APÓS o tenantGuard em 22 rotas:
- financeiro: GET/POST/PUT/DELETE + relatorio  (cap 'financeiro')
- glosas: listar/itens/recebiveis/criar/recorrer/recuperar/excluir (cap 'glosas')
- comissoes: regras GET/PUT/DELETE + relatorio (cap 'comissoes')
- repasses: fechar/listar/detalhe/pagar/cancelar/comprovante (cap 'comissoes')
/repasses/comprovante/:id/raw fica de fora (URL assinada, sem tenantGuard).

Antes: bastava ter vínculo (tenantGuard) p/ chamar a API — UI escondia, backend não.
Validado em DEV: dono 200; dentista 403; outra clínica 403 (isolamento intacto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:01:54 +02:00
VPS 4 Builder 1559387a7c security/fix(front): fecha /update p/ superadmin + corrige deep-links
- /update (migração de banco) sai da lista sempre-liberada do isViewAllowed;
  passa a exigir superadmin (não-superadmin cai na view padrão; deslogado vai p/ landing).
- ROUTE_MAP ganha /comissoes e /glosas (existiam em VIEW_TO_ROUTE mas faltavam aqui;
  refresh/deep-link nessas telas caía em dashboard).
- (cadastro-dentista já estava mapeado — correção de equívoco da auditoria.)
- Validado em DEV: build compila, app sobe, rotas resolvem (HTTP 200 SPA).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:43:15 +02:00
VPS 4 Builder 3aa6a444dc security(infra): remove senhas hardcoded do docker-compose
- Senha do banco deixa de ter default no compose: vem de SCOREO_DB_PASS (.env raiz
  em DEV; provisionar fora do git em PROD). `:?` faz o up falhar se faltar (fail-fast).
- DRAGONFLY_URL (com senha) sai do compose e passa a vir do env_file backend/.env.
- Host/usuário/banco mantêm default (não são segredo).
- Validado em DEV: db (pgdev) ok, cache ok, login ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:36:38 +02:00
VPS 4 Builder fcf2fb15ba security(infra): remove backend/.env do tracking + .dockerignore + .env.example
- backend/.env deixa de ser versionado (continha JWT_SECRET, DATABASE_URL,
  DRAGONFLY_URL e chaves de IA); arquivo preservado em disco (dev e prod).
- backend/.dockerignore: impede que segredos sejam copiados para a imagem
  (Dockerfile usa COPY . . e não havia .dockerignore).
- .gitignore reforçado (**/.env, .secrets, *.dump, backups de env).
- backend/.env.example documenta as variáveis sem valores.
- JWT_SECRET rotacionado em dev e prod (fora do git).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:30:04 +02:00
VPS 4 Deploy Agent 7c5b199d0e docs(LEIA): aponta para o repo Documentos-Unicos + regras-chave
build-and-promote / build (push) Successful in 1m9s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:34:03 +02:00
VPS 4 Builder 4e00e28bc2 docs: aponta deploy/infra para documentos-unicos (fonte única); remove DEPLOY-PRODUCAO local
build-and-promote / build (push) Successful in 1m21s
build-and-promote / promote (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:59:46 +02:00
VPS 4 Builder fc006221de feat(ui): versão vX.Y.Z fixa no canto inferior direito (todas as telas, lê /api/version)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:11:10 +02:00
VPS 4 Builder d77679a849 feat(api): GET /api/health — status do backend + banco + cache
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 3m18s
- Público, sem auth. Checa Postgres (crítico → HTTP 503 se down) e Dragonfly/Redis
  (opcional → "degraded" mas 200; "disabled" se cache off). Inclui version/commit/
  environment (de BUILD_INFO) e uptime_s. Sem vazar mensagens de erro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:58:48 +02:00
VPS 4 Builder 93bf37340f docs: Passo 5 validado em produção (v1.0.16) — modelo Build Once completo
build-and-promote / build (push) Successful in 56s
build-and-promote / promote (push) Has been skipped
- VERDADE-HOJE.md e DEPLOY-PRODUCAO.md: produção v1.0.16 (commit 471c2c2);
  tag promove por re-tag SEM rebuild (digest :v1.0.16 == :471c2c2, provado);
  versão nasce da tag; constants.ts/update-version.js removidos. Passo 5 .

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:51:49 +02:00
VPS 4 Builder 471c2c2603 feat(ci): Passo 5 — Build Once / Promote / Deploy no build.yml
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m31s
- build.yml dividido em 2 jobs por ref:
  • build (só push na main): builda 1x e publica por COMMIT (:<sha> + :latest), sem APP_VERSION.
  • promote (só tag vX.Y.Z): re-tagueia :<sha> -> :vX.Y.Z via 'docker buildx imagetools create'
    (SEM rebuild), valida integridade (imagem do commit existe; digest :versao == :commit;
    front+back do mesmo commit) e deploya na VPS1. Falha se o commit não foi pushado.
- Versão semântica nasce da TAG e é injetada no deploy em runtime (compose.prod).
- Removidos frontend/constants.ts e update-version.js (fim do APP_VERSION manual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:34:33 +02:00
VPS 4 Builder 5049ce9b8b docs: unifica documentação em doc/ (fonte única, sem sobreposição)
build-and-push / build (push) Successful in 1m10s
build-and-push / deploy (push) Has been skipped
- Pasta única scoreodonto.com/doc/ com 9 docs que prestam:
  VERDADE-HOJE (estado, principal), DEPLOY-PRODUCAO (runbook), BANCO-DEV-ESTRATEGIA,
  REGISTRY, AUDITORIA-FUNCIONAL, BACKLOG-FINANCEIRO-V1, CRM_Ortodontia, RX_ARQUITETURA, RX_BACKLOG.
- Removidos os docs de infra sobrepostos/desatualizados (estado consolidado em VERDADE-HOJE;
  histórico fica no git): arquitetura.md, deploy.md, CHECKLIST-DEPLOY-PRODUCAO.md,
  PENDENCIAS.md, PENDENCIAS_E_DEPLOY.md.
- Pasta documentação/ eliminada.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:04:08 +02:00
VPS 4 Builder 0f50835114 docs: reconcilia deploy/versionamento com o estado real (pós v1.0.15)
- VERDADE-HOJE.md: reescrito p/ a verdade de 17/jun — banco DEV isolado (pgdev, não
  compartilha mais), produção roda imagem v1.0.15, versão em runtime via /api/version.
  Declarado documento principal (prevalece em divergência).
- deploy.md: §5 rollback  (existe); §6 corrigida — remove o "não há CI/registry/deploy"
  (era falso/contraditório); CI/CD por tag ATIVO, falta só o Passo 5.
- DEPLOY-PRODUCAO.md: status 🧪 dos Passos 1/3/4 (promovidos em v1.0.15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:34:51 +02:00
199 changed files with 26406 additions and 1457 deletions
+68 -34
View File
@@ -1,24 +1,28 @@
name: build-and-push
name: build-and-promote
# Etapa 5: a cada push na main (ou tag vX.Y.Z), builda as imagens com o carimbo
# de versão e envia ao Container Registry do Gitea. NÃO faz deploy (Etapa 6 separada).
# Passo 5 — Build Once → Promote → Deploy:
# • push na main → job BUILD: builda 1x e publica por COMMIT (:<sha> + :latest). NÃO deploya.
# • tag vX.Y.Z → job PROMOTE: re-tagueia a imagem do commit como :vX.Y.Z (SEM rebuild),
# valida integridade e deploya na VPS1.
# A versão semântica NÃO entra no build: nasce da tag e é injetada no deploy em runtime
# (docker-compose.prod.yml). Não há mais APP_VERSION/constants.ts.
on:
push:
branches: [main]
tags: ['v*']
jobs:
# ───────────────────────── BUILD (só em push de branch) ─────────────────────────
build:
# Roda direto no host VPS4 (runner registrado com label de host + acesso ao Docker).
if: startsWith(github.ref, 'refs/heads/')
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Calcular versão/commit/data
- name: Vars (commit + data)
id: vars
run: |
echo "version=$(grep -oE 'V[0-9]+\.[0-9]+\.[0-9]+' frontend/constants.ts | head -1 | tr 'V' 'v')" >> "$GITHUB_OUTPUT"
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT"
@@ -27,65 +31,95 @@ jobs:
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.clube67.com -u ruicesar --password-stdin
else
echo "REGISTRY_TOKEN não definido — usando a credencial já presente no host (~/.docker/config.json)."
echo "REGISTRY_TOKEN ausente — usando a credencial já presente no host."
fi
- name: Build (compose, com carimbo de versão)
- name: Build (carimba commit/data; versão vem da tag no deploy, não aqui)
env:
# Fixa o nome do projeto p/ a imagem buildada bater com o passo de tag
# (em modo host o diretório do checkout muda o project name por padrão).
COMPOSE_PROJECT_NAME: scoreodontocom
APP_VERSION: ${{ steps.vars.outputs.version }}
GIT_COMMIT: ${{ steps.vars.outputs.commit }}
BUILD_TIME: ${{ steps.vars.outputs.build_time }}
APP_ENV: PROD
# SCOREO_DB_PASS tem fail-fast `:?` no compose (runtime). No build é só interpolado,
# nunca entra na imagem (é `environment:`, não build-arg) — placeholder satisfaz o compose.
SCOREO_DB_PASS: build-placeholder
run: docker compose build scoreodonto-backend scoreodonto-frontend
- name: Tag + push (com retry p/ 499 transitório)
- name: Publicar por COMMIT (:<sha> + :latest), com retry p/ 499
run: |
set -e
V="${{ steps.vars.outputs.version }}"
REG=git.clube67.com/ruicesar
C="${{ steps.vars.outputs.commit }}"
for app in backend frontend; do
SRC="scoreodontocom-scoreodonto-${app}:latest"
DST="git.clube67.com/ruicesar/scoreodonto-${app}"
docker tag "$SRC" "$DST:$V"
DST="${REG}/scoreodonto-${app}"
docker tag "$SRC" "$DST:$C"
docker tag "$SRC" "$DST:latest"
for tag in "$V" "$C" "latest"; do
for tag in "$C" "latest"; do
n=0
until docker push "$DST:$tag"; do
n=$((n+1)); [ $n -ge 4 ] && { echo "push falhou após 4 tentativas: $DST:$tag"; exit 1; }
n=$((n+1)); [ $n -ge 4 ] && { echo "push falhou: $DST:$tag"; exit 1; }
echo "retry $n (499 transitório) em $DST:$tag"; sleep 3
done
done
done
echo "Publicado: scoreodonto-{backend,frontend}:$C (+ :latest). Push NÃO deploya."
- name: Resumo
run: |
echo "Imagens publicadas:"
echo " git.clube67.com/ruicesar/scoreodonto-backend:${{ steps.vars.outputs.version }}"
echo " git.clube67.com/ruicesar/scoreodonto-frontend:${{ steps.vars.outputs.version }}"
echo "Deploy só acontece em TAG (job deploy)."
# Deploy automático SÓ em tags vX.Y.Z (a tag = 'aprovado para produção').
# Push na main NÃO deploya — só builda. Etapa 6.
deploy:
needs: build
# ─────────────────────── PROMOTE (só em tag vX.Y.Z) ───────────────────────
promote:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
steps:
- name: Checkout
- name: Checkout (na tag)
uses: actions/checkout@v4
- name: Promover a tag para produção (VPS1)
- name: Vars (tag + commit)
id: vars
run: |
echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Login no registry
run: |
if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.clube67.com -u ruicesar --password-stdin
fi
- name: Integridade — a imagem do commit existe? (nunca buildar na tag)
run: |
set -e
TAG="${GITHUB_REF_NAME}"
echo "Promovendo $TAG para a produção (VPS1)..."
# Garante que o mecanismo de deploy está atualizado na VPS1
REG=git.clube67.com/ruicesar
C="${{ steps.vars.outputs.commit }}"
for app in backend frontend; do
if ! docker buildx imagetools inspect "${REG}/scoreodonto-${app}:$C" >/dev/null 2>&1; then
echo "ERRO: ${REG}/scoreodonto-${app}:$C não existe no registry."
echo " Faça 'git push origin main' (que builda o commit $C) ANTES de criar a tag."
exit 1
fi
done
echo "OK: backend:$C e frontend:$C presentes — mesmo commit $C (front+back atômicos)."
- name: Promover — re-tag commit → versão (SEM rebuild) + conferir digest
run: |
set -e
REG=git.clube67.com/ruicesar
C="${{ steps.vars.outputs.commit }}"; V="${{ steps.vars.outputs.version }}"
for app in backend frontend; do
DST="${REG}/scoreodonto-${app}"
docker buildx imagetools create -t "$DST:$V" "$DST:$C"
dC=$(docker buildx imagetools inspect "$DST:$C" --format '{{.Manifest.Digest}}')
dV=$(docker buildx imagetools inspect "$DST:$V" --format '{{.Manifest.Digest}}')
[ "$dC" = "$dV" ] || { echo "ERRO: digest divergiu em $app ($V=$dV != $C=$dC)"; exit 1; }
echo "$app: $V -> $C (digest $dV)"
done
echo "Promovido sem rebuild: a imagem :$V E a imagem :$C (mesmo artefato)."
- name: Deploy na VPS1 (pull :versao + up --no-build; versao injetada em runtime)
run: |
set -e
V="${{ steps.vars.outputs.version }}"
scp -o StrictHostKeyChecking=accept-new \
docker-compose.prod.yml deploy-prod.sh \
deploy@10.99.0.1:/home/deploy/stack/scoreodonto.com/
ssh -o StrictHostKeyChecking=accept-new deploy@10.99.0.1 \
"/home/deploy/stack/scoreodonto.com/deploy-prod.sh $TAG"
"/home/deploy/stack/scoreodonto.com/deploy-prod.sh $V"
+11 -1
View File
@@ -12,11 +12,21 @@ backend/test-db-users.js
backend/test-db-users.cjs
backend/pg-convert.js
# Override local de ambiente DEV (aponta o banco p/ pgdev) — NÃO versionar
# Segredos de ambiente — NÃO versionar (qualquer nível)
.env
**/.env
backend/.env
.secrets
**/.secrets
*.dump
backend/server.js.bak
# Dependências e caches locais
node_modules/
frontend/node_modules/
backend/node_modules/
.claude/
# backups de env (segredos) — nunca versionar
*.env.bak*
backend/.env.bak*
+15
View File
@@ -0,0 +1,15 @@
# LEIA
A documentação deste projeto **NÃO fica aqui**. Fonte única (em português):
**`git.clube67.com/ruicesar/Documentos-Unicos`** · local: `/home/deploy/stack/documentos-unicos/`
Comece pelo `LEIA-PRIMEIRO.md`.
- Universal (vale para todos): `deploy/` e `infra/`
- Específico deste projeto, **só se diferir** do universal: `projetos/scoreodonto.com/`
## Regras-chave
- **Sempre testar em `https://dev.<dominio>`** antes de qualquer coisa.
- **Pedir aprovação** do administrador antes de **produção** ou de **novas melhorias/implementações**.
- **Em dúvida, consultar `desuso/`** (só consulta/histórico) — instrução nova vai **sempre** na doc oficial.
- **Não duplicar**: se o tema já existe, não recriar. Preferir doc **universal**; o que diferir vai em `projetos/scoreodonto.com/` (citando o que difere).
- **Nunca** criar documentação solta no projeto. **Tudo em português.**
@@ -0,0 +1,35 @@
# SCOREODONTO ENTERPRISE SKILLS
## REGRA Nº 1 — NÃO QUEBRAR O QUE JÁ FUNCIONA
Antes de qualquer alteração:
- Auditar impacto.
- Identificar dependências.
- Identificar telas afetadas.
- Identificar APIs afetadas.
- Identificar tabelas afetadas.
- Identificar permissões afetadas.
- Identificar integrações afetadas.
PROIBIDO:
- Refatorar sem auditoria.
- Remover funcionalidades existentes sem validação.
- Alterar identidade visual aprovada.
- Alterar fluxo financeiro sem mapeamento.
- Alterar entidades centrais sem documentação.
Toda implementação deve seguir:
Auditoria → Planejamento → Aprovação → Implementação → Validação.
Arquitetura oficial:
Pessoa → Vínculo → Workspace
Workspaces:
- Clínica
- Consultório
- Laboratório
- Sala
@@ -0,0 +1,69 @@
# SKILL — Modelagem Mestra
> **Skill raiz.** Todas as demais skills herdam destas regras. Baseado em evidências reais do
> `backend/server.js` e do schema. Antes de qualquer alteração: **Auditoria → Planejamento → Aprovação → Implementação → Validação.**
## Conceito de negócio
O ScoreOdonto **não é um sistema de clínica**; é uma **plataforma de pessoas** que atuam dentro de
workspaces. A clínica é apenas **um tipo de workspace**. A arquitetura oficial é:
```
Pessoa → Vínculo → Workspace → Serviços → Marketplace → Financeiro
```
## Objetivo da skill
Fixar o modelo mental único do sistema, para que nenhum módulo volte ao paradigma legado
"Clínica → tudo". Toda entidade nova deve se posicionar nessa cadeia.
## Entidades centrais (fonte da verdade)
| Camada | Tabela real | Papel |
|---|---|---|
| Pessoa | `usuarios` | Identidade única (login). Âncora de tudo. |
| Vínculo | `vinculos` | Liga pessoa↔workspace com papel e RBAC. |
| Workspace | `clinicas` (`tipo` ∈ pessoal/consultorio/clinica) + `salas` | Contexto operacional. |
| Capacidades | `usuario_plugins`, `usuario_areas` | Atributos da pessoa (não do workspace). |
## Relacionamentos
- `usuarios (1) → (N) vinculos (N) → (1) clinicas` — uma pessoa, vários workspaces, papel por workspace.
- `clinicas.owner_id → usuarios.id` — todo workspace tem dono.
- Ao registrar, cria-se automaticamente um **workspace pessoal** (`clinicas.tipo='pessoal'`).
## Regras obrigatórias
1. **A pessoa (`usuarios`) é a âncora.** Capacidades (áreas, plugins, tutor) pertencem à pessoa.
2. **Clínica/Consultório/Sala/Laboratório são `tipo` de workspace**, nunca entidades de topo.
3. **Todo dado operacional é escopado por workspace** (`clinica_id`) via `tenantGuard`.
4. **Nunca duplicar a pessoa** para representá-la em outro workspace — use `vinculos`.
5. **Migração aditiva**: colunas novas via `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` (padrão atual).
## Casos de uso
- Dentista atende em 3 clínicas → 1 `usuarios` + 3 `vinculos`.
- Profissional autônomo sem clínica → `usuarios` + workspace `tipo='pessoal'`.
- Locador de sala → `usuarios` + `salas` (workspace `tipo='sala'`).
## Anti-padrões (PROIBIDO)
- ❌ Criar tabela-entidade nova "espelhando" a pessoa por workspace (vide débito de `dentistas`).
- ❌ Tratar `clinica` como raiz hierárquica do sistema.
- ❌ Guardar identidade/capacidade no workspace em vez de na pessoa.
- ❌ Escrever dado operacional sem `clinica_id`.
## Impactos em outros módulos
Esta skill governa **todas**: Pessoa, Workspace, Vínculos, Multiempresa, RBAC e todos os módulos
de negócio. Qualquer conflito com ela é defeito de modelagem (gravidade ALTA).
## Débitos conhecidos (legado clínica-cêntrico ainda vivo)
- `pacientes` (sem `usuario_id`, preso à clínica) e `dentistas` (tabela paralela a `usuarios`).
- Workspace dividido em 2 tabelas (`clinicas` + `salas`).
> Ver `../FASE_08_AUDITORIA/SKILL_Entidades.md`.
```mermaid
flowchart TD
P[Pessoa · usuarios] --> V[Vínculo · vinculos]
V --> W{Workspace}
W -->|tipo=clinica| C[Clínica]
W -->|tipo=consultorio| K[Consultório]
W -->|tipo=sala| S[Sala]
W -->|tipo=pessoal| PP[Workspace Pessoal]
P --> CAP[Capacidades · areas/plugins]
W --> OP[Operação · pacientes/agenda/financeiro]
```
</content>
@@ -0,0 +1,48 @@
# SKILL — Multiempresa (Tenancy)
> Herda de `SKILL_ModelagemMestra.md`. Regra de isolamento de dados entre workspaces.
## Conceito de negócio
Cada workspace é um inquilino (tenant). Dados de uma clínica **nunca** podem vazar para outra.
A mesma pessoa pode transitar entre tenants via vínculos, mas os **dados ficam no tenant**.
## Objetivo
Garantir isolamento por `clinica_id` em 100% da operação e impedir queries sem escopo.
## Entidades envolvidas
- Toda tabela operacional tem `clinica_id` (`pacientes`, `agendamentos`, `financeiro`,
`procedimentos_realizados`, `orcamentos`, `contratos`, `repasses`, `glosas`, `protese_os`…).
- `audit_log` por `clinica_id`.
## Relacionamentos / mecanismo real
- **`tenantGuard`** (middleware) injeta `req.clinicaId` e é exigido na maioria das 277 rotas.
- **`authGuard`** é o plano de identidade (rotas `me/*`) — sem tenant.
- O cliente envia o workspace ativo; o backend valida o vínculo antes de servir dados.
## Regras obrigatórias
1. **Toda query operacional filtra `clinica_id = $req.clinicaId`.** Sem exceção.
2. Rota que toca dado de clínica usa `tenantGuard`; rota de identidade usa `authGuard`.
3. `superadmin` é o único plano transversal (gestão global) — auditar com cuidado.
4. Marketplace cross-tenant (salas/propostas) é **acoplamento intencional** e explícito.
## Casos de uso
- Listar pacientes → sempre do `clinica_id` ativo.
- Trocar workspace → recomputar tudo no novo `clinica_id`.
## Anti-padrões
-`SELECT ... FROM pacientes` sem `clinica_id`.
- ❌ Confiar em `clinica_id` vindo do corpo do request sem validar o vínculo.
- ❌ Agregar entre clínicas fora dos fluxos de marketplace/superadmin.
## Impactos
Base de segurança de TODOS os módulos. Falha aqui = vazamento de dados (gravidade ALTA) + risco LGPD.
```mermaid
flowchart TD
REQ[Request] --> AG{Guard}
AG -->|authGuard| ID[Identidade · me/*]
AG -->|tenantGuard| TEN[req.clinicaId]
TEN --> Q[Query SEMPRE com clinica_id]
Q --> DB[(Postgres)]
```
</content>
@@ -0,0 +1,48 @@
# SKILL — Pessoa
> Herda de `SKILL_ModelagemMestra.md`. A Pessoa é a **âncora única** do sistema.
## Conceito de negócio
Toda interação no ScoreOdonto parte de uma **pessoa** (humano com login). Papéis (dentista,
biomédico, protético, paciente, tutor, dono) são **facetas** da mesma pessoa, não entidades separadas.
## Objetivo
Garantir que identidade, capacidades e histórico fiquem na pessoa — nunca duplicados por workspace.
## Entidades envolvidas
- `usuarios` — identidade (login, `role` global, `is_tutor`).
- `usuario_areas` — áreas de atuação (multi-área; ref. `areas`).
- `usuario_plugins` — capacidades/add-ons por pessoa (`me/plugins`).
- `vinculos` — onde a pessoa atua e com qual papel.
## Relacionamentos
- `usuarios (1) → (N) vinculos` — pessoa em vários workspaces.
- `usuarios (1) → (N) usuario_areas` e `(N) usuario_plugins`.
- Marketplace de profissionais: `propostas_profissional.profissional_id → usuarios.id`.
## Regras obrigatórias
1. **Uma pessoa = um `usuarios`** (idealmente deduplicado por CPF/e-mail).
2. **Capacidades vivem na pessoa**, não no workspace.
3. `role` global em `usuarios` só para `superadmin`; os demais papéis vêm de `vinculos.role`.
4. Toda rota de identidade usa `authGuard` (não `tenantGuard`).
## Casos de uso
- Pessoa é dentista numa clínica e dono de sala em outra → 2 vínculos, papéis distintos.
- Pessoa ativa o plugin de Salas → grava em `usuario_plugins` (vale em qualquer dispositivo).
## Anti-padrões
- ❌ Criar `dentistas`/`proteticos` como tabela de identidade paralela (débito atual de `dentistas`).
- ❌ Persistir capacidade só no `localStorage` sem refletir em `usuario_plugins` (risco de divergência).
## Impactos em outros módulos
Base de Vínculos, RBAC, Marketplace, Tutoria, Pessoas (Fase 3). Mudou a pessoa → reflete em todos.
```mermaid
flowchart LR
U[usuarios] --> A[usuario_areas]
U --> PL[usuario_plugins]
U --> V[vinculos]
U --> PR[propostas_profissional]
V --> WS[Workspaces]
```
</content>
@@ -0,0 +1,47 @@
# SKILL — RBAC (Controle de Acesso)
> Herda de `SKILL_Vinculos.md`. Define quem vê e faz o quê, por papel e função, no workspace ativo.
## Conceito de negócio
Acesso é decidido pelo **papel da pessoa no workspace ativo** + permissões finas da `funcao`.
O frontend espelha o backend: menu e telas seguem a mesma matriz de papéis.
## Objetivo
Padronizar a decisão de acesso e manter front/back coerentes (sem permissão hardcoded).
## Entidades / mecanismo real
- **Backend:** `tenantGuard`/`authGuard`; `vinculos.role` + `funcoes` (permissões); `usuarios.role` só p/ `superadmin`.
- **Frontend:** `App.tsx → isViewAllowed(view, role)` e o filtro de menu em `components/Sidebar.tsx`.
- Papéis: `superadmin` (global), `admin`/`donoclinica`/`donoconsultorio` (amplo), `funcionario`,
`dentista`, `biomedico`, `protetico`, `paciente`, `donosala`.
## Regras obrigatórias
1. **Decisão por papel/função**, nunca por identidade individual.
2. Quando a `funcao` define permissões explícitas (`permissoes[]`), elas **prevalecem** sobre o default do papel.
3. Telas `superadmin*` e o catálogo de `plugins` são **exclusivos do superadmin**.
4. Workspace `tipo='sala'` restringe o menu a painel da sala/conta/notificações.
5. **Mudou regra de acesso no backend → atualizar `isViewAllowed` e `Sidebar` no mesmo PR.**
## Casos de uso
- Biomédico não vê Ortodontia/RX (não-odonto) — regra explícita no menu.
- Cargo customizado com `permissoes:['agenda','financeiro']` → menu reduzido a isso.
## Anti-padrões
- ❌ Liberar tela no front sem proteger a rota no back (e vice-versa).
- ❌ Checar `if (email === 'x')`.
- ❌ Esquecer de espelhar a permissão nos dois lados (front/back divergentes).
## Impactos
Governa todo o menu, todas as rotas e a experiência por papel. Ver `../FASE_08_AUDITORIA/SKILL_Rotas.md`.
```mermaid
flowchart TD
P[Pessoa] --> V[vinculo.role no workspace ativo]
V --> F{tem funcao com permissoes?}
F -->|sim| PERM[usa permissoes da funcao]
F -->|não| DEF[usa default do papel]
PERM --> UI[Sidebar + isViewAllowed]
DEF --> UI
UI --> API[rota protegida por guard]
```
</content>
@@ -0,0 +1,46 @@
# SKILL — Vínculos
> Herda de `SKILL_ModelagemMestra.md`. O vínculo é a **ponte** pessoa↔workspace e o lugar do papel.
## Conceito de negócio
Uma pessoa não "é" dentista no sistema — ela **tem um vínculo de papel dentista** num workspace.
Mudou de workspace, muda o papel. É o que permite multi-clínica sem duplicar a pessoa.
## Objetivo
Centralizar papel + RBAC na relação, nunca na identidade nem no workspace.
## Entidades envolvidas
- `vinculos(id, usuario_id, clinica_id, role, setor_id, funcao_id)`.
- `funcoes` (cargos com permissões) e `setores` — por workspace.
- Constraint real de papéis (`vinculos_role_check`):
`paciente, dentista, biomedico, protetico, funcionario, donoclinica, donoconsultorio, donosala, admin`.
## Relacionamentos
- `vinculos.usuario_id → usuarios.id`; `vinculos.clinica_id → clinicas.id`.
- `vinculos.funcao_id → funcoes.id` (permissões finas); `vinculos.setor_id → setores.id`.
## Regras obrigatórias
1. **Papel operacional vem do vínculo** (`vinculos.role`), não de `usuarios` (exceto `superadmin`).
2. **N vínculos por pessoa** — multi-workspace é nativo. Nunca duplicar a pessoa.
3. A permissão efetiva = papel do vínculo **+** permissões da `funcao` (quando houver).
4. Remover acesso = remover/alterar o vínculo, jamais apagar a pessoa.
## Casos de uso
- Convidar membro: cria `vinculos` com `role` + `funcao_id`.
- Profissional sai da clínica: deleta o vínculo; histórico e identidade permanecem.
## Anti-padrões
- ❌ Guardar papel em tabela-recurso (débito de `dentistas`).
- ❌ Permissão hardcoded por e-mail/nome em vez de papel/função.
## Impactos
Base direta de RBAC, Multiempresa, Gestão de Equipe e do menu/`isViewAllowed` do frontend.
```mermaid
flowchart LR
U[usuarios] --> VN[vinculos: role, setor, funcao]
VN --> CL[clinicas/workspace]
VN --> FU[funcoes: permissões]
VN --> SE[setores]
```
</content>
@@ -0,0 +1,50 @@
# SKILL — Workspace
> Herda de `SKILL_ModelagemMestra.md`. Workspace é o **contexto operacional** onde a pessoa atua.
## Conceito de negócio
Todo dado operacional (pacientes, agenda, financeiro) pertence a um **workspace**, nunca diretamente
à pessoa. A clínica é o workspace mais comum, mas é só um `tipo`.
## Objetivo
Padronizar o conceito de workspace e impedir o retorno ao modelo clínica-cêntrico.
## Entidades envolvidas
- `clinicas` — tabela de workspace. Colunas-chave: `tipo` (`pessoal`|`consultorio`|`clinica`),
`owner_id`, `cor`, `geo` (PostGIS). **Hoje "clinicas" É a tabela de workspaces.**
- `salas` — workspace `tipo='sala'` (tabela separada — débito de unificação).
- `vinculos` — quem pertence ao workspace e com qual papel.
## Relacionamentos
- `clinicas.owner_id → usuarios.id`.
- Operação: `pacientes.clinica_id`, `agendamentos.clinica_id`, `financeiro.clinica_id`, etc.
- Agregação no app: `GET /api/me/workspaces``listarWorkspaces()` reúne clínicas (via vínculo) + salas.
## Regras obrigatórias
1. **Tudo que é operacional carrega `clinica_id`** e passa por `tenantGuard`.
2. O **workspace ativo** (cacheado no cliente) governa menu, permissões e dados visíveis.
3. `tipo='sala'` **isola o contexto** (menu reduzido: painel da sala/conta/notificações).
4. Workspace pessoal (`tipo='pessoal'`) é criado automaticamente no registro.
## Casos de uso
- Trocar de workspace no seletor → recarrega contexto (cor de marca, menu, dados).
- Sala como workspace → usuário cai em `sala-home`, sem acesso à operação clínica.
## Anti-padrões
- ❌ Ler/gravar operação sem `clinica_id`.
- ❌ Tratar `salas` e `clinicas` como conceitos distintos na lógica de negócio (são ambos workspace).
- ❌ Vazar dados entre workspaces (sempre filtrar por `req.clinicaId`).
## Impactos
Governa Multiempresa, RBAC e todos os módulos de Workspace (Fase 2) e operação.
```mermaid
flowchart TD
WS[Workspace] -->|clinicas.tipo| T1[clinica]
WS --> T2[consultorio]
WS --> T3[pessoal]
WS -->|salas| T4[sala]
WS --> OP[pacientes / agenda / financeiro · clinica_id]
WS --> VN[vinculos · membros + papéis]
```
</content>
@@ -0,0 +1,42 @@
# SKILL — Clínicas (Workspace)
> Herda de `SKILL_Workspace.md`. Tipo de workspace mais completo do sistema.
## O que é?
Workspace `clinicas.tipo='clinica'` — a empresa odontológica com equipe, pacientes e operação plena.
## Quem usa?
Donos (`donoclinica`/`admin`), funcionários, dentistas, biomédicos, protéticos vinculados.
## Quais módulos possui?
Dashboard, Agenda, Pacientes, Ortodontia, Próteses/GTO, Financeiro, Comissões, Repasses, Glosas,
Orçamentos, Contratos, Relatórios, Leads, Equipe/RBAC, Planos/Procedimentos/Especialidades,
RX (plugin), Configurações. (Menu completo — ver `../FASE_08_AUDITORIA/SKILL_Rotas.md`.)
## Quais receitas gera?
Produção de procedimentos (RECEITA), contratos/cobrança, recuperação de glosas. Paga repasses (DESPESA).
> Detalhe do dinheiro em `../FASE_04_FINANCEIRO/`.
## Quais permissões possui?
Donos/admin: acesso amplo (menos telas `superadmin`). Demais papéis: subconjunto por papel/função (RBAC).
## Entidades envolvidas
`clinicas` + toda a operação por `clinica_id` + `vinculos`/`funcoes`/`setores`.
## Regras obrigatórias
1. Operação sempre escopada por `clinica_id` (`tenantGuard`).
2. A clínica é **um tipo de workspace**, não a raiz do sistema.
3. Equipe entra por vínculo; permissões por papel/função.
## Anti-padrões
- ❌ Assumir "uma clínica por usuário" (a pessoa pode ter várias).
- ❌ Tratar clínica como dona da pessoa.
```mermaid
flowchart LR
CL[Clínica] --> EQ[Equipe via vinculos]
CL --> PAC[Pacientes]
CL --> AG[Agenda]
CL --> FIN[Financeiro → Comissão → Repasse]
```
</content>
@@ -0,0 +1,37 @@
# SKILL — Consultórios (Workspace)
> Herda de `SKILL_Workspace.md`. Variante enxuta da clínica.
## O que é?
Workspace `clinicas.tipo='consultorio'` — operação individual/pequena de um profissional-dono.
Mesma tabela e mecânica da clínica; muda o `tipo` e o papel do dono (`donoconsultorio`).
## Quem usa?
`donoconsultorio` (profissional que opera por conta própria) e eventuais membros.
## Quais módulos possui?
Os mesmos da clínica (herda a operação), normalmente com equipe menor. O `tipo` distingue rótulo/UX,
não um conjunto de tabelas separado.
## Quais receitas gera?
Iguais às da clínica (produção, contratos), em escala individual.
## Quais permissões possui?
`donoconsultorio` tem acesso amplo (não-`superadmin`), idêntico ao dono de clínica.
## Regras obrigatórias
1. **Consultório = `tipo` de workspace**, não um módulo nem uma entidade separada.
2. `donoconsultorio` é o **papel do dono**; o `tipo='consultorio'` é o **workspace**.
3. Não criar tabelas próprias de "consultório" — reutilizar a operação por `clinica_id`.
## Anti-padrões / Débito conhecido
- ⚠️ Acoplamento **role↔tipo** (`donoconsultorio``tipo='consultorio'`): decisão duplicada.
Ao ramificar lógica, prefira o `tipo` do workspace, não a role.
- ❌ Duplicar telas/tabelas só para "consultório".
```mermaid
flowchart LR
K[Consultório · tipo=consultorio] --> OWN[Dono · donoconsultorio]
K --> OP[Operação reutilizada por clinica_id]
```
</content>
@@ -0,0 +1,43 @@
# SKILL — Delivery (Profissionais temporários) — ALVO / ROADMAP
> Herda de `SKILL_Marketplace.md`. **ATENÇÃO: não existe no código hoje.** Esta skill descreve o conceito-alvo
> e as **sementes reais** sobre as quais ele se apoiaria — sem inventar funcionalidade.
## O que NÃO existe hoje (estado real)
Não há nenhuma implementação de "delivery"/plantão/diária de profissional: sem contratação temporária,
sem vínculo por período, sem pagamento de profissional avulso. Buscas por `delivery/temporario/plantao/
freela/substituto` no `server.js` **não retornam feature** — apenas os fragmentos abaixo.
## Sementes reais já existentes (base para o alvo)
1. **Marketplace de Profissionais**: `profissionais-disponiveis`, `profissionais/marketplace`, `propostas`
(clínica→profissional). Hoje só **mensagem/conexão**, status `pendente`.
2. **Modelos de período** (vindos das Salas): `RESERVA_MODELOS = ['hora','turno','diaria','semanal','mensal']`
e `valor_diaria`/`diaria_consulta` — o conceito de **diária** já está modelado para salas.
3. **Vínculos temporários**: `vinculos` já suporta papel por workspace — a base para um vínculo com prazo.
## Conceito-alvo (Delivery)
Profissional aceita uma **escala temporária** numa clínica (ex.: diária/turno), com início/fim, valor e
pagamento — unindo a proposta de profissional ao modelo de período e a um vínculo com prazo.
## O que faltaria (lacunas — descrever, NÃO implementar)
- Evoluir `propostas_profissional` de mensagem → **acordo com período + valor + aceite**.
- Vínculo temporário (`vinculos` com janela de validade) e seu encerramento automático.
- Captura de pagamento (mesma lacuna de Salas) e repasse ao profissional avulso.
## Regras obrigatórias (se um dia evoluir)
1. Construir **sobre as sementes** (propostas + modelos de período + vínculos), sem entidade paralela nova.
2. Manter Pessoa→Vínculo→Workspace: delivery é **vínculo temporário**, não nova classe de pessoa.
3. Seguir Auditoria → Planejamento → **Aprovação** antes de qualquer implementação.
## Anti-padrões
- ❌ Documentar/assumir Delivery como se existisse.
- ❌ Criar tabela/role nova "delivery" em vez de reusar `vinculos` + `propostas`.
```mermaid
flowchart LR
PROF[Profissional · perfil] --> PROP[Proposta hoje: mensagem]
PROP -. alvo .-> ACORDO[Acordo: período + valor + aceite]
ACORDO -. alvo .-> VT[vinculo temporário]
VT -. alvo .-> PAY[(pagamento + repasse)]
```
</content>
@@ -0,0 +1,46 @@
# SKILL — Laboratórios (Workspace-alvo)
> Herda de `SKILL_Workspace.md`. **Atenção:** o estado ATUAL difere da arquitetura-alvo.
## O que é? (estado real hoje)
Hoje o laboratório é um **cadastro interno por clínica** (`protese_laboratorios`), usado nas Ordens de
Serviço de prótese e no **custo lab** que abate comissão. **Não** tem identidade própria nem login,
**não** cruza clínicas. (Modelo "A — cadastro auxiliar".)
## Arquitetura-alvo (declarada no LEIA-PRIMEIRO)
O laboratório é listado como **tipo de workspace** (Clínica/Consultório/Laboratório/Sala). Ou seja,
a visão oficial é elevá-lo a **participante/operador do ecossistema**, não apenas lookup.
## Quem usa?
Protéticos/laboratórios e as clínicas que enviam OS. (O protético-pessoa é tratado em
`../FASE_03_PESSOAS/SKILL_Proteticos.md` — não confundir pessoa com laboratório.)
## Quais módulos possui? (hoje)
Cadastro de laboratório + produtos (`protese_produtos`) + OS (`protese_os`, `protese_os_evento`,
`protese_os_foto`). Integra com Comissões via custo lab.
## Quais receitas gera?
Indireta hoje (operacional: custo lab compõe a margem). Como workspace, poderia faturar produção própria.
## Quais permissões possui?
Hoje: dentro do RBAC da clínica que cadastra. Como workspace: teria papéis/vínculos próprios.
## Regras obrigatórias
1. **Não confundir** laboratório (fornecedor/operador) com protético (pessoa).
2. Enquanto for cadastro, manter `clinica_id` e custo lab consistentes com Comissões.
3. Qualquer evolução para workspace é decisão arquitetural → seguir Auditoria→Aprovação (não implementar aqui).
## Anti-padrões
- ❌ Tratar o laboratório como pessoa (login) sendo cadastro, ou vice-versa.
- ❌ Recadastrar o mesmo laboratório em cada clínica como se fossem distintos (limitação atual a registrar).
```mermaid
flowchart LR
subgraph Hoje
LAB[protese_laboratorios · cadastro por clínica] --> OS[protese_os] --> COM[custo lab → Comissão]
end
subgraph Alvo
LABW[Laboratório · workspace] --> OPR[Opera no ecossistema]
end
```
</content>
@@ -0,0 +1,44 @@
# SKILL — Marketplace
> Herda de `../FASE_01_ARQUITETURA/SKILL_Workspace.md` e `SKILL_Pessoa.md`. O marketplace só existe
> porque a base é **Pessoa → Vínculo → Workspace** (atores cruzam fronteiras de clínica).
## Conceito
O marketplace conecta **pessoas/workspaces** entre si, cross-tenant. São **três frentes** reais hoje,
em maturidades diferentes:
| Frente | O que existe | Receita |
|---|---|---|
| **Salas (locação)** | CRUD + `salas/marketplace` + reservas (`sala_reservas`), modelos `hora/turno/diaria/semanal/mensal` com `valor_*` | `valor` registrado, **sem gateway** |
| **Profissionais** | `profissionais/perfil`, `profissionais/marketplace`, `profissionais-disponiveis`, `propostas` (clínica→profissional) | **Só mensagem**, sem cobrança |
| **Tutoria orto** | candidatura paga (MercadoPago) + solicitações | ✅ **única automatizada** (`comissao_pct`) |
## Entidades / rotas reais
- Salas: `salas`, `sala_reservas` · `GET/POST/PUT /api/salas*`, `/api/sala-reservas*`.
- Profissionais: `propostas_profissional` (`status='pendente'`, dup-check por clínica+profissional) ·
`/api/profissionais/*`, `/api/propostas*`. Perfil = `usuarios` (nível pessoa, `authGuard`).
- Tutoria: ver `../FASE_03_PESSOAS/SKILL_Tutores.md`.
## Permissões
- Salas: plugin `locacao-salas` (sempre ON), todos menos `superadmin`.
- Profissionais: plugin `profissionais-marketplace`, clínicos exceto `paciente`.
- O catálogo de plugins é exclusivo do `superadmin`.
## Regras obrigatórias
1. Marketplace é **cross-tenant intencional** — mas cada lado continua isolado por `clinica_id`/workspace.
2. **Sinalizar lacunas de pagamento** (salas/profissionais), nunca presumir transação concluída.
3. Proposta de profissional hoje é **conexão (mensagem)**, não contratação — não inventar fluxo inexistente.
## Anti-padrões
- ❌ Acoplar marketplace à clínica (Modelo A) — ele depende da pessoa/workspace (Modelo B).
- ❌ Tratar reserva/proposta como paga.
```mermaid
flowchart LR
P[Pessoa/Workspace] --> S[Salas: reserva valor/modelo]
P --> PR[Profissionais: perfil + proposta]
P --> T[Tutoria: MercadoPago]
S -. falta .-> PAY[(gateway)]
PR -. falta .-> CONTR[(contratação/cobrança)]
```
</content>
@@ -0,0 +1,43 @@
# SKILL — Salas (Workspace + Marketplace)
> Herda de `SKILL_Workspace.md`. Único workspace que já nasce no modelo novo + marketplace.
## O que é?
Workspace `tipo='sala'` (tabela `salas`) que pode ser **locado** a terceiros. Plugin `locacao-salas`
é **sempre ativo** (capability de plataforma).
## Quem usa?
- **Locador** (`donosala` ou qualquer conta): cadastra e administra salas ("Minhas Salas").
- **Locatário** (qualquer profissional): busca e reserva ("Alugar Salas").
## Quais módulos possui?
Painel da sala (`sala-home`), marketplace de salas, reservas (`sala_reservas`), geolocalização (PostGIS `geo`).
## Quais receitas gera?
**Potencial.** `sala_reservas` já tem `valor`, `modelo` e workflow de status (`pendente``aceita`),
mas **não há captura de pagamento** (gateway) — o fluxo financeiro **não fecha** hoje.
> É a alavanca de receita mais próxima de destravar. Ver `../FASE_04_FINANCEIRO/SKILL_Faturamento.md`.
## Quais permissões possui?
Workspace `tipo='sala'` **isola** o contexto: menu só painel da sala/conta/notificações.
Os itens "Minhas Salas"/"Alugar Salas" são de plugin (todos menos `superadmin`).
## Entidades envolvidas
`salas`, `sala_reservas` (cross-tenant: locação entre workspaces distintos).
## Regras obrigatórias
1. Reserva é **cross-tenant intencional** — validar disponibilidade (anti-overbooking de sala).
2. Contexto de sala isola da clínica (não misturar operação clínica no workspace de sala).
3. `valor` da reserva é registrado; **pagamento real é externo** (lacuna a sinalizar, não assumir).
## Anti-padrões
- ❌ Assumir que a reserva foi paga (não há gateway).
- ❌ Vazar dados da clínica para o contexto de sala.
```mermaid
flowchart LR
SAL[Sala · workspace] --> MKT[Marketplace de salas]
MKT --> RES[sala_reservas: valor, status]
RES -. falta .-> PAY[(Pagamento/gateway)]
```
</content>
@@ -0,0 +1,39 @@
# SKILL — Biomédicos (Pessoa)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Pessoa.md`. Profissional de **biomedicina estética**, não-odonto.
## Perfil
Pessoa (`usuarios`) com vínculo `biomedico`. Tem **catálogo próprio** (especialidades/procedimentos de
biomedicina) — semeável via `POST /api/me/seed-biomedicina`. Multi-área pelo perfil (`usuario_areas`/`areas`).
## Fluxos
- Agenda própria + Tratamentos + lançamento de procedimentos do seu catálogo.
- **Sem** itens odontológicos (Ortodontia, RX, tutoria orto) — separação explícita no menu.
## Permissões (RBAC)
Menu: `dashboard, agenda, tratamentos, especialidades, procedimentos, clinicas, notificacoes,
configuracoes, diretorio-profissionais`. Não vê Ortodontia/RX/tutoria.
## Marketplace
Publica perfil no diretório de Profissionais; recebe propostas; pode locar/alugar salas.
## Financeiro
Produção gera RECEITA/Comissão no workspace onde atua (mesma mecânica de `comissao_regras`/Repasse).
## Regras obrigatórias
1. Catálogo de biomedicina é **da área**, não misturar com catálogo odonto.
2. Identidade/áreas na pessoa; papel no vínculo.
3. Não expor telas odonto ao papel `biomedico` (regra de menu já existente).
## Anti-padrões
- ❌ Vazar Ortodontia/RX para o menu do biomédico.
- ❌ Duplicar a pessoa por local de atuação.
```mermaid
flowchart LR
B[Biomédico · usuarios] --> V[vinculo role=biomedico]
B --> AR[usuario_areas: biomedicina]
V --> AG[Agenda] --> PR[Procedimento área] --> COM[Comissão→Repasse]
B --> MKT[Diretório/Propostas]
```
</content>
@@ -0,0 +1,45 @@
# SKILL — Dentistas (Pessoa)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Pessoa.md` e `SKILL_Vinculos.md`.
## Perfil
Pessoa (`usuarios`) com vínculo de papel `dentista` num workspace. É o clínico odontológico completo.
**Débito conhecido:** existe também a tabela `dentistas` (recurso de agenda por clínica, com `usuario_id`
opcional) — a MESMA pessoa pode aparecer como `usuarios` + `dentistas`. Tratar `dentistas` como
**projeção de agenda**, não como identidade. Ver `../FASE_08_AUDITORIA/SKILL_Entidades.md`.
## Fluxos
- Atende na Agenda → registra Procedimento Realizado → gera RECEITA/Comissão.
- Ortodontia completa (triagem, plano, evoluções, fotos, contenção).
- Pode pedir tutoria de orto e/ou ser tutor (ver `SKILL_Tutores.md`).
## Permissões (RBAC)
Menu odonto: `dashboard, agenda, ortodontia, proteses, tratamentos, clinicas, configuracoes,
candidatura-tutor, diretorio-profissionais` (+ RX e tutoria-painel conforme plugin/aprovação).
**NÃO** vê telas administrativas globais nem `superadmin`.
## Marketplace
- Publica perfil no diretório de Profissionais (`profissionais/perfil`, nível pessoa via `authGuard`).
- Recebe propostas de clínicas (`propostas_profissional`).
- Pode locar/alugar salas (plugin `locacao-salas`).
## Financeiro
Gera comissão sobre procedimentos realizados (regra `comissao_regras`); recebe via Repasse.
Ver `../FASE_04_FINANCEIRO/SKILL_Comissoes.md` e `SKILL_Repasses.md`.
## Regras obrigatórias
1. Identidade e capacidades na pessoa; papel no vínculo (multi-clínica nativo).
2. Não duplicar a pessoa para representá-la em outra clínica — novo `vinculo`.
3. `dentistas` é recurso de agenda, não fonte de identidade.
## Anti-padrões
- ❌ Hardcodar permissão por e-mail. ❌ Criar identidade do dentista fora de `usuarios`.
```mermaid
flowchart LR
D[Dentista · usuarios] --> V[vinculo role=dentista]
V --> AG[Agenda] --> PR[Procedimento] --> COM[Comissão→Repasse]
D --> MKT[Diretório/Propostas]
D --> TUT[Tutoria orto]
```
</content>
@@ -0,0 +1,41 @@
# SKILL — Pacientes (Pessoa — com débito de modelagem)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Pessoa.md`. **Atenção:** estado atual contraria a arquitetura-alvo.
## Perfil (estado real hoje)
O paciente é registrado em `pacientes` com `clinica_id` e **sem `usuario_id`** — ou seja, **pertence à
clínica**, não ao ecossistema. O mesmo CPF em 2 clínicas vira **2 registros** (sem prontuário único).
Há resíduo legado: existem INSERTs com e sem `clinica_id`. Ver `../FASE_08_AUDITORIA/SKILL_Entidades.md`.
## Fluxos
- Origem: cadastro direto **ou** conversão de Lead (`leads/:id/converter` cria paciente + agendamento).
- Agenda, Orçamento, Procedimentos, Ortodontia, RX, Receita/Exame, Score, Faltas, Família.
## Permissões (RBAC)
- Operadores (funcionário/donos) gerenciam pacientes da clínica ativa.
- O **paciente-usuário** (papel `paciente`, quando existe login) vê só: `tratamentos, notificacoes, configuracoes`.
## Privacidade / LGPD
O modelo atual (paciente preso à clínica) **favorece isolamento** — cada clínica é controladora dos
seus dados. Compartilhar histórico entre clínicas exigiria base legal/consentimento. **Não** presumir
"paciente do ecossistema" sem decisão arquitetural + jurídica.
## Marketplace / Financeiro
Não participa de marketplace. É **origem de RECEITA** (procedimentos, contratos), nunca recebe repasse.
## Regras obrigatórias
1. Paciente sempre escopado por `clinica_id` (tenancy).
2. **Não** unificar pacientes entre clínicas sem aprovação (impacto LGPD direto).
3. Conversão de lead é anti-overbooking — preservar a checagem.
## Anti-padrões / Débito
- ⚠️ Falta de identidade de pessoa no paciente é o maior atrito com o modelo Pessoa→Workspace (gravidade ALTA).
- ❌ Deduplicar/“mesclar” pacientes entre clínicas sem base legal.
```mermaid
flowchart LR
L[Lead] -->|converter| PAC[Paciente · clinica_id]
PAC --> AG[Agenda] --> PR[Procedimento] --> FIN[RECEITA]
PAC --> ORC[Orçamento] --> CON[Contrato]
```
</content>
@@ -0,0 +1,42 @@
# SKILL — Protéticos (Pessoa)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Pessoa.md`. **Não confundir** com o Laboratório (entidade/fornecedor):
> ver `../FASE_02_NEGOCIO/SKILL_Laboratorios.md`.
## Perfil
Pessoa (`usuarios`) com vínculo `protetico` — profissional do laboratório. É **profissional do
ecossistema** (modelo B da auditoria), não um "fornecedor" abstrato. O fornecedor é o **laboratório**.
## Fluxos
- Agenda própria + Próteses (OS de laboratório: `protese_os`, produtos, materiais, custo lab, entrega).
- Lança GTO (guia de tratamento) quando aplicável.
## Permissões (RBAC)
Menu: `dashboard, agenda, proteses, tratamentos, lancar-gto, clinicas, notificacoes, configuracoes,
diretorio-profissionais`. Sem Ortodontia/RX/pacientes clínicos.
## Marketplace
Perfil no diretório de Profissionais; propostas; salas (plugin).
## Financeiro
O **custo lab** das próteses **abate a base de comissão** do procedimento (entra em `computeComissoes`).
Ver `../FASE_04_FINANCEIRO/SKILL_Comissoes.md`.
## Conflito conceitual a vigiar (gravidade baixa)
A faceta "fornecedor" pertence ao **laboratório** (cadastro `protese_laboratorios`), não à pessoa.
Manter pessoa (protético) e laboratório (entidade) **separados e bem ligados**.
## Regras obrigatórias
1. Protético = pessoa/profissional; Laboratório = entidade/fornecedor. Não fundir.
2. Custo lab consistente com Comissões.
## Anti-padrões
- ❌ Tratar o protético como cadastro de fornecedor (ou o laboratório como login).
```mermaid
flowchart LR
PT[Protético · usuarios] --> V[vinculo role=protetico]
V --> OS[Próteses/OS] --> CL[custo lab → Comissão]
PT -. usa .-> LAB[Laboratório · fornecedor]
```
</content>
@@ -0,0 +1,34 @@
# SKILL — Secretárias / Recepção (Pessoa)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Pessoa.md` e `SKILL_RBAC.md`.
> **Importante:** no código **não existe role `secretaria`** — a secretária é o papel `funcionario`
> (eventualmente com `funcao` customizada para refinar permissões).
## Perfil
Pessoa (`usuarios`) com vínculo `funcionario`. Opera o front-office da clínica (não-clínico).
## Fluxos
- Leads (captar/converter), Pacientes, Agenda, Financeiro, Orçamentos, Contratos, Relatórios, Glosas.
## Permissões (RBAC)
Menu padrão do `funcionario`: `dashboard, leads, pacientes, agenda, financeiro, tratamentos,
lancar-gto, proteses, relatorios, glosas, orcamentos, contratos, clinicas, notificacoes, configuracoes,
diretorio-profissionais`. Permissões podem ser **estreitadas por `funcao`** (RBAC fino) no workspace.
## Marketplace / Financeiro
Não participa de marketplace como profissional. Opera o financeiro da clínica, mas **não recebe comissão/repasse**.
## Regras obrigatórias
1. "Secretária" = papel `funcionario` + `funcao` opcional. Não criar role nova sem decisão arquitetural.
2. Para restringir acesso, use **`funcao` com `permissoes[]`** (prevalece sobre o default do papel).
## Anti-padrões
- ❌ Inventar role `secretaria` no código (quebra a constraint `vinculos_role_check`).
- ❌ Dar a funcionário telas de superadmin/admin global.
```mermaid
flowchart LR
S[Secretária · usuarios] --> V[vinculo role=funcionario + funcao]
V --> FO[Leads/Agenda/Pacientes/Financeiro]
```
</content>
@@ -0,0 +1,42 @@
# SKILL — Tutores de Ortodontia (Pessoa)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Pessoa.md`. Única faceta com **receita de plataforma automatizada**.
## Perfil
Pessoa (`usuarios`) com flag `is_tutor=true` após **candidatura paga** (MercadoPago). Não é uma role de
vínculo — é uma **capacidade da pessoa** (atributo), liberada pelo painel de tutoria.
## Fluxos
- Candidatura (`tutoria/candidaturas`) → pagamento (`preferences` MercadoPago) → `mp-webhook` ativa o tutor.
- Tutor resolve solicitações de orto (`ortho_tutoring_request`), troca mensagens/pacotes de fotos.
- Gestão pelo superadmin (`admin/candidaturas`, `admin/repasses`, `admin/tutor-config`).
## Permissões (RBAC)
- `candidatura-tutor`: disponível a dentistas.
- `tutoria-painel`: **só para tutores aprovados** (`is_tutor`).
- `admin-gestao-tutores`: **só superadmin**.
## Marketplace / Financeiro (modelo de receita)
- Configuração global em `ortho_tutor_config` (`comissao_pct`, default **20%**, `taxa_inscricao`, chave PIX,
credenciais MercadoPago).
- Cada consultoria registra `valor_consultoria`, `comissao_pct`, `valor_tutor`, `status_repasse`.
- **A plataforma fica com a comissão (`comissao_pct`)**; o tutor recebe `valor_tutor` (repasse próprio,
distinto do repasse de clínica em `../FASE_04_FINANCEIRO/SKILL_Repasses.md`).
## Regras obrigatórias
1. `is_tutor` é capacidade da pessoa — não criar role de vínculo "tutor".
2. Pagamento via MercadoPago é idempotente (webhook). Não duplicar ativação.
3. Repasse de tutoria é fluxo próprio (não confundir com repasse de comissão de clínica).
## Anti-padrões
- ❌ Ativar tutor sem confirmar pagamento (webhook/`confirmar-pagamento`).
- ❌ Misturar `comissao_pct` da tutoria com `comissao_regras` da clínica.
```mermaid
flowchart LR
D[Dentista] --> CAND[Candidatura]
CAND -->|MercadoPago| WH[mp-webhook] --> T[is_tutor=true]
T --> REQ[Solicitações orto]
REQ --> $[valor_tutor + comissão plataforma]
```
</content>
@@ -0,0 +1,34 @@
# SKILL — Assinaturas (da plataforma / SaaS)
> Herda de `SKILL_Financeiro.md`. **Receita da PLATAFORMA** (não da clínica). Hoje é manual.
## Conceito
Assinatura é a relação de **cobrança da plataforma com a pessoa/conta** — a monetização do SaaS.
**Distinta** de `contrato_assinaturas` (aceite de contratos da clínica). Não confundir.
## Entidades / mecanismo real
- `assinaturas(id, usuario_id, status, valor, validade, created_at)`.
- Gestão pelo **superadmin**: `PUT /api/superadmin/assinaturas/:userId` (define status/valor/validade) e
`GET|PUT /api/superadmin/pricing` (tabela de preços).
- **Estado atual: cobrança MANUAL** (superadmin define) — **não há gateway/recorrência automática**.
## Receita
É a alavanca de **receita recorrente da plataforma** mais direta (base multiempresa + pricing já existem),
porém ainda não automatizada. Ver auditoria estratégica (`documentação/AUDITORIA-ESTRATEGICA-MODELAGEM.md`).
## Regras obrigatórias
1. Assinatura é **da pessoa/conta** (`usuario_id`), coerente com Pessoa→Workspace.
2.`superadmin` gerencia (`superadminGuard`).
3. Não confundir com assinatura de contrato nem com repasse.
## Anti-padrões
- ❌ Tratar `assinaturas` como assinatura de documento.
- ❌ Assumir recorrência/gateway que não existe (cobrança é manual hoje).
```mermaid
flowchart LR
SA[superadmin] --> PRC[pricing]
SA --> ASS[assinaturas: usuario_id, status, valor, validade]
ASS -. falta .-> GW[(gateway/recorrência)]
```
</content>
@@ -0,0 +1,38 @@
# SKILL — Comissões
> Herda de `SKILL_Financeiro.md`. Cálculo da remuneração do profissional sobre a produção.
## Conceito
Comissão = **(base custo_lab) × percentual**. A base é a produção do profissional no período; o custo
lab (próteses) é abatido antes do percentual.
## Entidades / mecanismo real
- `comissao_regras(id, clinica_id, procedimento_id, profissional_id, percentual, custo_lab)`.
- Função `computeComissoes(pool, clinicaId, {inicio, fim, base, profissionalId})` agrega a produção.
- Três níveis de regra (precedência): **override por profissional** > **por procedimento** > **padrão da clínica**.
## Regras obrigatórias
1. Custo lab **sempre abatido** antes do percentual.
2. Lançamentos com `sem_comissao=true` (garantia/reabertura do mesmo profissional, despesa de repasse) **não entram**.
3. Base configurável (produção × recebido) — respeitar o parâmetro `base`.
4. Mudou regra de comissão → auditar impacto em Repasses (não recalcular repasses já fechados).
## Fluxos
Produção (`procedimentos_realizados``financeiro` RECEITA) → `computeComissoes`**Repasse** (fechar).
## Anti-padrões
- ❌ Ignorar `custo_lab` no cálculo. ❌ Comissionar lançamento `sem_comissao`.
- ❌ Alterar percentual e recalcular retroativamente repasses fechados (snapshot é imutável).
## Permissões
Definição de regras: donos/admin. Ver `../FASE_01_ARQUITETURA/SKILL_RBAC.md`.
```mermaid
flowchart LR
PROD[Produção do período] --> BASE[base]
LAB[custo_lab próteses] --> CALC
BASE --> CALC[(base custo_lab) × %]
REG[comissao_regras: prof > proc > clínica] --> CALC
CALC --> REP[Repasse]
```
</content>
@@ -0,0 +1,36 @@
# SKILL — Contratos
> Herda de `SKILL_Financeiro.md`. Formalização de acordos (paciente/profissional ↔ clínica) e cobrança.
## Conceito
Contrato transforma um acordo (orçamento, remuneração, prestação) em documento com **modelo + variáveis +
partes + assinaturas**, podendo **gerar cobrança** no financeiro.
## Entidades / fluxo real
- `contrato_modelos` (modelos com variáveis `{{...}}`, categorias: remuneração, prestação, etc.).
- `contratos` / instâncias (`status`: rascunho → aguardando_assinatura → assinado → vigente → encerrado/cancelado).
- `contrato_partes` (as partes do contrato) e `contrato_assinaturas` (aceite por parte: assinado_em, ip,
user_agent, hash) — **assinatura eletrônica de contrato** (≠ assinatura de plataforma, ver `SKILL_Assinaturas.md`).
- Ações: `enviar` (cria assinaturas pendentes), `assinar`, `gerar-cobranca` (→ `financeiro`), `cancelar`, `duplicar`.
## Variáveis de remuneração (já modeladas)
`{{PERCENTUAL}}`, `{{PERCENTUAL_REPASSE}}`, `{{VALOR}}`, `{{BASE_REPASSE}}`, `{{PRAZO_REPASSE}}` — contratos
de comissão/percentual referenciam o mesmo conceito de Repasse.
## Regras obrigatórias
1. Respeitar a máquina de estados (não pular rascunho→vigente sem assinatura).
2. `gerar-cobranca` cria lançamento em `financeiro` (`origem='contrato'`, `contrato_id`).
3. Assinatura grava trilha (ip/user_agent/hash) — não remover (valor probatório).
## Anti-padrões
- ❌ Confundir `contrato_assinaturas` (assinatura do documento) com `assinaturas` (mensalidade da plataforma).
- ❌ Encerrar/cancelar sem auditoria.
```mermaid
flowchart LR
M[contrato_modelos] --> I[contratos: rascunho]
I --> E[enviar → assinaturas pendentes]
E --> A[assinar → assinado/vigente]
A --> COB[gerar-cobranca → financeiro]
```
</content>
@@ -0,0 +1,49 @@
# SKILL — Faturamento (visão consolidada do dinheiro)
> Herda de `SKILL_Financeiro.md`. Mapa de **onde o dinheiro nasce, para onde vai e quem é o responsável.**
## As três camadas de receita (estado real)
1. **Receita da clínica** (operacional): produção de procedimentos, contratos/cobrança, recuperação de glosas.
Responsável: a clínica. Tabela: `financeiro` (RECEITA).
2. **Receita do profissional** (repasse): comissão sobre a produção. Responsável: clínica paga; profissional recebe.
Tabelas: `comissao_regras``repasses``financeiro` (DESPESA).
3. **Receita da plataforma**: (a) **Tutoria**`comissao_pct` via MercadoPago (**automatizada**);
(b) **Assinaturas** — SaaS por conta (**manual**, sem gateway); (c) **Locação de salas**`valor` na
reserva, **sem captura de pagamento** (lacuna).
## Mapa origem → destino → responsável
| Origem | Tabela | Destino | Responsável |
|---|---|---|---|
| Procedimento (RECEITA) | `financeiro` | caixa da clínica | Clínica |
| Contrato (cobrança) | `financeiro` | caixa da clínica | Clínica |
| Convênio Glosa | `glosas`/`financeiro` | recupera receita | Clínica |
| Comissão → Repasse pago | `repasses``financeiro` DESPESA | profissional | Clínica paga |
| Tutoria | `ortho_tutoring_request` + MercadoPago | plataforma (`comissao_pct`) + tutor (`valor_tutor`) | Plataforma |
| Assinatura SaaS | `assinaturas` | plataforma | Plataforma (manual) |
| Locação de sala | `sala_reservas.valor` | locador | — (sem gateway) |
## Regras obrigatórias
1. Não misturar as três camadas: clínica ≠ profissional ≠ plataforma.
2. Toda lacuna de captura (salas, assinaturas) deve ser **sinalizada, não presumida como paga**.
3. Mudança em qualquer camada → mapear efeito nas demais antes (ver `../FASE_08_AUDITORIA/SKILL_Fluxos.md`).
## Oportunidades (já mapeadas, não implementar aqui)
- Gateway na **Locação de Salas** (receita mais próxima) e **recorrência das Assinaturas**.
```mermaid
flowchart TD
subgraph Clínica
PR[Procedimento] --> R[financeiro RECEITA]
CON[Contrato] --> R
R --> GL[Glosas]
end
subgraph Profissional
R --> COM[Comissão] --> REP[Repasse] --> D[financeiro DESPESA]
end
subgraph Plataforma
TUT[Tutoria · MercadoPago] --> PLT[comissao_pct + valor_tutor]
ASS[Assinaturas · manual] --> PLT
SAL[Salas · valor] -. falta gateway .-> PLT
end
```
</content>
@@ -0,0 +1,41 @@
# SKILL — Financeiro (núcleo do caixa)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Multiempresa.md`. Tabela central do dinheiro: `financeiro`.
## Conceito
`financeiro` registra **RECEITA** e **DESPESA** da clínica (escopo `clinica_id`). É o caixa unificado de
onde derivam comissões, repasses e glosas.
## Entidades / colunas-chave (reais)
`financeiro(id, clinica_id, descricao, valor, tipo['RECEITA'|'DESPESA'], status['Pago'|'Pendente'|...],
formapagamento, datavencimento, data_competencia, pago_em, paciente_id, profissional_id, procedimento_id,
agendamento_id, realizado_id, contrato_id, origem, centro_custo, fornecedor, sem_comissao, deleted_at)`.
## Origens (origem=...)
- `procedimento` — gerado ao lançar Procedimento Realizado (RECEITA, se `valor>0`).
- `contrato` — cobrança de contrato (`gerar-cobranca`).
- `repasse` — DESPESA ao pagar repasse (`centro_custo='COMISSÕES'`, `sem_comissao=true`).
- Lançamento manual (avulso) — RECEITA/DESPESA direto na tela.
## Regras obrigatórias
1. Todo lançamento carrega `clinica_id` e `tipo`.
2. **`sem_comissao`** marca o que NÃO deve gerar repasse (garantia, despesa de repasse).
3. **`deleted_at`** = soft-delete (estorno reversível). Nunca DELETE físico de lançamento conciliado.
4. RECEITA de procedimento é **opcional** (`valor>0` e `gerarLancamento!==false`) — alterar muda o caixa.
## Fluxos a jusante
RECEITA → base de **Comissão**; DESPESA de repasse fecha o ciclo. Recebíveis de convênio → **Glosas**.
## Anti-padrões
- ❌ Apagar lançamento fisicamente. ❌ Lançar sem `clinica_id`/`tipo`.
- ❌ Recriar comissão a partir de despesa de repasse (use `sem_comissao`).
```mermaid
flowchart LR
PR[Procedimento] -->|origem=procedimento| R[financeiro RECEITA]
CON[Contrato] -->|gerar-cobranca| R
RP[Repasse pago] -->|origem=repasse| D[financeiro DESPESA]
R --> COM[Comissão]
R --> GL[Glosas]
```
</content>
@@ -0,0 +1,35 @@
# SKILL — Repasses
> Herda de `SKILL_Comissoes.md`. Fechamento e pagamento da comissão ao profissional.
## Conceito
Repasse é o **snapshot imutável** da comissão de um profissional num período, que vira **DESPESA** quando pago.
## Entidades / fluxo real
- `repasses(id, clinica_id, profissional_id, inicio, fim, base, base_valor, custo_lab_total,
comissao_valor, qtd_lancamentos, detalhes(JSON), status['fechado'|'pago'|'cancelado'], pago_em,
financeiro_id, comprovante_*)`.
- `POST /repasses/fechar` → calcula via `computeComissoes` e grava `status='fechado'` (snapshot em `detalhes`).
- `POST /repasses/:id/pagar` → cria `financeiro` DESPESA (`origem='repasse'`, `centro_custo='COMISSÕES'`,
`sem_comissao=true`) e marca `status='pago'` + `financeiro_id`.
- `POST /repasses/:id/cancelar` → se estava pago, **estorna** a DESPESA (soft-delete) e zera `financeiro_id`.
- Comprovante: `POST /repasses/:id/comprovante`.
## Regras obrigatórias
1. Só paga repasse **fechado**. O snapshot (`detalhes`) é **imutável** após fechar.
2. DESPESA de repasse nasce com `sem_comissao=true` (não recomissionar).
3. Cancelamento é **reversível** (estorno por soft-delete) — preservar.
4. Toda transição registra `audit_log` (`registrarAudit`). Manter.
## Anti-padrões
- ❌ Editar valores de um repasse fechado (refazer = cancelar + novo fechamento).
- ❌ Pagar sem gerar a DESPESA correspondente (quebra a conciliação).
```mermaid
flowchart LR
COM[Comissão do período] --> F[fechar → repasses status=fechado]
F --> P[pagar → financeiro DESPESA]
P --> C{cancelar?}
C -->|sim| EST[estorna DESPESA soft-delete]
```
</content>
@@ -0,0 +1,25 @@
# SKILL — Acessibilidade
> Base mínima de a11y, sem regredir a identidade visual. Pragmático para o estado atual.
## Estado atual (honesto)
O app prioriza densidade/visual; a11y é **básica**. Esta skill fixa o mínimo a manter/melhorar sem
quebrar o design.
## Regras obrigatórias
1. **Contraste**: texto sobre a cor de marca deve ser legível (a marca é escura → texto branco). Evitar
cinza-claro sobre branco em informação essencial.
2. **Alvos de toque** no mobile ≥ ~40px (os botões do bottom nav/menu já respeitam).
3. **Foco**: usar `focus:ring-brand` (já existe) em controles interativos; não remover outline sem substituto.
4. **Semântica**: `<button>` para ação, `<a>` para navegação; ícone-só precisa de `aria-label`/título.
5. **Estados**: feedback de loading/disabled (`disabled:opacity-60`) e toasts para resultado de ação.
## Como auditar
```bash
grep -rnE "onClick" frontend/views/<View>.tsx | grep -ivE "aria-label|title|>.*<" # ícones-botão sem rótulo
```
## Anti-padrões
- ❌ Remover outline de foco sem alternativa. ❌ Ícone clicável sem rótulo acessível.
- ❌ Texto essencial com contraste insuficiente. ❌ Sacrificar a identidade visual aprovada por a11y (equilibrar).
</content>
@@ -0,0 +1,39 @@
# SKILL — Containers & Identidade Visual (REGRA RÍGIDA)
> Herda de `SKILL_DesktopWidth.md`. Preservar a identidade visual aprovada. **Não inventar design.**
## Padrão real de container (não regredir)
- **Wrapper de página:** `max-w-7xl mx-auto` + `p-4 md:p-8` (definido em `App.tsx`).
- **Cards:** fundo branco, bordas suaves (`border-gray-200`), cantos arredondados (`rounded-xl`/`2xl`),
sombra leve. Tipografia de rótulo em caixa-alta, peso forte (`font-bold/black uppercase` em labels/menus).
- **Sidebar:** `w-64`, branca, item ativo pintado com a **cor de marca**.
## Cor de marca (token oficial — NÃO hardcodar)
- Variável CSS `--brand-color`, vinda de `activeWorkspace.cor` (default `#2563eb`; superadmin `#dc2626`).
- Classes utilitárias existentes: `bg-brand`, `text-brand`, `border-brand`, `hover:bg-brand`, `focus:ring-brand`.
- **Sempre** usar o token/classe de marca para realce — nunca cravar um hex novo.
## Regras obrigatórias
1. **Preservar cores, estilos e identidade visual** aprovados — alterações visuais exigem aprovação.
2. Realce/seleção usa **a cor do workspace** (`--brand-color`), não cor fixa.
3. Reutilizar componentes existentes (`PageHeader`, `Toast`, `StackModal`, `EmptyState`, `Header`) — não recriar.
4. Espaçamento e raio consistentes com o resto do app (não introduzir novo sistema de spacing).
## Anti-padrões
- ❌ Hardcodar `#2563eb` (ou qualquer hex) em vez de `var(--brand-color)`/classes `*-brand`.
- ❌ Criar um novo "design system" paralelo ou trocar a tipografia/raio/sombra padrão.
- ❌ Container de página fora do `max-w-7xl mx-auto`.
- ❌ Recriar modal/toast/header quando já existe componente.
## Validação
- A tela nova é **indistinguível em estilo** das telas existentes? Se não, parar.
- Trocar de workspace muda o realce conforme a cor da clínica? (token funcionando)
```mermaid
flowchart TD
WS[workspace.cor] --> VAR[--brand-color]
VAR --> BR[classes *-brand / realces]
COMP[Componentes existentes] --> UI[Tela consistente]
WRAP[max-w-7xl mx-auto] --> UI
```
</content>
@@ -0,0 +1,22 @@
# SKILL — Dashboard
> Tela inicial por papel. Herda de `SKILL_GridSystem.md` + `SKILL_DesktopWidth.md`.
## O que é
Visão consolidada (KPIs) de entrada. Dados via `GET /api/dashboard/stats`. View: `views/Dashboard.tsx`.
Cada papel tem seu ponto de entrada (`getDefaultViewForRole`): superadmin → overview; sala → painel da sala;
paciente → tratamentos; demais → dashboard.
## Padrão de layout (real)
- KPIs em grid `grid-cols-2 md:grid-cols-4` (cards compactos), dentro do wrapper `max-w-7xl mx-auto`.
- Realce com a cor do workspace; números grandes + rótulo caixa-alta.
## Regras obrigatórias
1. KPIs vêm do backend (`/api/dashboard/stats`) — não recalcular no cliente de forma divergente.
2. Respeitar o teto de largura (`max-w-7xl`) e o grid mobile-first (ver GridSystem).
3. Conteúdo do dashboard segue o papel/permissões (não mostrar KPI que o papel não pode ver).
## Anti-padrões
- ❌ Dashboard full-width esticado. ❌ KPI financeiro para papel sem acesso a financeiro.
- ❌ Recriar cards fora do padrão visual.
</content>
@@ -0,0 +1,29 @@
# SKILL — Design System (REGRA RÍGIDA)
> Tokens e padrões visuais REAIS do app. **Preservar a identidade aprovada. Não inventar.**
> Herda de `SKILL_Containers.md`.
## Stack
React 19 + **Tailwind 4** + lucide-react (ícones). Sem biblioteca de componentes externa.
## Cor de marca (token oficial)
- `--brand-color``activeWorkspace.cor` (default `#2563eb`; superadmin `#dc2626`).
- Classes utilitárias: `bg-brand`, `text-brand`, `border-brand`, `hover:bg-brand`, `focus:ring-brand`.
- **Sempre** o token/classe — nunca um hex novo cravado.
## Tipografia & forma (observado)
- Rótulos/menus/badges: **caixa-alta**, peso forte (`font-bold`/`font-black uppercase`, `tracking`).
- Cantos arredondados (`rounded-xl`/`2xl`), bordas suaves (`border-gray-200`), sombra leve.
- Ícones lucide-react com tamanho consistente (`size={16..22}`).
## Componentes a reutilizar (não recriar)
`PageHeader`, `Header`, `Sidebar`, `Toast`/`ToastContainer`, `StackModal`, `EmptyState`, `ConfirmContext`(`useConfirm`), `ToothIcon`.
## Regras obrigatórias
1. Realce/seleção usa a cor do workspace (`--brand-color`).
2. Reutilizar os componentes acima; modal/toast/confirm já existem.
3. Manter spacing/raio/sombra consistentes — não introduzir novo sistema.
## Anti-padrões
- ❌ Hex hardcoded. ❌ Novo design system paralelo. ❌ Recriar modal/toast/header. ❌ Trocar tipografia/raio padrão.
</content>
@@ -0,0 +1,44 @@
# SKILL — Desktop Width (REGRA RÍGIDA)
> Objetivo desta skill: **impedir que qualquer agente estique divs, quebre o desktop ou invente layout.**
> Tudo abaixo reflete a convenção REAL do código (`frontend/App.tsx`, Tailwind 4). Não inventar valores.
## Regra de ouro
**O conteúdo de toda página vive dentro do wrapper canônico:**
```
<div className="max-w-7xl mx-auto"> ... </div>
```
Esse wrapper já existe globalmente em `App.tsx` (a área de conteúdo é `max-w-7xl mx-auto` com `p-4 md:p-8`).
`max-w-7xl` = **1280px** centralizado. **NUNCA** remover, sobrepor ou furar esse limite.
## Regras obrigatórias
1. **PROIBIDO `w-screen`, `100vw`, `max-w-none` ou `max-w-full`** em containers de página no desktop.
2. **PROIBIDO full-width**: nada de conteúdo encostando nas bordas da viewport em telas largas.
3. **Largura máxima de conteúdo = `max-w-7xl` (1280px)**, centralizado com `mx-auto`. É o padrão do app.
4. Subseções podem ser mais estreitas (`max-w-2xl`, `max-w-3xl`, `max-w-md` para formulários) — **nunca mais largas** que o wrapper.
5. **Sidebar fixa `w-64` (256px)** — não alterar sem auditoria de UX.
6. Em ultrawide (>1280px) a sobra vira **margem lateral** (centralização), nunca esticão de conteúdo.
## Padrão real observado (não regredir)
- Wrapper de página: `max-w-7xl mx-auto` + `p-4 md:p-8`.
- Larguras de cards/formulários: `max-w-md / 2xl / 3xl` conforme densidade.
- Breakpoints usados: `sm:` `md:` `lg:` (o `xl:` é raro — **não** introduzir layouts dependentes de `xl/2xl` sem necessidade).
## Anti-padrões (o que QUEBRA o desktop)
- ❌ Trocar `max-w-7xl` por `max-w-full`/`w-full` no nível da página.
- ❌ Criar grids que se expandem indefinidamente em telas largas.
- ❌ Remover o `mx-auto` (conteúdo cola à esquerda).
- ❌ Inventar nova paleta/identidade (ver Containers/DesignSystem).
## Validação obrigatória antes de entregar
- Conferir em **desktop largo** (≥1920px): conteúdo centralizado, margens laterais, sem esticão.
- Conferir em **mobile**: `p-4`, coluna única, sem overflow horizontal.
```mermaid
flowchart LR
V[Viewport larga] --> M1[margem]
V --> C[max-w-7xl · 1280px · mx-auto]
V --> M2[margem]
C --> CONT[Conteúdo da página]
```
</content>
@@ -0,0 +1,40 @@
# SKILL — Grid System (REGRA RÍGIDA)
> Herda de `SKILL_DesktopWidth.md`. Layout responsivo **mobile-first**, como já feito no app.
## Padrão real (não regredir)
O app é **mobile-first**: começa em coluna única e cresce por breakpoint. Convenção observada:
```
grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 (cards/listas)
grid grid-cols-2 md:grid-cols-4 (KPIs/contadores)
```
Breakpoints em uso: `sm:` `md:` `lg:` (predominantes); `xl:` é raro — **não** depender dele.
## Regras obrigatórias
1. **Mobile-first sempre:** definir o estado base (1 coluna) e escalar com `md:`/`lg:`.
2. **Teto de colunas em `lg:`** (até `lg:grid-cols-4`). Não criar grids que multiplicam colunas em ultrawide.
3. Usar `gap-*` consistente com o app; não misturar grid com float/posicionamento absoluto para layout.
4. Tabelas largas: scroll interno (`overflow-x-auto`), **nunca** estourar o `max-w-7xl`.
5. Formulários: largura contida (`max-w-md/2xl`) e centralizada, não full-width.
## Casos de uso
- Dashboard de KPIs: `grid-cols-2 md:grid-cols-4`.
- Lista de cards: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3`.
- Form de cadastro: `max-w-2xl mx-auto`.
## Anti-padrões
-`grid-cols-6`/`grid-cols-12` que esticam em telas largas sem teto.
- ❌ Layout que só foi testado em desktop (quebra no mobile).
- ❌ Conteúdo de grid ultrapassando o wrapper `max-w-7xl`.
- ❌ Introduzir dependência de `xl:`/`2xl:` para o layout funcionar.
## Validação
- Mobile (1 coluna, sem overflow), tablet (`md:`), desktop (`lg:`), ultrawide (centralizado, sem esticão).
```mermaid
flowchart LR
B[grid-cols-1 · base/mobile] --> M[md:grid-cols-2/3]
M --> L[lg:grid-cols-3/4 · TETO]
L --> X[ultrawide: centraliza, não estica]
```
</content>
@@ -0,0 +1,23 @@
# SKILL — Responsividade (REGRA RÍGIDA)
> Mobile-first, como o app já é. Herda de `SKILL_GridSystem.md`.
## Padrão real (não regredir)
- **Mobile-first**: estado base = mobile; cresce com `sm:`/`md:`/`lg:` (uso: `sm:`≈238, `md:`≈186, `lg:`≈100; `xl:` raro≈8).
- **Sidebar**: `w-64` fixa no desktop; off-canvas no mobile (drawer com overlay `bg-black/50 md:hidden`).
- **Bottom nav mobile** por papel (`BOTTOM_TABS` em `App.tsx`) com `+ Mais`.
- Padding de página `p-4 md:p-8`; safe-area no bottom nav (`env(safe-area-inset-bottom)`).
## Regras obrigatórias
1. Toda tela nova **funciona no mobile** (coluna única, sem overflow horizontal) E no desktop.
2. Não depender de `xl:`/`2xl:` para o layout funcionar.
3. Sidebar drawer no mobile; conteúdo dentro de `max-w-7xl mx-auto`.
4. Tabelas largas → `overflow-x-auto` (scroll interno), nunca estourar o container.
## Validação (obrigatória antes de entregar)
- Mobile (≤640): 1 coluna, sem scroll horizontal, bottom nav ok.
- Tablet (`md`), Desktop (`lg`), Ultrawide (centraliza, sem esticar).
## Anti-padrões
- ❌ Testar só no desktop. ❌ Overflow horizontal no mobile. ❌ Layout que quebra sem `xl:`.
</content>
@@ -0,0 +1,28 @@
# SKILL — Módulo Agenda
> Coração operacional. RBAC: capability `'agenda'` (staff clínico, bloqueia paciente).
## O que é
Agendar/confirmar/remarcar consultas, controle de presença, anti-overbooking e pendências de reagendamento.
## Entidades
`agendamentos`, `agenda_presenca`, `agenda_pendencias`, `interesses_agenda`, `dentistas`+`dentistas_horarios`.
## Rotas principais
- `GET/POST /api/agendamentos`, `PUT/DELETE /api/agendamentos/:id`
- Ações `/:id`: `confirmar`, `falta`, `remarcar`, `restaurar`, `reencaminhar`, `solicitar-avaliacao`
- `GET/POST /api/agenda/presenca`, `GET /api/agenda/{pendencias,atividade}`, `POST /api/agenda/pendencias/:id/resolver`
- `POST /api/agenda/importar-google`, horários: `GET /api/dentistas/:id/horarios`, `POST/DELETE /api/dentistas/horarios`
## Regras de negócio (reais)
- **Anti-overbooking**: checa conflito ANTES de criar (ex.: conversão de lead não cria paciente se o slot está ocupado).
- **Falta** abre pendência e impacta o **score** do paciente.
- **Presença/confirmação** libera o lançamento financeiro a jusante.
- Tempo real via WebSocket (`wsBroadcast('agenda', ...)`) → refetch instantâneo na clínica.
## Impactos a jusante
Procedimento realizado → Financeiro → Comissão. Integra Google Calendar (importar).
## Anti-padrões
- ❌ Remover a checagem de overbooking. ❌ Mutação de agenda sem `registrarAudit`. ❌ Expor agenda ao paciente.
</content>
@@ -0,0 +1,28 @@
# SKILL — Módulo CRM (Leads)
> Topo do funil comercial. RBAC: capability `'leads'` (funcionário + dono).
## O que é
Captar interessados e **converter** em paciente + agendamento (início do ciclo clínico).
## Entidades
`leads` (status: Novo|Convertido|Arquivado), `interesses_agenda`, gera `pacientes` + `agendamentos`.
## Rotas
`GET/POST /api/leads`, `POST /api/leads/:id/converter`. Origem pública: `PublicContactForm`/`/api/interesses`.
## Regra de negócio central (verificada)
`POST /api/leads/:id/converter`:
1. valida que o lead não foi convertido (409 se já foi);
2. checa **anti-overbooking** do slot ANTES de criar (evita paciente órfão);
3. cria o **paciente**; 4) cria o **agendamento** inicial (se slot válido e livre).
## Impactos a jusante
Paciente → Agenda → atendimento → Financeiro.
## Oportunidade (do mapa funcional)
CRM é a entrada; integrar com WhatsApp/origem de marketing é alavanca de crescimento (hoje básico).
## Anti-padrões
- ❌ Converter sem anti-overbooking. ❌ Duplicar paciente na reconversão. ❌ Expor leads a papel clínico/paciente.
</content>
@@ -0,0 +1,25 @@
# SKILL — Módulo Documentos (Receitas, Exames, Contratos, Mídia)
> Geração e guarda de documentos clínicos/jurídicos e mídia. Cruza Prontuário e Contratos.
## O que é
Receitas, pedidos de exame, contratos (com assinatura eletrônica) e arquivos (fotos, comprovantes, PDFs).
## Entidades
`modelos_receita`/`items_receita`, `pedidos_exame`/`items_pedido_exame`,
`contratos`/`contrato_modelos`/`contrato_partes`/`contrato_assinaturas`, e fotos por entidade
(`*_foto`). PDFs no frontend via `jspdf`.
## Acesso à mídia (padrão real)
Arquivos são servidos por **URL assinada**: `/api/.../raw?t=signMedia(id)` — sem token de sessão.
A URL só é entregue a quem já passou pelo guard da listagem. **Não** gatear o `/raw` por capability
(quebraria a imagem); a proteção é o token assinado + a lista gateada.
## Contratos (assinatura)
`contrato_assinaturas` grava trilha probatória (ip, user_agent, hash do corpo). É **registro de aceite**
não remover. Capability `'contratos'` (funcionário+dono). Ver `../FASE_04_FINANCEIRO/SKILL_Contratos.md`.
## Anti-padrões
- ❌ Servir mídia sem URL assinada. ❌ Gatear `/raw` por capability. ❌ Apagar trilha de assinatura.
- ❌ Confundir assinatura de contrato (`contrato_assinaturas`) com assinatura de plataforma (`assinaturas`).
</content>
@@ -0,0 +1,28 @@
# SKILL — Módulo Prontuário (Paciente + Ortodontia)
> O histórico clínico do paciente. Detalhes acessíveis a staff clínico (capability `'agenda'`).
## O que é
Prontuário do paciente: dados, família, score, faltas, procedimentos realizados, receitas, pedidos de exame,
e o tratamento **ortodôntico** completo.
## Entidades
`pacientes`, `procedimentos_realizados`(+foto), `modelos_receita`/`items_receita`, `pedidos_exame`/`items_pedido_exame`,
`avaliacoes`/`avaliacao_foto`; **orto**: `ortho_tratamento`, `ortho_evolucao`, `ortho_photo`(+session/annotation).
## Rotas
`GET/POST/PUT /api/pacientes[...]`, `GET /api/pacientes/:id/{score,faltas,familia}`,
`GET/POST /api/orto/tratamentos[/:id/{evolucoes,finalizar}]`, `/api/orto/{sessoes,fotos}`, `/api/avaliacoes`.
## Regras de negócio
- **Ortodontia** tem triagem, diagnóstico, plano, evoluções, fotos e contenção (vide `frontend/types.ts`).
- `procedimentos_realizados` gera RECEITA (opcional por valor) e alimenta comissão.
- **`OrthoView` (dentista) cria/edita paciente** → por isso o cap de mutação de paciente é `'agenda'` (clínico), não `'pacientes'`.
## Privacidade/LGPD
Paciente é **da clínica** (escopo `clinica_id`), não do ecossistema. Não compartilhar histórico entre clínicas
sem base legal — ver `../FASE_03_PESSOAS/SKILL_Pacientes.md`.
## Anti-padrões
- ❌ Vazar prontuário entre clínicas. ❌ Gatear mutação de paciente a `'pacientes'` (bloquearia o dentista do Ortho).
</content>
@@ -0,0 +1,25 @@
# SKILL — Módulo Próteses / GTO
> Controle de laboratório. Liga-se à Comissão pelo **custo lab**. Ver `../FASE_03_PESSOAS/SKILL_Proteticos.md`.
## O que é
Ordens de Serviço de prótese (produto, dentes, material, custo lab, prazo, entrega) e GTO (guia de tratamento).
## Entidades
`protese_os`, `protese_os_evento`, `protese_os_foto`, `protese_produtos`, `protese_laboratorios`;
GTO legado: `gto_builders`/`gto_items`, `guias_odontologicas`.
## Rotas
`GET/POST /api/protese/os`, `GET /api/protese/os/:id`, `POST /api/protese/os/:id/{status,foto}`,
`GET/POST/PUT/DELETE /api/protese/produtos`, `GET /api/protese/laboratorios[/:id/produtos]`, `GET /api/protese/bancada`.
## Regra de negócio central
O **custo lab** da prótese **abate a base de comissão** do procedimento (`computeComissoes` =
`(base custo_lab) × %`). Mexer aqui afeta o repasse — ver `../FASE_04_FINANCEIRO/SKILL_Comissoes.md`.
## Papéis
Protético (pessoa, profissional do ecossistema) opera; laboratório = fornecedor (cadastro hoje).
## Anti-padrões
- ❌ Alterar custo lab sem considerar o efeito na comissão. ❌ Confundir protético (pessoa) com laboratório (fornecedor).
</content>
@@ -0,0 +1,24 @@
# SKILL — Módulo Radiologia (RX)
> Plugin `rx-scoreodonto`. Radiografias são ODONTOLÓGICAS (papéis odonto, não bio/protético).
## O que é
Captura/gestão de radiografias e dos dispositivos (PCs/sensores) por clínica. Integra com o produto externo
**rx.scoreodonto.com** (server + client.exe) — ver `documentos-unicos/projetos/rx.scoreodonto.com*`.
## Rotas
`GET /api/rx/{config,devices,images,patients,health}`, `PUT /api/rx/config`, `POST /api/rx/devices`.
Views: `plugins/RXPlugin.tsx` (RX/Radiografias), `RxConfigView` (Dispositivos RX).
## Permissões (do Sidebar real)
- **RX/Radiografias**: dentista + donos/funcionário (papéis odonto). **Biomédico e protético NÃO veem RX.**
- **Dispositivos RX**: admin/donos + superadmin.
- Requer o plugin `rx-scoreodonto` ativo na conta.
## Integração
O RX é um sistema próprio (auto-update do client via `/updates/latest.yml`) — **não quebrar** esse mecanismo.
Aqui é só a ponte (config/devices/images) dentro do ScoreOdonto.
## Anti-padrões
- ❌ Expor RX a biomédico/protético. ❌ Acoplar a lógica do client.exe aqui. ❌ Mexer no `/updates/` do RX por engano.
</content>
@@ -0,0 +1,26 @@
# SKILL — Módulo Tutoria de Ortodontia
> **Única receita de plataforma automatizada hoje** (MercadoPago). Ver `../FASE_03_PESSOAS/SKILL_Tutores.md`.
## O que é
Dentista vira **tutor** (pagando inscrição) e atende solicitações de orto de outros dentistas; a plataforma
fica com uma comissão sobre a consultoria.
## Entidades
`ortho_tutor_config` (`comissao_pct` default 20, `taxa_inscricao`, chave PIX, credenciais MP),
`ortho_tutor_profile`, `ortho_tutor_candidatura`, `ortho_tutoring_request`/`message`/`photo_package`.
## Rotas
`POST /api/tutoria/candidaturas[/:id/pagamento]`, `POST /api/tutoria/mp-webhook` (público),
`GET/POST /api/tutoria/solicitacoes[...]`, admin: `/api/admin/{candidaturas,repasses,tutor-config}`.
## Fluxo de receita (verificado)
Candidatura → **MercadoPago** (`checkout/preferences`) → `mp-webhook` (idempotente) ativa `is_tutor`
solicitações registram `valor_consultoria`, `comissao_pct`, `valor_tutor`, `status_repasse`.
## Permissões
`candidatura-tutor` (dentistas), `tutoria-painel` (só tutores aprovados, `is_tutor`), `admin-gestao-tutores` (superadmin).
## Anti-padrões
- ❌ Ativar tutor sem confirmar pagamento. ❌ Misturar `comissao_pct` da tutoria com a comissão de clínica.
</content>
@@ -0,0 +1,33 @@
# SKILL — Build Once → Promote → Deploy
> Orientação de infra oficial. Fonte: `documentos-unicos/deploy/`. Implementação de referência: scoreodonto.com.
## Conceito
**Push** na `main` builda a imagem **uma vez** e publica por **commit** (`:<sha>` + `:latest`) — **sem deploy**.
A **tag `vX.Y.Z`** **re-tagueia** essa MESMA imagem (mesmo digest, sem rebuild) e deploya. **Produção nunca builda.**
## Garantia central
A imagem em produção é **byte-idêntica** à que foi buildada e testada. A tag é só um rótulo de release
sobre um artefato imutável. Validado em scoreodonto (`:v1.0.16` == `:471c2c2`, mesmo digest).
## Fluxo real
```
PUSH main (commit C) → CI build 1x → publica :C + :latest (NÃO deploya)
TAG vX.Y.Z (em C) → CI re-tag :C → :vX.Y.Z (sem rebuild) + deploy (up --no-build)
```
## Regras obrigatórias
1. **Produção só executa imagem pronta** (`pull` + `up -d --no-build`). Nunca `build` na VPS1.
2. **Tag exige commit já publicado** (push antes). Sem isso o CI **falha de propósito** (não builda na tag).
3. Versão nasce da **tag git**, injetada em runtime (`APP_VERSION`), não escrita à mão.
4. Push e tag exigem **autorização explícita do administrador**.
## Anti-padrões
- ❌ Buildar em produção. ❌ Tag sem push. ❌ Versão hardcoded em arquivo.
```mermaid
flowchart LR
PUSH[push main · commit C] --> B[CI build 1x] --> REG[registry :C + :latest]
TAG[tag vX.Y.Z] --> PROM[CI re-tag :C→:vX.Y.Z] --> DEP[VPS1 up --no-build]
```
</content>
@@ -0,0 +1,32 @@
# SKILL — CI/CD (Gitea Actions)
> Herda de `SKILL_BuildOnce.md`. Automação de build/promote/deploy por evento git.
## Conceito
O pipeline roda no **Gitea Actions**, runner `vps4-builder` (VPS4, rootless, **instance-scoped** — atende
todos os repos). Workflow: `.gitea/workflows/build.yml`.
## Gatilhos reais
- **Push na `main`** → job build: builda `scoreodonto-*:<sha>` e **publica no registry** (não deploya).
- **Tag `v*`** → job promote/deploy: re-tag (digest idêntico) → `deploy-prod.sh` na VPS1.
## Regras obrigatórias
1. O runner builda; **produção só consome**. Não mover build para a VPS1.
2. Segredos do CI vêm de **secrets do Gitea**, nunca commitados no repo. (Ver `⚠️ SKILL_PostgreSQL`/Registry.)
3. Tag só promove se o commit já está no registry (senão falha proposital).
4. Manter o pipeline idempotente e auditável (logs do run).
## Pré-requisitos
- Gitea Actions habilitado no repo; runner `vps4-builder` online; `docker login` no registry.
- SSH VPS4→VPS1 para o passo de deploy (rsync/`deploy-prod.sh`).
## Anti-padrões
- ❌ Credencial em texto no `build.yml`. ❌ Deploy manual fora do pipeline sem registrar.
- ❌ Pipeline que builda na tag (quebra o Build-Once).
```mermaid
flowchart LR
PUSH[push main] --> RUN[vps4-builder] --> REG[(registry)]
TAG[tag v*] --> RUN2[vps4-builder promote] --> SSH[VPS1 deploy-prod.sh]
```
</content>
@@ -0,0 +1,33 @@
# SKILL — Deploy Imutável & Rollback
> Herda de `SKILL_BuildOnce.md`. Como promover e reverter sem rebuild.
## Conceito
O deploy troca **qual imagem** roda, não o código no servidor. Reverter = apontar para outra imagem.
## Fluxo real
- Promover: `deploy-prod.sh <tag>` na VPS1 → `pull` + `docker compose -f docker-compose.prod.yml up -d --no-build`.
- `docker-compose.prod.yml` usa `image:` do registry, `APP_VERSION`/`GIT_COMMIT` em runtime.
- Rollback: `deploy-prod.sh <ref-anterior>` (segundos/minutos, só troca imagem).
## Regras obrigatórias
1. **Referência confiável = commit (`:<sha>`)**. Tags antigas (pré-Build-Once) podem ter sido republicadas.
2. **Migrações são aditivas** (`ADD COLUMN IF NOT EXISTS`) → voltar o código é seguro (schema extra fica inerte).
3. **Backup do banco antes de release sensível** (container `pg_dump -Fc``/home/deploy/backups/`).
4. Validar `/api/version` e `/api/health` após deploy.
## Pré-requisitos de PROD (uma vez, por projeto)
- Arquivo de segredos presente na VPS1 (env_file do compose) — **copiado manualmente, fora do git** (chmod 600).
- `docker login git.clube67.com` na VPS1 (para o `pull`).
## Anti-padrões
- ❌ Reverter via rebuild. ❌ Migração destrutiva (DROP/ALTER que apaga) — quebra rollback.
- ❌ Confiar em tag `:vX.Y.Z` como imutável se for anterior ao Build-Once.
```mermaid
flowchart LR
REG[(registry)] --> PULL[VPS1 pull :ref] --> UP[up -d --no-build]
UP --> CHK[/api/version + /api/health/]
CHK -->|quebrou| RB[deploy-prod.sh ref-anterior]
```
</content>
@@ -0,0 +1,34 @@
# SKILL — Docker Rootless & Roteamento
> Herda de `SKILL_DeployImutavel.md`. Como os containers rodam e são expostos.
## Topologia real
- **Rede privada WireGuard** `10.99.0.0/24`.
- **VPS1 (produção):** Docker **rootless** — comandos usam `DOCKER_HOST=unix:///run/user/1000/docker.sock`.
Roda Traefik (borda TLS) + apps (scoreodonto, rx, newwhats, subdomínio, mercado).
- **VPS4 (dev+builder):** Docker rootless; roda `dev.<projeto>` (imagem buildada, **não** HMR) + runner.
- **Roteamento (Traefik na VPS1):** termina TLS e roteia por host:
`scoreodonto.com` → containers de produção locais; `dev.scoreodonto.com``10.99.0.4:8020` (VPS4) via WireGuard.
## Regras obrigatórias
1. Na VPS1, sempre exportar `DOCKER_HOST` rootless antes de comandos `docker`.
2. Exposição pública **só via Traefik** (TLS `certresolver=le`); apps não publicam porta na internet.
3. `dev.<projeto>` é descartável (VPS4); produção é a real (VPS1) — **confirmar DEV ou PROD antes de agir**.
4. Editar dev exige **rebuild + recreate** (não há hot-reload no container atual).
## ⚠️ Para abrir produção
- Garantir que apenas Traefik exponha portas; conferir labels de roteador/host por app.
- Rede WireGuard fechada; serviços de dados (VPS3) **nunca** expostos à internet.
## Anti-padrões
-`docker` na VPS1 sem `DOCKER_HOST` rootless. ❌ Publicar porta de app direto na internet.
- ❌ Confundir `dev.` (VPS4) com produção (VPS1).
```mermaid
flowchart LR
NET[Internet] -->|TLS| TR[Traefik VPS1]
TR -->|host scoreodonto.com| APP[containers prod]
TR -->|host dev.*| WG[WireGuard → VPS4:8020]
APP --> DADOS[(VPS3 dados · só rede privada)]
```
</content>
@@ -0,0 +1,30 @@
# SKILL — Gitea (código + registry + CI)
> Herda de `SKILL_CICD.md`. A VPS3 concentra a "fonte da verdade" do código.
## Papel real
- **Código:** Gitea na **VPS3** é a fonte da verdade (repo `ruicesar/scoreodonto.com`).
- **Registry:** Container Registry embutido do Gitea (ver `SKILL_Registry.md`).
- **CI:** Gitea Actions + runner `vps4-builder` (ver `SKILL_CICD.md`).
- Doc oficial da stack: repo `ruicesar/Documentos-Unicos` (local `documentos-unicos/`).
## Regras obrigatórias
1. **Nunca `git push --force` na `main`.** Push/tag exigem autorização do administrador.
2. **Segredos via Gitea Secrets / webhooks**, nunca commitados.
3. Idioma português em commits/docs (regra da stack).
4. Webhooks/Actions antigos desligados não devem ser reativados sem auditoria.
## ⚠️ Higiene de repositório (para abrir produção)
- **Não versionar `.env`/segredos.** Hoje `backend/.env` está rastreado — corrigir e **rotacionar** as
credenciais expostas (ver `SKILL_PostgreSQL`). Confirmar `.gitignore` cobrindo `**/.env` e `.secrets`.
## Anti-padrões
-`push --force` na main. ❌ Commitar segredo. ❌ Reativar webhook legado sem auditoria.
```mermaid
flowchart LR
DEV[VPS4 dev] -->|push| GITEA[Gitea VPS3]
GITEA --> ACT[Actions · vps4-builder]
GITEA --> REG[(Registry)]
```
</content>
@@ -0,0 +1,25 @@
# SKILL — PostGIS (geolocalização)
> Herda de `SKILL_PostgreSQL.md`. Extensão geoespacial usada por busca por proximidade.
## Onde é usado (real)
- Coluna `geo geography(Point,4326)` em `clinicas` e `salas` (e `cep_geo` para CEP→coordenada).
- Suporta o **marketplace de salas** (achar salas próximas) e geocódigo de endereço.
- O container `pgdev` é uma imagem **PostGIS** (dev espelha a capacidade da produção).
## Regras obrigatórias
1. Usar `geography(Point,4326)` (lat/long padrão) — não inventar SRID.
2. Índice espacial (GIST) quando houver busca por distância em volume.
3. Geocódigo (`cep_geo`) é cache — tratar ausência com fallback, não quebrar o cadastro.
## Anti-padrões
- ❌ Guardar lat/long como texto solto em vez de tipo geográfico.
- ❌ Busca por proximidade sem índice (varredura completa).
```mermaid
flowchart LR
END[Endereço/CEP] --> GEO[cep_geo → ponto]
GEO --> SAL[salas.geo]
SAL --> PROX[busca por proximidade · marketplace]
```
</content>
@@ -0,0 +1,32 @@
# SKILL — PostgreSQL (dados)
> Herda de `../FASE_01_ARQUITETURA/SKILL_Multiempresa.md`. Banco de produção e isolamento de dev.
## Topologia real
- **Produção:** PostgreSQL no host da **VPS3** (`10.99.0.3:5432`), banco `scoreodonto`, user `scoreodonto_user`.
- **DEV isolado:** container **`pgdev`** (PostGIS) na VPS4, banco `scoreodonto_dev`, user `devadmin`
**não toca produção**. `pg-dev-refresh.sh` / `pg-dev-reset.sh` para repuxar/limpar.
- Acesso via WireGuard (`10.99.0.0/24`). Sem shell na VPS3 → operar por container (`pg_dump`/`pg_restore`).
## Regras obrigatórias
1. **Toda query operacional filtra `clinica_id`** (tenancy — ver Multiempresa).
2. **Migrações aditivas** no boot (`CREATE TABLE/ADD COLUMN IF NOT EXISTS`). Nunca DROP/ALTER destrutivo.
3. **Backup antes de release sensível**: `pg_dump -Fc``/home/deploy/backups/`.
4. Testar migração arriscada **sempre em `pgdev`** antes da produção.
## ⚠️ SEGREDOS (crítico para abrir produção)
- **A senha do banco NÃO pode ficar em arquivo versionado nem como default em `docker-compose.yml`.**
Hoje há default hardcoded no compose (`SCOREO_DB_PASS:-...`) e `backend/.env` está **rastreado no git**
isso vaza credenciais. Segredos devem vir de **env_file fora do git** (chmod 600) ou secrets do CI.
- Rotacionar qualquer credencial que tenha entrado no histórico do git.
## Anti-padrões
- ❌ Query sem `clinica_id`. ❌ Migração destrutiva. ❌ Senha no compose/`.env` versionado.
```mermaid
flowchart LR
APP[Backend VPS1] -->|WireGuard| PG[(Postgres VPS3 · scoreodonto)]
DEVAPP[Backend DEV VPS4] --> PGDEV[(pgdev · scoreodonto_dev)]
PG --> BKP[/home/deploy/backups · pg_dump -Fc/]
```
</content>
@@ -0,0 +1,25 @@
# SKILL — Registry de Imagens
> Herda de `SKILL_BuildOnce.md`. Onde os artefatos imutáveis vivem.
## Conceito
Registry = **Container Registry embutido do Gitea** (`git.clube67.com`, namespace `ruicesar/`).
Não há `registry:2` separado. Imagens: `git.clube67.com/ruicesar/scoreodonto-{backend,frontend}:<tag>`.
## Regras obrigatórias
1. Imagem por **commit** (`:<sha>`) é a fonte; `:vX.Y.Z` é alias imutável do mesmo digest.
2. `docker login git.clube67.com` na VPS1 (pull) e no runner (push) — credenciais via secrets, não no git.
3. **Imagem enxuta**: `.dockerignore` deve excluir `node_modules`, `data/`, dumps, mídia pesada.
(Lição do RX: camadas grandes davam PUT 500/499 no registry.)
4. Não sobrescrever uma tag de release com código diferente (quebra a imutabilidade).
## Anti-padrões
- ❌ Republicar `:vX.Y.Z` apontando para outro código. ❌ Empurrar imagem gigante (sem `.dockerignore`).
- ❌ Guardar credencial do registry em arquivo versionado.
```mermaid
flowchart LR
RUN[runner build] -->|push :sha| REG[(Gitea Registry · ruicesar/)]
REG -->|pull :ref| VPS1[Produção]
```
</content>
@@ -0,0 +1,28 @@
# SKILL — Auditoria de APIs
> Como mapear e evoluir a API sem quebrar consumidores. Complementa `SKILL_Rotas.md`.
## Realidade
- **~277 rotas** REST `/api/*` num único `backend/server.js` (Express monolítico, ~516 KB). Sem camada de
controllers/services separada.
- Guards: `authGuard` (identidade), `tenantGuard` (tenant), `requireCapability*` (cargo), `superadminGuard`.
- Endpoints transversais: `GET /api/version`, `GET /api/health` (ver FASE_09); mídia por **URL assinada**
(`/raw?t=signMedia(...)`).
- Frontend consome via `services/backend.ts` (`HybridBackend`/`apiFetch`).
## Como auditar
```bash
grep -oE "app\.(get|post|put|delete|patch)\('[^']+" backend/server.js | sort -u # todas as rotas
grep -rn "API_URL}/<recurso>" frontend/services/backend.ts # consumidores
```
## Regras obrigatórias
1. **Nunca** mudar o caminho/contrato de uma rota sem achar TODOS os consumidores (`services/backend.ts` + views).
2. Toda rota nova: definir guard correto (identidade vs tenant vs cargo) — ver `SKILL_Permissoes.md`.
3. Erros consistentes (`{ error }` / `{ success:false, message }` conforme o padrão vizinho).
4. Rota que toca dado de clínica → `clinica_id` + `tenantGuard`.
## Anti-padrões
- ❌ Quebrar contrato sem mapear consumidores. ❌ Rota sensível sem guard de cargo.
- ❌ Mídia sem token assinado. ❌ Inventar convenção de resposta diferente da do arquivo.
</content>
@@ -0,0 +1,27 @@
# SKILL — Auditoria de Banco
> Como inspecionar e mexer no banco sem quebrar. Herda de `../FASE_07_INFRA/SKILL_PostgreSQL.md`.
## Realidade
- **PostgreSQL** (PostGIS) na VPS3 (`10.99.0.3`), banco `scoreodonto`. ~70 tabelas acessadas por **SQL direto**
(`pg`), via helpers `buildInsert`/`buildUpdate`. **Prisma é vestigial** (4 modelos GTO legados).
- DEV isolado: container `pgdev` (banco `scoreodonto_dev`). Mexer aqui tem zero impacto em produção.
- **Migrações aditivas no boot**: blocos `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`.
## Regras obrigatórias
1. **Só aditivo** (add tabela/coluna). Nunca DROP/ALTER destrutivo → quebra rollback (ver Deploy Imutável).
2. **Toda query operacional filtra `clinica_id`** (tenancy).
3. Validar migração arriscada **no `pgdev`** antes; **backup `pg_dump -Fc`** antes de release sensível.
4. Sem shell na VPS3 → operar por container (`pg_dump`/`pg_restore`/`psql`).
## Como auditar (via container, com creds do `.env`)
```bash
# tabelas e colunas
grep -niE "CREATE TABLE IF NOT EXISTS|ADD COLUMN IF NOT EXISTS" backend/server.js
# índices declarados
grep -niE "CREATE INDEX" backend/server.js
```
## Anti-padrões
- ❌ Migração destrutiva. ❌ Query sem `clinica_id`. ❌ Mexer direto na produção sem testar no `pgdev`/backup.
</content>
@@ -0,0 +1,28 @@
# SKILL — Auditoria de Botões
> Como rastrear o que cada botão faz (rota → tabela → fluxo) antes de mexer. Base:
> `documentação/MAPA-FUNCIONAL-COMPLETO.md` (Etapa 3).
## Método
Para cada botão: **rótulo → handler no front → método HTTP/rota → tabela impactada → fluxo a jusante**.
## Exemplos reais (verificados)
| Botão (tela) | Rota | Tabela | Próximo fluxo |
|---|---|---|---|
| Novo Agendamento (Agenda) | `POST /api/agendamentos` | `agendamentos` | Procedimento → Financeiro |
| Confirmar Presença (Agenda) | `POST /api/agenda/presenca` | `agenda_presenca` | libera lançamento |
| Lançar Procedimento (Pacientes) | `POST /api/procedimentos-realizados` | `procedimentos_realizados` (+`financeiro`) | Comissão → Repasse |
| Fechar Repasse (Comissões) | `POST /api/repasses/fechar` | `repasses` | Pagar → DESPESA |
| Gerar Contrato (Contratos) | `POST /api/contratos/instancias` | `contratos` | gerar-cobrança → Financeiro |
| Converter (Leads) | `POST /api/leads/:id/converter` | `pacientes` + `agendamentos` | atendimento |
## Como auditar
```bash
grep -oE ">[A-ZÀ-Ú][a-zà-úA-ZÀ-Ú /]{3,28}<" frontend/views/<View>.tsx # rótulos
# depois localize o onClick → método do HybridBackend → rota em services/backend.ts
```
## Anti-padrões
- ❌ Alterar a ação de um botão sem mapear a tabela e o fluxo a jusante (sobretudo financeiro).
- ❌ Botão que muta sem confirmação onde o padrão da tela usa `useConfirm`.
</content>
@@ -0,0 +1,29 @@
# SKILL — Auditoria de Entidades
> Como verificar se as entidades estão bem classificadas. Herda de
> `../FASE_01_ARQUITETURA/SKILL_ModelagemMestra.md`. Detalhe em `documentação/AUDITORIA-ESTRATEGICA-MODELAGEM.md`.
## Entidades-âncora (corretas)
- `usuarios` (Pessoa) · `vinculos(usuario_id, clinica_id, role, setor_id, funcao_id)` (Vínculo+RBAC)
- `clinicas(tipo: pessoal|consultorio|clinica, owner_id)` + `salas(tipo='sala')` (Workspaces)
- `usuario_areas`, `usuario_plugins`, `funcoes` (capacidades/cargo)
## Débitos de modelagem (gravidade ALTA — vigiar)
- **`dentistas`**: tabela paralela a `usuarios` (mesma pessoa duplicada por clínica, `usuario_id` opcional)
→ tratar como **projeção de agenda**, não identidade.
- **`pacientes`**: tem `clinica_id` e **não** `usuario_id` (preso à clínica, não-portável); há 2 variantes de
INSERT (com/sem `clinica_id`) — resíduo legado.
- **MÉDIA**: workspace dividido em 2 tabelas (`clinicas` + `salas`); acoplamento role↔tipo (`donoconsultorio`).
## Como auditar
```bash
grep -niE "CREATE TABLE" backend/server.js | sort # tabelas reais (banco é SQL puro, Prisma vestigial)
grep -oiE "(FROM|JOIN|INTO|UPDATE) [a-z_]+" backend/server.js | awk '{print $2}' | sort | uniq -c | sort -rn
```
Para cada entidade, pergunte: é **Pessoa**, **Vínculo**, **Workspace**, **capacidade** ou **dado operacional**?
Se não encaixa, é candidata a má-classificação.
## Anti-padrões
- ❌ Criar tabela-entidade que espelha a pessoa por workspace (repetir o erro de `dentistas`).
- ❌ Dado operacional sem `clinica_id`. ❌ Identidade fora de `usuarios`.
</content>
@@ -0,0 +1,47 @@
# SKILL — Auditoria de Fluxos
> Objetivo: ensinar a **mapear o fluxo de negócio ponta a ponta antes de alterar** — sobretudo o do dinheiro.
> Regra Nº1: não quebrar o que funciona. Toda mudança em fluxo financeiro exige mapeamento prévio.
## Fluxo principal (núcleo — verificado no código)
```
Lead ─converter→ Paciente + Agendamento ─presença→ Procedimento Realizado
→ Financeiro RECEITA (origem='procedimento', flag sem_comissao)
→ Comissão = (base custo_lab) × % [comissao_regras + computeComissoes]
→ Repasse FECHADO → pagar → Financeiro DESPESA (origem='repasse', centro_custo='COMISSÕES')
```
Ramos: Orçamento → Contrato → `gerar-cobranca` → Financeiro; Glosas sobre recebíveis de convênio.
## Pontos sensíveis (não quebrar)
1. **RECEITA é opcional** no procedimento (`valor>0` e `gerarLancamento!==false`) — alterar isso muda o caixa.
2. **`sem_comissao`** propaga garantia/reabertura e o repasse pago (evita recomissionar). Manter.
3. **Repasse cancelado estorna** a despesa (soft-delete). Preservar a reversibilidade.
4. **Anti-overbooking** na conversão de lead e na agenda — não remover a checagem.
5. **Marketplace**: só Tutoria captura pagamento (MercadoPago); Salas tem `valor` sem gateway.
## Como auditar um fluxo (comandos)
```bash
# Achar o handler de uma etapa
grep -n "repasses/fechar\|procedimentos-realizados\|leads/:id/converter" backend/server.js
# Ver o que cada etapa INSERE/ATUALIZA
grep -nE "INSERT INTO (financeiro|repasses|procedimentos_realizados)" backend/server.js
# Conferir auditoria
grep -n "registrarAudit" backend/server.js | head
```
## Checklist antes de alterar um fluxo
1. Onde começa, onde termina, o que cada etapa grava (tabela + colunas)?
2.`audit_log` (`registrarAudit`) na etapa? Manter.
3. A mudança afeta comissão/repasse/caixa? Mapear o impacto a jusante.
4. Existe reversão (estorno/soft-delete)? Não quebrá-la.
5. Validar em `dev.scoreodonto.com` com banco `pgdev` antes de qualquer promoção.
## Anti-padrões
- ❌ Alterar geração de RECEITA/DESPESA sem mapear o efeito em Comissões/Repasses.
- ❌ Remover flags `sem_comissao`/`deleted_at` (quebram não-duplicação e estorno).
- ❌ Mexer no fluxo sem reproduzir ponta a ponta em DEV.
## Entregável
Diagrama Mermaid do fluxo + tabela etapa→tabela→efeito + riscos. (Modelo:
`documentação/MAPA-FUNCIONAL-COMPLETO.md`, Etapas 4 e 8.)
</content>
@@ -0,0 +1,28 @@
# SKILL — Auditoria de Performance
> Onde olhar antes de otimizar. Sem micro-otimização cega — medir primeiro.
## Pontos de atenção reais
- **`server.js` monolítico** (~516 KB): carga cognitiva alta, mas não é gargalo de runtime por si.
- **Guards fazem queries por request**: `tenantGuard` (1 SELECT em `vinculos`) + `requireCapability` (12
SELECTs em `usuarios`/`vinculos`/`funcoes`). Aceitável; se virar gargalo, considerar cache curto do papel.
- **Catálogos/listas**: `GET /api/{procedimentos,especialidades,planos,dentistas,pacientes,agendamentos}`
garantir índices em `clinica_id` e filtros corretos.
- **PostGIS**: busca por proximidade (salas) precisa de índice GIST em `geo`.
- **Cache DragonflyDB** (`/1`): usado para acelerar; `/api/health` reporta latência de banco e cache.
## Como auditar
```bash
grep -niE "CREATE INDEX" backend/server.js # índices existentes
grep -niE "SELECT .* FROM (pacientes|agendamentos)" backend/server.js | head # queries quentes
curl -s https://<projeto>/api/health # latency_ms de database/cache
```
## Regras obrigatórias
1. **Medir** (latências do `/api/health`, EXPLAIN) antes de otimizar.
2. Índice em toda coluna usada em filtro frequente (`clinica_id`, FKs, status).
3. Não introduzir N+1 em loop de query; preferir `IN`/JOIN.
## Anti-padrões
- ❌ Otimizar sem medir. ❌ Remover guard "para acelerar" (quebra segurança). ❌ Lista sem filtro por `clinica_id`.
</content>
@@ -0,0 +1,36 @@
# SKILL — Auditoria de Permissões (RBAC)
> Como o controle de acesso REAL funciona hoje e como auditá-lo. Herda de
> `../FASE_01_ARQUITETURA/SKILL_RBAC.md`. Implementado no backend nesta stack.
## Três planos de autorização (backend)
1. **Identidade**`authGuard`: valida o JWT (assinado com `JWT_SECRET`), popula `req.authUser.userId`.
2. **Tenant**`tenantGuard`: valida que o usuário tem **vínculo** com a `clinica_id` pedida (ou é dono da
sala); seta `req.clinicaId`. Sem vínculo → 403 `[TENANT VIOLATION]`.
3. **Cargo (capability)**`requireCapability(cap)` / `requireCapabilityRow(cap, tabela)` /
`requireCapabilityFromOrders(cap, tabela)`: exige a capability no workspace ativo. **Superadmin**`superadminGuard` (checa `usuarios.role` no banco).
## `capabilityCore` (espelha o `isViewAllowed` do frontend)
- superadmin global → ok;
- dono (`donoclinica`/`donoconsultorio`/`admin`, `CAP_OWNER_ROLES`) → ok;
- cargo com `funcoes.permissoes` (JSONB) explícitas → ok se inclui a capability;
- senão, **mapa padrão por papel** (`CAP_DEFAULTS`, cópia fiel do frontend).
## Cobertura (99 rotas gateadas)
financeiro (`'financeiro'`/`'comissoes'`/`'glosas'`), equipe (`'gestao-equipe'`), cadastros
(`'dentistas'`/`'especialidades'`/`'procedimentos'`/`'planos'`, incl. `/reorder`), operação clínica
(`'agenda'`/`'leads'`), orçamentos/contratos. Marketplace (salas/propostas) = autorização por **posse** no handler.
## Como auditar
```bash
grep -nE "tenantGuard|authGuard|requireCapability|superadminGuard" backend/server.js # guards por rota
grep -nE "CAP_DEFAULTS|CAP_OWNER_ROLES" backend/server.js # matriz de papéis
```
Regra de ouro: **a capability do backend tem que casar com o `isViewAllowed`/Sidebar do frontend**.
## Anti-padrões
- ❌ Rota sensível só com `tenantGuard` (membersia ≠ cargo).
-`authGuard` puro em rota que toca dado de clínica (falta tenant).
- ❌ Capability no backend divergente do menu do frontend.
- ❌ Confiar em estado do cliente (`localStorage`/preview) para autorizar.
</content>
@@ -0,0 +1,43 @@
# SKILL — Auditoria de Rotas
> Objetivo: ensinar qualquer agente a **mapear e entender as rotas antes de alterar** — front e back.
> Regra Nº1: não quebrar o que funciona. Sempre Auditoria → Planejamento → Aprovação.
## Onde a verdade está
- **Frontend (navegação):** `frontend/App.tsx``ROUTE_MAP` (URL→view), `VIEW_TO_ROUTE` (view→URL),
`renderView()` (view→componente), `isViewAllowed()` (RBAC), e o filtro de menu em `components/Sidebar.tsx`.
Não há `react-router` — o roteamento é próprio.
- **Backend (API):** `backend/server.js`**277 rotas** `app.<método>('/api/...')`, quase todas com `tenantGuard`.
## Como mapear (comandos de auditoria)
```bash
# Rotas de API (método + caminho)
grep -oE "app\.(get|post|put|delete|patch)\('[^']+" backend/server.js | sort -u
# Rotas do frontend (URL → view)
sed -n '/ROUTE_MAP/,/};/p' frontend/App.tsx
# Permissões por papel
sed -n '/isViewAllowed/,/^ };/p' frontend/App.tsx
sed -n '/menuItems/,/];/p' frontend/components/Sidebar.tsx
```
## Checklist antes de mexer numa rota
1. A URL existe em **ambos** `ROUTE_MAP` e `VIEW_TO_ROUTE`? (senão deep-link quebra)
2. Qual `isViewAllowed`/menu libera essa view e para quais papéis?
3. Qual rota de API ela consome? Tem `tenantGuard` (escopo de clínica)?
4. Quais tabelas a rota toca? (ver `SKILL_Banco.md`/`SKILL_Entidades.md`)
5. Alterou acesso no back? **Espelhe** em `isViewAllowed` + `Sidebar` no mesmo PR.
## Débitos conhecidos a vigiar (já auditados)
- `/comissoes`, `/glosas`, `/cadastro-dentista`: existem em `VIEW_TO_ROUTE` mas **faltam em `ROUTE_MAP`**
→ deep-link/refresh cai em `dashboard`. (Documentado em `MAPA-FUNCIONAL-COMPLETO.md`.)
- `/admin/sync`: rota existe, mas `isViewAllowed` retorna `false` sempre (tela inacessível).
## Anti-padrões
- ❌ Adicionar view sem registrar nas DUAS direções (`ROUTE_MAP` + `VIEW_TO_ROUTE`).
- ❌ Liberar tela no menu sem proteger a rota de API correspondente.
- ❌ Alterar caminho de API sem buscar todos os consumidores no frontend (`services/backend.ts`).
## Entregável da auditoria
Tabela URL → view → componente → papéis → rota(s) de API → tabelas, + riscos. (Modelo:
`documentação/MAPA-FUNCIONAL-COMPLETO.md`, Etapa 1.)
</content>
@@ -0,0 +1,28 @@
# SKILL — Diagnósticos (runbook de incidente)
> Passo a passo quando algo quebra. Combina Health, Logs, Deploy Imutável.
## Triagem rápida
1. `GET /api/health``database down` (503)? `cache degraded`? `version` esperada?
2. `GET /api/version` — bate com a tag que deveria estar no ar?
3. `docker ps` (rootless) — container `Up`? reiniciando (`RestartCount`)?
4. `docker logs --tail 80 <container>` — erro no boot? `[PG]`/`[DRAGONFLY]` conectaram?
## Causas comuns (reais nesta stack)
- **`env file .secrets/.env not found`** → falta o env_file no host (VPS1). Copiar manualmente (fora do git).
- **`database down`** → Postgres VPS3 inacessível (WireGuard) ou credencial errada.
- **`cache down`** → Dragonfly/`ping()` (degradável; não derruba).
- **Migração** falhou no boot → ver log `[MIGRATION]`.
## Recuperação
- App quebrado pós-deploy → **rollback por imagem** (segundos): `deploy-prod.sh <ref-anterior>` (ver Deploy Imutável).
- Dado corrompido → restaurar `pg_dump` de `/home/deploy/backups/` (caso extremo).
## Regras obrigatórias
1. Diagnosticar com `/api/health` + logs **antes** de mexer.
2. Preferir **rollback de imagem** a "consertar em produção no quente".
3. Reproduzir/testar a correção no `dev` (`pgdev`) antes de promover.
## Anti-padrões
- ❌ Editar código direto na VPS1. ❌ Mexer no banco de produção sem backup. ❌ Deploy às cegas sem health.
</content>
@@ -0,0 +1,26 @@
# SKILL — Health Checks
> Como saber se o app está vivo e saudável, sem SSH. Fonte: `documentos-unicos/deploy/versionamento.md`.
## Endpoint real
`GET /api/health``{ status, version, commit, environment, uptime_s, checks }`:
- **`database`** é crítico: se cair → `status: down` + **HTTP 503**.
- **`cache`** (DragonflyDB) é opcional: se cair → `status: degraded` (HTTP 200); `disabled` se desligado.
- Cada check traz `latency_ms` — útil para perceber lentidão de banco/cache.
## Como usar
```bash
curl -s https://<projeto>/api/health # produção
curl -s https://dev.<projeto>/api/health # dev
```
## Regras obrigatórias
1. **Validar `/api/health` após todo deploy** (e na rotina de monitoramento).
2. `database down` (503) é incidente → investigar conexão VPS1→VPS3 / Postgres.
3. `cache down/degraded` não derruba o app, mas registre (ex.: pré-existente no RX — `redis.ping()`).
4. Não remover/alterar o contrato do `/api/health` (monitor externo depende dele).
## Anti-padrões
- ❌ Deploy sem checar health. ❌ Tornar o cache crítico (deve ser degradável).
- ❌ Vazar segredo/detalhe interno no payload do health.
</content>
@@ -0,0 +1,28 @@
# SKILL — Logs & Auditoria
> Onde está o rastro do que aconteceu. Dois níveis: log de aplicação e trilha de auditoria persistida.
## Auditoria de negócio (persistida)
- Tabela **`audit_log`** (`clinica_id, entidade, entidade_id, acao, actor_id, actor_nome, detalhes, at`),
gravada por `registrarAudit(...)` em mutações sensíveis (procedimento, repasse, comissão, contrato, equipe…).
- **Imutável** — é o rastro de quem fez o quê. Mutações financeiras/RBAC devem registrar.
- `login_log` registra acessos (visível em SuperAdmin → Acessos).
## Log de aplicação (runtime)
- `console.*` no `server.js` (boot, `[PG] Connected`, `[DRAGONFLY] Connected`, `[WS] broadcast`,
`[TENANT VIOLATION]`, `[CLINICA] Created`…). Visível em `docker logs <container>` (rootless).
## Como usar
```bash
DOCKER_HOST=unix:///run/user/1000/docker.sock docker logs --tail 50 <container>
grep -n "registrarAudit" backend/server.js # pontos auditados
```
## Regras obrigatórias
1. Toda mutação sensível (dinheiro, cargo, contrato) chama `registrarAudit`. **Não** remover.
2. **Nunca** logar segredo/credencial/token.
3. `[TENANT VIOLATION]` no log = tentativa de acesso cross-tenant → investigar.
## Anti-padrões
- ❌ Mutação sensível sem auditoria. ❌ Logar dado pessoal/segredo. ❌ Apagar `audit_log`.
</content>
@@ -0,0 +1,26 @@
# SKILL — Versionamento em Runtime
> De onde vem a versão exibida e por que ela é confiável. Herda de `../FASE_07_INFRA/SKILL_BuildOnce.md`.
## Como funciona (real)
- A versão **nasce da tag git** (`vX.Y.Z`) e é **injetada no deploy em runtime** (`APP_VERSION`), não escrita à
mão (não existe mais `constants.ts`/`update-version.js`).
- `commit` e `build` são **carimbados na imagem** (imutáveis, propriedade do artefato).
- Em **DEV** aparece `dev`; o número só existe em produção.
- Exposição: `GET /api/version``{ name, version, commit, build, environment }`. O frontend **não embute**
versão — lê de `/api/version` (fonte única = backend). Mostrada no rodapé (canto inf. direito) e em
Configurações → "Sobre o sistema".
## Como verificar
```bash
curl -s https://<projeto>/api/version # → vX.Y.Z · <commit> · PROD
```
## Regras obrigatórias
1. **Não** hardcodar versão em arquivo — só a tag define.
2. A MESMA imagem promovida sob a tag = `commit`/`build` imutáveis + `version`/`environment` em runtime.
3. Conferir `/api/version` após deploy (bate com a tag promovida?).
## Anti-padrões
- ❌ Escrever versão manualmente. ❌ Frontend embutir versão. ❌ Rebuild na tag (quebra o build-once).
</content>
@@ -0,0 +1,26 @@
# SKILL — Backlog (Roadmap)
> Itens reais levantados nas auditorias e na sessão de hardening. Não inventar; manter atualizado.
## Segurança / produção (parcialmente feito em DEV)
- ✅ JWT rotacionado (dev+prod); `.env` fora do git; `.dockerignore`; senhas fora do compose; RBAC por cargo (99 rotas).
- ⬜ Provisionar `SCOREO_DB_PASS` na VPS1; push imagem limpa `v1.0.19`; rotacionar DB/cache/API keys.
## Correções (do MAPA-FUNCIONAL)
- ⬜ Deep-links residuais já tratados (`/comissoes`, `/glosas`); revisar outros se surgirem.
- ⬜ RECEITA de procedimento opcional → decidir se vira padrão (risco de caixa).
- ⬜ Estado de plugins divergente cliente/servidor (`localStorage` vs `usuario_plugins`).
## Modelagem (do AUDITORIA-ESTRATEGICA)
-`dentistas` como projeção da pessoa; `pacientes` com identidade (LGPD/jurídico antes).
- ⬜ Unificar workspace (`clinicas`+`salas`); entidade "rede" acima da clínica.
## Receita (do SKILL_Monetizacao)
- ⬜ Gateway na Locação de Salas; recorrência das Assinaturas; marketplace premium.
## Limpeza
- ⬜ Órfã `OrthoReports.tsx`; `SyncView` desativada; `server.js.bak`/scripts legados.
## Regra
Backlog reflete a **realidade**; ao concluir, marcar ✅ com referência ao commit/PR.
</content>
@@ -0,0 +1,24 @@
# SKILL — Escalabilidade (Roadmap)
> O que o modelo suporta em 5 anos e onde estão os gargalos. Base: `AUDITORIA-ESTRATEGICA-MODELAGEM.md`.
## O que JÁ escala (base Pessoa→Vínculo→Workspace)
- Pessoa em **múltiplos workspaces** (N `vinculos`), workspace pessoal automático, salas/tutoria/marketplace.
## Gargalos conhecidos
| Cenário | Suporta? | Gargalo |
|---|---|---|
| Dentista em várias clínicas | parcial | `dentistas` **duplica a pessoa** por clínica (agenda/horários presos ao `dentistas.id`) |
| Paciente entre clínicas | não | `pacientes` preso à clínica, sem identidade de pessoa (não-portável) |
| Redes de clínicas | não | **não existe entidade "rede/grupo"** acima da clínica (workspaces são planos) |
| Visão consolidada da pessoa | não | tenancy de `clinica_id` único impede agregar cross-clínica |
| Profissionais temporários (delivery) | não | sem contrato/pagamento/vínculo temporário (ver `../FASE_02_NEGOCIO/SKILL_Delivery.md`) |
| Locação/Tutoria/Marketplace | sim/parcial | falta captura de pagamento (salas) |
## Direção (sem propor implementação)
Consolidar a **Pessoa** como âncora única (unificar `dentistas`→projeção; paciente→identidade) destrava
multi-clínica, redes e portabilidade. Coexistir os 2 modelos é o que gera as contradições de gravidade ALTA.
## Anti-padrões
- ❌ Duplicar a pessoa por workspace. ❌ Tratar clínica como raiz. ❌ Operação sem `clinica_id`.
</content>
@@ -0,0 +1,25 @@
# SKILL — Monetização (Roadmap)
> O que gera (ou pode gerar) receita. Base: `documentação/MAPA-FUNCIONAL-COMPLETO.md` + `AUDITORIA-ESTRATEGICA-MODELAGEM.md`.
## Três camadas de receita (estado real)
1. **Receita da clínica** (operacional): produção, contratos/cobrança, recuperação de glosas.
2. **Receita do profissional** (repasse de comissão).
3. **Receita da PLATAFORMA**:
- **Tutoria** — MercadoPago + `comissao_pct`: **única automatizada hoje**. ✅
- **Assinaturas SaaS** (`assinaturas`/`superadmin/pricing`): existe, mas **manual** (sem gateway/recorrência).
- **Locação de salas** (`sala_reservas.valor`): registra valor, **sem captura de pagamento**.
## Alavancas (ordem de retorno mais rápido)
1. **Gateway na Locação de Salas** — reserva + `valor` já existem; falta cobrança + taxa da plataforma.
2. **Recorrência das Assinaturas SaaS** — base multiempresa + pricing prontos; falta gateway/automação.
3. **Marketplace de Profissionais premium** — taxa de match/destaque (hoje só mensagem).
4. **Escalar Tutoria** (já fatura).
## Regras
- Toda lacuna de captura deve ser **sinalizada, não presumida como paga**.
- Não misturar as 3 camadas (clínica ≠ profissional ≠ plataforma).
## Anti-padrões
- ❌ Assumir que reserva/assinatura foi paga. ❌ Construir cobrança fora do modelo Pessoa→Workspace.
</content>
@@ -0,0 +1,22 @@
# SKILL — Priorização (Roadmap)
> Como decidir a ordem. Critérios concretos para esta stack.
## Critérios (em ordem)
1. **Não quebrar o que funciona** (REGRA Nº 1) — auditar impacto antes.
2. **Segurança / pré-produção** primeiro (abrir a usuários reais exige base segura) — ver `SKILL_Backlog`.
3. **Receita mais rápida com menor esforço** — alavanca onde a infra já existe (ex.: gateway de salas).
4. **Destravar arquitetura** (unificar Pessoa) — alto valor, maior esforço/risco → deliberado, não no susto.
5. **Esforço × risco × reversibilidade** — preferir mudanças aditivas e reversíveis (build-once/rollback).
## Fluxo obrigatório por item
`Auditoria → Planejamento → Aprovação → Implementação (em DEV/`pgdev`) → Validação → Promoção`.
## Recomendação estratégica única (da auditoria)
Assumir a **Pessoa como âncora** e **parar de manter o legado clínica-cêntrico em paralelo** — é a coexistência
dos dois modelos que gera as contradições graves. Mas: só após segurança/produção estável e com aprovação.
## Anti-padrões
- ❌ Implementar sem auditoria/aprovação. ❌ Priorizar features novas sobre segurança pré-lançamento.
- ❌ Refator arquitetural grande sem janela e rollback.
</content>
+28
View File
@@ -0,0 +1,28 @@
# Segredos e ambiente — NUNCA entram na imagem
.env
.env.*
*.secret
.secrets
# Dependências e caches (reinstalados no build)
node_modules
npm-debug.log
# Artefatos locais / backups / dados que não pertencem à imagem
*.bak
server.js.bak
sessions/
media/
data/
bkp/
*.dump
# Diagnóstico temporário
test-db-users.js
test-db-users.cjs
pg-convert.js
# Diversos
.git
.DS_Store
*.log
-45
View File
@@ -1,45 +0,0 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
PORT=8018
NODE_ENV=development
JWT_SECRET=scoreodonto_jwt_secret_forte_2026
# ─────────────────────────────────────────────
# PostgreSQL (Prisma)
# ─────────────────────────────────────────────
DATABASE_URL="postgresql://scoreodonto_user:clube67_scoreodonto_pass_9903@10.99.0.3:5432/scoreodonto?schema=public"
# ─────────────────────────────────────────────
# DragonflyDB (compatível com Redis)
# ─────────────────────────────────────────────
DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379/1
DRAGONFLY_TTL_SECONDS=86400
# ─────────────────────────────────────────────
# NATS JetStream
# ─────────────────────────────────────────────
NATS_URL=nats://10.99.0.3:4222
# ─────────────────────────────────────────────
# Temporal.io
# ─────────────────────────────────────────────
TEMPORAL_ADDRESS=10.99.0.3:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=scoreodonto-queue
# ─────────────────────────────────────────────
# IA (OpenAI + Google Gemini)
# ─────────────────────────────────────────────
OPENAI_API_KEY=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
GEMINI_API_KEY=AIza...
# ─────────────────────────────────────────────
# WhatsApp / Baileys
# ─────────────────────────────────────────────
BAILEYS_SESSIONS_PATH=/home/deploy/scoreodonto-sessions
# ─────────────────────────────────────────────
# Frontend (CORS)
# ─────────────────────────────────────────────
FRONTEND_URL=http://localhost:3013
+26
View File
@@ -0,0 +1,26 @@
# Modelo de variáveis de ambiente do backend (SEM segredos).
# Copie para .env e preencha com valores REAIS. O .env NÃO é versionado.
PORT=8018
NODE_ENV=production
# Segredo de assinatura dos tokens JWT — gerar com: openssl rand -hex 48
JWT_SECRET=
# Banco e cache (não versionar valores reais)
DATABASE_URL=postgresql://USER:SENHA@HOST:5432/BANCO?schema=public
DRAGONFLY_URL=redis://:SENHA@HOST:6379/1
DRAGONFLY_TTL_SECONDS=3600
# Mensageria / workflows
NATS_URL=nats://HOST:4222
TEMPORAL_ADDRESS=HOST:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=scoreodonto
# Chaves de IA (rotacionáveis apenas nos painéis dos provedores)
OPENAI_API_KEY=
GEMINI_API_KEY=
# Caminhos / front
BAILEYS_SESSIONS_PATH=/app/sessions
FRONTEND_URL=https://scoreodonto.com
+70
View File
@@ -0,0 +1,70 @@
# Integração NewWhats (satélite) — scoreodonto.com ↔ Motor
> **Único deste projeto** (scoreodonto.com). Clonado e **adaptado ao domínio
> odonto** a partir do satélite do `mercado.clube67.com`. Backend **ESM** — sem o
> host de plugins do mercado; é um módulo registrado no `server.js`. v1.0.0.
## ⚠️ Regra de registro bidirecional (obrigatória)
- Toda mudança que toque o contrato (rotas, payloads, auth, webhook) **deve ser
registrada no motor** e na doc central
`documentos-unicos/projetos/newwhats.clube67.com/integracao-satelites.md`.
- Toda mudança no motor que afete o contrato **deve ser refletida aqui**.
- O satélite é um **braço inteligente do motor** — consome/alimenta, não duplica.
## Diferenças vs o satélite do mercado
- **ESM** (não CommonJS) e **sem PluginManager** — registrado via
`registerNewwhats(app, pool)` no `server.js`.
- **Config por ENV** (não tabela `plugin_configs`): `NEWWHATS_URL`,
`NEWWHATS_INTEGRATION_KEY`, `NEWWHATS_WEBHOOK_SECRET`, `NEWWHATS_CLINICA_ID`.
- **Negócio adaptado ao odonto**: `/nw/*` e `sync-knowledge` usam
clínica/especialidades/dentistas/procedimentos/planos (multi-tenant por
`clinica_id`), no lugar de clubes/sorteio/vagas.
- **Removidos** (não úteis aqui): `media-transcribe` (o motor já transcreve —
ver Fase 5), `smart-router`, `personas`, `follow-up-detector`, `context-builder`
(heurísticas específicas do mercado). Reavaliar se necessário.
## Arquivos
| Arquivo | Papel |
|---|---|
| `config.js` | lê a config da integração das ENV |
| `auth.js` | `nwAuth` — valida `x-nw-key`/Bearer nas rotas `/nw/*` |
| `routes.js` | `/nw/*` odonto: `/ping`, `/clinica`, `/especialidades`, `/dentistas`, `/procedimentos`, `/planos` |
| `sync-knowledge.js` | `buildKnowledgeText(pool, clinicaId)` + `syncKnowledge(pool)` → motor `/api/secretaria/*` |
| `webhook-receiver.js` | `POST /api/webhook/newwhats` (HMAC) — `message.new`, `session.status` (enxuto) |
| `proxy.js` | `/api/nw/v1/*` → motor `/api/ext/v1/*` (injeta `x-nw-key`) |
| `index.js` | `registerNewwhats(app, pool)` — registra tudo |
| `manifest.json` | metadados + `config_env` |
## Contrato (resumo)
- **Satélite → Motor**: proxy `/api/nw/v1/*` → ext-api `/api/ext/v1/*` (Bearer/x-nw-key);
`sync-knowledge``/api/secretaria/*`.
- **Motor → Satélite**: webhook `POST /api/webhook/newwhats` (HMAC `webhook_secret`);
Secretária consulta `/nw/*` (`integration_key`).
## Frontend (React SPA)
- Plugin `newwhats` registrado em `frontend/views/PluginsView.tsx` (catálogo
PLUGINS) — **config exclusiva do superadmin** (a view `plugins` já é gateada em
`App.tsx isViewAllowed`).
- `ALWAYS_ON` em `pluginRegistry.ts` → Inbox/Secretária aparecem para **todos** os
usuários (a ativação por plugin é localStorage e não propaga entre contas).
- Views: `frontend/views/WaInboxView.tsx` (conversas/thread/envio) e
`WaSecretariaView.tsx` (ask) — consomem `/api/nw/v1/*`. **Estágio 1** (funcional,
visual aproximado do motor; refinar para ficar idêntico).
- Sidebar: itens WHATSAPP + SECRETÁRIA IA (gated por `isPluginActive('newwhats')`).
## Pendências / TODO
- [ ] **Segurança**: gatear o proxy `/api/nw/v1/*` com a auth do scoreodonto (hoje
aberto — qualquer um que alcance a rota é proxiado ao motor).
- [x] **Ponte de config** (feito): config persiste na tabela `settings`
(`id=newwhats_config`); backend lê do DB com fallback ENV
(`config.js loadConfigFromDb`). UI dedicada (`NewwhatsConfigModal`) com
**dropdown de clínicas**, secrets que mantêm valor se em branco e **Testar
conexão** (`GET /api/nw/status`). Endpoints superadmin: `GET/PUT /api/nw/config`,
`GET /api/nw/clinicas`, `GET /api/nw/status`.
- [ ] `webhook message.new`: capturar lead / acionar fluxo (hoje só loga).
- [ ] Proxy WS (`/api/nw/v1/stream`) — só o REST foi portado (Inbox sem tempo real).
- [ ] Parear scoreodonto com o motor (criar apiKey/pair + agente) e preencher as ENV.
- [ ] Endpoint/admin para disparar `syncKnowledge` (hoje exportado, sem rota).
- [ ] Refinar Inbox/Secretária para ficarem idênticas ao motor.
> Fonte da verdade do contrato: `documentos-unicos/projetos/newwhats.clube67.com/`.
+583
View File
@@ -0,0 +1,583 @@
// Ponte de agenda: expõe /api/nw/agenda/* para o MOTOR (tools da Secretária)
// operarem na agenda REAL do scoreodonto — em vez do calendário isolado do motor.
//
// Segurança: chamada server-to-server autenticada por segredo compartilhado
// (header x-nw-agenda-secret === webhookSecret configurado). Todo acesso é
// preso ao clinica_id enviado (o motor conhece a clínica da instância).
//
// Endpoints:
// GET /slots — horários livres (jornada dos dentistas agendamentos)
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
import express from 'express';
import { getConfig } from './config.js';
import { cautelasParaIA } from './checklist-defs.js';
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
const HORIZON_DAYS = 7; // janela padrão de busca
const MAX_SLOTS = 20; // quantos slots RETORNAR (após ordenar por horário)
const CANDIDATE_CAP = 240; // teto de GERAÇÃO (clínica + salas competem por horário)
// Fallback quando a CLÍNICA não configurou horário de funcionamento: comercial
// padrão (segsex 0818). Uma vez configurado (clinicas_horarios), manda ele.
const DEFAULT_INI = '08:00';
const DEFAULT_FIM = '18:00';
const DIAS_NOME = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
const pad = (n) => String(n).padStart(2, '0');
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
const hm = (t) => String(t ?? '').slice(0, 5); // 'HH:MM:SS'|Date → 'HH:MM'
// Interseção de duas listas de janelas [ini,fim] (strings 'HH:MM'). Retorna as
// sobreposições (ex.: clínica 0818 ∩ dentista 1320 = 1318).
function interseccao(a, b) {
const out = [];
for (const [ai, af] of a) for (const [bi, bf] of b) {
const ini = ai > bi ? ai : bi;
const fim = af < bf ? af : bf;
if (ini < fim) out.push([ini, fim]);
}
return out;
}
// Carrega o horário de FUNCIONAMENTO da clínica → { byDow: Map<dow,[[ini,fim]]>, hasConfig }.
async function carregarHorariosClinica(pool, clinicaId) {
const byDow = new Map();
let hasConfig = false;
try {
const { rows } = await pool.query(
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
[clinicaId]);
hasConfig = rows.length > 0;
for (const r of rows) {
const arr = byDow.get(r.dia_semana) || [];
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
byDow.set(r.dia_semana, arr);
}
} catch { /* tabela pode não existir ainda → cai no fallback */ }
return { byDow, hasConfig };
}
// Carrega as folgas (por data) dos dentistas da clínica → Map<dentista_id,[{ini,fim}]>.
async function carregarFolgas(pool, clinicaId) {
const byDent = new Map();
try {
const { rows } = await pool.query(
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
WHERE d.clinica_id = $1`, [clinicaId]);
for (const r of rows) {
const arr = byDent.get(r.dentista_id) || [];
arr.push([r.ini, r.fim]);
byDent.set(r.dentista_id, arr);
}
} catch { /* tabela pode não existir ainda */ }
return byDent;
}
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
const fer = await resolverFeriado(pool, clinicaId, dateStr);
if (fer && fer.fecha) return { janelas: [], fechado: true, motivo: `Feriado: ${fer.nome}` };
// Feriado com horário especial (fecha=0 + horas) sobrepõe o horário normal.
if (fer && !fer.fecha && fer.hora_inicio && fer.hora_fim) {
return { janelas: [[hm(fer.hora_inicio), hm(fer.hora_fim)]], fechado: false, motivo: `Horário especial (${fer.nome})` };
}
// Horário normal do dia da semana.
let janelas;
if (clinicHours.hasConfig) {
janelas = (clinicHours.byDow.get(dow) || []).map(([i, f]) => [i, f]);
} else {
janelas = (dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : [];
}
if (!janelas.length) return { janelas: [], fechado: true, motivo: DIAS_NOME[dow] === 'domingo' || DIAS_NOME[dow] === 'sábado' ? `Fechado (${DIAS_NOME[dow]})` : 'Fechado neste dia' };
return { janelas, fechado: false, motivo: null };
}
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
// se a regra do scoreodonto mudar, revisar aqui também.
async function findConflict(db, dentistaId, start, end) {
if (!dentistaId || !start) return null;
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
let ids = [dentistaId];
const uid = dr[0]?.usuario_id || null;
const email = (dr[0]?.email || '').trim().toLowerCase() || null;
if (uid) {
const { rows } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [uid]);
if (rows.length) ids = rows.map((r) => r.id);
} else if (email) {
const { rows } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [email]);
if (rows.length) ids = rows.map((r) => r.id);
}
const { rows } = await db.query(
`SELECT id FROM agendamentos
WHERE dentistaid = ANY($1)
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
LIMIT 1`,
[ids, start, end || start]);
return rows[0] || null;
}
export function createAgendaBridge(pool) {
const router = express.Router();
// Auth server-to-server por segredo compartilhado.
router.use((req, res, next) => {
const secret = getConfig().webhookSecret;
if (!secret || req.headers['x-nw-agenda-secret'] !== secret) {
return res.status(401).json({ error: 'Segredo da ponte inválido' });
}
next();
});
// ── GET /slots ──────────────────────────────────────────────────────────────
// Query: clinica_id (obrig.), dentista_id?, date? (YYYY-MM-DD), days?, duration?
router.get('/slots', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
const periodo = req.query.periodo ? String(req.query.periodo).toLowerCase() : null; // manha|tarde
try {
// Dentistas no escopo da clínica.
const dParams = [clinicaId];
let dWhere = 'clinica_id = $1';
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
if (!dentistas.length) return res.json({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
// Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista.
const { rows: horarios } = await pool.query(
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
const hoursBy = new Map();
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
// Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa.
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
// Folgas por data dos dentistas (férias/indisponibilidade).
const folgasBy = await carregarFolgas(pool, clinicaId);
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
const { rows: busy } = await pool.query(
`SELECT dentistaid, start_time, end_time FROM agendamentos
WHERE clinica_id = $1 AND start_time >= $2 AND start_time < $3
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
const now = new Date();
const slots = [];
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
const dow = day.getDay();
// Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia.
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours);
if (fechado || !clinicWins.length) continue;
for (const dent of dentistas) {
if (slots.length >= CANDIDATE_CAP) break;
if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia
const cfg = hoursBy.get(dent.id);
// Dentista: se configurou jornada, usa a do dia; senão, atende sempre
// que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA.
const dentWins = (cfg && cfg.length)
? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)])
: clinicWins;
const windows = interseccao(clinicWins, dentWins);
const bs = busyBy.get(dent.id) || [];
for (const [ini, fim] of windows) {
const [hi, mi] = String(ini).split(':').map(Number);
const [hf, mf] = String(fim).split(':').map(Number);
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) {
const s = new Date(t);
const e = new Date(t.getTime() + duration * 60000);
t = e;
if (s <= now) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
slots.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e) });
}
}
}
}
// ── B (salas): slots do dentista num QUARTO alugado (sala_reservas aprovada).
// Disponibilidade = janela da reserva [inicio,fim]; casa o dentista da clínica
// pelo usuario_id. Respeita folgas e agendamentos existentes (por dentista).
if (slots.length < CANDIDATE_CAP) {
try {
const { rows: reservas } = await pool.query(
`SELECT r.sala_id, r.inicio, r.fim, s.nome AS sala_nome, d.id AS dentista_id, d.nome AS dentista_nome
FROM sala_reservas r
JOIN salas s ON s.id = r.sala_id
JOIN dentistas d ON d.usuario_id = r.profissional_usuario_id
WHERE d.clinica_id = $1
AND lower(coalesce(r.status,'')) NOT IN ('cancelada','cancelado','recusada','recusado','rejeitada','pendente','negada')
AND r.fim > $2 AND r.inicio < $3
ORDER BY r.inicio`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const step = duration * 60000;
for (const rv of reservas) {
if (slots.length >= CANDIDATE_CAP) break;
const bs = busyBy.get(rv.dentista_id) || [];
const rIni = new Date(rv.inicio), rFim = new Date(rv.fim);
let t = new Date(Math.max(rIni.getTime(), start0.getTime(), now.getTime()));
const off = (t.getTime() - rIni.getTime()) % step;
if (off !== 0) t = new Date(t.getTime() + (step - off)); // alinha ao grid da reserva
while (t.getTime() + step <= rFim.getTime() && slots.length < CANDIDATE_CAP) {
const s = new Date(t), e = new Date(t.getTime() + step); t = e;
if (s <= now) continue;
if (emFolga(folgasBy.get(rv.dentista_id), ymd(s))) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
slots.push({ dentista_id: rv.dentista_id, dentista_nome: rv.dentista_nome, start: fmt(s), end: fmt(e), sala_id: rv.sala_id, local: rv.sala_nome });
}
}
} catch { /* sala_reservas pode não existir */ }
}
let out = slots.sort((a, b) => a.start.localeCompare(b.start));
// Filtro manhã/tarde (a Secretária pergunta o período antes de oferecer).
if (periodo === 'manha' || periodo === 'manhã') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) < 12);
else if (periodo === 'tarde') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) >= 12);
res.json({ slots: out.slice(0, MAX_SLOTS) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── GET /status ──────────────────────────────────────────────────────────────
// Situação de funcionamento da clínica p/ a Secretária SABER quando responder
// "estamos fechados". Query: clinica_id (obrig.), date? (YYYY-MM-DD, def: hoje BR).
// Retorna: hoje (aberto/motivo/horários), próximo dia aberto, grade da semana e
// próximos feriados.
router.get('/status', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
// "Hoje" no fuso de Brasília (o servidor pode rodar em UTC).
const hojeBR = req.query.date
? String(req.query.date)
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
const dateAt = (str) => new Date(`${str}T12:00:00`); // meio-dia: evita borda de fuso
const addYmd = (str, k) => { const d = dateAt(str); d.setDate(d.getDate() + k); return ymd(d); };
const statusDoDia = async (str) => {
const dow = dateAt(str).getDay();
const r = await janelasClinicaNoDia(pool, clinicaId, str, dow, clinicHours);
return {
data: str, dia_semana: dow, dia_nome: DIAS_NOME[dow],
aberto: !r.fechado && r.janelas.length > 0, motivo: r.motivo,
horarios: r.janelas.map(([i, f]) => ({ inicio: i, fim: f })),
};
};
// Grade dos próximos 7 dias (inclui hoje).
const semana = [];
for (let k = 0; k < 7; k++) semana.push(await statusDoDia(addYmd(hojeBR, k)));
const hoje = semana[0];
// Próximo dia aberto: primeiro aberto na semana; se nenhum, varre +14.
let proximo = semana.find((s) => s.aberto) || null;
if (!proximo) for (let k = 7; k <= 21; k++) { const s = await statusDoDia(addYmd(hojeBR, k)); if (s.aberto) { proximo = s; break; } }
// Próximos feriados (45 dias) — clínica (1 query) + nacionais (em memória).
const feriados = [];
let excMap = new Map();
try {
const { rows } = await pool.query(
`SELECT data, nome, fecha, recorrente FROM feriados WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) {
const ds = (r.data instanceof Date) ? ymd(r.data) : String(r.data).slice(0, 10);
excMap.set(ds, r);
if (r.recorrente === 1) excMap.set('R' + ds.slice(5), r);
}
} catch { /* tabela pode não existir */ }
for (let k = 0; k < 45; k++) {
const str = addYmd(hojeBR, k);
const exc = excMap.get(str) || excMap.get('R' + str.slice(5));
if (exc) { if (exc.fecha !== 0) feriados.push({ data: str, nome: exc.nome, origem: 'clinica' }); continue; }
const nac = feriadoNacionalNome(str);
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
}
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── POST /pedido ──────────────────────────────────────────────────────────────
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
// nome_cliente, telefone?, procedimento? }
router.post('/pedido', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
try {
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
// Resolve o dentista pelo nome (opcional).
let dentistaId = null;
if (b.dentista_nome) {
const { rows } = await pool.query(
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
[b.clinica_id, b.dentista_nome]);
dentistaId = rows[0]?.id || null;
}
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /duvida ──────────────────────────────────────────────────────────────
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
router.post('/duvida', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
try {
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /contexto ─────────────────────────────────────────────────────────────
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
// (regras da clínica). Query: clinica_id.
router.get('/contexto', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const { rows: dentistas } = await pool.query(
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
let situacoes = [];
try {
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
situacoes = rows.map((r) => r.texto);
} catch { /* tabela pode não existir ainda */ }
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
let cautelas = [];
try {
const respostas = {};
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
} catch { /* checklist novo/indisponível */ }
res.json({
dentistas: dentistas.map((d) => ({
nome: d.nome,
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
})),
situacoes,
cautelas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /dentista-contato ─────────────────────────────────────────────────────
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
// Query: clinica_id (obrig.), nome? OU dentista_id?
router.get('/dentista-contato', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const params = [clinicaId]; let where = 'clinica_id = $1';
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
const { rows } = await pool.query(
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
if (!rows.length) return res.json({ encontrado: false });
const d = rows[0];
const tel = String(d.telefone || '').replace(/\D/g, '');
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /book ────────────────────────────────────────────────────────────────
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
router.post('/book', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.dentista_id || !b.start) {
return res.status(400).json({ error: 'clinica_id, dentista_id e start obrigatórios' });
}
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
const client = await pool.connect();
try {
await client.query('BEGIN');
const conflict = await findConflict(client, b.dentista_id, b.start, end);
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário já ocupado.', conflict_id: conflict.id }); }
// Vincula paciente pelo telefone (escopo da clínica; casa pelos últimos 8 dígitos).
let pacienteId = null;
let pacienteCriado = false;
if (b.paciente_celular) {
const tel = String(b.paciente_celular).replace(/\D/g, '').slice(-8);
if (tel) {
const { rows } = await client.query(
`SELECT id FROM pacientes
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2 LIMIT 1`,
[b.clinica_id, tel]);
pacienteId = rows[0]?.id || null;
}
}
// Não encontrado → cria um paciente mínimo (lead do WhatsApp vira paciente).
if (!pacienteId && b.paciente_nome) {
const pid = `pac_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
await client.query(
'INSERT INTO pacientes (id, nome, telefone, clinica_id) VALUES ($1,$2,$3,$4)',
[pid, b.paciente_nome, b.paciente_celular || null, b.clinica_id]);
pacienteId = pid;
pacienteCriado = true;
}
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1 AND clinica_id = $2', [b.dentista_id, b.clinica_id]);
const setorId = dd[0]?.setor_id || null;
const id = `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const { rows: ins } = await client.query(
`INSERT INTO agendamentos
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11)
RETURNING id, start_time, end_time, dentistaid`,
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]);
await client.query('COMMIT');
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally {
client.release();
}
});
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
function idadeDe(dob) {
if (!dob) return null;
const s = String(dob).trim();
let d = null, m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) d = new Date(+m[1], +m[2] - 1, +m[3]);
else { m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) d = new Date(+m[3], +m[2] - 1, +m[1]); }
if (!d || isNaN(d.getTime())) return null;
const n = new Date(); let a = n.getFullYear() - d.getFullYear();
const mo = n.getMonth() - d.getMonth();
if (mo < 0 || (mo === 0 && n.getDate() < d.getDate())) a--;
return a >= 0 && a < 130 ? a : null;
}
// GET /pacientes/lookup?clinica_id=&phone= — quem está cadastrado NESTE número (a família).
router.get('/pacientes/lookup', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const tel = soDigitos(req.query.phone).slice(-8);
if (!clinicaId || !tel) return res.status(400).json({ error: 'clinica_id e phone obrigatórios' });
try {
const { rows } = await pool.query(
`SELECT id, nome, cpf, telefone, datanascimento, convenio, grupofamiliarid, grupofamiliarrelacao
FROM pacientes
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
ORDER BY (grupofamiliarrelacao = 'titular' OR grupofamiliarrelacao IS NULL) DESC, nome`,
[clinicaId, tel]);
res.json({
encontrados: rows.length,
pacientes: rows.map((p) => ({
id: p.id, nome: p.nome, cpf: p.cpf || null,
data_nascimento: p.datanascimento || null, idade: idadeDe(p.datanascimento),
convenio: p.convenio || null, relacao: p.grupofamiliarrelacao || null, grupo_id: p.grupofamiliarid || null,
})),
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /pacientes — cadastra/atualiza. Dedup por CPF; idade por nascimento; grupo familiar
// (dependente vinculado ao titular via grupofamiliarid = id do titular).
router.post('/pacientes', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.nome) return res.status(400).json({ error: 'clinica_id e nome obrigatórios' });
const cpf = soDigitos(b.cpf);
const telefone = b.telefone ? soDigitos(b.telefone) : null;
const idade = idadeDe(b.data_nascimento);
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1) Dedup forte por CPF (identificador confiável — evita duplicar quem já existe).
let existente = null;
if (cpf) {
const { rows } = await client.query(
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, cpf]);
existente = rows[0] || null;
}
// 2) Resolve o RESPONSÁVEL (para dependentes): por id, cpf ou telefone (o titular do número).
let responsavel = null;
if (b.responsavel_id) {
const { rows } = await client.query('SELECT * FROM pacientes WHERE id=$1 AND clinica_id=$2', [b.responsavel_id, b.clinica_id]); responsavel = rows[0] || null;
} else if (b.responsavel_cpf) {
const { rows } = await client.query(`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, soDigitos(b.responsavel_cpf)]); responsavel = rows[0] || null;
} else if (b.responsavel_telefone) {
const { rows } = await client.query(
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
AND (grupofamiliarrelacao='titular' OR grupofamiliarrelacao IS NULL) ORDER BY nome LIMIT 1`,
[b.clinica_id, soDigitos(b.responsavel_telefone).slice(-8)]); responsavel = rows[0] || null;
}
// Dependente = tem responsável OU menor de 18 (padrão) sem marcar eh_titular.
const ehDependente = !!responsavel || (idade != null && idade < 18 && !b.eh_titular);
let relacao = b.relacao || (ehDependente ? (idade != null && idade < 18 ? 'filho(a)' : 'dependente') : (b.eh_titular ? 'titular' : null));
// Telefone do dependente sem número próprio → herda o do responsável (compartilhado).
let telFinal = telefone;
if (ehDependente && !telFinal && responsavel && responsavel.telefone) telFinal = soDigitos(responsavel.telefone);
// 3) Grupo familiar: titular vira cabeça (grupofamiliarid = id do titular) + grupos_familiares.
let grupoId = null;
if (ehDependente && responsavel) {
grupoId = responsavel.grupofamiliarid || responsavel.id;
if (!responsavel.grupofamiliarid) {
await client.query(`UPDATE pacientes SET grupofamiliarid=$1, grupofamiliarrelacao=COALESCE(grupofamiliarrelacao,'titular') WHERE id=$1`, [responsavel.id]);
}
await client.query(`INSERT INTO grupos_familiares (id, nome, responsavel_id, created_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (id) DO NOTHING`, [grupoId, responsavel.nome, responsavel.id]);
}
// 4) Atualiza (dedup) OU cria.
if (existente) {
await client.query(
`UPDATE pacientes SET nome=$2, telefone=COALESCE($3,telefone), email=COALESCE($4,email),
convenio=COALESCE($5,convenio), datanascimento=COALESCE($6,datanascimento),
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao) WHERE id=$1`,
[existente.id, b.nome, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
await client.query('COMMIT');
return res.json({ ok: true, atualizado: true, paciente: { id: existente.id, nome: b.nome, relacao, grupo_id: grupoId, idade } });
}
const id = `pac_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await client.query(
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[id, b.clinica_id, b.nome, cpf || null, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
await client.query('COMMIT');
res.json({ ok: true, criado: true, paciente: { id, nome: b.nome, cpf: cpf || null, relacao, grupo_id: grupoId, idade, dependente: ehDependente } });
} catch (e) {
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
res.status(500).json({ error: e.message });
} finally { client.release(); }
});
return router;
}
+404
View File
@@ -0,0 +1,404 @@
// Config de horários/feriados — API para o FRONTEND do scoreodonto (dono/dentista).
// Auth: JWT do scoreodonto (Authorization: Bearer). Ownership:
// - Horário da CLÍNICA + feriados → só o DONO (clinicas.owner_id / vínculo dono).
// - Horário do DENTISTA → o próprio dentista (dentistas.usuario_id) OU o dono.
//
// Montado em /api/nw/agenda-config pelo registerNewwhats.
import express from 'express';
import jwt from 'jsonwebtoken';
import { feriadosNacionais } from './feriados.js';
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
function authFromReq(req) {
try {
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
if (!tk) return null;
const p = jwt.verify(tk, process.env.JWT_SECRET);
return { userId: p.userId || null, email: (p.email || '').toLowerCase() || null };
} catch { return null; }
}
// É dono do workspace (clínica)? owner_id direto OU vínculo dono/admin.
async function isDono(pool, clinicaId, userId) {
if (!clinicaId || !userId) return false;
try {
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl[0]?.owner_id && cl[0].owner_id === userId) return true;
const { rows: v } = await pool.query(
`SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2
AND role IN ('donoclinica','donoconsultorio','admin') LIMIT 1`, [clinicaId, userId]);
return v.length > 0;
} catch { return false; }
}
const uid = (p) => `${p}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
const hhmm = (v) => { const m = String(v ?? '').match(/^(\d{1,2}):(\d{2})/); return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null; };
export function createAgendaConfig(pool) {
const router = express.Router();
router.use((req, res, next) => {
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida.' });
req.nwUser = auth;
next();
});
// ── Horário de FUNCIONAMENTO da clínica (dono) ────────────────────────────────
router.get('/clinica/:clinicaId/horarios', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
FROM clinicas_horarios WHERE clinica_id = $1 ORDER BY dia_semana, hora_inicio`,
[req.params.clinicaId]);
res.json({ horarios: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui TODA a grade da clínica (mais simples e previsível que patch por dia).
// Body: { horarios: [{ dia_semana, hora_inicio, hora_fim }] }
router.put('/clinica/:clinicaId/horarios', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode configurar o horário da clínica.' });
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM clinicas_horarios WHERE clinica_id = $1', [clinicaId]);
for (const h of linhas) {
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
await client.query(
`INSERT INTO clinicas_horarios (id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ($1,$2,$3,$4,$5,1)`, [uid('ch'), clinicaId, dow, ini, fim]);
}
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Horário do DENTISTA (o próprio dentista ou o dono) ────────────────────────
router.get('/dentista/:dentistaId/horarios', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
to_char(hora_fim,'HH24:MI') AS hora_fim,
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
[req.params.dentistaId]);
res.json({ horarios: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.put('/dentista/:dentistaId/horarios', async (req, res) => {
const dentistaId = req.params.dentistaId;
let dent;
try { const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]); dent = rows[0]; }
catch (e) { return res.status(500).json({ error: e.message }); }
if (!dent) return res.status(404).json({ error: 'Dentista não encontrado.' });
const ehProprio = dent.usuario_id && dent.usuario_id === req.nwUser.userId;
const ehDono = await isDono(pool, dent.clinica_id, req.nwUser.userId);
if (!ehProprio && !ehDono)
return res.status(403).json({ error: 'Só o próprio dentista (ou o dono) pode configurar estes horários.' });
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM dentistas_horarios WHERE dentista_id = $1', [dentistaId]);
for (const h of linhas) {
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
await client.query(
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
}
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Folgas/férias do DENTISTA por data (o próprio dentista ou o dono) ─────────
async function guardDentista(pool, dentistaId, user) {
const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
const dent = rows[0];
if (!dent) return { ok: false, code: 404, msg: 'Dentista não encontrado.' };
const ehProprio = dent.usuario_id && dent.usuario_id === user.userId;
const ehDono = await isDono(pool, dent.clinica_id, user.userId);
if (!ehProprio && !ehDono) return { ok: false, code: 403, msg: 'Só o próprio dentista (ou o dono) pode alterar.' };
return { ok: true, dent };
}
router.get('/dentista/:dentistaId/folgas', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo
FROM dentistas_folgas WHERE dentista_id = $1 AND data_fim >= CURRENT_DATE ORDER BY data_inicio`,
[req.params.dentistaId]);
res.json({ folgas: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Body: { data_inicio (YYYY-MM-DD), data_fim? (def = inicio), motivo? }
router.post('/dentista/:dentistaId/folgas', async (req, res) => {
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
if (!g.ok) return res.status(g.code).json({ error: g.msg });
const b = req.body || {};
if (!b.data_inicio) return res.status(400).json({ error: 'data_inicio obrigatória' });
const fim = b.data_fim && b.data_fim >= b.data_inicio ? b.data_fim : b.data_inicio;
try {
const { rows } = await pool.query(
`INSERT INTO dentistas_folgas (id, dentista_id, clinica_id, data_inicio, data_fim, motivo)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo`,
[uid('fol'), req.params.dentistaId, g.dent.clinica_id, b.data_inicio, fim, b.motivo || null]);
res.status(201).json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/dentista/:dentistaId/folgas/:id', async (req, res) => {
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
if (!g.ok) return res.status(g.code).json({ error: g.msg });
try { await pool.query('DELETE FROM dentistas_folgas WHERE id = $1 AND dentista_id = $2', [req.params.id, req.params.dentistaId]); res.json({ ok: true }); }
catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Feriados/fechamentos (dono) ───────────────────────────────────────────────
// Lista os NACIONAIS (computados) + as exceções da clínica de um ano.
router.get('/clinica/:clinicaId/feriados', async (req, res) => {
const clinicaId = req.params.clinicaId;
const ano = Number(req.query.ano) || new Date().getFullYear();
try {
const nac = Object.entries(feriadosNacionais(ano)).map(([data, nome]) => ({ data, nome, origem: 'nacional' }));
const { rows: exc } = await pool.query(
`SELECT id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha,
to_char(hora_inicio,'HH24:MI') AS hora_inicio, to_char(hora_fim,'HH24:MI') AS hora_fim, recorrente
FROM feriados WHERE clinica_id = $1 AND (extract(year from data) = $2 OR recorrente = 1) ORDER BY data`,
[clinicaId, ano]);
res.json({ nacionais: nac, excecoes: exc.map((e) => ({ ...e, origem: 'clinica' })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Cria uma exceção da clínica (fechamento/ponte/horário especial). Dono.
// Body: { data (YYYY-MM-DD), nome, fecha?(bool), hora_inicio?, hora_fim?, recorrente?(bool) }
router.post('/clinica/:clinicaId/feriados', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode configurar feriados/fechamentos.' });
const b = req.body || {};
if (!b.data || !b.nome) return res.status(400).json({ error: 'data e nome obrigatórios' });
const fecha = b.fecha === false ? 0 : 1;
const ini = fecha ? null : hhmm(b.hora_inicio);
const fim = fecha ? null : hhmm(b.hora_fim);
try {
const { rows } = await pool.query(
`INSERT INTO feriados (id, clinica_id, data, nome, fecha, hora_inicio, hora_fim, recorrente)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha, recorrente`,
[uid('fer'), clinicaId, b.data, b.nome, fecha, ini, fim, b.recorrente ? 1 : 0]);
res.status(201).json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.delete('/clinica/:clinicaId/feriados/:id', async (req, res) => {
if (!(await isDono(pool, req.params.clinicaId, req.nwUser.userId)))
return res.status(403).json({ error: 'Apenas o dono pode remover feriados/fechamentos.' });
try {
await pool.query('DELETE FROM feriados WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
try {
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
res.json({ situacoes: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui toda a lista. Body: { situacoes: [texto] }
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
let i = 0;
for (const s of linhas) {
const t = String(s || '').trim(); if (!t) continue;
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
async function contagens(clinicaId) {
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
return { dentistas, pacientes };
}
async function respostasChecklist(clinicaId) {
const map = {};
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
return map;
}
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
try {
const map = await respostasChecklist(clinicaId);
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
const respostas = req.body?.respostas || {};
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const q of CHECKLIST_QUESTOES) {
if (!(q.chave in respostas)) continue;
const v = respostas[q.chave];
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
await client.query(
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
[clinicaId, q.chave, r]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
try {
const counts = await contagens(clinicaId);
const respostas = await respostasChecklist(clinicaId);
const config = pendenciasDeConfig(respostas, counts);
let horarios = 0, duvidas = 0;
try {
const { rows } = await pool.query(
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
} catch { /* tabela nova */ }
res.json({
contagens: counts,
config,
operacionais: { horarios, duvidas, total: horarios + duvidas },
total: config.length + horarios + duvidas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
async function ehMembro(clinicaId, userId) {
if (await isDono(pool, clinicaId, userId)) return true;
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
catch { return false; }
}
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const status = req.query.status ? String(req.query.status) : 'pendente';
try {
const { rows } = await pool.query(
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
[req.params.clinicaId, status]);
res.json({ pedidos: rows, total: rows.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Confirma → cria o agendamento real e marca o pedido como confirmado.
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
const ped = rows[0];
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
const dentistaId = req.body?.dentista_id || ped.dentista_id;
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
const hi = String(ped.hora_inicio).slice(0, 5);
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await client.query(
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
[agId, req.nwUser.userId, req.params.id]);
await client.query('COMMIT');
res.json({ ok: true, agendamento_id: agId });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, nome, usuario_id, ativo FROM dentistas WHERE clinica_id = $1 ORDER BY ordem NULLS LAST, nome`,
[req.params.clinicaId]);
res.json({ dentistas: rows.map((d) => ({ ...d, eh_voce: d.usuario_id === req.nwUser.userId })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
return router;
}
+118
View File
@@ -0,0 +1,118 @@
// Schema (idempotente) das tabelas de horário/feriado usadas pela Secretária.
// Chamado uma vez no registerNewwhats. Espelha os tipos de `dentistas_horarios`.
//
// clinicas_horarios — horário de FUNCIONAMENTO da clínica (config do DONO).
// feriados — EXCEÇÕES da clínica (fechamento/ponte/horário especial);
// os feriados NACIONAIS são computados (ver feriados.js).
export async function ensureAgendaSchema(pool) {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS clinicas_horarios (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
dia_semana integer NOT NULL, -- 0=domingo … 6=sábado
hora_inicio time without time zone NOT NULL,
hora_fim time without time zone NOT NULL,
ativo smallint DEFAULT 1
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_clinicas_horarios_clinica ON clinicas_horarios (clinica_id)`);
await pool.query(`
CREATE TABLE IF NOT EXISTS feriados (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
data date NOT NULL,
nome varchar NOT NULL,
fecha smallint DEFAULT 1, -- 1=fechado o dia; 0=abre em horário especial
hora_inicio time without time zone, -- horário especial (quando fecha=0)
hora_fim time without time zone,
recorrente smallint DEFAULT 0, -- 1=repete todo ano (mesmo dia/mês)
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feriados_clinica_data ON feriados (clinica_id, data)`);
// ── dentistas_folgas — folga/férias/indisponibilidade por DATA do dentista.
// O dentista (ou o dono) marca dias em que NÃO atende; o /slots pula.
await pool.query(`
CREATE TABLE IF NOT EXISTS dentistas_folgas (
id varchar PRIMARY KEY,
dentista_id varchar NOT NULL,
clinica_id varchar,
data_inicio date NOT NULL,
data_fim date NOT NULL, -- inclusive; = data_inicio p/ 1 dia
motivo varchar,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_dentistas_folgas_dent ON dentistas_folgas (dentista_id, data_inicio, data_fim)`);
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
// humana. Nulo = sem zona cinza (só a janela livre).
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
// (vira agendamento) ou recusa.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_pedidos (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
dentista_id varchar,
sala_id varchar,
data date NOT NULL,
hora_inicio time without time zone NOT NULL,
hora_fim time without time zone,
paciente_nome varchar,
paciente_telefone varchar,
procedimento varchar,
origem varchar DEFAULT 'secretaria_ia',
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
agendamento_id varchar, -- preenchido ao confirmar
created_at timestamptz DEFAULT now(),
resolved_at timestamptz,
resolved_by varchar
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_situacoes (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
texto text NOT NULL,
ativo smallint DEFAULT 1,
ordem integer DEFAULT 0,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_checklist (
clinica_id varchar NOT NULL,
chave varchar NOT NULL,
resposta smallint,
updated_at timestamptz DEFAULT now(),
PRIMARY KEY (clinica_id, chave)
)`);
return true;
} catch (e) {
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
console.error('[newwhats] ensureAgendaSchema falhou:', e.message);
return false;
}
}
+25
View File
@@ -0,0 +1,25 @@
// Middleware de auth das rotas /nw/* — valida a integration_key enviada pelo
// motor NewWhats. Aceita header x-nw-key ou Authorization: Bearer <key>.
import { timingSafeEqual } from 'crypto'
import { getConfig } from './config.js'
export function nwAuth(req, res, next) {
const key = req.headers['x-nw-key']
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()
if (!key) {
return res.status(401).json({ error: 'Chave de integração ausente. Use o header x-nw-key.' })
}
const stored = getConfig().integrationKey
if (!stored) {
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_INTEGRATION_KEY).' })
}
const a = Buffer.from(stored)
const b = Buffer.from(key)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'Chave de integração inválida.' })
}
next()
}
+99
View File
@@ -0,0 +1,99 @@
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
//
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
// (cautelas) consomem esta mesma definição para nunca divergirem.
export const CHECKLIST_QUESTOES = [
{
chave: 'multi_dentista',
pergunta: 'A clínica tem mais de um dentista?',
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
},
{
chave: 'pacientes_cadastrados',
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
ajuda: 'Mesmo que sim, o cadastro pode estar INCOMPLETO (nem todos os familiares). A Secretária confirma a identidade antes de agendar e encaminha ao humano quando não reconhece quem fala.',
},
{
chave: 'telefone_compartilhado',
pergunta: 'Um mesmo telefone pode ser de vários pacientes (família)?',
ajuda: 'Ex.: o número está no cadastro do filho, mas quem manda mensagem é a mãe. Se sim, a Secretária SEMPRE confirma com quem está falando e para quem é o atendimento — nunca assume pelo número.',
},
{
chave: 'dentista_fixo',
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
},
];
const sim = (r, k) => r[k] === 1;
const nao = (r, k) => r[k] === 0;
// Pendências de CONFIG do dono: a declaração não bate com o banco.
// counts = { dentistas, pacientes }.
export function pendenciasDeConfig(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const P = [];
if (sim(r, 'multi_dentista') && dentistas <= 1)
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
if (nao(r, 'multi_dentista') && dentistas > 1)
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
if (sim(r, 'telefone_compartilhado'))
P.push({ chave: 'telefone_compartilhado', severidade: 'media', titulo: 'Complete o cadastro de cada familiar',
detalhe: 'Como um mesmo telefone pode ser de vários pacientes (ex.: cadastrado só no filho, mas quem fala é a mãe), a Secretária vai SEMPRE confirmar quem está falando e não assume pelo número. Mantenha cada familiar cadastrado e vinculado ao número para ela acertar mais e encaminhar menos ao humano.' });
return P;
}
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
export function cautelasParaIA(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const C = [];
const multi = sim(r, 'multi_dentista') || dentistas > 1;
const temPacientes = pacientes > 0 || sim(r, 'pacientes_cadastrados');
const telShared = sim(r, 'telefone_compartilhado') || (temPacientes && r['telefone_compartilhado'] == null);
if (multi)
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
if (sim(r, 'multi_dentista') && dentistas <= 1)
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
if (sim(r, 'dentista_fixo'))
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
// Identidade de quem fala — o cadastro é incompleto e o telefone é ambíguo.
if (temPacientes)
C.push('IDENTIDADE: o cadastro pode estar INCOMPLETO. Ao achar um paciente pelo telefone, NÃO assuma que é quem está falando — confirme o NOME de quem fala e PARA QUEM é o atendimento antes de qualquer coisa.');
if (telShared)
C.push('TELEFONE COMPARTILHADO: um mesmo número pode ser de vários da família (ex.: está no cadastro do filho, mas quem fala é a mãe; ou perguntam pelo tratamento do marido). SEMPRE pergunte com quem você fala e de quem é o atendimento; se a pessoa/atendimento não bater com o cadastro do número, encaminhe ao humano — não agende no paciente errado.');
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
// Profissional citado que não existe na lista de dentistas (sempre possível).
C.push('PROFISSIONAL NÃO CADASTRADO: se o cliente citar um dentista/profissional que NÃO está na lista de dentistas acima (ex.: prótese com um Dr. de fora), NÃO invente nem agende — encaminhe ao humano com encaminhar_humano e diga NO RESUMO que o profissional citado não está cadastrado, para o dono cadastrá-lo.');
return C;
}
+64
View File
@@ -0,0 +1,64 @@
// Config da integração com o motor NewWhats.
// Fonte: tabela `settings` (id='newwhats_config') — editável pelo super-admin na UI.
// Fallback: variáveis de ambiente (NEWWHATS_*), úteis para bootstrap/deploy.
//
// NEWWHATS_URL / motorUrl URL do motor
// NEWWHATS_INTEGRATION_KEY / integrationKey x-nw-key
// NEWWHATS_WEBHOOK_SECRET / webhookSecret HMAC do webhook
// NEWWHATS_CLINICA_ID / clinicaId clínica que este satélite atende
let cache = null; // { motorUrl, integrationKey, webhookSecret, clinicaId } | null
function fromEnv() {
return {
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
clinicaId: process.env.NEWWHATS_CLINICA_ID || '',
};
}
// DB (se houver valor) tem prioridade; ENV preenche o que faltar.
export function getConfig() {
const env = fromEnv();
if (!cache) return env;
return {
motorUrl: (cache.motorUrl || env.motorUrl || '').replace(/\/$/, ''),
integrationKey: cache.integrationKey || env.integrationKey || '',
webhookSecret: cache.webhookSecret || env.webhookSecret || '',
clinicaId: cache.clinicaId || env.clinicaId || '',
};
}
export function isConfigured() {
const c = getConfig();
return Boolean(c.motorUrl && c.integrationKey);
}
// Carrega a config do banco para o cache (chamado no boot e após salvar).
export async function loadConfigFromDb(pool) {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'newwhats_config'");
cache = rows[0]?.data || {};
} catch {
if (!cache) cache = {};
}
return getConfig();
}
// Persiste no banco e atualiza o cache.
export async function saveConfigToDb(pool, cfg) {
const data = {
motorUrl: (cfg.motorUrl || '').replace(/\/$/, ''),
integrationKey: cfg.integrationKey || '',
webhookSecret: cfg.webhookSecret || '',
clinicaId: cfg.clinicaId || '',
};
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('newwhats_config', 'integration', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[JSON.stringify(data)]
);
cache = data;
return getConfig();
}
+92
View File
@@ -0,0 +1,92 @@
// Feriados nacionais brasileiros (fixos + móveis) + resolução com exceções da
// clínica (tabela `feriados`). Usado pela agenda-bridge para a Secretária saber
// quando é feriado e o /slots pular esses dias.
//
// Fixos: datas nacionais. Móveis: derivados da Páscoa (Computus de Meeus).
// Consciência Negra (20/11) é nacional desde 2024 (Lei 14.759/2023).
const pad = (n) => String(n).padStart(2, '0');
const ymd = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
// Domingo de Páscoa (algoritmo de Meeus/Jones/Butcher) — retorna {mes, dia}.
function pascoa(ano) {
const a = ano % 19;
const b = Math.floor(ano / 100);
const c = ano % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const mes = Math.floor((h + l - 7 * m + 114) / 31);
const dia = ((h + l - 7 * m + 114) % 31) + 1;
return { mes, dia };
}
// Soma dias a uma data (UTC, evita fuso) → 'YYYY-MM-DD'.
function addDias(y, m, d, delta) {
const base = new Date(Date.UTC(y, m - 1, d));
base.setUTCDate(base.getUTCDate() + delta);
return ymd(base.getUTCFullYear(), base.getUTCMonth() + 1, base.getUTCDate());
}
// Mapa { 'YYYY-MM-DD': 'Nome do feriado' } dos feriados NACIONAIS do ano.
export function feriadosNacionais(ano) {
const p = pascoa(ano);
const py = ano, pm = p.mes, pd = p.dia;
const map = {
[ymd(ano, 1, 1)]: 'Confraternização Universal',
[ymd(ano, 4, 21)]: 'Tiradentes',
[ymd(ano, 5, 1)]: 'Dia do Trabalho',
[ymd(ano, 9, 7)]: 'Independência do Brasil',
[ymd(ano, 10, 12)]: 'Nossa Senhora Aparecida',
[ymd(ano, 11, 2)]: 'Finados',
[ymd(ano, 11, 15)]: 'Proclamação da República',
[ymd(ano, 11, 20)]: 'Consciência Negra',
[ymd(ano, 12, 25)]: 'Natal',
// Móveis (relativos ao Domingo de Páscoa)
[addDias(py, pm, pd, -48)]: 'Carnaval (segunda-feira)',
[addDias(py, pm, pd, -47)]: 'Carnaval (terça-feira)',
[addDias(py, pm, pd, -2)]: 'Sexta-feira Santa',
[addDias(py, pm, pd, 60)]: 'Corpus Christi',
};
return map;
}
// Nome do feriado nacional numa data 'YYYY-MM-DD', ou null.
export function feriadoNacionalNome(dateStr) {
const ano = Number(String(dateStr).slice(0, 4));
if (!ano) return null;
return feriadosNacionais(ano)[dateStr] ?? null;
}
// Resolve o feriado de uma data para uma clínica: nacional OU exceção da clínica
// (tabela `feriados`, que também pode ANULAR um dia — ver `fecha`). Retorna
// { fecha: bool, nome, hora_inicio?, hora_fim? } ou null (dia normal).
//
// Regras:
// - Exceção da clínica tem prioridade (permite abrir num feriado nacional, ou
// fechar num dia comum, ou definir horário especial).
// - Sem exceção: cai no feriado nacional (fecha o dia inteiro).
export async function resolverFeriado(pool, clinicaId, dateStr) {
try {
const mmdd = String(dateStr).slice(5); // 'MM-DD'
const { rows } = await pool.query(
`SELECT nome, fecha, hora_inicio, hora_fim FROM feriados
WHERE clinica_id = $1
AND (data = $2 OR (recorrente = 1 AND to_char(data,'MM-DD') = $3))
ORDER BY (data = $2) DESC LIMIT 1`,
[clinicaId, dateStr, mmdd]);
if (rows.length) {
const r = rows[0];
return { fecha: r.fecha !== false, nome: r.nome || 'Fechamento', hora_inicio: r.hora_inicio || null, hora_fim: r.hora_fim || null, origem: 'clinica' };
}
} catch { /* tabela pode não existir ainda */ }
const nac = feriadoNacionalNome(dateStr);
if (nac) return { fecha: true, nome: nac, hora_inicio: null, hora_fim: null, origem: 'nacional' };
return null;
}
+182
View File
@@ -0,0 +1,182 @@
// Integração NewWhats (satélite) para o scoreodonto — ESM, adaptada do mercado.
// Registra rotas no app Express existente.
//
// Uso no server.js:
// import { registerNewwhats } from './newwhats/index.js'
// registerNewwhats(app, pool, { superadminGuard })
import { createRoutes } from './routes.js'
import { createWebhookReceiver } from './webhook-receiver.js'
import { createRestProxy, createAccountsRoute, provisionNewwhatsAccount } from './proxy.js'
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
import { Readable } from 'node:stream'
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
export { createWsTunnel } from './proxy.js'
// Provisiona (eager) a conta do email no motor NewWhats, best-effort e idempotente.
// Chamado no cadastro de usuário/clínica do scoreodonto: "assim que a conta nasce
// aqui, já nasce lá — pronta esperando quando a pessoa for mexer com WhatsApp".
export async function provisionNewwhatsAccountEager(pool, email) {
try {
const cfg = getConfig()
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false
return await provisionNewwhatsAccount(pool, cfg, email)
} catch { return false }
}
import { createAgendaBridge } from './agenda-bridge.js'
import { createAgendaConfig } from './agenda-config.js'
import { ensureAgendaSchema } from './agenda-schema.js'
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
// (= chave OK); 401 = chave inválida.
async function testMotorKey(motorUrl, key) {
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, {
headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' },
});
if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' };
return { ok: false, detail: `Motor respondeu ${r.status}` };
} catch (e) {
return { ok: false, detail: `Motor inacessível: ${e.message}` };
}
}
export function registerNewwhats(app, pool, opts = {}) {
// passthrough middleware caso o guard não seja fornecido (dev)
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
// ── Ponte de agenda (motor → agenda real do scoreodonto) ───────────────────
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
app.use('/api/nw/agenda', createAgendaBridge(pool));
// ── Schema de horários/feriados (idempotente) + config (dono/dentista) ─────
// clinicas_horarios + feriados; usados pela agenda-bridge (Secretária) e pelas
// telas de configuração do scoreodonto.
ensureAgendaSchema(pool).catch((e) => console.error('[newwhats] schema agenda:', e?.message));
app.use('/api/nw/agenda-config', createAgendaConfig(pool));
// ── Proxy de MÍDIA: /api/nw/media/* → motor ────────────────────────────────
// A mídia/avatar vivem no motor (Wasabi ou local). O Wasabi é servido por
// /api/storage/view/* (plugin UnifiedStorageProvider, que cobre Wasabi + fallback
// local); a mídia local ANTIGA fica no /media estático. Tentamos o storage do
// plugin primeiro e caímos no /media se der 404. Same-origin no <img>/<video>.
app.use('/api/nw/media', async (req, res) => {
if (req.method !== 'GET') { res.status(405).end(); return; }
const cfg = getConfig();
if (!cfg.motorUrl) { res.status(503).end(); return; }
const rest = req.path.replace(/^\//, '');
const base = cfg.motorUrl.replace(/\/$/, '');
const headers = {};
if (req.headers.range) headers.range = req.headers.range;
const candidates = [`${base}/api/storage/view/${rest}`, `${base}/media/${rest}`];
try {
let r = null;
for (const url of candidates) {
r = await fetch(url, { headers });
if (r.status !== 404) break; // achou (ou erro que não é 404) → usa esse
}
res.status(r.status);
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
const v = r.headers.get(h);
if (v) res.setHeader(h, v);
}
// Mídia é imutável (path único por arquivo) → cache longo no browser.
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'public, max-age=604800, immutable');
if (r.body) Readable.fromWeb(r.body).pipe(res);
else res.end();
} catch (e) {
res.status(502).json({ error: `Mídia indisponível: ${e.message}` });
}
});
// Carrega a config do banco para o cache (não bloqueia o boot).
loadConfigFromDb(pool).then((c) =>
console.log(`[newwhats] config carregada — ${isConfigured() ? 'configurada' : 'AGUARDANDO config'} (clinica=${c.clinicaId || '—'})`)
).catch(() => {});
// ── Config (super-admin) ───────────────────────────────────────────────────
// GET: nunca devolve os segredos em claro — só se estão definidos.
app.get('/api/nw/config', superadminGuard, (_req, res) => {
const c = getConfig();
res.json({
motorUrl: c.motorUrl,
clinicaId: c.clinicaId,
hasIntegrationKey: Boolean(c.integrationKey),
hasWebhookSecret: Boolean(c.webhookSecret),
configured: isConfigured(),
});
});
// PUT: secrets em branco MANTÊM o valor atual (não precisa redigitar).
app.put('/api/nw/config', superadminGuard, async (req, res) => {
try {
const cur = getConfig();
const b = req.body || {};
const next = {
motorUrl: (b.motorUrl ?? cur.motorUrl) || '',
clinicaId: (b.clinicaId ?? cur.clinicaId) || '',
integrationKey: b.integrationKey ? String(b.integrationKey) : cur.integrationKey,
webhookSecret: b.webhookSecret ? String(b.webhookSecret) : cur.webhookSecret,
};
await saveConfigToDb(pool, next);
res.json({ ok: true, configured: isConfigured() });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Aplica um TOKEN de pareamento — auto-configura URL + chave + segredo do webhook.
// Token = "nwpair_" + base64url(JSON({ u: motorUrl, k: integrationKey, s: webhookSecret })).
// A clínica continua sendo escolhida localmente (o motor não conhece os IDs daqui).
app.post('/api/nw/config/token', superadminGuard, async (req, res) => {
try {
let raw = String((req.body || {}).token || '').trim();
raw = raw.replace(/^nwpair[_:]\s*/i, '');
let p;
try { p = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { return res.status(400).json({ error: 'Token inválido (não foi possível decodificar).' }); }
const motorUrl = (p.u || p.url || '').replace(/\/$/, '');
const integrationKey = p.k || p.key || p.integrationKey || '';
const webhookSecret = p.s || p.secret || p.webhookSecret || '';
if (!motorUrl || !integrationKey) {
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
}
// Valida a chave contra o motor ANTES de salvar.
const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey);
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
const cur = getConfig();
await saveConfigToDb(pool, { motorUrl, integrationKey, webhookSecret: webhookSecret || cur.webhookSecret, clinicaId: cur.clinicaId });
res.json({ ok: true, configured: isConfigured(), motorUrl, hasWebhookSecret: Boolean(webhookSecret || cur.webhookSecret), detail });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lista de clínicas para o dropdown.
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
try {
const { rows } = await pool.query('SELECT id, nome_fantasia FROM clinicas ORDER BY nome_fantasia');
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Testa a conexão com o motor (usa a config atual).
app.get('/api/nw/status', superadminGuard, async (_req, res) => {
const c = getConfig();
if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' });
const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey);
res.json({ configured: true, motorOk: ok, detail });
});
// ── Operação ────────────────────────────────────────────────────────────────
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
app.get('/api/nw/accounts', createAccountsRoute(pool)); // seletor multi-conta da inbox (próprias + liberadas) — ANTES do catch-all
app.use('/api/nw/v1', createRestProxy(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
}
+21
View File
@@ -0,0 +1,21 @@
{
"id": "newwhats",
"name": "NewWhats — Integração WhatsApp (Odonto)",
"version": "1.0.0",
"project": "scoreodonto.com",
"description": "Satélite do motor NewWhats adaptado ao domínio odontológico: expõe /nw/* (clínica, especialidades, dentistas, procedimentos, planos) para a Secretária IA, recebe webhooks do motor e faz proxy do inbox. Config por variáveis de ambiente (ESM, sem tabela plugin_configs).",
"runtime": "esm-module",
"entry": "index.js#registerNewwhats",
"features": [
"Rotas /nw/* (dados da clínica para a Secretária IA)",
"sync-knowledge (base de conhecimento odonto → motor)",
"Webhook receiver (message.new, session.status) com HMAC",
"Proxy REST /api/nw/v1/* → motor /api/ext/v1/*"
],
"config_env": [
{ "key": "NEWWHATS_URL", "required": true, "help": "URL do motor NewWhats." },
{ "key": "NEWWHATS_INTEGRATION_KEY", "required": true, "help": "x-nw-key: valida /nw/* e é injetada no proxy. Igual à apiKey criada no motor." },
{ "key": "NEWWHATS_WEBHOOK_SECRET", "required": false, "help": "Segredo HMAC para validar x-nw-signature nos webhooks. Recomendado." },
{ "key": "NEWWHATS_CLINICA_ID", "required": true, "help": "Qual clínica este satélite atende (multi-tenant)." }
]
}
+455
View File
@@ -0,0 +1,455 @@
// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*.
//
// Segurança: valida o JWT do scoreodonto (só usuário logado acessa).
// Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo
// email). Todo usuário da clínica tem conta própria no motor — se o motor não
// conhecer o email, é erro (404 "conta não encontrada"), não há fallback.
// O email vem SEMPRE do token verificado — nunca do cliente.
import net from 'node:net';
import tls from 'node:tls';
import jwt from 'jsonwebtoken';
import { getConfig } from './config.js';
function authFromReq(req) {
try {
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
if (!tk) return null;
const p = jwt.verify(tk, process.env.JWT_SECRET);
const email = (p.email || '').toLowerCase() || null;
if (!email) return null;
return { email, userId: p.userId || null };
} catch { return null; }
}
// Resolve o dono do workspace (clínica OU sala). Retorna o userId dono ou null.
async function resolveOwnerId(pool, clinicaId) {
if (!pool || !clinicaId) return null;
try {
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
if (cl.length) {
if (cl[0].owner_id) return cl[0].owner_id;
// Clínicas antigas sem owner_id: dono = vínculo donoclinica/donoconsultorio.
const { rows: v } = await pool.query(
"SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1",
[clinicaId]);
return v[0]?.usuario_id || null;
}
const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]);
return sl[0]?.owner_usuario_id || null;
} catch { return null; }
}
// Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null.
// Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do
// dono no motor, então falamos com o motor usando o e-mail DELE.
async function resolveOwner(pool, clinicaId) {
const ownerId = await resolveOwnerId(pool, clinicaId);
if (!ownerId) return null;
try {
const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]);
return { ownerId, email: rows[0]?.email || null };
} catch { return { ownerId, email: null }; }
}
// Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp.
// Mapeamento das ações do motor:
// POST /sessions → scan INICIAL (criar) → só o dono
// GET /sessions/:id/qr → re-scan (parear QR) → dono OU can_rescan
// POST /sessions/:id/connect → re-scan (reconectar) → dono OU can_rescan
// DELETE /sessions/:id → excluir → dono OU can_delete
// Retorna { ok: true } ou { ok: false, status, error }.
async function guardSessionAction(pool, req, tail, userId) {
const method = req.method.toUpperCase();
const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/);
if (!m) return { ok: true }; // não é uma rota de sessão
const instanceId = m[1] || null;
const sub = m[2] || null;
const isCreate = method === 'POST' && !instanceId; // POST /sessions
const isRescan = (method === 'GET' && sub === '/qr') ||
(method === 'POST' && sub === '/connect'); // parear/reconectar
const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id
if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true }; // dono: poder total
// Scan inicial é exclusivo do dono — não delegável.
if (isCreate) return { ok: false, status: 403, error: 'Apenas o dono pode conectar um novo WhatsApp.' };
// Re-scan / exclusão: exigem delegação por sessão.
if (!instanceId) return { ok: false, status: 403, error: 'Sessão não identificada.' };
let authz = null;
try {
const { rows } = await pool.query(
'SELECT can_rescan, can_delete FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
[clinicaId, instanceId, userId]);
authz = rows[0] || null;
} catch { /* nega por padrão */ }
if (isRescan && authz?.can_rescan) return { ok: true };
if (isDelete && authz?.can_delete) return { ok: true };
return {
ok: false, status: 403,
error: isDelete
? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.'
: 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.',
};
}
// Auto-provisiona (idempotente) a conta do email no motor, via chave mestra.
// Usado como self-heal quando o motor responde "Conta não encontrada".
export async function provisionNewwhatsAccount(pool, cfg, email) {
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false;
let name = email;
try {
if (pool) {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE lower(email) = $1', [email.toLowerCase()]);
if (rows[0]?.nome) name = rows[0].nome;
}
} catch { /* usa email como nome */ }
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/satellites/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey },
body: JSON.stringify({ email, name }),
});
return r.ok;
} catch { return false; }
}
// Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver
// outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial
// do primeiro sobrenome). Retorna null se não houver nome.
export async function operatorSignature(pool, userId, clinicaId) {
if (!pool || !userId) return null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]);
const full = String(rows[0]?.nome || '').trim();
if (!full) return null;
const parts = full.split(/\s+/);
const first = parts[0];
let ambiguous = false;
if (clinicaId) {
const { rows: m } = await pool.query(
`SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
WHERE v.clinica_id = $1 AND u.id <> $2`, [clinicaId, userId]);
ambiguous = m.some(x => String(x.nome || '').trim().split(/\s+/)[0].toLowerCase() === first.toLowerCase());
}
if (ambiguous && parts.length > 1 && parts[1][0]) {
return `${first} ${parts[1][0].toUpperCase()}.`;
}
return first;
} catch { return null; }
}
// Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com
// delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace.
async function guardAreaAccess(pool, tail, userId, clinicaId) {
const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
if (!isSecretaria && !isInbox) return { ok: true };
if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true };
const col = isSecretaria ? 'can_secretaria' : 'can_inbox';
try {
const { rows } = await pool.query(
`SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND ${col} = true LIMIT 1`,
[clinicaId, userId]);
if (rows.length) return { ok: true };
} catch { /* nega por padrão */ }
return { ok: false, status: 403, error: isSecretaria ? 'Sem acesso à Secretária. Peça autorização ao dono.' : 'Sem acesso ao Inbox. Peça autorização ao dono.' };
}
// Gate de ações DESTRUTIVAS no inbox (apagar conversa / apagar mensagem).
// Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.)
async function guardInboxDestructive(pool, method, tail, userId, clinicaId) {
const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail);
const isClearMsgs = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/?(\?|$)/.test(tail); // limpar conversa (todas as msgs)
const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
if (!isDeleteChat && !isClearMsgs && !isDeleteMsg) return { ok: true };
if (!pool || !clinicaId) return { ok: true };
const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true };
try {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_delete_msg = true LIMIT 1',
[clinicaId, userId]);
if (rows.length) return { ok: true };
} catch { /* nega por padrão */ }
return { ok: false, status: 403, error: 'Sem permissão para apagar conversas/mensagens. Peça autorização ao dono.' };
}
export function createRestProxy(pool) {
return async function restProxy(req, res) {
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const { email, userId } = auth;
const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '');
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
// Enforcement de ownership/permissões antes de repassar ao motor.
const guard = await guardSessionAction(pool, req, tail, userId);
if (!guard.ok) return res.status(guard.status).json({ error: guard.error });
// Gate de acesso às áreas Inbox / Secretária.
const area = await guardAreaAccess(pool, tail, userId, clinicaId);
if (!area.ok) return res.status(area.status).json({ error: area.error });
// Gate de ações destrutivas do inbox (apagar conversa/mensagem).
const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId);
if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error });
// Assinatura do operador: prefixa o texto enviado com "*Nome:*\n" (identifica quem
// respondeu numa caixa compartilhada). O ":" fica DENTRO do negrito para que TODOS
// os dispositivos (contato/outros operadores) e jids vejam "Nome:" em negrito
// consistente — não só a nossa UI. Só no envio de texto por humano.
if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) {
const sig = await operatorSignature(pool, userId, clinicaId);
if (sig) req.body.text = `*${sig}:*\n${req.body.text}`;
}
// Conta-alvo no motor. Por padrão é a do próprio usuário (e-mail do JWT). Mas a
// inbox da clínica vive na conta (tenant) do DONO — então, nas rotas de inbox,
// se o usuário NÃO é dono do workspace e chegou até aqui (guardAreaAccess já
// exigiu can_inbox), falamos com o motor como o dono. Assim, logado como ele
// mesmo, o usuário vê a caixa do número da clínica no seletor — sem trocar de
// login. Só a inbox comuta a conta; demais rotas seguem com o e-mail próprio.
let targetEmail = email;
if (/^\/(inbox|conversations)(\/|$|\?)/.test(tail) && clinicaId) {
const owner = await resolveOwner(pool, clinicaId);
if (owner && owner.ownerId !== userId && owner.email) targetEmail = owner.email;
}
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
// Upload de arquivo grande: o corpo é multipart e NÃO passa pelo body-parser JSON
// (o stream chega intacto). Encaminhamos o stream cru ao motor, preservando o
// Content-Type (com boundary) — sem base64, sem limite de JSON.
const isMultipart = /multipart\/form-data/i.test(req.headers['content-type'] || '');
const opts = { method: req.method, headers: {
'x-nw-key': cfg.integrationKey,
'x-nw-email': targetEmail,
// clínica do workspace ativo (modelo de canal) — repassa ao motor se veio.
...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}),
...(isMultipart
? { 'content-type': req.headers['content-type'] }
: { 'Content-Type': 'application/json' }),
} };
if (isMultipart) {
opts.body = req; // stream cru do upload → motor
opts.duplex = 'half'; // exigido pelo fetch (undici) ao enviar stream
} else if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body);
}
try {
let r = await fetch(url, opts);
let text = await r.text();
// Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente)
// e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona".
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && targetEmail) {
const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
if (ok) { r = await fetch(url, opts); text = await r.text(); }
}
res.status(r.status);
try { res.json(JSON.parse(text)); } catch { res.send(text); }
} catch (e) {
res.status(502).json({ error: `Motor indisponível: ${e.message}` });
}
};
}
// ── GET /api/nw/accounts — contas de WhatsApp que o usuário logado pode operar ──
//
// Alimenta o seletor de número da inbox (multi-conta, SEM trocar de login) e o
// ícone (i) de papéis. Junta: (1) as sessões da PRÓPRIA conta do usuário no motor
// e (2) as sessões LIBERADAS pelo dono (can_inbox por sessão em wa_session_authz),
// buscadas no motor com o e-mail do dono. Cada item traz o papel (sec_numbers).
export function createAccountsRoute(pool) {
return async function accountsRoute(req, res) {
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const { email, userId } = auth;
const motorGet = async (path, asEmail) => {
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': asEmail },
});
if (!r.ok) return [];
const j = await r.json();
return Array.isArray(j) ? j : [];
} catch { return []; }
};
try {
// Papéis: sec_numbers são globais no motor — um fetch, indexado por instance_id.
const numbers = await motorGet('/secretaria/numbers', email);
const roleOf = new Map(numbers.map((n) => [n.instance_id, n]));
const meta = (instanceId, fallbackName) => {
const n = roleOf.get(instanceId) || {};
return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null };
};
const out = [];
const seen = new Set();
const push = (s, extra) => {
if (!s || seen.has(s.id)) return;
seen.add(s.id);
out.push({
instanceId: s.id, phone: s.phone || null, name: s.name || null,
status: s.status || null, avatar: s.avatar || null,
...meta(s.id, s.name), ...extra,
});
};
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor).
for (const s of await motorGet('/sessions', email)) {
push(s, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true });
}
// 2) Sessões LIBERADAS por outros donos (can_inbox por sessão).
const { rows: grants } = await pool.query(
`SELECT DISTINCT clinica_id, instance_id FROM wa_session_authz
WHERE usuario_id = $1 AND can_inbox = true`, [userId]);
const byClinica = new Map();
for (const g of grants) {
if (!byClinica.has(g.clinica_id)) byClinica.set(g.clinica_id, new Set());
byClinica.get(g.clinica_id).add(g.instance_id);
}
for (const [clinicaId, allowed] of byClinica) {
const owner = await resolveOwner(pool, clinicaId);
if (!owner || !owner.email || owner.ownerId === userId) continue; // dono próprio já veio em (1)
let ownerNome = null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]);
ownerNome = rows[0]?.nome || null;
} catch { /* opcional */ }
for (const s of await motorGet('/sessions', owner.email)) {
if (allowed.has(s.id)) push(s, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true });
}
}
res.json(out);
} catch (e) {
res.status(502).json({ error: `Falha ao montar contas: ${e.message}` });
}
};
}
// ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ──
//
// O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda
// sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a
// x-nw-key no handshake com o motor — a chave nunca chega ao browser.
//
// Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então
// o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só
// usuário logado abre o túnel; o token fica local — não é repassado ao motor.
//
// Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a
// mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem
// query — por isso reescrevemos um request-line limpo. O email vem do JWT.
//
// Conta-alvo do stream: por padrão a própria. Se o browser mandar ?clinica= (a
// conta ativa do seletor) e o usuário não for o dono mas tiver can_inbox nela,
// o stream passa a ser o do DONO — assim a caixa compartilhada recebe mensagens
// novas em tempo real. Mesma regra do proxy REST (reescrita de x-nw-email).
export function createWsTunnel(pool) {
return async function wsTunnel(req, clientSocket, head) {
const fail = (statusLine) => {
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ }
clientSocket.destroy();
};
// Auth: JWT do scoreodonto no query string (?token=); conta ativa em ?clinica=.
let token, clinica;
try {
const u = new URL(req.url, 'http://localhost');
token = u.searchParams.get('token');
clinica = u.searchParams.get('clinica');
} catch { return fail('400 Bad Request'); }
let email, userId;
try {
const p = jwt.verify(token, process.env.JWT_SECRET);
email = (p.email || '').toLowerCase();
userId = p.userId || null;
} catch { return fail('401 Unauthorized'); }
if (!email) return fail('401 Unauthorized');
// Reescrita da conta-alvo p/ a caixa compartilhada (só leitura via can_inbox).
let targetEmail = email;
if (clinica && pool && userId) {
try {
const owner = await resolveOwner(pool, clinica);
if (owner && owner.email && owner.ownerId !== userId) {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_inbox = true LIMIT 1',
[clinica, userId]);
if (rows.length) targetEmail = owner.email;
}
} catch { /* mantém a própria conta */ }
}
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable');
let target;
try { target = new URL(cfg.motorUrl); }
catch { return fail('502 Bad Gateway'); }
const isHttps = target.protocol === 'https:';
const host = target.hostname;
const port = Number(target.port) || (isHttps ? 443 : 80);
const onConnect = () => {
// Reescreve o handshake: path EXATO + Host do motor + x-nw-key.
// Preserva os headers do WS (sec-websocket-*, upgrade, connection).
const headers = Object.entries(req.headers)
.filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase()))
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
proxySocket.write(
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`x-nw-key: ${cfg.integrationKey}\r\n` +
`x-nw-email: ${targetEmail}\r\n` +
`${headers}\r\n\r\n`
);
if (head && head.length) proxySocket.write(head);
proxySocket.pipe(clientSocket);
clientSocket.pipe(proxySocket);
};
const proxySocket = isHttps
? tls.connect({ host, port, servername: host }, onConnect)
: net.connect(port, host, onConnect);
proxySocket.on('error', (err) => {
console.error('[newwhats] túnel WS erro:', err.message);
clientSocket.destroy();
});
clientSocket.on('error', () => proxySocket.destroy());
clientSocket.on('close', () => proxySocket.destroy());
proxySocket.on('close', () => clientSocket.destroy());
};
}
+98
View File
@@ -0,0 +1,98 @@
// Rotas /nw/* — a Secretária IA do motor consulta os dados desta clínica.
// Adaptado do satélite mercado para o domínio ODONTO (scoreodonto).
// Todas as rotas exigem a integration_key (nwAuth).
import { Router } from 'express'
import { nwAuth } from './auth.js'
import { getConfig } from './config.js'
const money = (v) =>
v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
// Resolve a clínica-alvo: query ?clinica_id= tem prioridade; senão a do config.
function resolveClinicaId(req) {
return String(req.query.clinica_id || getConfig().clinicaId || '').trim()
}
export function createRoutes(pool) {
const router = Router()
router.use(nwAuth)
const needClinica = (req, res) => {
const id = resolveClinicaId(req)
if (!id) res.status(400).json({ error: 'clinica_id ausente (configure NEWWHATS_CLINICA_ID ou passe ?clinica_id=).' })
return id
}
router.get('/ping', (_req, res) =>
res.json({ ok: true, service: 'scoreodonto', ts: new Date().toISOString() }))
// Dados da clínica
router.get('/clinica', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro, cep
FROM clinicas WHERE id = $1`, [id])
res.json(rows[0] || null)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Especialidades atendidas
router.get('/especialidades', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome, descricao, area FROM especialidades
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
res.json(rows)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Dentistas + horários de atendimento
router.get('/dentistas', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows: dentistas } = await pool.query(
`SELECT id, nome, especialidade FROM dentistas
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
for (const d of dentistas) {
const { rows: hr } = await pool.query(
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
d.horarios = hr
delete d.id
}
res.json(dentistas)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Procedimentos + valor particular + valores por plano
router.get('/procedimentos', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows: procs } = await pool.query(
`SELECT id, nome, descricao, categoria, valorparticular FROM procedimentos
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
for (const p of procs) {
const { rows: vp } = await pool.query(
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
p.valor_particular = money(p.valorparticular)
p.valores_planos = vp.map((x) => ({ plano: x.planonome, valor: money(x.valor) }))
delete p.id; delete p.valorparticular
}
res.json(procs)
} catch (e) { res.status(500).json({ error: e.message }) }
})
// Planos / convênios aceitos
router.get('/planos', async (req, res) => {
const id = needClinica(req, res); if (!id) return
try {
const { rows } = await pool.query(
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [id])
res.json(rows)
} catch (e) { res.status(500).json({ error: e.message }) }
})
return router
}
+135
View File
@@ -0,0 +1,135 @@
// sync-knowledge.js (ODONTO) — monta o nó de conhecimento da Secretária IA com
// os dados reais da clínica e sincroniza com o motor NewWhats.
// Adaptado do satélite mercado. Fluxo:
// 1. Lê dados do banco (clínica, especialidades, dentistas, procedimentos, planos)
// 2. Monta texto estruturado do knowledge node
// 3. PUT/POST no motor (/api/secretaria/*) — rotas internas, sem auth de sessão
import { getConfig } from './config.js'
const money = (v) => v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']
async function motorFetch(motorUrl, method, path, body) {
const res = await fetch(`${motorUrl}/api/secretaria${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) throw new Error(`Motor ${method} /api/secretaria${path}${res.status}: ${(await res.text()).slice(0, 200)}`)
return res.json()
}
export async function buildKnowledgeText(pool, clinicaId) {
const lines = []
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows)
const [clinica] = await q(
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro
FROM clinicas WHERE id = $1`, [clinicaId])
const nome = clinica?.nome_fantasia || 'Nossa clínica odontológica'
lines.push(`=== ${nome.toUpperCase()} ===`)
lines.push(`Clínica odontológica. Atendimento via WhatsApp.`)
if (clinica?.telefone) lines.push(`Telefone: ${clinica.telefone}`)
if (clinica?.logradouro) {
const end = [clinica.logradouro, clinica.numero, clinica.bairro, clinica.cidade, clinica.estado].filter(Boolean).join(', ')
lines.push(`Endereço: ${end}`)
}
lines.push('')
// Especialidades
const esp = await q(
`SELECT nome, descricao FROM especialidades WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (esp.length) {
lines.push(`=== ESPECIALIDADES ATENDIDAS ===`)
for (const e of esp) lines.push(`${e.nome}${e.descricao ? `${e.descricao}` : ''}`)
lines.push('')
}
// Dentistas + horários
const dentistas = await q(
`SELECT id, nome, especialidade FROM dentistas WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (dentistas.length) {
lines.push(`=== DENTISTAS ===`)
for (const d of dentistas) {
lines.push(`${d.nome}${d.especialidade ? ` (${d.especialidade})` : ''}`)
const hr = await q(
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
if (hr.length) {
const fmt = hr.map((h) => `${DIAS[h.dia_semana] ?? h.dia_semana} ${String(h.hora_inicio).slice(0, 5)}-${String(h.hora_fim).slice(0, 5)}`)
lines.push(` Atende: ${fmt.join(' | ')}`)
}
}
lines.push('')
}
// Procedimentos + valores
const procs = await q(
`SELECT id, nome, categoria, valorparticular FROM procedimentos
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
if (procs.length) {
lines.push(`=== PROCEDIMENTOS E VALORES ===`)
for (const p of procs) {
const part = p.valorparticular != null ? ` — particular: ${money(p.valorparticular)}` : ''
lines.push(`${p.nome}${p.categoria ? ` [${p.categoria}]` : ''}${part}`)
const vp = await q(
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
const comValor = vp.filter((x) => x.valor != null)
if (comValor.length) lines.push(` Por plano: ${comValor.map((x) => `${x.planonome} ${money(x.valor)}`).join(', ')}`)
}
lines.push('')
}
// Planos / convênios
const planos = await q(
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [clinicaId])
if (planos.length) {
lines.push(`=== PLANOS / CONVÊNIOS ACEITOS ===`)
for (const pl of planos) {
const desc = pl.descontopadrao ? ` — desconto padrão ${pl.descontopadrao}%` : ''
lines.push(`${pl.nome}${pl.tipo ? ` (${pl.tipo})` : ''}${desc}`)
}
lines.push('')
}
lines.push(`=== ATENDIMENTO ===`)
lines.push(`Para agendar, remarcar ou tirar dúvidas, fale pelo WhatsApp.`)
lines.push(`Dúvidas sobre valores, convênios ou disponibilidade: informe que vai encaminhar para a equipe.`)
return lines.join('\n')
}
export async function syncKnowledge(pool) {
const cfg = getConfig()
if (!cfg.motorUrl) throw new Error('NEWWHATS_URL não configurado')
if (!cfg.clinicaId) throw new Error('NEWWHATS_CLINICA_ID não configurado')
const knowledgeText = await buildKnowledgeText(pool, cfg.clinicaId)
const agents = await motorFetch(cfg.motorUrl, 'GET', '/agents')
if (!agents.length) throw new Error('Nenhum agente no motor. Crie um agente na Secretária IA.')
const agent = agents.find((a) => a.active) || agents[0]
const nodes = await motorFetch(cfg.motorUrl, 'GET', `/agents/${agent.id}/nodes`)
const kn = nodes.find((n) => n.type === 'knowledge')
let nodeId
const title = 'Base de Conhecimento — Clínica Odontológica'
if (kn) {
await motorFetch(cfg.motorUrl, 'PUT', `/nodes/${kn.id}`, {
title, content: knowledgeText, active: true, sort_order: kn.sort_order ?? 3,
})
nodeId = kn.id
} else {
const created = await motorFetch(cfg.motorUrl, 'POST', `/agents/${agent.id}/nodes`, {
type: 'knowledge', title, content: knowledgeText, sort_order: 3,
})
nodeId = created.id
}
return {
ok: true, agent_id: agent.id, agent: agent.name, node_id: nodeId,
chars: knowledgeText.length, preview: knowledgeText, synced_at: new Date().toISOString(),
}
}
+52
View File
@@ -0,0 +1,52 @@
// Receptor de webhooks do motor NewWhats (motor → satélite).
// POST /api/webhook/newwhats
// Header x-nw-signature: sha256=HMAC-SHA256(webhook_secret, rawBody)
// Eventos: message.new (nova mensagem WA), session.status (conexão da instância)
//
// Versão ENXUTA p/ scoreodonto: valida HMAC, registra o evento e deixa ganchos
// (TODO) para captura de lead / auto-resposta. NÃO porta o auto-responder do
// mercado (a Secretária do motor já responde via /secretaria/ask).
import { Router } from 'express'
import { createHmac, timingSafeEqual } from 'crypto'
import { getConfig } from './config.js'
function verifySignature(secret, rawBody, sigHeader) {
if (!sigHeader) return false
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'))
const received = Buffer.from(String(sigHeader))
return expected.length === received.length && timingSafeEqual(expected, received)
}
export function createWebhookReceiver(_pool) {
const router = Router()
router.post('/api/webhook/newwhats', async (req, res) => {
try {
const secret = getConfig().webhookSecret
if (secret) {
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
if (!verifySignature(secret, rawBody, req.headers['x-nw-signature'])) {
return res.status(401).json({ error: 'Assinatura inválida' })
}
} else {
console.warn('[nw-webhook] NEWWHATS_WEBHOOK_SECRET ausente — assinatura ignorada')
}
const { event, data } = req.body || {}
if (event === 'message.new') {
// TODO(odonto): registrar lead / acionar fluxo. Hoje só loga.
console.log(`[nw-webhook] message.new de ${data?.author ?? data?.pushName ?? '?'}: ${String(data?.body ?? '').slice(0, 60)}`)
} else if (event === 'session.status') {
console.log(`[nw-webhook] session.status → ${data?.instanceId}: ${data?.status}`)
} else {
console.log(`[nw-webhook] evento não tratado: ${event}`)
}
res.json({ ok: true })
} catch (e) {
console.error('[nw-webhook] erro:', e.message)
res.status(500).json({ error: e.message })
}
})
return router
}
+1903 -152
View File
File diff suppressed because it is too large Load Diff
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# =============================================================================
# Build + sobe o ambiente de DEV (porta 8020 / dev.scoreodonto.com) carimbando
# a VERSÃO a partir do git — para que /api/version (e o rótulo no app) deixe de
# mostrar apenas "dev".
#
# Uso (NA VPS): ./dev-build.sh
#
# Diferença p/ produção: a prod consome imagem versionada e injeta a tag em
# runtime (deploy-prod.sh). Em DEV buildamos local, então derivamos a versão do
# git AQUI (no host, onde o .git existe) e passamos como build-args do compose.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
export DOCKER_HOST=unix:///run/user/1000/docker.sock
# Versão semântica + commit + carimbo de build, derivados do git.
# APP_VERSION -> ex.: v1.0.18-16-g6adf353 (tag mais próxima + distância + sha)
# GIT_COMMIT -> sha curto
export APP_VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
export GIT_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo dev)"
export BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
export APP_ENV="DEV"
echo "──────────────────────────────────────────────────────────────"
echo " Build ScoreOdonto → DEV (porta 8020)"
echo " APP_VERSION = $APP_VERSION"
echo " GIT_COMMIT = $GIT_COMMIT"
echo " BUILD_TIME = $BUILD_TIME"
echo "──────────────────────────────────────────────────────────────"
docker compose up -d --build scoreodonto-backend scoreodonto-frontend nginx
echo
echo -n " versão no ar (DEV): "
sleep 8
curl -s --max-time 10 http://localhost:8020/api/version || echo "(ainda subindo — confira em alguns segundos)"
echo
echo "✅ DEV buildado com versão carimbada."
+47
View File
@@ -0,0 +1,47 @@
# Agenda & Coleta do laboratório
> Desenho + implementação (24/jun/2026). Gap apontado na auditoria: o lab não tem agenda de
> produção nem coleta logística (só "próximas entregas" por prazo e custódia de posse).
## Conceito
- **Coleta** = o lab **busca** o trabalho na clínica (trabalho vai p/ o laboratório).
- **Entrega** = o lab **leva** o trabalho de volta (trabalho volta p/ a clínica).
- Cada agendamento tem data/hora, endereço, responsável, observação e status
(`agendada | realizada | cancelada`). Ligado a uma OS.
- **Integra com a custódia:** ao marcar `realizada`, a custódia muda automaticamente
(coleta→`laboratorio`, entrega→`clinica`) e entra no histórico da OS. Sem dado duplicado.
## Modelo
```sql
CREATE TABLE protese_coleta (
id TEXT PRIMARY KEY,
os_id TEXT NOT NULL,
clinica_id TEXT,
laboratorio_id TEXT,
protetico_id TEXT,
tipo TEXT NOT NULL, -- coleta | entrega
data_hora TIMESTAMPTZ,
endereco TEXT,
responsavel TEXT,
observacao TEXT,
status TEXT DEFAULT 'agendada', -- agendada | realizada | cancelada
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
## Endpoints
- `POST /api/protese/os/:id/coleta` — agenda (clínica ou lab).
- `GET /api/protese/lab/agenda` — coletas/entregas do lab (cronológica) + próximas entregas por prazo.
- `POST /api/protese/coleta/:id/status``realizada` (move a custódia) | `cancelada`.
- GET detalhe da OS passa a trazer `coletas[]`.
## UI
- **Lab — tela Agenda** (`lab-agenda`, novo item de menu): linha do tempo de coletas/entregas
agendadas + entregas previstas (prazo da OS), com ação "marcar realizada".
- **OS (aba Monitoramento)** — agendar coleta/entrega ao lado da custódia (mesmo contexto físico).
## Decisões
- 1 coleta por OS (MVP). Agrupar várias OS numa rota/motoboy fica como evolução.
- `realizada` é a fonte única do movimento físico (atualiza custódia) — evita registrar custódia
e coleta em separado.
+158
View File
@@ -0,0 +1,158 @@
# Análise — Área da Recepção / Coordenação de Tratamentos (Treatment Coordinator)
> Gatilho (23/jun/2026): `recep.consulttclinic@gmail.com` lançou uma prótese mas **não tinha
> a aba para acompanhá-la**. Análise do gap + recomendação fundamentada em modelos validados
> de software odontológico. Conecta com `FASE2-PROTETICO-AREA.md` (o outro lado do fluxo).
---
## 1. Diagnóstico (DEV `scoreodonto_dev`)
| Entidade | Achado |
|---|---|
| Dono | `consulttclinic@gmail.com`*Rui Cesar*, `donoclinica` |
| Funcionária | `recep.consulttclinic@gmail.com` (`u_1780962424310_cqsxn`), role `funcionario`, vínculo na **CONSULTT CLINIC LTDA** (`c_1780871001549_koqa3o`) |
| Cargo | função "Recepcionista" (`fnc_1780962382528_m28go`) |
| Permissões (antes) | `["agenda","ortodontia","contratos","tratamentos","pacientes","lancar-gto"]` |
| Prótese lançada | OS `pos_1782228267898_zgfzn` — Prótese Total, paciente ABIGAIR, R$600, protético "Zeca Andrade Fake", status `solicitado`, **via GTO** (`gto_item_id=QOJWR6D`) |
**Gap exato:** o cargo tinha `lancar-gto` (lançar) mas **não** `proteses` (acompanhar). O RBAC da
`Sidebar.tsx` é literal (`return wsPerms.includes(item.id)`), então a OS criada por ela ficava
**invisível** para ela própria. A Fase 1 (GTO→OS) funcionou; o problema era de **configuração de
cargo**, não de arquitetura.
---
## 2. Modelos validados (referência externa)
- **Papel = "Treatment Coordinator" + Front Desk.** É a ponte entre paciente e equipe clínica:
sequencia planos multi-etapa, conduz o aceite do caso (case acceptance), arranjo financeiro e
follow-up até a conclusão — exatamente "gerir tratamentos" de ponta a ponta.
- **RBAC por papel é o padrão.** Permissões mapeadas a papéis, não a indivíduos; o front desk
recebe agenda/cadastro/cobrança, tipicamente **sem notas clínicas**. O ScoreOdonto já faz isso
via `funcoes.permissoes[]`.
- **Prótese = "Lab Case", acompanhada pelo front desk.** No Dentrix Lab Case Manager: clínico cria
a prescrição → status *Sent***o front desk muda para *Received* quando volta do lab**
*Finished* ao assentar. Ou seja: acompanhar a OS de prótese é, por modelo consagrado, **atribuição
da recepção** — o acesso que faltava.
Fontes:
- The Role of a Treatment Coordinator — https://www.dentalmanagers.com/blog/role-of-treatment-coordinator-in-dental-practice/
- What Does a Treatment Plan Coordinator Do? — https://www.teero.com/blog/treatment-plan-coordinator
- Dental Software User Permissions (Curve) — https://www.curvedental.com/blog/dental-software-user-permissions
- RBAC in Healthcare — https://www.accountablehq.com/post/role-based-access-control-rbac-in-healthcare-benefits-examples-and-best-practices
- Manage Your Lab Cases in Dentrix — https://magazine.dentrix.com/manage-your-lab-cases-in-dentrix/
- Open Dental — Lab Cases — https://www.opendental.com/manual/labcasemanage.html
---
## 3. Ação aplicada — Nível 1 (configuração, sem código)
**Aplicado no DEV em 23/jun/2026.** Função "Recepcionista" da CONSULTT CLINIC LTDA atualizada:
```sql
UPDATE funcoes
SET permissoes = '["agenda","pacientes","tratamentos","ortodontia","proteses","lancar-gto","orcamentos","contratos"]'
WHERE id = 'fnc_1780962382528_m28go'; -- UPDATE 1
```
Acréscimos: **`proteses`** (acompanhar a OS que ela lança) e **`orcamentos`** (case acceptance —
coração do papel TC). Efeito imediato: a OS `pos_1782228267898_zgfzn` passa a aparecer para ela.
> Escopo: alteração **só no DEV**. Não há mudança de código nem migração — é dado de configuração.
> Para refletir em outras clínicas, ajustar o cargo equivalente de cada uma (ou rever o template
> de cargos padrão, se existir).
---
## 3b. Causa raiz (investigação 23/jun) — `proteses` não é concedível pela UI
A correção de dado acima trata o sintoma. A **causa raiz** é um bug de configuração de RBAC:
- Cargos **não** têm seed no backend (signup não cria funções). São criados manualmente pelo
dono em Gestão de Equipe (`POST → INSERT INTO funcoes`, `server.js:806`), partindo de 4
templates do frontend (`GestaoEquipeView.tsx:96`).
- **Nenhum** template inclui `proteses`: RECEPÇÃO (`dashboard,agenda,pacientes,leads,orcamentos`),
AUXILIAR (`...,ortodontia,tratamentos`), FINANCEIRO (`...,financeiro,relatorios,lancar-gto`),
GERENTE (`[...TODAS_PAGINAS]`).
- **Raiz:** `proteses` **não está em `PAGINAS_RBAC`** (`GestaoEquipeView.tsx:57`), a lista mestre
de páginas atribuíveis. Como `TODAS_PAGINAS = PAGINAS_RBAC.flatMap(...)`, **nem o GERENTE /
"MARCAR TODAS"** concede prótese. ⇒ **É impossível um dono dar `proteses` a um funcionário
pela interface** — só via UPDATE direto no banco (o que foi feito na §3).
- Também ausentes da lista atribuível, embora em uso: **`contratos`** (a função tinha — legado),
`comissoes`, `glosas`.
**Resposta à pergunta "o cargo Recepcionista padrão nasce sem proteses?":** Sim — e mais,
*nenhum* cargo consegue tê-la pela UI hoje.
### Fix de raiz (código) — ✅ aplicado e validado no DEV (23/jun)
Em `frontend/views/GestaoEquipeView.tsx`:
- `PAGINAS_RBAC`, grupo `ATENDIMENTO`: + `{ key: 'proteses', label: 'PRÓTESES' }` e
`{ key: 'contratos', label: 'CONTRATOS' }` → agora concedíveis pela UI (e entram em
`TODAS_PAGINAS`/GERENTE/"MARCAR TODAS").
- Templates: RECEPÇÃO ganhou `contratos, lancar-gto, proteses`; AUXILIAR ganhou `proteses`
(alinhado ao papel TC).
**Validação:** `vite build` transformou 2382 módulos OK; container DEV rebuildado e no ar; o
bundle servido pelo nginx DEV (`index-*.js`, alvo de `dev.scoreodonto.com`) contém
`key:"proteses",label:"PRÓTESES"` e a string `proteses` nos 3 pontos editados. Cargos padrão
passam a nascer coerentes — sem UPDATE manual por clínica.
> ⚠️ Pendente de commit + deploy à produção (a mudança está só no DEV). Os fixes de **dado**
> (§3, função da CONSULTT CLINIC) continuam sendo só-DEV e não se propagam por este fix de código.
## 4. Recomendação — Nível 2 (estrutural, "Fase 3")
Em vez de abas soltas (`tratamentos`, `proteses`, `ortodontia`), um **hub de Coordenação de
Tratamentos por paciente**: pipeline único com o status de cada frente (clínico / prótese-lab /
orto), espelhando o "lab case visível para toda a equipe" do Dentrix. View nova que **agrega o que
já existe** (`tratamentos` + `protese_os` + ortodontia por paciente) — sem dado novo.
## 5. Conexão com a Fase 2
Dois lados do mesmo fluxo, unidos pela mesma `protese_os`:
- **Fase 2** → casa do **protético** (quem produz; avança status na Bancada).
- **Esta análise** → **recepção/TC** (quem encomenda e acompanha; lê o status no hub).
O status que o lab avança é o mesmo que a recepção lê — o "Lab Case Manager" repartido entre os papéis.
---
## 6. Salas: acesso do funcionário corrigido + bloqueio de manutenção (23/jun)
Gatilho: a mesma recepcionista aparecia com **MINHAS SALAS** e **ALUGAR SALAS** (gestão de
locação, de titular) e deveria ter apenas a **agenda da sala** e o **bloqueio para manutenção**.
### 6a. Bug — vazamento dos menus de locação ✅ corrigido (`Sidebar.tsx`)
`MINHAS SALAS` (locador) e `ALUGAR SALAS` (locatário) eram empurrados a "todos menos
superadmin" (`Sidebar.tsx:42`), vazando para **funcionário e paciente** — papéis que não são
titulares de sala. Passaram a exibir apenas a `PAPEIS_LOCACAO`
(`donosala/donoclinica/donoconsultorio/admin/dentista/biomedico/protetico`). O backend já era
seguro por ownership; era vazamento só de UI.
### 6b. Feature — acesso operacional + manutenção ✅ implementada e validada no DEV
Não existia caminho para "funcionário vê só a agenda" nem "bloqueio para manutenção" — foram
criados (modelos de mercado: *Dentrix/Open Dental Lab... não; aqui é gestão de espaço* — o
padrão é **front desk opera a agenda da sala sem tocar no financeiro do locador**).
- **Acesso operacional** (`server.js` `listarWorkspaces`): sala cuja `clinica_id` é uma clínica
onde o usuário tem vínculo (e ele **não** é o dono) entra como workspace `tipo='sala'` com
`papel_sala='operador'`. O `SalaHomeView` detecta o modo operador e **esconde recebíveis,
relatório e baixa** — mostra só a **agenda** + botão de bloqueio.
- **Bloqueio de manutenção** (`POST /api/salas/:id/manutencao`): reserva `tipo='manutencao'`
(coluna nova, default `'locacao'`), `valor=0`, nasce `'confirmada'` (sem aprovação) e **não
gera recebível**. Acesso: dono **ou** membro com vínculo na clínica da sala. Reaproveita o
anti-conflito de horário (409) e o cancelamento por solicitante/unidade. A agenda renderiza
o bloqueio em **cinza** (legenda "Manutenção").
**Validação ponta a ponta (DEV, JWT real da recepcionista, sala de teste vinculada à clínica):**
| Caso | Resultado |
|---|---|
| Sala da clínica vira workspace operador dela | ✅ query retorna a sala |
| POST bloqueio | ✅ `status:confirmada, tipo:manutencao` |
| GET agenda mostra bloqueio + motivo | ✅ `valor:0, motivo:"Conserto do equipo"` |
| Conflito de horário | ✅ HTTP 409 |
| Não gera financeiro | ✅ 0 lançamentos |
Commits: `fix(salas)` (menu) + `feat(salas)` (operador + manutenção). Backend e frontend no ar
no DEV; dados de teste limpos.
> ⚠️ Pendente de push + deploy (produção segue em v1.0.19).
+217
View File
@@ -0,0 +1,217 @@
# Fase 2 — Detalhamento de implementação: passos 2.0 e 2.1
> ✅ **IMPLEMENTADO e validado em DEV (23/jun/2026).** Commits `feat(protese): Fase 2 backend`
> + `frontend`. Validações: registro de protético nasce `laboratorio`; backfill preenche
> `laboratorio_id`; bancada roteia por lab (fallback por pessoa); entrega gera **par espelhado**
> DESPESA(clínica)+RECEITA(lab) R$600, idempotente (409 na re-entrega); menu do lab no bundle.
>
> ⚠️ **Bug pré-existente achado e corrigido junto:** o INSERT da DESPESA de prótese (Fase 1, já
> em produção v1.0.20) usava `paciente_nome` — coluna inexistente em `financeiro` (é
> `pacientenome`). A entrega de OS com `custo>0` **abortava** em produção. Corrigido no mesmo
> commit de backend. **Recomenda-se priorizar o deploy** por causa disso.
> Companheiro de `FASE2-PROTETICO-AREA.md` (o modelo). Aqui está o **como**, a nível de
> arquivo/função, fiel ao código atual de `backend/server.js` e `frontend/`.
> Decisões aplicadas: **1 lab por protético**, **só terceirizado**, **receita espelhada na 2.1**.
> Disciplina herdada da Fase 1: migrações aditivas/idempotentes; blocos novos em `try/catch`
> que nunca derrubam o fluxo principal; `protetico_id` sempre preservado (fallback).
---
# PASSO 2.0 — Dados invisíveis (backfill `laboratorio_id`)
Nenhuma mudança de UI. Objetivo: todo protético tem um workspace `tipo='laboratorio'` e toda
OS/produto carrega `laboratorio_id`. Validar 100% em DEV (`pgdev`) antes de seguir.
## 2.0.a — Migrações (em `runMigrations()`, junto às da Fase 1, ~linha 5663)
```js
// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`, // RECEITA espelhada (2.1)
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
// laboratorio_id em protese_os JÁ EXISTE (coluna reservada na criação da tabela) — não recriar.
// re-tag: o workspace pessoal do protético passa a ser 'laboratorio'
`UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal'
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,
// backfill: cada OS herda o laboratorio do workspace do seu protético
`UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,
// backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
`UPDATE protese_produtos p SET laboratorio_id = c.id
FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
// índice por lab (o de protetico_id já existe; adiciona o do lab)
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
```
> O `UPDATE especialidades … area='protese'` da Fase 1 já está logo acima — não duplicar.
## 2.0.b — Helper: resolver o laboratório de um protético
Novo helper perto de `proteseOsAccesso` (~linha 3772). 1 lab por protético ⇒ `LIMIT 1`.
```js
// O workspace 'laboratorio' do protético (1 por protético nesta fase).
async function labDoProtetico(proteticoId) {
if (!proteticoId) return null;
const { rows } = await pool.query(
"SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' AND COALESCE(deleted_at IS NULL, true) LIMIT 1",
[proteticoId]);
return rows[0]?.id || null;
}
```
> Se `clinicas` não tiver `deleted_at`, usar só `WHERE owner_id=$1 AND tipo='laboratorio'`.
## 2.0.c — Gravar `laboratorio_id` na CRIAÇÃO da OS (2 pontos)
**(i) `POST /api/protese/os`** (~linha 3893): resolver o lab e incluí-lo no INSERT.
```js
const labId = await labDoProtetico(b.protetico_id); // antes do INSERT
// no INSERT: acrescentar a coluna laboratorio_id e o valor labId
// ... protetico_id, laboratorio_id, protetico_nome, ...
// VALUES ($1,$2,$3,$LAB,$4,...)
```
**(ii) Bloco GTO→protese_os (Fase 1)** em `PUT /api/gto-builder/:id/finalizar`
(o INSERT em `protese_os` que você acabou de escrever): mesma adição.
```js
const labId = await labDoProtetico(it.protetico_id); // dentro do for, antes do INSERT
// incluir laboratorio_id = labId no INSERT da OS gerada por GTO
```
> Assim, OS novas já nascem com `laboratorio_id`; as antigas foram cobertas pelo backfill.
## 2.0.d — Bancada roteia por laboratório (com fallback por pessoa)
`GET /api/protese/bancada` (~linha 3926). Hoje: `WHERE protetico_id=$1`. Passa a aceitar OS
do(s) laboratório(s) do usuário, mantendo fallback para OS ainda sem `laboratorio_id`:
```js
const uid = req.authUser.userId;
const labId = await labDoProtetico(uid); // o lab do protético logado
const vals = [uid, labId];
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2)
OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`;
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) {
vals.push(req.query.status); where += ` AND status=$${vals.length}`;
}
// resto idêntico (ORDER BY / LIMIT / map)
```
> `proteseOsAccesso` **não muda na 2.0** (1 lab por protético ⇒ `protetico_id===uid` ainda
> identifica o lab). O `OR por vínculo no lab` entra só na 2.3 (equipe/`tecnico_protese`).
## 2.0.e — Novos protéticos já nascem com `tipo='laboratorio'`
No `POST /api/register` (~linha 625), o INSERT do workspace usa `tipo='pessoal'` para todos.
Diferenciar o protético:
```js
const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,$4)` // $3 = wsTipo
```
> O seed de catálogo (`seedProteseProdutos(userId)`) continua por `protetico_id` (owner) —
> sem mudança. (O `laboratorio_id` do catálogo é resolvido pelo backfill / por leitura.)
## ✅ Critérios de aceite 2.0 (validar em DEV)
- `SELECT count(*) FROM protese_os WHERE laboratorio_id IS NULL`**0** (após backfill).
- Toda `clinicas` de protético tem `tipo='laboratorio'`.
- Criar OS nova (via Nova OS **e** via finalizar GTO) → `laboratorio_id` preenchido.
- Bancada continua mostrando exatamente as mesmas OS de antes (fallback cobre o legado).
- **Zero** mudança visível na UI.
---
# PASSO 2.1 — Casa própria + receita espelhada
Agora o protético entra numa **área de Laboratório** (menu próprio) e a entrega passa a
lançar a receita no workspace do lab.
## 2.1.a — BACKEND: receita espelhada na entrega
Em `POST /api/protese/os/:id/status`, dentro do `if (novo === 'entregue') { ... }`, logo após
o bloco que cria a **DESPESA** (`finId`). Mesma condição (`custo>0 && tipo_os≠'garantia'`):
```js
// Espelho: RECEITA no workspace do laboratório (clinica_id = laboratorio_id). Idempotente.
let finLabId = os.financeiro_lab_id || null;
try {
const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, paciente_nome, origem, centro_custo, sem_comissao)
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
[finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${clinicaNome || ''})`.trim(), Number(os.custo),
os.paciente_id || null, os.paciente_nome || null]);
}
} catch (e) { console.error('[protese entrega→receita lab]', e.message); }
```
E no `UPDATE protese_os` da entrega, persistir o id:
```js
// ...financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($N,financeiro_lab_id)...
```
> `clinicaNome` = nome da clínica de origem (1 query opcional, ou usar `os.clinica_id`).
> **Estorno** (cancelar entrega) é tarefa à parte: reverter os dois `financeiro` — fica para
> a sub-fase de cancelamento; por ora a entrega é terminal (já é hoje: 409 se `entregue`).
## 2.1.b — FRONTEND: menu da área de Laboratório (`Sidebar.tsx`)
**(i)** Acrescentar os itens ao array `menuItems` (~linha 53):
```js
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
```
(catálogo/clientes/agenda/financeiro entram na 2.2/2.3 — não criar itens mortos agora.)
**(ii)** Isolar o menu quando o workspace ativo é laboratório — **espelhar** o bloco que já
existe para `tipo==='sala'` (~linha 98). Inserir antes dele:
```js
// Contexto de LABORATÓRIO: área própria do protético (não é clínica).
if ((activeWorkspace as any)?.tipo === 'laboratorio')
return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);
```
> Como o protético tem **1** workspace e ele é `tipo='laboratorio'`, esse branch governa o
> menu dele. O antigo filtro `currentRole==='protetico'` (linha ~131) deixa de ser alcançado
> para o caso comum, mas **mantê-lo** como rede de segurança (protético sem workspace lab).
## 2.1.c — FRONTEND: roteamento das telas novas (`App.tsx`)
No switch/router de `view` do `App.tsx`, mapear:
```jsx
case 'lab-bancada': return <ProteseView />; // já renderiza modo 'bancada' p/ role protetico
case 'lab-home': return <LabHomeView />; // novo painel (abaixo)
```
> `ProteseView` já decide `modo='bancada'` por `getCurrentRole()==='protetico'` — reaproveita
> 100%. Só muda o **id de menu** que a abre.
## 2.1.d — FRONTEND: `LabHomeView` (painel mínimo)
Nova view `frontend/views/LabHomeView.tsx`. Cards a partir de dados que já existem:
- **Fila por status** e **atrasadas**: derivar de `getProteseBancada()` (já retorna `atrasada`).
- **Faturamento do mês**: somar `financeiro` RECEITA `origem='protese'` do workspace lab
(rota de financeiro existente, escopada por `clinicaId` = id do lab).
- **Prazo médio / entregues no mês**: agregação simples sobre a bancada.
Sem rota nova obrigatória na 2.1 (reusa `bancada` + `financeiro`); se quiser um resumo de 1
chamada, criar `GET /api/protese/lab/resumo` depois.
## ✅ Critérios de aceite 2.1
- Logado como protético, a Sidebar mostra **PAINEL DO LAB** + **BANCADA** (+ notificações/config).
- A clínica continua com a tela PRÓTESES (modo `clinica`) **intacta**.
- Entregar uma OS com `custo>0` (não-garantia) cria **2** lançamentos: DESPESA na clínica e
RECEITA no lab (mesmo valor `custo`); reentregar/reprocessar **não** duplica (`COALESCE`).
- OS de garantia entregue → **nenhum** lançamento (nos dois lados).
- `LabHomeView` mostra fila + faturamento do mês do laboratório.
---
# Ordem de execução e deploy
1. Branch a partir da `main` (que já tem a Fase 1 não-commitada — **commitar a Fase 1 antes**).
2. 2.0 (backend só) → subir em DEV (`dev.scoreodonto.com`/`pgdev`), rodar os SELECTs de aceite.
3. 2.1 (backend receita + front) → validar aceite em DEV.
4. Commit por sub-fase; push → CI builda por commit; `git tag vX.Y.Z` promove à produção.
> Migrações aditivas ⇒ rollback de código é seguro (padrão já validado no projeto).
+220
View File
@@ -0,0 +1,220 @@
# Fase 2 — Área do Protético (Laboratório como workspace de 1ª classe)
> Desenho de modelo. Continuação da **Fase 1 (GTO → Prótese)**, onde finalizar uma GTO
> abre automaticamente uma OS na Bancada (`protese_os.gto_item_id`).
> **Objetivo da Fase 2:** o protético deixa de ser "um papel que só vê a Bancada" e passa
> a ter uma **área de gestão própria** — o Laboratório — equivalente ao que a Clínica é para
> o dentista. Ele gere catálogo, equipe, clientes (clínicas), financeiro e agenda de entregas.
---
## 1. Onde estamos hoje (não reinventar)
O alicerce já existe — a Fase 2 **promove**, não cria do zero:
| Já existe | Onde |
|---|---|
| Role `protetico` (válido em `usuarios` e `vinculos`) | `server.js` VALID_ROLES / constraints |
| Workspace pessoal "LABORATÓRIO DE …" criado no signup | `clinicas (tipo='pessoal')` + `vinculos (role='protetico')` |
| Catálogo de produtos (preço fixo + garantia) | `protese_produtos (protetico_id)` |
| Bancada (fila de OS do protético) | `GET /api/protese/bancada` (roteia por `protetico_id`) |
| OS com eventos, fotos, status, garantia, financeiro | `protese_os`, `protese_os_evento`, `protese_os_foto` |
| `ProteseView` com `modo: 'bancada' | 'clinica'` por role | `frontend/views/ProteseView.tsx` |
| **`protese_os.laboratorio_id`** — reservado, hoje `null` | comentário: "workspace destino (futuro)" |
| Menu filtrado por role (protético = subset) | `Sidebar.tsx` linha ~131 |
**Lacuna que a Fase 2 fecha:** o Laboratório não é uma *entidade gerenciável*. O protético
roteia OS por `protetico_id` (a pessoa), não por um laboratório (a empresa). Não há painel,
equipe, visão de clientes, nem financeiro do lado do laboratório. A OS conhece a *pessoa*,
não a *bancada/empresa*.
---
## 2. Decisão de modelo: Laboratório = workspace `tipo='laboratorio'`
O sistema já trata **workspace** de forma polimórfica: `clinicas` é a tabela-base e `tipo`
distingue (`clinica` | `pessoal` | `sala`). A Fase 2 introduz **`tipo='laboratorio'`** em vez
de criar uma tabela nova. Isso reaproveita: vínculos, troca de workspace, RBAC por cargo,
financeiro, notificações e o seletor de workspace da Sidebar.
```
usuarios (role='protetico')
│ vinculos (role='protetico' | 'tecnico_protese')
clinicas (tipo='laboratorio') ◄── "LABORATÓRIO DE X" deixa de ser 'pessoal'
┌───────────┼─────────────────────────────┐
▼ ▼ ▼
protese_produtos protese_os (laboratorio_id) equipe (vinculos do lab)
(catálogo) ▲ roteamento por LAB,
│ não mais só por pessoa
clínicas-cliente enviam OS ──┘
```
### Migração do dado existente (aditiva, idempotente)
```sql
-- 1) re-tag do workspace pessoal do protético → laboratorio
UPDATE clinicas SET tipo='laboratorio'
WHERE tipo='pessoal'
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico');
-- 2) backfill: toda OS legada ganha o laboratorio_id do workspace do seu protetico
UPDATE protese_os o SET laboratorio_id = c.id
FROM clinicas c
WHERE o.laboratorio_id IS NULL
AND c.tipo='laboratorio'
AND c.owner_id = o.protetico_id;
-- 3) o catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT;
UPDATE protese_produtos p SET laboratorio_id = c.id
FROM clinicas c
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id;
```
> `protetico_id` **continua** populado em todas as tabelas (compatibilidade da Fase 1 e do
> roteamento por pessoa). `laboratorio_id` passa a ser a **chave de escopo** da área.
---
## 3. A Área do Laboratório (navegação)
Quando o `activeWorkspace.tipo === 'laboratorio'`, a Sidebar troca para um menu próprio
(análogo ao `tipo==='sala'` que já isola o menu). Itens:
| Item | Conteúdo | Reaproveita |
|---|---|---|
| `lab-home` | **Painel** do laboratório: OS por status, atrasadas, faturamento do mês, prazo médio | novo dashboard + agregações de `protese_os` |
| `lab-bancada` | **Bancada** (fila de produção, kanban por status) | `ProteseView` modo bancada (já existe) |
| `lab-catalogo` | **Catálogo** de produtos (preço/garantia) — vira tela, não modal | `ProdutosModal` → view |
| `lab-clientes` | **Clínicas-cliente**: quem envia OS, volume, ticket, pendências | distinct de `protese_os.clinica_id` |
| `lab-agenda` | **Agenda de entregas** por `prazo_entrega` | AgendaView adaptada / nova |
| `lab-financeiro` | **A receber** das clínicas (a OS gera DESPESA na clínica = RECEITA no lab) | `protese_os.financeiro_id` (elo) |
| `lab-equipe` | **Técnicos** do laboratório (RBAC por cargo já existe) | `vinculos` + permissões de workspace |
| `notificacoes` / `configuracoes` | base | já existe |
A tela "PRÓTESES" da **clínica** (modo `clinica`) não muda — ela continua **enviando** OS.
O que muda é o **lado de quem recebe**, que ganha uma casa própria.
### Roteamento de OS: pessoa → laboratório
- Hoje: `bancada` filtra `WHERE protetico_id = me`.
- Fase 2: `bancada` filtra `WHERE laboratorio_id = activeWorkspace.id` (com fallback
`OR (laboratorio_id IS NULL AND protetico_id = me)` durante a transição).
- Ganho imediato: **mais de um técnico** pode atender a mesma bancada (equipe), e o protético
dono pode ter **mais de um laboratório**.
---
## 4. Modelo de dados — deltas da Fase 2
Tudo aditivo (migrações no padrão `ALTER TABLE … IF NOT EXISTS`, como a Fase 1):
```sql
-- workspace
clinicas.tipo -- novo valor possível: 'laboratorio'
-- escopo por laboratório (a empresa), além de protetico_id (a pessoa)
protese_os ADD laboratorio_id TEXT -- (já reservado; passa a ser populado/backfill)
protese_os ADD financeiro_lab_id TEXT -- RECEITA espelhada no lab (§9; idempotência)
protese_produtos ADD laboratorio_id TEXT
-- equipe do laboratório: novo role de vínculo (técnico não-dono)
vinculos.role -- incluir 'tecnico_protese' no CHECK constraint
-- relação clínica↔laboratório (opcional, p/ "meus laboratórios favoritos"/preços negociados)
CREATE TABLE IF NOT EXISTS lab_clientes (
id TEXT PRIMARY KEY,
laboratorio_id TEXT NOT NULL,
clinica_id TEXT NOT NULL,
apelido TEXT,
desconto_pct NUMERIC DEFAULT 0, -- preço negociado (futuro)
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (laboratorio_id, clinica_id)
);
```
Índices: `idx_protese_os_lab` passa a ser `(laboratorio_id, status)`.
---
## 5. Permissões (RBAC)
Reaproveita o RBAC por cargo que já existe na Sidebar (`wsRole`/`wsPerms`):
| Papel no laboratório | Pode |
|---|---|
| `protetico` (dono do lab) | tudo: catálogo, equipe, financeiro, status de qualquer OS |
| `tecnico_protese` (membro) | bancada + avançar status + fotos; **sem** financeiro/equipe/catálogo |
| clínica-cliente (`isClinic` em `proteseOsAccesso`) | criar OS, ver suas OS, entregar/cancelar, **sem** ver custo/bancada de outros |
`proteseOsAccesso(os, userId)` ganha o caso "membro do laboratório dono da OS"
(`vinculo em laboratorio_id`), além do dono atual.
---
## 6. Plano de implementação incremental
Cada passo é deployável e reversível (migrações aditivas → rollback de código é seguro).
- **2.0 — Dados (invisível):** migrações + backfill `laboratorio_id`; `bancada` lê por lab com
fallback por pessoa. Nada muda na UI. *Risco mínimo, valida o backfill em DEV (`pgdev`).*
- **2.1 — Casa própria + receita espelhada:** `tipo='laboratorio'` re-tagueado; Sidebar com
menu do laboratório; `lab-home` (painel) + `lab-bancada`; **espelho de RECEITA na entrega**
(§9) — o financeiro mínimo do lab já nesta fase, conforme decisão 3.
- **2.2 — Catálogo e clientes:** `ProdutosModal` vira `lab-catalogo`; tela `lab-clientes`.
- **2.3 — Financeiro completo e equipe:** `lab-financeiro` (extrato/a-receber por cliente,
sobre as receitas já lançadas na 2.1) + `tecnico_protese` (convite de membro reusando o
fluxo de equipe existente).
- **2.4 — Agenda de entregas + multi-laboratório** (expansão; modelo já suporta, sem migração).
---
## 7. O que **não** muda (contrato preservado)
- O lado **clínica** de `ProteseView` (modo `clinica`) e o envio de OS.
- A **Fase 1**: GTO finalizada continua abrindo OS (`gto_item_id`), agora já com
`laboratorio_id` preenchido na criação.
- `protetico_id` permanece em todas as tabelas — roteamento por pessoa segue funcionando
como fallback; nenhuma OS legada fica órfã.
---
## 8. Decisões de produto (fechadas em 23/jun/2026)
1. **Um laboratório por protético.** A UI assume 1 lab. O modelo de dados já suporta N
(`laboratorio_id` por OS/produto), então multi-lab é expansão futura **sem migração**.
2. **Laboratório terceirizado** (empresa separada que cobra da clínica). A flag `interno`
de `getProteseLaboratorios` fica como gancho futuro, mas **não** é tratada agora —
todo lab gera receita/despesa cruzada entre tenants.
3. **Financeiro espelhado já na 2.1.** A OS entregue, que hoje lança **DESPESA na clínica**
(`protese_os.financeiro_id`), passa a lançar simultaneamente uma **RECEITA no workspace
do laboratório**. Ver §9.
---
## 9. Financeiro espelhado (decisão 3) — DESPESA na clínica ↔ RECEITA no lab
Hoje a entrega da OS gera um lançamento de **DESPESA** na clínica de origem
(`protese_os.financeiro_id`), no valor de **`os.custo`** (o preço de catálogo do lab — o que a
clínica paga ao laboratório; `os.valor` é o preço ao paciente, markup da clínica, e **não**
entra aqui). Como o laboratório agora é um workspace de 1ª classe, o mesmo evento de entrega
lança um **par espelhado**, ambos sobre `os.custo`:
```
entrega da OS (status='entregue', custo>0, tipo_os≠'garantia')
├─► financeiro (DESPESA) clinica_id = os.clinica_id valor = os.custo [já existe]
└─► financeiro (RECEITA) clinica_id = os.laboratorio_id valor = os.custo [novo]
```
- Mesma **condição** da despesa já existente: só lança se `custo > 0` e `tipo_os ≠ 'garantia'`
(OS de garantia refaz sem cobrar). Sai no mesmo `if (novo === 'entregue')`.
- Idempotente, no padrão da Fase 1: novo `protese_os.financeiro_lab_id` guarda o id do
lançamento de receita; se já existe (`COALESCE`), não duplica.
- Isolado em `try/catch` para **nunca** derrubar a transição de status (mesma disciplina do
bloco GTO→protese_os da Fase 1).
- Estorno: cancelar/estornar a entrega deve reverter **os dois** lançamentos.
Por isso o **passo 2.1 já inclui o financeiro mínimo** (apenas o espelho de receita na
entrega); o item de menu `lab-financeiro` completo (extrato, a-receber por cliente) continua
na 2.3.
> ⚠️ Pré-requisito: `laboratorio_id` precisa estar populado **antes** de ligar o espelho
> (senão a receita nasce sem destino). Por isso a ordem **2.0 (backfill) → 2.1** é obrigatória.
+58
View File
@@ -0,0 +1,58 @@
# Marketplace transacional de Prótese
> Desenho (24/jun/2026). O último grande item do backlog do Provedor de Prótese.
> Princípio: **transformar o diretório (descoberta) em jornada de compra** — descobrir → ver
> serviços/preços → solicitar → cotar → contratar → vira OS. Reaproveita ao máximo o que já existe.
## O que já existe (não recriar)
- **Descoberta:** `/api/profissionais/marketplace` (busca por proximidade/especialidade) **+ Nota
ScoreOdonto** no card (feito).
- **Catálogo:** `protese_produtos` (nome, preço, garantia) + `GET /api/protese/laboratorios/:id/produtos`
— já acessível a quem está no **diretório** (`proteticoAcessivelPelaClinica` aceita `disponivel_diretorio`).
- **Propostas:** `propostas_profissional` (clínica→profissional: mensagem, `pendente→aceita/recusada`).
- **Contratação:** a clínica **já cria OS para qualquer protético do diretório** (Nova OS).
- **Pós-venda:** OS completa (etapas, custódia, ocorrências), financeiro, score — tudo pronto.
**Conclusão:** o "transacional" não exige nova fundação — falta **costurar a jornada** dentro do
marketplace (hoje a clínica vê nome/nota/distância, mas não os serviços, e contrata por outra tela).
## Fatias
### ✅ Fatia 1 — Vitrine (catálogo no marketplace) — IMPLEMENTADA
No card do protético no marketplace, **"Ver catálogo"** expande os produtos (nome, preço, garantia)
via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica passa a **decidir vendo
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
prótese"** (proposta com contexto de prótese).
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
### ✅ Fatia 3 — Cotação (RFQ) — IMPLEMENTADA
Entidades novas `protese_rfq` + `protese_rfq_cotacao`. Fluxo: a clínica descreve o trabalho e
**convida N labs** → cada lab **cota** (valor/prazo/obs) → a clínica **compara** (ordenado por preço)
e **escolhe** → vira **OS** (custo = valor da cotação) e a RFQ fecha (demais cotações recusadas).
Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese/rfq/cotacao/:id`,
`POST /protese/rfq/:id/escolher`. UI: lado lab = tela **Cotações** (`lab-cotacoes`, responde); lado
clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA
A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS,
reavaliável). A satisfação **entra na Nota ScoreOdonto**: `0.6·operacional + 0.4·(estrelas/5·10)` (sem
avaliações = só operacional). **Selo "Verificado"** com ≥ 5 entregas. Exposto no marketplace (★nota ·
nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Endpoint `POST
/protese/os/:id/avaliar`. Validado em DEV: 6 entregas + avaliações 5★/3★ → nota **9.2**, satisfação 4★,
verificado, em marketplace e indicadores.
**Marketplace transacional concluído (Fatias 1-4).**
## Decisões de modelagem
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
- **Solicitação reusa a OS** (não cria entidade paralela): toda contratação termina em `protese_os`,
preservando rastreabilidade/financeiro/score.
- **Cotação (Fatia 3)** é o único caso que precisa de entidade nova (pré-OS multi-lab).
+117
View File
@@ -0,0 +1,117 @@
# Modo 1 — Provedor de Prótese SEM conta (link seguro `/p/TOKEN`)
> Desenho (24/jun/2026). Evolução registrada como "fora de escopo" em `PROVEDOR-PROTESE-DECISAO.md`.
> Princípio (decisão arquitetural): **a clínica opera 100% da OS; o protético não precisa ter conta.**
> O link é a porta de entrada do ecossistema — acompanha hoje, vira conta amanhã.
## Objetivo
A clínica cria/gere a OS normalmente. O protético recebe um **link seguro** (WhatsApp/e-mail) e,
**sem login**, acompanha e atua na OS dele. Com CTA para criar conta grátis (conversão).
```
Clínica cria OS ──► gera link /p/TOKEN ──► WhatsApp/e-mail ──► Protético abre (sem conta)
├─ vê a OS (sem CPF/valores do paciente)
├─ confirma recebimento/devolução
├─ avança etapa / anexa foto
└─ responde ocorrência
"Crie sua conta grátis" ──► cadastro (card Protético)
```
## 1. Escopo do token: **por OS** (não por protético)
Escopo mínimo = menor superfície de risco. Um token dá acesso a **uma OS**. Vantagens: revogável
individualmente, expira por trabalho, não expõe a carteira inteira do protético. (Evolução: um token
"carteira" por protético-na-clínica que lista várias OS — fica para depois.)
## 2. Modelo de dados (revogável e auditável)
```sql
CREATE TABLE IF NOT EXISTS protese_os_link (
token TEXT PRIMARY KEY, -- aleatório longo (≥ 32 bytes base62), NÃO sequencial
os_id TEXT NOT NULL,
protetico_id TEXT, -- a quem se destina (rastreio)
created_by TEXT, -- quem na clínica gerou
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- opcional (ex.: +30 dias); null = sem expirar
revoked_at TIMESTAMPTZ, -- revogação manual
last_access_at TIMESTAMPTZ,
acessos INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id);
```
> Preferir tabela a JWT puro: permite **revogar** e **auditar acessos**. O token nunca contém dados.
## 3. Rotas públicas (sem `authGuard`; o token é a credencial)
Todas resolvem `os_id` pelo token (valida não-revogado, não-expirado) e aplicam a **mesma blindagem
do provedor**: sem `paciente_id`/CPF, sem valores do paciente (reusa a lógica `soLab`).
| Rota | Ação |
|---|---|
| `GET /api/p/:token` | OS: nº, clínica, dentista, paciente (nome, conforme permissão), prazo, status, observações, fotos, componentes, ocorrências, custódia. **Sem CPF/valores do paciente.** |
| `POST /api/p/:token/custodia` | confirmar recebimento / devolução (clínica↔lab) |
| `POST /api/p/:token/status` | avançar a produção (recebido→…→finalizado) |
| `POST /api/p/:token/foto` | anexar foto do trabalho (+ ocorrência) |
| `POST /api/p/:token/ocorrencias/:id/resposta` | responder ocorrência (contraditório) |
Cada ação registra no histórico da OS um **actor "Protético (link)"** com o `protetico_id` do token —
mantendo a auditoria. Mesmas validações de status/foto já existentes.
## 4. Geração e envio (lado clínica)
Na OS (modal, aba Etapas ou Monitoramento): botão **"Enviar ao protético"** →
`POST /api/protese/os/:id/link` gera/recupera o token e devolve a URL
`https://scoreodonto.com/p/TOKEN`. Atalhos: **WhatsApp** (`https://wa.me/?text=…`) e **e-mail**
(`mailto:`). Botão **"Revogar link"** para invalidar.
## 5. Frontend público `/p/:token`
Rota **fora do app autenticado** (sem Sidebar/login). Página enxuta que reusa as seções do
`OSDetailModal` em modo "público/lab" (etapas, fotos, componentes, custódia, ocorrências — sem
financeiro/clientes). Topo com a marca + rodapé fixo:
> *"Gostou de acompanhar por aqui? Crie sua conta grátis e gerencie todos os seus laboratórios."*
> → leva ao cadastro **já com o card Protético** (adicionado) e e-mail pré-preenchido.
## 6. Segurança (checklist)
- Token aleatório ≥ 32 bytes, base62, **não sequencial**; comparação em tempo constante.
- Escopo de **1 OS**; expiração opcional; **revogável**; rate-limit por token/IP.
- **Sem CPF e sem valores do paciente** (mesma blindagem `soLab` da Fase 1).
- Ações registram actor no histórico (auditoria); `last_access_at`/`acessos` para detectar abuso.
- OS `entregue`/`cancelado` → link vira **somente leitura**.
## 7. Conversão (o "porquê de negócio")
O link é aquisição: o protético experimenta o produto sem fricção e, ao criar conta, **herda o
histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele). Liga direto ao
marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o score do protético.
## 8. Faseamento sugerido
- **✅ 1a (núcleo) — IMPLEMENTADA 24/jun:** tabela `protese_os_link` (token aleatório 24 bytes
base64url, expira em 60 dias, revogável); `POST /api/protese/os/:id/link` (gera/recupera) +
`POST …/link/revogar`; `GET /api/p/:token` (leitura pública curada — sem CPF, sem valor do
paciente; conta acessos). Página pública `/p/:token` (`ProtesePublicView`, mobile-first 320px+,
interceptada no `index.tsx` antes do app autenticado); botão "Gerar link / WhatsApp / Copiar" na
aba Monitoramento (modo clínica). CTA "Criar conta grátis". Validado em DEV (leitura, blindagem,
token inválido→404, revogação→404).
- **✅ 1b (ação) — IMPLEMENTADA 24/jun:** o protético, pelo link, **confirma recebimento/devolução**
(`POST /api/p/:token/custodia`), **avança a produção** (`/status`, só `recebido…finalizado` — nunca
entregue/cancelado, que são da clínica) e **anexa foto** (`/foto`). Tudo auditado como
`"<nome> (link)"`. Página pública ganha a seção "Atualizar status" (botões de custódia, etapa e foto)
enquanto a OS não está finalizada. Validado em DEV: recebimento, avanço, foto, e `entregue` pelo link
→ 400 (bloqueado).
- **✅ 1c (gestão) — IMPLEMENTADA 24/jun:** UI da clínica (aba Monitoramento) gere o link:
`GET /api/protese/os/:id/link/status` traz **link ativo, nº de acessos, último acesso e expiração**;
botões **Copiar / WhatsApp / Revogar**. Validado em DEV: acessos contados, expiração 60 dias,
revogação → status inativo + leitura pública 404.
> Conversão por herança: nativa — a OS já aponta para o `protetico_id` do destinatário, então ao
> entrar/criar conta ele já é dono do histórico. (Vincular um cadastro NOVO com e-mail diferente às
> OS é caso de borda — fica para evolução, evitando duplicar usuário.)
**Modo 1 concluído (1a leitura + 1b ação + 1c gestão).**
### ✅ Extras (24/jun)
- **Carteira pela link** (`GET /api/p/:token/carteira`): botão "Meus trabalhos" na página pública →
trabalhos em andamento + finalizados + **mini financeiro mensal** (valores pagos ao protético).
Escopo = a relação clínica↔protético da OS do token (não vaza outras clínicas; sem dados do paciente).
- **Protético INTERNO (sem conta)** (`POST /api/protese/proteticos-internos`): a clínica cadastra um
protético que não usa a plataforma — cria um usuário "mudo" (e-mail placeholder `..@interno.local`,
senha-fantasma; `usuarios.email` é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). Botão
"Cadastrar protético interno" na Nova OS. A partir daí recebe OS e acompanha pelo link, sem conta.
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
> camada de token público.
+121
View File
@@ -0,0 +1,121 @@
# Decisão arquitetural — Provedor de Prótese (módulo de Prótese)
> Decisão de negócio/interface (24/jun/2026). Base: auditoria do código (`AUDITORIA-PROTESE.md`).
> Princípio-guia: **a Clínica controla 100% do fluxo, mesmo que o Protético nunca crie conta.**
> O login do Provedor é **alavanca de crescimento** (marketplace/ecossistema), não pré-requisito.
## Conceito
"**Provedor de Prótese**" é rótulo de **negócio/interface**. Internamente **NÃO se renomeia** o
role `protetico` (aparece 87× no backend + 15 arquivos no front — refatorar = risco alto, ganho
zero). As três formas são variações do **mesmo** workspace `tipo='laboratorio'`, diferenciadas por
metadados (futuros `pessoa_tipo`, `tem_equipe`), não por novos roles:
```
PROVEDOR DE PRÓTESE (role=protetico, workspace tipo='laboratorio')
├── Profissional Individual (PF)
├── Laboratório (PJ)
└── Laboratório com Equipe (PJ + técnicos)
```
## Modos de operação
- **Modo 1 — Protético SEM conta:** a Clínica opera tudo (OS, etapas, envio/recebimento/devolução,
pagamentos, componentes, ocorrências, encerramento). O Protético acompanha por **link seguro**
(`/p/TOKEN` via WhatsApp/e-mail), podendo confirmar recebimento/devolução, anexar fotos, responder
ocorrências. CTA: "crie sua conta ScoreOdonto".
- **Modo 2 — Protético COM conta:** Dashboard, Ordens, Produção, Entregas, Clientes, Financeiro,
Catálogo, Notificações, Perfil.
- **Modo 3 — Laboratório:** + Equipe, Relatórios, Marketplace, Configurações.
## Cadastro/Login
Card próprio **[ PROTÉTICO / LABORATÓRIO ]** com opção PF (Protético Individual) / PJ (Laboratório).
Campos futuros (não obrigatórios no MVP): Nome, Nome Fantasia, CPF, CNPJ, CROT, Responsável Técnico,
Cidade, Estado, Especialidades, Telefone, E-mail. (Campo `clinicas.documento` já existe para CPF/CNPJ.)
## Rastreabilidade de componentes (modelo)
Uma tabela (`protese_os_componente`) com eixo de direção/conferência, não duas:
`enviado pela clínica → recebido pelo protético → devolvido pelo protético → conferido pela clínica`.
A **ocorrência referencia o componente** (FK opcional).
## Ocorrências + Contraditório
Tabela própria. Categorias: não devolvido, danificado, incompatível, parafuso substituído/desgastado,
peça incorreta, ajuste inadequado, falha de fabricação, retrabalho, outro. Campos: autor, data,
categoria, descrição, fotos, evidências, anexos, responsável, status.
**Fluxo com contraditório** (proteção dos dois lados): `Aberta → Respondida → Em análise → Resolvida`
ou `Contestada`. **Só ocorrências resolvidas** alimentam indicadores — nunca automático.
## Abas da OS (alvo)
`Dados · Fotos · Componentes · Pagamentos · Custódia · Histórico · Ocorrências` (nova).
## Indicadores (POR ÚLTIMO)
Capturar primeiro (eventos, componentes, ocorrências, evidências, histórico); só depois derivar
Score/ranking/marketplace. Não codar indicador antes de existir dado.
---
## Roadmap (5 fases)
### ✅ FASE 1 — Origem da OS + LGPD (IMPLEMENTADA 24/jun)
- **Clínica de origem** na bancada e no detalhe (`clinica_nome` via subquery) — visível ao protético
(badge na lista e no modal). Destrava o cenário multi-clínica.
- **Blindagem de CPF:** confirmado que `pacientes.id` **É o CPF**. O protético (lab) deixa de receber
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
- **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/
ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`.
- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status.
Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia).
- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final
(409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`.
- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes.
- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o
contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5.
### ✅ FASE 3 — Operação multi-clínica (IMPLEMENTADA 24/jun)
- **Clientes** (tela `lab-clientes`): tabela das clínicas que enviam OS, com OS totais, ativas,
atrasadas, entregues, faturado e a-receber por cliente. Endpoint `GET /protese/lab/clientes`.
- **Financeiro do lab** (tela `lab-financeiro`): KPIs (faturado/recebido/a-receber) + extrato de
recebimentos (pagamentos lado lab) com clínica/paciente/forma/data. Endpoint `GET /protese/lab/pagamentos`.
Regra: faturado = custo das OS entregues (exceto garantia); a-receber = faturado recebido.
- Menu do laboratório agora: Painel · Bancada · **Clientes** · **Financeiro** · Notificações · Config.
- Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida).
- Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato.
### ✅ FASE 4 — Empresa de verdade (IMPLEMENTADA 24/jun)
- **PF/PJ + documento**: `clinicas.pessoa_tipo` (PF=individual | PJ=laboratório); `documento` = CPF/CNPJ
(já existia, agora editável). Tela **Laboratório & Equipe** (`lab-equipe`): GET/PATCH `/protese/lab`.
- **Equipe/Técnicos**: novo `vinculos.role='tecnico_protese'`. O dono convida por e-mail
(`ensureUsuarioPorEmail` → credencial temporária); endpoints `GET/POST/DELETE /protese/lab/equipe`.
- **Acesso estendido**: `proteseOsAccesso` reconhece o técnico (vínculo no `laboratorio_id`); a bancada
usa `labDoUsuario` (dono **ou** técnico). Técnico opera a produção; **não** vê clientes/financeiro
(menu reduzido + endpoints retornam vazio/403). Gestão é exclusiva do dono.
- Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê
clientes (0) e leva 403 ao tentar gerenciar equipe.
### ✅ FASE 5 — Indicadores & Score (IMPLEMENTADA 24/jun)
- **Indicadores derivados** (`GET /protese/lab/indicadores`, só dono) calculados sobre a captura das
fases 2-4: entregues, tempo médio (solicitado→entregue), atraso %, retrabalho % (garantia),
ocorrências resolvidas %, componentes ausentes. Tela `lab-indicadores`.
- **Nota ScoreOdonto** (0-10): `10 (atraso%×3 + retrabalho%×4 + ocorrência%×3)`, só com **mínimo de
3 entregas** (senão "score em formação"). Só ocorrências **resolvidas** entram (contraditório respeitado).
- **Ranking no marketplace**: `getProteseLaboratorios` passa a devolver a `nota` por protético e ordena
internos→maior nota→nome; a clínica vê `★ nota` no dropdown de Nova OS e do Lançar GTO.
- Validado em DEV: 4 entregas (1 atrasada, 1 garantia) → atraso 25% / retrab 25% / **nota 8.3**; a
clínica vê a nota no dropdown; "score em formação" abaixo do mínimo.
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
### Pós-roadmap (entregue depois das Fases 1-5)
- **✅ Modo 1** (protético sem conta via link `/p/TOKEN`) — completo (1a leitura, 1b ação, 1c gestão).
Ver `MODO1-LINK-SEGURO-PROTESE.md`.
- **✅ Higiene** — menu legado do protético removido (Sidebar/App): o protético opera só no workspace
laboratório; fallback mínimo fora dele.
- **✅ Score público no marketplace** — `GET /api/profissionais/marketplace` traz a `nota` ScoreOdonto
dos protéticos (helper `notasScorePorProtetico`, mesma fórmula da Fase 5), exibida como ★ no card.
Ordem mantida por proximidade; a nota é critério de comparação visível.
### Ainda em aberto (futuro)
Marketplace **transacional** (anúncio de serviços + solicitação/contratação/propostas dentro da
plataforma); **agenda de produção/coleta**; **multi-unidade** do laboratório; envio automático do
link (e-mail/WhatsApp API) e herança de cadastro novo com e-mail diferente.
+42
View File
@@ -0,0 +1,42 @@
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
> Resposta direta às 6 perguntas essenciais, **verificada em 23/jun/2026**. Somente fatos do estado atual.
> **Documento principal: em caso de divergência entre docs, este prevalece.**
> Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo).
---
### 1. Onde está o código?
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.31 · commit `4673f3c`**.
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
### 2. Onde está o banco?
- **Produção:** PostgreSQL no host da **VPS3** (`10.99.0.3:5432`), banco `scoreodonto`, user `scoreodonto_user`.
-**DEV ISOLADO:** a VPS4 usa um container PostGIS próprio **`pgdev`** (banco `scoreodonto_dev`), **NÃO mais o banco de produção**. Dev e prod **não compartilham** mais. (Ver `BANCO-DEV-ESTRATEGIA.md`.)
- Cache **DragonflyDB** (`10.99.0.3:6379`, índice `/1`). **NATS** (`:4222`) e **Temporal** (`:7233`).
### 3. Onde estão os containers?
- **Produção (VPS1, docker rootless):** `scoreodontocom-scoreodonto-{frontend,backend}-1` rodam a **imagem do registry** (`git.clube67.com/ruicesar/scoreodonto-*:<tag>`). Produção **não builda**. Também na VPS1: `traefik` (borda TLS), rx, newwhats, subdominio, mercado.
- **Dev (VPS4, docker rootless):** `nginx-1` (`8020→80`, alvo do Traefik para `dev.`), `frontend-1` (`:3013`), `backend-1` (`:8018`) — **buildados localmente** e apontados ao `pgdev`.
- **Roteamento:** Traefik na VPS1 → `scoreodonto.com` (containers locais) e `dev.scoreodonto.com``http://10.99.0.4:8020`.
### 4. Quem faz deploy?
- **CI/CD por tag (Gitea Actions).** Push na `main` → o runner `act_runner` "vps4-builder" (VPS4, rootless) builda e **publica no registry** (NÃO deploya). `git tag vX.Y.Z && git push` → CI + job `deploy` promove para a VPS1 (`deploy-prod.sh`).
- **Produção consome imagem** (`pull` + `up -d --no-build`).
-**Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
### 5. Como versiona?
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.31 · `4673f3c` · PROD**.
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
-**`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
### 6. Como volta uma versão (rollback)?
- **Por imagem, sem rebuild:** na VPS1, `./deploy-prod.sh <ref-anterior>``pull` + `up -d --no-build`.
- ⚠️ A referência confiável é o **commit** (`:<sha>`): tags `:vX.Y.Z` podem ter sido **republicadas** com código diferente (colisão de tags pré-Passo 5 — confirmado: `:v1.0.14` do registry ≠ o que rodava). Migrações são **aditivas** (voltar o código é seguro). Dump em `/home/deploy/backups/` só em caso extremo.
---
## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.31**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.

Some files were not shown because too many files have changed in this diff Show More