Compare commits

...

92 Commits

Author SHA1 Message Date
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
176 changed files with 24163 additions and 613 deletions
+3
View File
@@ -40,6 +40,9 @@ jobs:
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: Publicar por COMMIT (:<sha> + :latest), com retry p/ 499
+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/`.
+194
View File
@@ -0,0 +1,194 @@
// 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';
const SLOT_MIN = 30; // duração padrão do slot (min)
const HORIZON_DAYS = 7; // janela padrão de busca
const MAX_SLOTS = 20;
// A agenda do scoreodonto é livre (quase ninguém preenche dentistas_horarios).
// Sem jornada configurada, oferecemos horário comercial padrão (segsex 0818).
const DEFAULT_INI = '08:00';
const DEFAULT_FIM = '18:00';
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`;
// 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();
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.' });
// Jornadas configuradas (opcional).
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); }
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 < MAX_SLOTS; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
const dow = day.getDay();
for (const dent of dentistas) {
if (slots.length >= MAX_SLOTS) break;
const cfg = hoursBy.get(dent.id);
// Janelas do dia: jornada configurada OU padrão comercial (segsex).
const windows = (cfg && cfg.length)
? cfg.filter((h) => h.dia_semana === dow).map((h) => [h.hora_inicio, h.hora_fim])
: ((dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : []);
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 < MAX_SLOTS) {
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) });
}
}
}
}
slots.sort((a, b) => a.start.localeCompare(b.start));
res.json({ slots: slots.slice(0, MAX_SLOTS) });
} 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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW())
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]);
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();
}
});
return router;
}
+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()
}
+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();
}
+166
View File
@@ -0,0 +1,166 @@
// 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, 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'
// 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));
// ── Proxy de MÍDIA: /api/nw/media/* → motor /media/* ───────────────────────
// A mídia do WhatsApp vive no motor (servida do Wasabi/local, pública). O
// satélite a repassa mantendo same-origin no <img>/<video> (sem exigir header
// de auth no browser), com suporte a Range para áudio/vídeo.
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 url = `${cfg.motorUrl.replace(/\/$/, '')}/media/${rest}`;
try {
const headers = {};
if (req.headers.range) headers.range = req.headers.range;
const r = await fetch(url, { headers });
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);
}
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'private, max-age=3600');
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.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)." }
]
}
+313
View File
@@ -0,0 +1,313 @@
// 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; }
}
// 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.
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 isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
if (!isDeleteChat && !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). 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}`;
}
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
const opts = { method: req.method, headers: {
'Content-Type': 'application/json',
'x-nw-key': cfg.integrationKey,
'x-nw-email': email,
// 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']) } : {}),
} };
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) && email) {
const ok = await provisionNewwhatsAccount(pool, cfg, email);
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}` });
}
};
}
// ── 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.
export function createWsTunnel() {
return 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=).
let token;
try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); }
catch { return fail('400 Bad Request'); }
let email;
try {
const p = jwt.verify(token, process.env.JWT_SECRET);
email = (p.email || '').toLowerCase();
} catch { return fail('401 Unauthorized'); }
if (!email) return fail('401 Unauthorized');
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: ${email}\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
}
+1860 -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).
-117
View File
@@ -1,117 +0,0 @@
# Deploy & Versionamento — Runbook ScoreOdonto
> Auditado contra o sistema em **17/jun/2026**. Cada item carrega seu **status real**.
>
> **Legenda:** ✅ EM PRODUÇÃO · 🧪 EM DEV (não promovido) · 🎯 ALVO (não implementado).
>
> **Produção AGORA:** `v1.0.16 · commit 471c2c2 · 2026-06-17` (`curl https://scoreodonto.com/api/version`).
> Modelo **Build Once → Promote → Deploy** completo — Passo 5 validado em v1.0.16. Tudo abaixo ✅.
---
## 1. O modelo ✅
> **Push** na `main` builda **uma vez** e publica por **commit**; a **tag `vX.Y.Z`** **re-tagueia** esse mesmo artefato (sem rebuild) e deploya. **Produção nunca builda — só executa imagens.**
```
PUSH main (commit C) TAG vX.Y.Z (no commit C)
│ │
▼ ▼
CI: build (1x) CI: promote (SEM build) ✅
publica :C + :latest re-tag :C → :vX.Y.Z (MESMO digest) + deploy
▼ ▼
registry VPS1: pull :vX.Y.Z + up --no-build
APP_VERSION=tag injetada em runtime
```
Prova (v1.0.16): `:v1.0.16` tem **digest idêntico** a `:471c2c2` — a tag é só um rótulo sobre a imagem já testada.
---
## 2. Estado por componente (verificado)
| Componente | Status | Detalhe |
|---|---|---|
| Infra VPS1/3/4 + Docker rootless | ✅ | Mapa na §3 |
| Registry (`git.clube67.com/ruicesar/...`) | ✅ | Ver `REGISTRY.md` |
| CI: job `build` publica por **commit** em push (sem `APP_VERSION`) | ✅ | `.gitea/workflows/build.yml` (runner `vps4-builder`) |
| Job `promote` por tag: re-tag `:<commit>→:vX.Y.Z` (sem rebuild) + deploy | ✅ | validado em v1.0.16 (`:v1.0.16` == `:471c2c2`) |
| Validação de integridade no promote (commit existe? digest igual? front+back mesmo commit) | ✅ | falha se o commit não foi pushado |
| Produção não builda (`image:` + `pull` + `up --no-build`) | ✅ | `docker-compose.prod.yml` |
| Banco DEV isolado em `pgdev`; prod no Postgres da VPS3 | ✅ | Ver `BANCO-DEV-ESTRATEGIA.md` |
| Versão em runtime (`/api/version`) → Configurações + telas públicas | ✅ | `useAppVersion()`; sem `APP_VERSION` manual |
| `APP_VERSION`/`APP_ENV` injetados no deploy em runtime | ✅ | `compose.prod` `environment` |
| Versão **nasce da tag git** (`constants.ts`/`update-version.js` removidos) | ✅ | Passo 5 |
---
## 3. Mapa de máquinas ✅
| Onde | Host | Papel | Docker |
|---|---|---|---|
| VPS1 | `10.99.0.1` | **Produção** (Traefik + apps) | rootless |
| VPS3 | `10.99.0.3` | Postgres (prod) + Gitea + registry | — |
| VPS4 | `10.99.0.4` | **Dev + Builder** (código; runner CI; `pgdev`) | rootless |
- Repo: `ssh://git@10.99.0.3/ruicesar/scoreodonto.com.git`. Project Compose: `scoreodontocom`.
---
## 4. De onde vem a versão (`/api/version`)
```json
{ "name": "ScoreOdonto API", "version": "v1.0.16", "commit": "471c2c2",
"build": "2026-06-17 10:35 UTC", "environment": "PROD" }
```
- **commit / build** = carimbados no **build** (imutáveis — identidade do artefato).
- **version / environment** = **injetados no deploy em runtime** (a partir da tag). A mesma imagem é promovida sob qualquer tag, sem rebuild.
- **Frontend** lê tudo de `/api/version` (`useAppVersion()`); não embute versão. Fonte única = backend.
---
## 5. Runbook — subir um release ✅
> ⚠️ **Regra de ouro:** confirme **DEV ou PRODUÇÃO**. `dev.scoreodonto.com` = VPS4 (banco `pgdev`). `scoreodonto.com` = VPS1 (real). **Nunca** `git push --force` na `main`. Push/tag exigem autorização explícita.
```bash
# 1) VPS4: validar em DEV
cd /home/deploy/stack/scoreodonto.com
node --check backend/server.js
docker compose build scoreodonto-backend scoreodonto-frontend
docker compose up -d --force-recreate scoreodonto-backend scoreodonto-frontend # https://dev.scoreodonto.com
# 2) commit + push → CI builda 1x e publica :<commit> + :latest (NÃO deploya)
git add -A && git commit -m "feat: <descrição>"
git push origin main
# 3) tag = "aprovado p/ produção" → job PROMOTE re-tagueia :<commit>→:vX.Y.Z (SEM rebuild) + deploya
# (o número da versão é livre — não precisa casar com nenhum arquivo)
git tag -a vX.Y.Z -m "release vX.Y.Z" && git push origin vX.Y.Z
# 4) verificar (sem SSH)
curl -s https://scoreodonto.com/api/version # → vX.Y.Z · <commit> · PROD
```
**Backup do banco de PRODUÇÃO antes de releases sensíveis** (via container, pois não há shell na VPS3):
```bash
docker run --rm -e PGPASSWORD=<senha> -v /home/deploy/backups:/b postgres:18 \
pg_dump -h 10.99.0.3 -U scoreodonto_user -d scoreodonto -Fc -f /b/scoreodonto_$(date +%Y%m%d_%H%M).dump
```
---
## 6. Rollback ✅
Por imagem, **sem rebuild** — na VPS1:
```bash
/home/deploy/stack/scoreodonto.com/deploy-prod.sh <tag-ou-commit-anterior> # pull + up --no-build
```
⚠️ A referência confiável é o **commit** (`:<sha>`). Tags `:vX.Y.Z` antigas (pré-Passo 5) podem ter sido republicadas com código diferente — a partir do Passo 5, `:vX.Y.Z` é sempre um alias imutável do commit. Migrações são aditivas (voltar o código é seguro). Dump em `/home/deploy/backups/` só em extremo.
---
## 7. Follow-ups (não bloqueiam) 🎯
- `GET /api/health` (monitoramento/diagnóstico).
- **Política de retenção** do registry (uma imagem `:<commit>` por push acumula).
- Reconciliar o repo na VPS1 (working tree sujo, não afeta o deploy por imagem).
Racional do modelo em `deploy.md`; histórico em `project-cicd-tag-promove-nao-rebuilda` (memória).
+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.
+4 -4
View File
@@ -1,6 +1,6 @@
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
> Resposta direta às 6 perguntas essenciais, **verificada em 17/jun/2026**. Somente fatos do estado atual.
> 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).
@@ -9,7 +9,7 @@
### 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.16 · commit `471c2c2`**.
- **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?
@@ -28,7 +28,7 @@
- ✅ **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.16 · `471c2c2` · PROD**.
- 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.
@@ -39,4 +39,4 @@
---
## Veredito de uma linha
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.16**), 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.
> 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.
+24 -10
View File
@@ -1,11 +1,18 @@
# ─── Dev override — NO docker compose build required ─────────────────────────
# ─── Dev override — modo desenvolvimento com HMR ─────────────────────────────
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Uso:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# What changes in dev:
# - Backend: Runs node server.js with direct hot mount of backend/
# - Frontend: Runs vite dev server on port 3013 with Hot Module Replacement (HMR)
# O que muda em relação à produção:
# - Backend: monta ./backend ao vivo (node server.js), preservando o
# node_modules da imagem (node:20-slim) — código reflete sem rebuild.
# - Frontend: builda o stage `builder` (node:20-alpine, que TEM vite +
# node_modules no arch musl correto) e roda o vite dev server na 3013 com
# Hot Module Replacement. O nginx (default.conf) já faz proxy de / → :3013
# com headers de Upgrade, então o WS de HMR passa.
#
# ⚠️ Este box serve APENAS dev.scoreodonto.com. A produção (scoreodonto.com)
# roda em outro servidor — flipar esta stack para dev NÃO afeta produção.
# ─────────────────────────────────────────────────────────────────────────────
services:
@@ -15,13 +22,20 @@ services:
- NODE_ENV=development
volumes:
- "/home/deploy/scoreodonto-sessions:/app/sessions"
- "./backend:/app"
- "/home/deploy/scoreodonto-media:/app/media"
- "./backend:/app" # source ao vivo (server.js + newwhats/ do túnel)
- "/app/node_modules" # preserva node_modules da imagem (arch correto)
- "./plugins:/app/plugins"
scoreodonto-frontend:
build:
context: "."
dockerfile: frontend/Dockerfile
target: builder # node:20-alpine com vite + node_modules (musl)
command: ["npx", "vite", "--port", "3013", "--host", "0.0.0.0"]
environment:
- PORT=3013
- NODE_ENV=development
- DEV_PUBLIC_HOST=dev.scoreodonto.com # libera host + roteia HMR por wss:443 (vite.config)
volumes:
- "./frontend:/app"
- /app/node_modules
- "./frontend:/app/frontend" # source ao vivo (HMR)
- "/app/frontend/node_modules" # preserva node_modules da imagem (musl)
+5 -2
View File
@@ -37,8 +37,11 @@ services:
- BAILEYS_ENGINE=infinite
- PORT=8018
- NODE_ENV=production
- DATABASE_URL=postgresql://${SCOREO_DB_USER:-scoreodonto_user}:${SCOREO_DB_PASS:-clube67_scoreodonto_pass_9903}@${SCOREO_DB_HOST:-10.99.0.3}:5432/${SCOREO_DB_NAME:-scoreodonto}?schema=public
- DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379/1
# Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD).
# `:?` falha o `up` se a senha não estiver definida — evita subir com senha vazia/errada.
# Host/usuário/banco mantêm default (não são segredo); só a senha é obrigatória via env.
- DATABASE_URL=postgresql://${SCOREO_DB_USER:-scoreodonto_user}:${SCOREO_DB_PASS:?defina SCOREO_DB_PASS no .env}@${SCOREO_DB_HOST:-10.99.0.3}:5432/${SCOREO_DB_NAME:-scoreodonto}?schema=public
# DRAGONFLY_URL (com senha) vem do env_file backend/.env — não fica em texto no compose.
- NATS_URL=nats://10.99.0.3:4222
- TEMPORAL_ADDRESS=10.99.0.3:7233
- BAILEYS_SESSIONS_PATH=/app/sessions
@@ -0,0 +1,157 @@
# AUDITORIA ESTRATÉGICA DE MODELAGEM — ScoreOdonto
> **Análise crítica da modelagem e da visão de negócio.** Não altera código, banco, documentação ou migrations.
> Baseada exclusivamente em evidências reais: `backend/server.js`, schema real (tabelas), fluxos existentes e
> `MAPA-FUNCIONAL-COMPLETO.md`. Estado verificado: produção `v1.0.18` · DEV `dev`. Data: 19/jun/2026.
---
## Evidências-âncora (o que o código realmente diz)
| Camada | Evidência no código | Tabela/linha |
|---|---|---|
| **Identidade (Pessoa)** | `usuarios` é o ponto único de login; perfil multi-área (`usuario_areas`), capacidades por pessoa (`usuario_plugins`), `is_tutor` | `usuarios`, `me/areas`, `me/plugins` |
| **Vínculo (join)** | `vinculos(usuario_id, clinica_id, role, setor_id, funcao_id)` + `CHECK role IN (paciente, dentista, biomedico, protetico, funcionario, donoclinica, donoconsultorio, donosala, admin)` | `server.js:4851` |
| **Workspace** | `clinicas` ganhou `tipo ∈ {pessoal, consultorio, clinica}` + `owner_id`; comentário literal **"Clinicas → workspaces (Fase 1)"** | `server.js:4822-4824` |
| **Workspace pessoal automático** | No registro, cria `clinicas(tipo='pessoal', owner_id=usuario)`**toda pessoa nasce com um workspace** | `server.js:537,550` |
| **Agregação** | `me/workspaces``listarWorkspaces(userId)` reúne clínicas (via vínculo) + salas | `server.js:5680` |
| **Marketplace = pessoa** | `propostas_profissional.profissional_id``JOIN usuarios u` | `server.js:6307` |
| **Legado clínica-cêntrico** | `pacientes` tem `clinica_id` e **não tem `usuario_id`**; `dentistas` é tabela à parte com `usuario_id` opcional; tudo passa por `tenantGuard`+`clinica_id` | `pacientes`, `dentistas:1542` |
**Leitura imediata:** a *espinha de identidade e acesso* **já é** Pessoa → Vínculo → Workspace (e isso está em migração ativa, declarada como "Fase 1"). Os *dados clínicos/operacionais* **ainda são** Clínica → Pessoa. O sistema é **híbrido, com a direção já apontada pelo próprio código**.
---
## ANÁLISE 1 — Pessoa × Workspace
1. **Centrado em clínica ou pessoa?** Híbrido. **Autenticação/autorização/workspaces:** centrado em **pessoa**. **Operação clínica e financeira:** centrado em **clínica** (`tenantGuard` + `clinica_id`).
2. **A pessoa é entidade principal?** **Sim, há evidência forte:** `usuarios` é a âncora; áreas, plugins e tutor são da pessoa; o profissional do marketplace é a pessoa; e cada pessoa recebe um **workspace pessoal** ao se cadastrar.
3. **A clínica é apenas um workspace?** **Sim, há evidência explícita:** `clinicas.tipo` pode ser `pessoal | consultorio | clinica`, com `owner_id`. A clínica é hoje **um tipo de workspace** dentro da própria tabela `clinicas` (que virou, de fato, a tabela de workspaces).
4. **Módulos que favorecem Pessoa→Vínculo→Workspace:** login/identidade, `vinculos`+RBAC (`funcoes`/`setores`), `me/workspaces`, `usuario_plugins`, `usuario_areas`, Salas (workspace `tipo='sala'`), Marketplace de Profissionais, Tutoria.
5. **Módulos que favorecem Clínica→Pessoa:** Pacientes, Dentistas (tabela-recurso), Agenda, Financeiro/Comissão/Repasse, Orçamentos/Contratos, Glosas — **toda a operação** ainda é escopada por `clinica_id`.
6. **Mais sustentável em 5 anos:** **Pessoa → Vínculo → Workspace.** Justificativa técnica: a tabela `vinculos` é N:N (uma pessoa, vários workspaces, papéis distintos por workspace) — é o único modelo que comporta dentista em várias clínicas, locação de salas, tutoria e marketplace **sem duplicar a pessoa**. O modelo clínica-cêntrico exige duplicar o humano por clínica (é o que `dentistas` já faz, e é exatamente o gargalo).
---
## ANÁLISE 2 — Consultório
**Hoje é simultaneamente (A) Papel e (C) Workspace:** existe a *role* `donoconsultorio` em `vinculos` **e** o `tipo='consultorio'` em `clinicas` (`server.js:590` decide o `tipoWs` a partir da role do dono).
**Classificação ideal:** **(C) Workspace** — um `tipo` de workspace, sendo `donoconsultorio` apenas o *papel do dono* dentro dele. O sistema já está ~80% nesse modelo.
**Impactos:** baixo do ponto de vista de dados (já existe `tipo='consultorio'`). O resíduo é **semântico**: a role codifica o tipo do workspace (acoplamento role↔tipo). Enquanto persistir, decisões de UI/permissão ramificam em dois lugares ("é consultório?" pode ser perguntado pela role *ou* pelo tipo), gerando divergência.
---
## ANÁLISE 3 — Protéticos
**Hoje o protético é:** **Usuário/Profissional.** É `usuarios` com role `protetico` em `vinculos`, recebe workspace pessoal, e produz trabalho via `protese_os`/`protese_produtos`.
**Existe conflito conceitual? Sim.** O protético-pessoa está modelado como **profissional do ecossistema**, mas o **laboratório** (onde o trabalho/custo realmente reside, `protese_laboratorios` + custo lab que abate comissão) é um **cadastro interno separado**. Ou seja, a faceta "fornecedor" está **descolada** da pessoa: a pessoa é profissional, o fornecedor é um cadastro à parte.
**Modelo que faz mais sentido: (B) Protético = profissional do ecossistema** — e o **laboratório** é que ocupa o papel de *fornecedor*. Justificativa: o protético, como humano, se encaixa perfeitamente em Pessoa→Vínculo→Workspace (igual a dentista/biomédico); empurrá-lo para "fornecedor" o trataria como entidade não-humana e quebraria o login/marketplace/áreas que já funcionam para ele. A natureza "fornecimento" pertence ao **laboratório**, não ao indivíduo (ver Análise 4). (C) só seria verdade se um protético autônomo *fosse* o próprio laboratório — caso que o modelo atual não distingue.
---
## ANÁLISE 4 — Laboratórios
**Hoje aparecem como:** **(A) Cadastro interno** — `protese_laboratorios` escopado por clínica, alimentando OS e custo lab. Não há identidade própria nem login; não cruzam clínicas.
| Classificação | Vantagens | Desvantagens |
|---|---|---|
| **A) Cadastro auxiliar** (estado atual) | Simples; isolado por clínica; zero atrito | Cada clínica recadastra o mesmo laboratório; sem visão consolidada; sem relação direta com a pessoa protético |
| **B) Participante do ecossistema** (entidade própria) | Laboratório único reaproveitável; liga custo lab à entidade real; base para reputação/histórico | Exige resolver identidade/duplicidade e governança de dados entre clínicas |
| **C) Workspace independente** | Coerente com a linha Pessoa/Workspace; laboratório operaria como ator (recebe OS de várias clínicas) | Maior complexidade de tenancy; só se justifica se o laboratório for *operar dentro* da plataforma |
**Análise (sem assumir locação):** hoje é **A**, e isso é consistente com o legado clínica-cêntrico. A tensão é que o laboratório é a **única entidade "fornecedor" real** do sistema e está modelada como simples lookup — o que limita qualquer evolução para histórico/qualidade/integração entre clínicas. **B** é o ponto de equilíbrio entre o estado atual e a direção arquitetural; **C** só se sustenta se o laboratório virar *operador* na plataforma.
---
## ANÁLISE 5 — Pacientes
**Hoje o paciente pertence à CLÍNICA.** Evidências: `pacientes.clinica_id`, **ausência de `usuario_id`**, e até **dois variantes de INSERT** (um com `clinica_id`, outro sem — resíduo legado). Não há identidade de paciente no ecossistema; o mesmo CPF em duas clínicas vira **dois registros**.
- **Compartilhamento entre clínicas:** inexistente (duplicação por clínica).
- **Histórico:** preso à clínica; não há prontuário único da pessoa.
- **Multiempresa:** o isolamento é forte (bom para tenancy), mas impede portabilidade.
- **Privacidade/LGPD:** o modelo atual é, na verdade, **mais seguro por isolamento** (cada clínica é controladora dos seus dados). Um paciente "do ecossistema" exigiria base legal/consentimento para compartilhar histórico entre controladores distintos — não trivial.
**Abordagem mais coerente com a arquitetura ATUAL:** **o paciente pertence à clínica.** É o que o código faz e o que o LGPD-por-isolamento favorece. **Porém** isso é a **maior contradição** com a direção Pessoa→Workspace: o paciente é a única "pessoa" do sistema que **não** é tratada como pessoa. É coerente com o presente e **incoerente com o futuro declarado**.
---
## ANÁLISE 6 — Marketplace
| Módulo | Encaixa em | Evidência |
|---|---|---|
| Salas | **Modelo B** | workspace `tipo='sala'`, reservas cross-clínica |
| Profissionais | **Modelo B** | `profissional_id``usuarios`; cruza clínicas |
| Tutoria | **Modelo B** | candidatura/pagamento ligados à pessoa (`is_tutor`, MercadoPago) |
| Próteses | **Modelo A** (hoje) | `protese_os` por `clinica_id` |
| Laboratórios | **Modelo A** (hoje) | cadastro interno por clínica |
**Conclusão:** o marketplace **só faz sentido no Modelo B (Pessoa→Vínculo→Workspace→Marketplace)**. Salas, Profissionais e Tutoria já estão lá porque dependem da **pessoa cruzando fronteiras de clínica** — algo que o Modelo A (Clínica→Tudo) torna impossível sem duplicação. Próteses/Laboratórios são os **retardatários** ainda presos ao Modelo A; são a fronteira onde os dois modelos colidem.
---
## ANÁLISE 7 — Escalabilidade (horizonte 5 anos)
| Cenário | Suporta hoje? | Gargalo |
|---|---|---|
| Dentista em múltiplas clínicas | **Parcial** | `vinculos` suporta a identidade, mas `dentistas` **duplica a pessoa por clínica** (agenda/horários presos a `dentistas.id`); não há visão unificada do profissional |
| Biomédico em vários locais | Parcial | mesma duplicação via tabela-recurso |
| Profissionais temporários (Delivery) | **Não** | marketplace só tem proposta-mensagem; sem contrato/pagamento/vínculo temporário |
| Locação de salas | **Sim (estrutura)** | falta captura de pagamento (fluxo financeiro aberto) |
| Tutoria | **Sim** | escala de oferta de tutores |
| Marketplace | Parcial | só Tutoria fatura |
| Protéticos independentes | Parcial | pessoa ok, mas laboratório-fornecedor é cadastro preso à clínica |
| Redes de clínicas | **Não** | **não existe entidade "rede/grupo" acima da clínica**; workspaces são planos, sem hierarquia |
**O modelo escala?** A **camada de identidade sim**; a **camada operacional não**. Gargalos críticos: (1) **duplicação da pessoa** em `dentistas`/recursos; (2) **paciente não-portável**; (3) **ausência de hierarquia** (rede→clínica); (4) **tenancy de `clinica_id` único** impede consolidação cross-clínica (uma pessoa ver tudo o que é seu em todas as clínicas).
---
## ANÁLISE 8 — Contradições (por gravidade)
| Gravidade | Contradição | Evidência |
|---|---|---|
| **ALTA** | `dentistas` é entidade paralela a `usuarios` (mesmo humano duplicado por clínica, com `usuario_id` *opcional*) → identidade fragmentada | `dentistas:1542`, `:328` |
| **ALTA** | `pacientes` é entidade clínica-presa sem identidade de pessoa, com **dois INSERTs** (com/sem `clinica_id`) → ambiguidade legada + bloqueio de portabilidade | INSERTs de `pacientes` |
| **MÉDIA** | O "workspace" está **dividido em duas tabelas**: `clinicas` (pessoal/consultorio/clinica) **e** `salas` — conceito único, dois lugares | `clinicas:4823`, `salas` |
| **MÉDIA** | Acoplamento **role ↔ tipo de workspace** (`donoconsultorio``tipo='consultorio'`) | `server.js:590` |
| **MÉDIA** | Estado de capacidades em **dois lugares**: `usuario_plugins` (servidor) vs `localStorage` (cliente) → divergência entre dispositivos | `pluginRegistry.ts`, `me/plugins` |
| **BAIXA** | Protético-pessoa (profissional) × laboratório (fornecedor) descolados | `protese_laboratorios` |
| **BAIXA** | `superadmin` fora de `vinculos``usuarios.role`) → plano de auth separado (aceitável, mas assimétrico) | `me/workspaces:5682` |
---
## RESULTADO FINAL
**1. Resumo executivo.** O ScoreOdonto é um ERP odontológico multiempresa em **transição arquitetural declarada no próprio código** ("Fase 1: Clinicas → workspaces"). A **identidade, o acesso e os workspaces já operam no modelo Pessoa→Vínculo→Workspace**; a **operação clínica/financeira permanece clínica-cêntrica**. As três frentes de marketplace já vivem no modelo novo; pacientes, dentistas-recurso e laboratórios são o legado que ainda não migrou. As contradições graves são todas de **identidade duplicada** (a mesma pessoa existindo como `usuarios` + `dentistas`, e o paciente sem identidade de pessoa).
**2. Modelo que melhor representa o sistema HOJE:** **híbrido**`Pessoa → Vínculo → Workspace` na espinha; `Clínica → Pacientes/Dentistas/Financeiro` na operação.
**3. Modelo que melhor representa o sistema NO FUTURO:** `Pessoa → Vínculo → Workspace → Serviços → Marketplace → Financeiro` — e o código já está pavimentado para ele.
**4. Entidades CORRETAMENTE classificadas:** `usuarios` (Pessoa), `vinculos` (Vínculo+RBAC), Clínica/Consultório/Sala como **workspaces**, Dentista/Biomédico/Protético/Tutor como **papéis da pessoa**, Profissional do marketplace e capacidades (`areas`/`plugins`) como atributos da pessoa.
**5. Entidades MAL classificadas:**
- **`dentistas`** — modelada como entidade própria; é, na verdade, uma **projeção da pessoa por workspace** (duplica o humano).
- **`pacientes`** — entidade presa à clínica; é a única "pessoa" do sistema **não tratada como pessoa**.
- **Laboratórios** — cadastro auxiliar; é o **único fornecedor real** e está subdimensionado.
- **Consultório** — vive como role **e** tipo (redundância).
**6. Riscos arquiteturais:** identidade fragmentada (dentistas); paciente não-portável e com resíduo de schema; conceito de workspace partido em duas tabelas; ausência de hierarquia "rede→clínica"; tenancy de `clinica_id` único barrando visão consolidada da pessoa; estado de plugins divergente cliente/servidor.
**7. Oportunidades arquiteturais:** a "Fase 1" **já entregou o mais difícil** (identidade + vínculo + workspace + workspace pessoal automático). O ecossistema (marketplace, salas, tutoria) **só existe** porque essa base existe. Unificar as entidades-recurso (dentista) e a entidade-paciente sob a pessoa **destravaria de uma vez** multi-clínica, portabilidade de histórico, redes e o marketplace — sem reescrever o que já está certo.
**8. Recomendação estratégica única:** **assumir e consolidar a PESSOA como âncora única do sistema, reconhecendo que a clínica já é — no código — apenas um tipo de workspace.** A decisão estratégica de fundo não é "se" migrar para Pessoa→Vínculo→Workspace (o sistema já escolheu isso na Fase 1), e sim **parar de manter o legado clínica-cêntrico em paralelo** nas duas entidades que ainda o sustentam (paciente e dentista-recurso) — pois é a coexistência dos dois modelos, não a escolha de um, que gera todas as contradições de gravidade ALTA.
---
> *Nota de método: tudo acima decorre de leitura de `backend/server.js`, do schema real (tabelas `usuarios`,
> `vinculos`, `clinicas`, `pacientes`, `dentistas`, `salas`, `protese_*`, `propostas_profissional`) e do
> `MAPA-FUNCIONAL-COMPLETO.md`. Onde o código tinha ambiguidade (ex.: dois INSERTs de `pacientes`, role↔tipo),
> descreveu-se o fato observado, sem inferir intenção. Conforme solicitado, não há propostas de implementação,
> migrations, código ou tarefas.*
</content>
+485
View File
@@ -0,0 +1,485 @@
# MAPA FUNCIONAL COMPLETO — ScoreOdonto
> **Auditoria funcional baseada SOMENTE em evidências do código.** Nada aqui foi assumido.
> Fonte: `frontend/` (React 19 + Vite, roteador próprio em `App.tsx`) e `backend/server.js`
> (Express monolítico, ~516 KB, **277 rotas**, ~70 tabelas Postgres acessadas por SQL direto).
> Estado verificado: produção `v1.0.18` · DEV `dev` (banco isolado `pgdev`). Data: 19/jun/2026.
---
## Sumário executivo (o que o sistema É, na prática)
O ScoreOdonto é um **ERP odontológico multiempresa** (SaaS) cujo núcleo é o ciclo
**Lead → Paciente → Agenda → Procedimento → Financeiro → Comissão → Repasse**, com módulos
clínicos (Ortodontia, Próteses/GTO, RX), administrativos (Equipe/RBAC, Planos, Procedimentos)
e três frentes de marketplace (**Locação de Salas**, **Diretório/Propostas de Profissionais**,
**Tutoria de Ortodontia** — esta última a única com cobrança automatizada via MercadoPago).
A arquitetura é **multi-tenant real**: quase toda rota passa por `tenantGuard` e filtra por
`clinica_id`. O "workspace" ativo (clínica **ou** sala) governa menu, permissões e dados visíveis.
**Evidências estruturais:**
- Frontend sem `react-router`: roteamento próprio (`ROUTE_MAP` / `VIEW_TO_ROUTE` em `App.tsx`).
- 44 views (`frontend/views/*.tsx`), 16 componentes, 3 plugins.
- Backend num único `server.js` (516 KB) — sem camada de serviços/controllers separada.
- Prisma quase não usado (4 modelos legados de GTO); o banco real é SQL puro via `pg`.
---
# ETAPA 1 — INVENTÁRIO DE ROTAS
Roteamento definido em `frontend/App.tsx` (`ROUTE_MAP`, `VIEW_TO_ROUTE`, `renderView`, `isViewAllowed`).
**Papéis (roles):** `superadmin`, `admin`, `donoclinica`, `donoconsultorio`, `funcionario`,
`dentista`, `biomedico`, `protetico`, `paciente`, `donosala`.
**Tipos de workspace:** `clínica` (padrão) e `sala` (isola o contexto). **Plugins:** `rx-scoreodonto`,
`locacao-salas` (sempre ON), `profissionais-marketplace`.
| URL | View | Componente / Arquivo | Acesso (papéis) | Origem | Destino |
|---|---|---|---|---|---|
| `/` | landing | `LandingPage.tsx` | Público | — | login / área |
| `/login` | login | `LoginView.tsx` | Público | landing | dashboard por papel |
| `/contato` | public | `PublicContactForm.tsx` | Público | landing | cria interesse/lead |
| `/dashboard` | dashboard | `Dashboard.tsx` | admin/donos/func/dentista/bio/prot | login, menu | agenda, pacientes |
| `/leads` | leads | `LeadsView.tsx` | admin/donos/funcionário | menu | converter→paciente+agenda |
| `/pacientes` | pacientes | `PatientsView.tsx` | admin/donos/funcionário | menu, dashboard | agenda, orçamento, procedimento, ortodontia, RX |
| `/agenda` | agenda | `AgendaView.tsx` | quase todos os papéis clínicos | menu, dashboard | paciente, avaliação, procedimento |
| `/ortodontia` | ortodontia | `OrthoView.tsx` | dentista (odonto) | menu, paciente | evolução, fotos, tutoria |
| `/financeiro` | financeiro | `FinanceiroView.tsx` | admin/donos/funcionário | menu | comissão, glosa |
| `/comissoes` | comissoes | `ComissoesView.tsx` | admin/donos | **menu apenas** ⚠️ | repasse |
| `/glosas` | glosas | `GlosasView.tsx` | admin/donos/funcionário | **menu apenas** ⚠️ | recebíveis convênio |
| `/proteses` | proteses | `ProteseView.tsx` | dentista/protético/func | menu | OS de laboratório, GTO |
| `/orcamentos` | orcamentos | `OrcamentosView.tsx` | admin/donos/funcionário | menu, paciente | contrato |
| `/contratos` | contratos | `ContratosView.tsx` | admin/donos/funcionário | menu, orçamento | cobrança/financeiro |
| `/relatorios` | relatorios | `ReportsView.tsx` | admin/donos/funcionário | menu | — |
| `/tratamentos` | tratamentos | `MeusTratamentos.tsx` | paciente + clínicos | menu | — |
| `/lancar-gto` | lancar-gto | `LancarGTO.tsx` | biomédico/protético/func | menu | guias odontológicas |
| `/notificacoes` | notificacoes | `NotificationsView.tsx` | todos | menu | — |
| `/clinicas` | clinicas | `ClinicasView.tsx` | membros | menu, seletor de workspace | criar-clinica |
| `/gestao-equipe` | gestao-equipe | `GestaoEquipeView.tsx` | admin/donos | menu | membros, cargos (RBAC) |
| `/admin/dentistas` | dentistas | `AdminViews.tsx` | admin/donos | menu | horários |
| `/admin/especialidades` | especialidades | `AdminViews.tsx` | admin/donos/biomédico | menu | procedimentos |
| `/admin/procedimentos` | procedimentos | `AdminViews.tsx` | admin/donos/biomédico | menu | valores por plano |
| `/admin/planos` | planos | `AdminViews.tsx` | admin/donos | menu | — |
| `/admin/sync` | sync | `AdminViews.tsx` (`SyncView`) | **NINGUÉM (bloqueado)** ⚠️ | — | — |
| `/plugins` | plugins | `PluginsView.tsx` | **superadmin apenas** | menu (superadmin) | ativa plugins |
| `/rx-radiografias` | rx-radiografias | `plugins/RXPlugin.tsx` | dentista/donos/func (plugin RX) | menu | dispositivos RX |
| `/rx-config` | rx-config | `RxConfigView.tsx` | admin/donos/superadmin | menu | dispositivos RX |
| `/salas` `/minhas-salas` `/alugar-salas` | salas | `plugins/SalasPlugin.tsx` | todos (plugin `locacao-salas`, sempre ON) | menu | reservas |
| `/sala-home` | sala-home | `SalaHomeView.tsx` | workspace tipo `sala` | seletor de workspace | reservas da sala |
| `/marketplace-profissionais` | marketplace-profissionais | `plugins/ProfissionaisPlugin.tsx` | clínicos exceto paciente (plugin) | menu | propostas |
| `/diretorio-profissionais` | diretorio-profissionais | `DiretorioProfissionaisView.tsx` (absorvido pelo plugin) | clínicos | menu | — |
| `/candidatura-tutor` | candidatura-tutor | `TutorCandidaturaView.tsx` | dentista | menu | pagamento MercadoPago |
| `/tutoria-painel` | tutoria-painel | `TutorPainelView.tsx` | **tutores aprovados** (`is_tutor`) | menu | solicitações |
| `/admin-gestao-tutores` | admin-gestao-tutores | `AdminGestaoTutoresView.tsx` | **superadmin** | menu | confirma pagamentos/repasses |
| `/criar-clinica` | criar-clinica | `CreateClinicView.tsx` | admin/donos sem workspace | pós-cadastro | dashboard |
| `/cadastro-dentista` | cadastro-dentista | `DentistRegisterView.tsx` | standalone (magic-link) | **menu apenas** ⚠️ | aguardando-convite |
| `/aguardando-convite` | aguardando-convite | `WaitingInviteView.tsx` | dentista/bio/prot sem vínculo | pós-cadastro | — |
| `/superadmin/*` | superadmin-* | `SuperAdminView.tsx` (tabs) | **superadmin** | menu | usuários, clínicas, logs, financeiro, notificações |
| `/update` | update | `UpdateDbView.tsx` | técnica (URL direta, sempre liberada) | **fora do menu** | migração de banco |
**⚠️ Inconsistências de roteamento encontradas (evidência: `ROUTE_MAP` vs `VIEW_TO_ROUTE` em `App.tsx`):**
- `/comissoes`, `/glosas` e `/cadastro-dentista` **existem em `VIEW_TO_ROUTE`** (a navegação por menu empurra a URL),
mas **NÃO existem em `ROUTE_MAP`**. Consequência: abrir essas URLs direto (deep-link / refresh) cai em `dashboard`.
**Deep-link quebrado** para 3 telas reais.
- `/admin/sync` está mapeada, mas `isViewAllowed` retorna `false` **sempre** (substituída por "backup por clínica").
A tela e a rota existem, porém são **inacessíveis** (código morto navegável).
---
# ETAPA 2 — INVENTÁRIO DE TELAS
(Resumo por tela — objetivo / usuário-alvo / valor / dependências / impacto. Apenas telas reais.)
### Dashboard
- **Objetivo:** visão consolidada (KPIs via `GET /api/dashboard/stats`). **Usuário:** gestores/clínicos.
- **Valor:** ponto de entrada operacional. **Depende de:** agenda, pacientes, financeiro. **Impacta:** navegação.
### Leads / Gestão de Leads
- **Objetivo:** captar e **converter** interessados em pacientes. **Usuário:** funcionário/donos.
- **Valor:** topo do funil comercial. **Depende de:** `interesses`/contato público. **Impacta:** Pacientes + Agenda
(a conversão cria ambos — `POST /api/leads/:id/converter`).
### Pacientes
- **Objetivo:** prontuário e cadastro (dados, família, score, faltas). **Usuário:** funcionário/donos.
- **Valor:** entidade central do negócio. **Depende de:** clínica ativa. **Impacta:** Agenda, Orçamento,
Procedimento realizado, Ortodontia, RX, Receita/Exame. É o hub clínico (view de 92 KB).
### Agenda
- **Objetivo:** agendar/confirmar/remarcar; controle de presença e anti-overbooking. **Usuário:** todos clínicos.
- **Valor:** coração operacional. **Depende de:** Pacientes, Dentistas+horários. **Impacta:** Avaliação,
Procedimento, Financeiro (presença libera lançamento).
### Ortodontia
- **Objetivo:** tratamento orto completo (triagem, diagnóstico, plano, evoluções, fotos, contenção). **Usuário:** dentista.
- **Valor:** vertical clínica de alto valor recorrente. **Depende de:** Paciente. **Impacta:** Tutoria, fotos.
### Próteses / GTO
- **Objetivo:** OS de laboratório (`protese_os`), produtos, custo lab, entrega. **Usuário:** protético/dentista.
- **Valor:** controle do laboratório e do custo que abate comissão. **Depende de:** Procedimentos, Laboratórios.
**Impacta:** Comissão (custo lab entra no cálculo).
### Financeiro
- **Objetivo:** contas a receber/pagar (`financeiro`: RECEITA/DESPESA), formas, centro de custo, fornecedor. **Usuário:** gestores.
- **Valor:** caixa da clínica. **Depende de:** Procedimentos realizados, Contratos, Repasses. **Impacta:** Comissão, Glosa.
### Comissões
- **Objetivo:** definir % (padrão da clínica, por procedimento, override por profissional) e ver produção/comissão.
- **Usuário:** donos. **Depende de:** Procedimentos realizados + Próteses (custo lab). **Impacta:** Repasses.
### Glosas
- **Objetivo:** registrar glosa sobre recebíveis (convênio), recorrer, recuperar. **Usuário:** gestores.
- **Valor:** recupera receita negada por convênio. **Depende de:** Financeiro (recebíveis). **Impacta:** caixa.
### Orçamentos → Contratos
- **Objetivo:** propor tratamento (itens/valores) e formalizar em contrato (modelos com variáveis, assinatura, cobrança).
- **Usuário:** funcionário/donos. **Depende de:** Paciente, Procedimentos. **Impacta:** Financeiro (`gerar-cobranca`).
### Equipe / Usuários (RBAC)
- **Objetivo:** membros, cargos (`funcoes`), setores e permissões por workspace. **Usuário:** donos.
- **Valor:** governa quem vê o quê. **Impacta:** menu e `isViewAllowed` de todos os membros.
### Salas (Locação) / Painel da Sala
- **Objetivo:** cadastrar salas, publicar no marketplace, receber/aceitar reservas. **Usuário:** qualquer conta.
- **Valor:** receita de locação P2P. **Depende de:** conta/sala. **Impacta:** `sala_reservas`.
### Marketplace de Profissionais / Diretório
- **Objetivo:** profissional publica perfil; clínica envia proposta (`propostas_profissional`). **Usuário:** clínicas e profissionais.
- **Valor:** conexão oferta/demanda de mão de obra. **Impacta:** mensagens/propostas (sem cobrança hoje).
### Tutoria de Ortodontia (Candidatura / Painel / Gestão)
- **Objetivo:** dentista vira tutor (pagando inscrição via MercadoPago); resolve solicitações orto.
- **Usuário:** dentistas + superadmin (gestão). **Valor:** **única receita de plataforma automatizada**.
### SuperAdmin (overview/users/clínicas/logs/financeiro/notificações)
- **Objetivo:** administração global da plataforma (preços/assinaturas, broadcast, auditoria de acesso).
- **Usuário:** superadmin. **Valor:** operação do SaaS.
### Configurações / Notificações / Minhas Clínicas
- Conta, integrações (Google), backup por clínica; notificações; seletor/gestão de workspaces.
---
# ETAPA 3 — INVENTÁRIO DE BOTÕES (telas transacionais)
Rótulos extraídos das views; ações mapeadas para as rotas/tabelas do backend.
### Agenda (`AgendaView.tsx`)
- **Novo Agendamento**`POST /api/agendamentos` → tabela `agendamentos`. Fluxo: Paciente ↓ Procedimento ↓ Financeiro.
- **Confirmar / Confirmar Presença**`POST /api/agendamentos/:id/confirmar` + `/api/agenda/presenca``agendamentos`, `agenda_presenca`. **Libera lançamento financeiro.**
- **Reagendar / Remarcar**`POST /api/agendamentos/:id/remarcar``agendamentos`.
- **Registrar Falta**`POST /api/agendamentos/:id/falta``agendamentos` (impacta score do paciente).
- **Solicitar Avaliação**`POST /api/agendamentos/:id/solicitar-avaliacao``avaliacoes`.
### Pacientes (`PatientsView.tsx`)
- **Editar/Cadastrar Paciente**`POST|PUT /api/pacientes``pacientes`.
- **Agendar** → abre fluxo da Agenda. **Lançar Procedimento** (`LancarProcedimentoModal`) → `POST /api/procedimentos-realizados``procedimentos_realizados` (+ gera `financeiro`).
- **Orçamento** (`OrcamentoModal`) → `orcamentos`. **Receita** (`ReceitaModal`) → `modelos_receita`/`items_receita`. **Pedido de Exame** (`PedidoExameModal`) → `pedidos_exame`.
### Financeiro (`FinanceiroView.tsx`)
- **Novo lançamento** (RECEITA/DESPESA, forma, vencimento, centro de custo, fornecedor) → `POST /api/financeiro``financeiro`.
- **Marcar Pago / Alterar / Excluir**`PUT|DELETE /api/financeiro/:id``financeiro`.
### Comissões (`ComissoesView.tsx`)
- **Salvar % padrão / por procedimento / override por profissional**`PUT /api/comissoes/regras``comissao_regras`.
- **Base de cálculo** (produção × recebido) e **Custo lab** abatido. Próximo fluxo: **Fechar repasse**.
### Repasses (em Financeiro/Comissões)
- **Fechar Repasse**`POST /api/repasses/fechar``repasses` (status `fechado`).
- **Pagar**`POST /api/repasses/:id/pagar` → cria DESPESA em `financeiro` (origem `repasse`, centro de custo `COMISSÕES`).
- **Anexar Comprovante**`POST /api/repasses/:id/comprovante`. **Cancelar** → estorna a despesa.
### Glosas (`GlosasView.tsx`)
- **Registrar Glosa** (total/livre, motivo, valor) → `POST /api/glosas``glosas`.
- **Recorrer**`POST /api/glosas/:id/recorrer`. **Recuperar**`POST /api/glosas/:id/recuperar`.
### Orçamentos / Contratos
- **Novo Orçamento** (itens, valor, entrada, parcelas) → `POST /api/orcamentos``orcamentos`/`orcamento_itens`.
- **Gerar Contrato** (modelo + variáveis + prévia) → `POST /api/contratos/instancias``contratos`.
- **Enviar / Assinar / Gerar Cobrança / Cancelar**`POST /api/contratos/instancias/:id/{enviar,assinar,gerar-cobranca,cancelar}` → liga ao `financeiro`.
### Próteses (`ProteseView.tsx`)
- **Nova OS** (produto, dentes, material, custo lab, preço, prazo) → `POST /api/protese/os``protese_os`.
- **Marcar Entregue / Mudar Status**`POST /api/protese/os/:id/status``protese_os_evento`.
### Leads (`LeadsView.tsx`)
- **Cadastrar Lead**`POST /api/leads``leads`.
- **Cadastrar o Paciente / Converter** (com data, hora, dentista) → `POST /api/leads/:id/converter` → cria `pacientes` **+** `agendamentos` (anti-overbooking).
---
# ETAPA 4 — FLUXOS DE NEGÓCIO
## Fluxo principal (núcleo do produto) — COMPLETO
```
Lead/Interesse ──converter──▶ Paciente ──▶ Agenda ──confirmar/presença──▶ Atendimento
(leads) (pacientes) (agendamentos) │
Procedimento Realizado
(procedimentos_realizados)
│ valor>0
Financeiro RECEITA (origem='procedimento')
(financeiro, flag sem_comissao)
computeComissoes = (base custo_lab) × %
Repasse FECHADO (repasses) ──pagar──▶
Financeiro DESPESA (origem='repasse',
centro_custo='COMISSÕES')
```
- **Onde começa:** Lead/Interesse (ou cadastro direto de Paciente).
- **Onde termina:** pagamento do repasse ao profissional (DESPESA conciliada no caixa) — ciclo fechado.
- **Onde quebra / desvio:**
- **Geração de RECEITA é opcional** (`gerarLancamento !== false` e `valor>0`): um procedimento sem valor
**não** entra no financeiro — possível receita não registrada (depende do operador).
- **Glosa** é um ramo paralelo sobre recebíveis de convênio (recupera receita), não integrado ao repasse.
- **Garantia/Reabertura** marcam `sem_comissao` (regra: garantia sempre; reabertura só se mesmo profissional)
— corretamente excluídas do repasse.
- **Duplicidade:** não há dupla geração de comissão — o repasse pago entra como DESPESA com `sem_comissao=true`
(evita recomissionar). Bom controle.
## Fluxo comercial paralelo
```
Orçamento (orcamentos) ──▶ Contrato (contratos) ──gerar-cobranca──▶ Financeiro (a receber)
```
## Fluxos de marketplace (ver Etapas 7 e 8)
```
Sala (salas) ──publica──▶ Marketplace ──reserva──▶ sala_reservas (valor, status) [pagamento MANUAL]
Profissional ──perfil──▶ Diretório ──proposta──▶ propostas_profissional (mensagem) [sem cobrança]
Dentista ──candidatura──▶ MercadoPago (pago) ──webhook──▶ Tutor ativo ──▶ Solicitações de tutoria
```
---
# ETAPA 5 — MATRIZ DE MÓDULOS
| Módulo | Existe? | Acessível? | Funcional? | Completo? | Em uso? | Classificação |
|---|---|---|---|---|---|---|
| Autenticação / Workspaces / RBAC | Sim | Sim | Sim | Sim | Sim | **Maduro** |
| Pacientes | Sim | Sim | Sim | Sim | Sim | **Maduro** |
| Agenda (+presença/anti-overbooking) | Sim | Sim | Sim | Sim | Sim | **Maduro** |
| Financeiro (receita/despesa) | Sim | Sim | Sim | Sim | Sim | **Maduro** |
| Comissão + Repasse | Sim | Sim | Sim | Sim | Sim | **Operacional** |
| Glosas | Sim | menu apenas (deep-link quebrado) | Sim | Parcial | Sim | **Operacional** |
| Orçamentos → Contratos | Sim | Sim | Sim | Sim | Sim | **Operacional** |
| Ortodontia | Sim | Sim | Sim | Sim | Sim | **Maduro** |
| Próteses / GTO | Sim | Sim | Sim | Parcial | Sim | **Operacional** |
| Leads | Sim | Sim | Sim | Sim | Sim | **Operacional** |
| Plano/Procedimentos/Especialidades | Sim | Sim | Sim | Sim | Sim | **Operacional** |
| RX / Radiografias (plugin) | Sim | por plugin/papel | Sim | Parcial | depende clínica | **Parcial** |
| Locação de Salas (marketplace) | Sim | Sim (sempre ON) | Sim | Parcial (sem gateway) | baixo | **Parcial** |
| Marketplace Profissionais | Sim | por plugin | Sim | Parcial (só mensagem) | baixo | **MVP** |
| Tutoria de Ortodontia (MercadoPago) | Sim | Sim | Sim | Sim | baixo | **Operacional** |
| SuperAdmin | Sim | superadmin | Sim | Sim | Sim | **Operacional** |
| Sync Google/legado (global) | Sim | **bloqueado** | parcial | — | **não** | **Abandonado** |
| Relatórios | Sim | Sim | Sim | Parcial | Sim | **Parcial** |
---
# ETAPA 6 — MULTIEMPRESA
**Evidência:** quase todas as 277 rotas usam `tenantGuard` e filtram por `req.clinicaId`/`clinica_id`.
O workspace ativo é resolvido por `GET /api/me/workspaces` (tabela `vinculos` = usuário↔clínica) + salas próprias.
| Entidade | Como é modelada | Multiempresa respeitado? |
|---|---|---|
| Workspaces | `vinculos` (clínica) + `salas` (tipo `sala`); seletor no Sidebar | ✅ Sim |
| Clínicas | `clinicas` + `clinica_id` em todas as tabelas operacionais | ✅ Sim |
| Consultórios | papel `donoconsultorio` (variação de dono, mesmo modelo de clínica) | ✅ Sim |
| Dentistas / Biomédicos / Protéticos | papéis distintos com menus próprios; vínculo por clínica | ✅ Sim |
| Salas | `salas` + workspace `tipo='sala'` **isola** o contexto (menu reduzido) | ✅ Sim |
| Laboratórios | `protese_laboratorios` por clínica | ✅ Sim |
- **Acoplamento indevido:** **não** encontrado acoplamento a "clínica única" — o `clinica_id` é onipresente.
- **Ponto de atenção:** o contexto é **cacheado no `localStorage`** (`SCOREODONTO_USER_DATA`, plugins) e
reconciliado via `hydrateMyPlugins()`. Plugins ativos vivem em `localStorage` (cliente), não só no servidor —
risco de divergência entre dispositivos (mitigado por `me/plugins`, mas é o elo mais frágil do multiempresa).
- O **marketplace de salas é cross-tenant** por desenho (uma clínica aluga sala de outra) — acoplamento *intencional*.
**Veredito:** conceito multiempresa **bem respeitado** no backend; a fragilidade é o estado de UI/plugins no cliente.
---
# ETAPA 7 — MARKETPLACE
```
Salas (salas) ─┬─ Locação (sala_reservas: valor, modelo, status pendente/aceita)
└─ Profissionais (perfil) ─ Propostas (propostas_profissional: mensagem)
└─ Protéticos/Laboratórios (protese_*)
```
| Frente | O que existe hoje | O que falta | Gera receita? | Pode gerar |
|---|---|---|---|---|
| **Locação de salas** | CRUD de salas, publicação no marketplace, reserva com `valor` e workflow de status (`pendente``aceita`), painel da sala | **Gateway de pagamento** e taxa da plataforma (hoje o `valor` é registrado, mas o pagamento é **manual/externo**) | **Não diretamente** | **Sim** — comissão sobre locação |
| **Profissionais** | Perfil público + envio de proposta (mensagem) clínica→profissional | Negociação, contrato, e qualquer cobrança | **Não** | Sim — taxa de match/assinatura premium |
| **Protéticos / Laboratórios** | OS, produtos, custo lab integrados ao financeiro/comissão | Marketplace aberto de laboratórios (hoje é interno por clínica) | Indireto (operacional) | Sim — rede de laboratórios |
| **Tutoria de Ortodontia** | Candidatura **paga via MercadoPago** (`preferences` + `mp-webhook`), `comissao_pct`, solicitações/mensagens | Escala (oferta de tutores), avaliação | ✅ **Sim (a única automatizada)** | Já gera |
**Conclusão:** a infraestrutura de marketplace existe, mas **só a Tutoria tem captura de pagamento real**.
Salas é a frente mais próxima de monetizar (já tem `valor` e reservas) — falta o gateway + a taxa.
---
# ETAPA 8 — FINANCEIRO
```
ORIGEM DO DINHEIRO → DESTINO → RESPONSÁVEL
Procedimento realizado (RECEITA) → financeiro (a receber) → Clínica
Contrato (gerar-cobranca) → financeiro (a receber) → Clínica
Convênio (recebíveis) Glosa → financeiro / recupera → Clínica
Comissão (base custo lab × %) → repasses (fechado) → Profissional
Repasse pago → financeiro DESPESA (COMISSÕES) → Clínica paga Profissional
Locação de sala (valor reserva) → sala_reservas (status) → Locador [pagamento manual]
Tutoria (inscrição) → MercadoPago → ativa tutor → Plataforma (comissao_pct)
```
| Item | Tabela | Estado |
|---|---|---|
| Orçamentos | `orcamentos`/`orcamento_itens` | ✅ completo |
| Contratos | `contratos`/`contrato_*` | ✅ completo (modelos, variáveis, assinatura, cobrança) |
| Convênios/Planos | `planos`, `procedimentos_valores_planos` | ✅ |
| Produção | `procedimentos_realizados``financeiro` | ✅ (RECEITA opcional por valor) |
| Comissão | `comissao_regras` + `computeComissoes` | ✅ (3 níveis de regra; abate custo lab) |
| Repasse | `repasses` (fechar/pagar/cancelar/comprovante) | ✅ ciclo fechado, com estorno |
| Glosas | `glosas` (recorrer/recuperar) | ✅ funcional, isolado do repasse |
| Locação | `sala_reservas.valor` | ⚠️ registra valor, **sem captura de pagamento** |
**Pontos fortes:** rastreabilidade (cada DESPESA de repasse referencia o período/profissional; `audit_log` em tudo),
e não-duplicação de comissão (`sem_comissao` propagado). **Lacuna:** receita de procedimento depende de ação manual.
---
# ETAPA 9 — MAPA DE CONEXÕES (Mermaid)
```mermaid
flowchart TD
U[Usuário] --> WS{Workspace ativo}
WS -->|clínica| CL[Clínica / RBAC]
WS -->|sala| SH[Painel da Sala]
CL --> LEAD[Leads]
LEAD -->|converter| PAC[Pacientes]
PAC --> AG[Agenda]
AG --> AV[Avaliação]
AG --> PR[Procedimento Realizado]
PAC --> ORTO[Ortodontia]
PAC --> RX[RX / Radiografias]
PAC --> ORC[Orçamento]
ORC --> CON[Contrato]
CON -->|gerar-cobranca| FIN[Financeiro]
PR -->|RECEITA| FIN
PR --> COM[Comissão]
PROT[Próteses / Custo Lab] --> COM
COM --> REP[Repasse]
REP -->|pagar DESPESA| FIN
FIN --> GLO[Glosas]
FIN --> REL[Relatórios]
SH --> RES[Reservas de Sala]
CL --> SAL[Salas Marketplace]
SAL --> RES
CL --> MKP[Marketplace Profissionais]
MKP --> PROP[Propostas]
ORTO --> TUT[Tutoria Orto]
TUT -->|MercadoPago| MP[(Pagamento)]
SUPER[SuperAdmin] --> CL
SUPER --> PLUG[Plugins]
```
```mermaid
flowchart LR
subgraph Núcleo financeiro
PR[procedimentos_realizados] --> FINr[financeiro RECEITA]
FINr --> CR[comissao_regras]
CR --> RP[repasses]
RP --> FINd[financeiro DESPESA]
FINr --> GL[glosas]
end
```
---
# ETAPA 10 — OPORTUNIDADES
## Funcionalidades órfãs (existem no código, ninguém usa)
- **`views/OrthoReports.tsx`** — não é roteada em `App.tsx` **nem** importada por nenhuma view. Órfã real
(importa `OrthoPhotoWorkspace`, mas nada importa `OrthoReports`). Relatórios de ortodontia prontos e desligados.
- **`SyncView` (`/admin/sync`)** — tela e rota existem, mas `isViewAllowed` retorna `false` sempre. Código morto navegável.
## Funcionalidades escondidas (existem mas não aparecem no menu)
- **`/update` (`UpdateDbView`)** — migração de banco; acessível por URL direta, fora do menu (ferramenta técnica).
- **`/cadastro-dentista`** — só alcançável por fluxo/magic-link; sem entrada no menu e com deep-link quebrado.
- **Comissões deep-link** — visível no menu (admin/donos) mas `/comissoes` direto cai em dashboard.
## Funcionalidades incompletas (começam mas não terminam o fluxo)
- **Locação de salas** — reserva com `valor` e status, mas **sem captura de pagamento** (fluxo financeiro não fecha).
- **Marketplace de profissionais** — proposta é só mensagem; **não** evolui para contratação/cobrança.
- **RECEITA de procedimento opcional** — pode não gerar lançamento (receita potencialmente não registrada).
- **Glosas isoladas** — recuperam receita, mas não reconciliam com o repasse correspondente.
## Funcionalidades de alto potencial (podem gerar receita)
1. **Gateway na Locação de Salas** (já existe `valor`+reserva): cobrança + taxa da plataforma → receita recorrente.
2. **Assinatura/planos da plataforma** (`superadmin/pricing`, `assinaturas` já existem): monetizar o SaaS por clínica.
3. **Marketplace de Profissionais premium**: taxa de match / destaque de perfil.
4. **Tutoria de Ortodontia em escala** (único pago hoje): aumentar oferta de tutores e volume.
---
# RESULTADO FINAL
## Notas (0100) — com base nas evidências
| Eixo | Nota | Justificativa (evidência) |
|---|---:|---|
| **Arquitetura funcional** | 72 | Roteamento+RBAC robustos, mas `server.js` monolítico (516 KB), roteador próprio e 3 deep-links quebrados. |
| **Integração dos módulos** | 78 | Cadeia Lead→…→Repasse bem costurada, com auditoria e não-duplicação de comissão. |
| **Fluxos de negócio** | 70 | Núcleo clínico+financeiro completo; ramos de marketplace incompletos; receita às vezes manual. |
| **Multiempresa** | 82 | `tenantGuard`+`clinica_id` onipresentes; isolamento de sala; fragilidade no estado de plugins no cliente. |
| **Marketplace** | 45 | Infra existe; só Tutoria captura pagamento; Salas e Profissionais sem fechar fluxo financeiro. |
| **Financeiro** | 80 | Receita/despesa, comissão (3 níveis), repasse com estorno, glosas, contratos com cobrança. |
| **Experiência do usuário** | 70 | Menus por papel, nav mobile, "visualizar como"; prejudicada por deep-links quebrados e estado em localStorage. |
| **Potencial de monetização** | 65 | Tutoria já fatura; Salas e Assinatura SaaS são alavancas prontas e subutilizadas. |
## 1. O que remover
- `views/OrthoReports.tsx` (órfã) — ou religar ao menu de Ortodontia se desejado.
- `SyncView` / rota `/admin/sync` e telas de sync global desativadas (já substituídas por backup por clínica).
- `server.js.bak` e scripts de transformação legados (`transform.js`, `pg-convert.js`) do diretório de produção.
## 2. O que corrigir
- **Adicionar `/comissoes`, `/glosas`, `/cadastro-dentista` ao `ROUTE_MAP`** (deep-link/refresh quebrado).
- Reconciliação de **plugins ativos**: tratar o servidor como fonte da verdade (reduzir dependência de `localStorage`).
- Tornar a **geração de RECEITA do procedimento** o padrão claro (evitar receita não registrada).
## 3. O que finalizar
- **Locação de Salas**: captura de pagamento + taxa da plataforma (fecha o fluxo financeiro do marketplace).
- **Marketplace de Profissionais**: evoluir proposta → contratação → cobrança.
- **Relatórios**: consolidar (há `reports/productivity` + telas parciais).
## 4. O que priorizar
1. Corrigir deep-links e estado de plugins (baixo esforço, destrava UX e confiabilidade).
2. Gateway de pagamento na Locação de Salas (alavanca de receita mais próxima).
3. Cobrança por assinatura da plataforma (`superadmin/pricing`+`assinaturas` já existem).
## 5. O que gera receita mais rapidamente
- **Locação de Salas com pagamento** — o `valor` e o fluxo de reserva já existem; falta só a captura + taxa.
- **Assinatura SaaS por clínica** — a base multiempresa e o `pricing` já estão prontos no superadmin.
## 6. Próximo módulo mais estratégico
- **Monetização da plataforma (Billing/Assinaturas + taxa de marketplace de salas).** É o que converte uma base
técnica madura (ERP odontológico multiempresa funcional) em **receita recorrente da plataforma** — hoje só a
Tutoria cobra. Tudo o mais já está construído; falta o módulo que **fatura**.
---
> **Nota de método:** este mapa reflete exclusivamente o que foi encontrado no código em 19/jun/2026
> (`frontend/App.tsx`, `frontend/views/*`, `frontend/components/Sidebar.tsx`, `backend/server.js`).
> Onde havia ambiguidade (ex.: receita opcional, pagamento manual de salas), o fato foi descrito como está,
> sem inferir intenção. Nenhuma funcionalidade foi inventada.
</content>
</invoke>
@@ -0,0 +1,129 @@
# Status — Segurança & Produção (ScoreOdonto)
> Resumo do que foi feito e do que falta. Atualizado em **22/jun/2026**.
> Produção no ar: **v1.0.19** (`715b2dc`) — fila **A** concluída (push → CI → tag → deploy). **Commits
> publicados (`git push` feito).** A rotação do `JWT_SECRET` segue aplicada e verificada em produção.
---
## ✅ O QUE JÁ FOI FEITO
### 1. Segredos
- **`JWT_SECRET` rotacionado em DEV e PRODUÇÃO** (recriado o backend na VPS1 na mesma tag, sem rebuild;
`/api/health` e `/api/version` validados). → **vetor de tomada de conta FECHADO** (o segredo vazado não
forja mais tokens). **Ação operacional na VPS1 — sem commit** (o segredo não é versionado).
- **`backend/.env` fora do tracking do git** + `backend/.dockerignore` (o Dockerfile fazia `COPY . .` sem
`.dockerignore` → segredos iam para dentro da imagem) + `.env.example`. Commits `fcf2fb1`.
- **Senhas hardcoded removidas do `docker-compose.yml`**: banco via `SCOREO_DB_PASS` (com `:?` fail-fast);
`DRAGONFLY_URL` (com senha) movido para `env_file`. Commit `3aa6a44`. Validado em DEV (pgdev/cache ok).
### 2. Frontend
- **`/update` (migração de banco) restrito a superadmin** (era acessível a qualquer um, até deslogado).
- **Deep-links corrigidos** (`/comissoes`, `/glosas` faltavam no `ROUTE_MAP` → caíam em dashboard).
- Commit `1559387`. (Build compila; validação visual de RBAC precisa de navegador.)
### 3. RBAC intra-clínica no backend (99 rotas) — **NÚCLEO FECHADO**
Antes: qualquer membro autenticado (até paciente) podia chamar ações privilegiadas direto na API
(a UI escondia, o backend não barrava). Agora há autorização por **cargo**, espelhando o `isViewAllowed`
do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requireCapabilityFromOrders`.
| Grupo | Commit |
|---|---|
| Financeiro (comissões/repasses/financeiro/glosas) | `f4e7fe3` |
| Gestão de equipe/clínica (membros/setores/funções/reset-senha/cor) | `9ad65c3` |
| Cadastros administrativos (dentistas/especialidades/procedimentos/planos + reorder) | `116200c` + `0667456` |
| Operação clínica (pacientes/agenda/leads) | `5b58a8a` |
| Orçamentos/Contratos | `16c13ad` |
| Sub-pass clínico (presença/pendências/horários/detalhes de paciente) | `0fc69bb` |
- **Marketplace (salas/sala-reservas/propostas): auditado — já protegido por POSSE** nos handlers
(`owner_usuario_id`, profissional destinatário, dono/solicitante). Sem gap; sem mudança. Commit `0667456`.
- Validado em DEV com tokens forjados: dono passa, dentista/paciente 403, isolamento cross-tenant intacto.
### 4. Documentação & Skills
- `documentação/MAPA-FUNCIONAL-COMPLETO.md` (inventário rotas/telas/botões/fluxos/módulos).
- `documentação/AUDITORIA-ESTRATEGICA-MODELAGEM.md` (Pessoa→Vínculo→Workspace, débitos, riscos).
- `ScoreOdonto_Skills_Enterprise/`: **62/62 skills preenchidas** com conteúdo real. Commits `5b99292`, `e83af04`, `f22e30e`.
### 5. Pré-requisito de produção
- **`SCOREO_DB_PASS` provisionado na VPS1** (`/home/deploy/stack/scoreodonto.com/.env`, 0600), valores
extraídos do container em execução, `.gitignore` da VPS1 ajustado, conexão validada (`SELECT 1` = ok).
### 6. Fila A concluída — **PRODUÇÃO EM `v1.0.19`** (22/jun/2026)
- **`git push` do lote** (RBAC 99 rotas + segredos fora do compose/imagem + correções de frontend).
- **CI desbloqueada:** o build falhou na 1ª vez (`SCOREO_DB_PASS` com `:?` quebrava a interpolação do
compose no `docker compose build` do runner) → corrigido com placeholder runtime-only no `build.yml`
(commit `715b2dc`). Build verde → imagens `:715b2dc` (back+front, atômicas) publicadas no registry.
- **Tag `v1.0.19` → `715b2dc`:** job `promote` re-tagueou `:715b2dc``:v1.0.19` **sem rebuild**
(digests idênticos conferidos) e rodou `deploy-prod.sh v1.0.19` na VPS1.
- **Backup do banco antes do deploy:** `~/backups/scoreodonto_20260622_0005.dump` na VPS1 (PG18).
- **Verificado em produção:** `/api/version` = `v1.0.19` (`715b2dc`, PROD); `/api/health` = ok
(DB ok, cache ok). DEV (8020) realinhado ao mesmo commit via `./dev-build.sh` (mostra `v1.0.19`).
- **Rollback trivial:** `./deploy-prod.sh v1.0.18` (+ dump do banco, se necessário).
---
## ⏳ O QUE FALTA
### A) Levar o trabalho a produção (fila) — ✅ CONCLUÍDA
1. ~~**Imagem limpa `v1.0.19`**~~**FEITO** (ver "Fila A concluída" acima). Produção em `v1.0.19`/`715b2dc`.
2. **Compose novo na VPS1 (opcional, passo 2b):** `deploy-prod.sh` **não faz `git pull`** → o
`docker-compose.yml` da VPS1 segue o antigo. Para tirar a senha hardcoded do compose local, copiar o
novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso. **Ainda pendente.**
### B) Rotação dos demais segredos (ainda vivos no histórico/artefatos antigos)
3. **Senha do banco** (`scoreodonto_user`) — contida ao scoreodonto; exige refactor já feito no compose +
`ALTER USER` + atualizar `.env`/`backend/.env` + recreate. Risco de outage → com cuidado/janela.
4. **Senha do Dragonfly (cache)** — ⚠️ **compartilhada** entre apps (rx, newwhats, subdominio, mercado);
trocar derruba os outros → exige **janela coordenada**.
5. **`OPENAI_API_KEY` / `GEMINI_API_KEY`** — **só você** rotaciona (painéis OpenAI/Google); depois colo os
novos valores no `.env` e recrio.
6. **Histórico do git**`backend/.env` (e um `backend/.env.bak`) estão no histórico. Após rotacionar tudo,
o *scrub* de histórico (force-push, destrutivo) vira **cosmético** — não recomendado às pressas.
### C) Residual de qualidade (não-bloqueante; do MAPA / AUDITORIA)
- RECEITA de procedimento é **opcional** (`gerarLancamento`/`valor>0`) → decidir se vira padrão (risco de caixa).
- Estado de plugins divergente cliente/servidor (`localStorage` vs `usuario_plugins`).
- Órfã `OrthoReports.tsx`; `SyncView` desativada; `server.js.bak`/scripts legados → limpeza.
- Marketplace: **gateway de pagamento** (salas têm `valor`, sem captura) e **assinaturas SaaS** (manual).
### D) Débitos de modelagem (estratégico; exige aprovação + janela)
- `dentistas` como **projeção** da pessoa (hoje duplica a pessoa por clínica).
- `pacientes` com **identidade de pessoa** (hoje preso à clínica, não-portável) — antes, decisão LGPD/jurídica.
- Unificar workspace (`clinicas` + `salas`); entidade **"rede/grupo"** acima da clínica.
> Recomendação estratégica única: assumir a **Pessoa como âncora** e parar de manter o legado clínica-cêntrico
> em paralelo. Detalhe em `AUDITORIA-ESTRATEGICA-MODELAGEM.md`.
---
## Commits da sessão — **PUBLICADOS** (`git push` feito; produção em `v1.0.19`)
> Todos empurrados para `origin/main`. Promovidos a produção via tag `v1.0.19` (commit `715b2dc`).
> A rotação do `JWT_SECRET` **não** está nesta lista (foi ação operacional na VPS, sem commit).
```
715b2dc ci(build): desbloqueia build — SCOREO_DB_PASS placeholder na interpolação
8a27f3b docs+infra: corrige status (JWT/contagem) + versão em DEV (dev-build.sh)
6adf353 docs: status geral de segurança & produção (este documento)
f22e30e docs(skills): completa as 62 skills
e83af04 docs(skills): biblioteca operacional
5b99292 docs: auditoria funcional + estratégica
0667456 security(rbac): reorder + auditoria do marketplace
0fc69bb security(rbac): sub-pass clínico
16c13ad security(rbac): orçamentos e contratos
5b58a8a security(rbac): operação clínica
116200c security(rbac): cadastros administrativos
9ad65c3 security(rbac): gestão de equipe/clínica
f4e7fe3 security(rbac): rotas financeiras
1559387 security/fix(front): /update + deep-links
3aa6a44 security(infra): senhas fora do compose
fcf2fb1 security(infra): .env fora do git + .dockerignore
```
## Como retomar
- **Fila A:** ✅ concluída — produção em **`v1.0.19`** (`715b2dc`). Próximo release: `git push` → CI
builda `:<sha>``git tag vX.Y.Z` → job `promote` deploya na VPS1.
- **Antes de abrir a usuários reais:** concluir **B** (rotações) — sobretudo as API keys (você) e a senha do banco.
- **Pendência pontual:** passo **A.2** (compose novo na VPS1 via scp + recreate) para tirar a senha
hardcoded do compose local.
- Código rodando em **DEV** (`dev.scoreodonto.com`) e **PROD** no mesmo commit `715b2dc`; rollback de
prod: `./deploy-prod.sh v1.0.18` (+ dump `~/backups/scoreodonto_20260622_0005.dump` se necessário).
</content>
+96 -6
View File
@@ -4,7 +4,7 @@ import { HybridBackend } from './services/backend.ts';
import { Sidebar } from './components/Sidebar.tsx';
import {
Menu, LayoutDashboard, Users, Calendar as CalendarIcon,
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X, PanelLeftOpen
} from 'lucide-react';
import { ToastContainer } from './components/Toast.tsx';
@@ -12,6 +12,11 @@ import { useAppVersion } from './buildInfo.ts';
import { Dashboard } from './views/Dashboard.tsx';
import { LeadsView } from './views/LeadsView.tsx';
import { WaInboxView } from './views/WaInboxView.tsx';
import { SessionsView } from './views/newwhats/SessionsView.tsx';
import { InboxView } from './views/newwhats/InboxView.tsx';
import { SecretariaView } from './views/newwhats/SecretariaView.tsx';
import { WaSecretariaView } from './views/WaSecretariaView.tsx';
import { PublicContactForm } from './views/PublicContactForm.tsx';
import { PatientsView } from './views/PatientsView.tsx';
import { AgendaView } from './views/AgendaView.tsx';
@@ -31,6 +36,12 @@ import { ReportsView } from './views/ReportsView.tsx';
import { ComissoesView } from './views/ComissoesView.tsx';
import { GlosasView } from './views/GlosasView.tsx';
import { SalaHomeView } from './views/SalaHomeView.tsx';
import { LabHomeView } from './views/LabHomeView.tsx';
import { LabAgendaView } from './views/LabAgendaView.tsx';
import { LabClientesView } from './views/LabClientesView.tsx';
import { LabCotacoesView } from './views/LabCotacoesView.tsx';
import { LabEquipeView } from './views/LabEquipeView.tsx';
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
import { OrcamentosView } from './views/OrcamentosView.tsx';
import { ContratosView } from './views/ContratosView.tsx';
@@ -80,10 +91,21 @@ export type ViewKey =
| 'contratos'
| 'plugins'
| 'rx-radiografias'
| 'wa-inbox'
| 'wa-sessions'
| 'wa-secretaria'
| 'salas'
| 'minhas-salas'
| 'alugar-salas'
| 'sala-home'
| 'lab-home'
| 'lab-bancada'
| 'lab-agenda'
| 'lab-clientes'
| 'lab-financeiro'
| 'lab-cotacoes'
| 'lab-equipe'
| 'lab-indicadores'
| 'marketplace-profissionais'
| 'criar-clinica'
| 'rx-config'
@@ -130,10 +152,21 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/contratos': 'contratos',
'/plugins': 'plugins',
'/rx-radiografias': 'rx-radiografias',
'/wa-inbox': 'wa-inbox',
'/wa-sessions': 'wa-sessions',
'/wa-secretaria': 'wa-secretaria',
'/salas': 'salas',
'/minhas-salas': 'minhas-salas',
'/alugar-salas': 'alugar-salas',
'/sala-home': 'sala-home',
'/lab-home': 'lab-home',
'/lab-bancada': 'lab-bancada',
'/lab-agenda': 'lab-agenda',
'/lab-clientes': 'lab-clientes',
'/lab-financeiro': 'lab-financeiro',
'/lab-cotacoes': 'lab-cotacoes',
'/lab-equipe': 'lab-equipe',
'/lab-indicadores': 'lab-indicadores',
'/marketplace-profissionais': 'marketplace-profissionais',
'/criar-clinica': 'criar-clinica',
'/rx-config': 'rx-config',
@@ -151,6 +184,9 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/aguardando-convite': 'aguardando-convite',
'/diretorio-profissionais': 'diretorio-profissionais',
'/proteses': 'proteses',
// Corrige deep-link/refresh: existiam em VIEW_TO_ROUTE mas faltavam aqui (caíam em dashboard).
'/comissoes': 'comissoes',
'/glosas': 'glosas',
};
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
@@ -182,10 +218,21 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
contratos: '/contratos',
plugins: '/plugins',
'rx-radiografias': '/rx-radiografias',
'wa-inbox': '/wa-inbox',
'wa-sessions': '/wa-sessions',
'wa-secretaria': '/wa-secretaria',
salas: '/salas',
'minhas-salas': '/minhas-salas',
'alugar-salas': '/alugar-salas',
'sala-home': '/sala-home',
'lab-home': '/lab-home',
'lab-bancada': '/lab-bancada',
'lab-agenda': '/lab-agenda',
'lab-clientes': '/lab-clientes',
'lab-financeiro': '/lab-financeiro',
'lab-cotacoes': '/lab-cotacoes',
'lab-equipe': '/lab-equipe',
'lab-indicadores': '/lab-indicadores',
'marketplace-profissionais': '/marketplace-profissionais',
'criar-clinica': '/criar-clinica',
'rx-config': '/rx-config',
@@ -268,21 +315,35 @@ const MobileBottomNav: React.FC<{
const App: React.FC = () => {
const ver = useAppVersion();
const [isSidebarOpen, setSidebarOpen] = useState(false);
// Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda).
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
});
const toggleSidebarCollapsed = (v: boolean) => {
setSidebarCollapsed(v);
try { localStorage.setItem('scoreodonto:sidebar-collapsed', v ? '1' : '0'); } catch { /* ignore */ }
};
const [, forcePwRerender] = useState(0); // re-avalia o modal de troca obrigatória de senha
const currentRole = HybridBackend.getCurrentRole();
const activeWorkspace = HybridBackend.getActiveWorkspace();
const clinColor = activeWorkspace?.cor || '#2563eb';
const isViewAllowed = (view: ViewKey, role: string): boolean => {
if (['landing', 'login', 'public', 'update'].includes(view)) return true;
if (['landing', 'login', 'public'].includes(view)) return true;
// /update (migração de banco) é EXCLUSIVA do superadmin (liberado na linha seguinte).
if (role === 'superadmin') return true;
// Sync GLOBAL removido do app (substituído pelo backup por clínica em Configurações).
if (view === 'sync') return false;
// Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer
// usuário com o plugin ativo na conta — independe de ter clínica/cargo.
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
// NewWhats: Inbox e Secretária liberadas a qualquer usuário com o plugin ativo
// (a CONFIG do plugin é exclusiva do superadmin via view 'plugins').
if (view === 'wa-inbox' || view === 'wa-sessions' || view === 'wa-secretaria') return isPluginActive('newwhats');
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'marketplace-profissionais', 'diretorio-profissionais', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
if (view === 'plugins') return false;
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
@@ -301,7 +362,7 @@ const App: React.FC = () => {
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
protetico: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
};
@@ -312,6 +373,8 @@ const App: React.FC = () => {
if (role === 'superadmin') return 'superadmin-overview';
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home';
// Contexto de LABORATÓRIO (workspace tipo='laboratorio') cai no painel do lab.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return 'lab-home';
if (role === 'paciente') return 'tratamentos';
if (role === 'donosala') return 'minhas-salas';
return 'dashboard';
@@ -445,7 +508,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} />;
case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />;
@@ -468,10 +531,21 @@ const App: React.FC = () => {
case 'contratos': return <ContratosView />;
case 'plugins': return <PluginsView />;
case 'rx-radiografias': return <RXPlugin />;
case 'wa-inbox': return <InboxView onNavigate={handleNavigate} />;
case 'wa-sessions': return <SessionsView onNavigate={handleNavigate} />;
case 'wa-secretaria': return <SecretariaView onNavigate={handleNavigate} />;
case 'salas': return <SalasPlugin />;
case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />;
case 'sala-home': return <SalaHomeView />;
case 'lab-home': return <LabHomeView />;
case 'lab-bancada': return <ProteseView />;
case 'lab-agenda': return <LabAgendaView />;
case 'lab-clientes': return <LabClientesView tab="clientes" />;
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
case 'lab-cotacoes': return <LabCotacoesView />;
case 'lab-equipe': return <LabEquipeView />;
case 'lab-indicadores': return <LabIndicadoresView />;
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
case 'rx-config': return <RxConfigView />;
@@ -495,7 +569,11 @@ const App: React.FC = () => {
}
};
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].includes(currentView);
// Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do
// scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair.
// /wa-sessions (gestão de sessões WhatsApp) roda COM a sidebar do scoreodonto
// (é área de administração). wa-inbox/wa-secretaria seguem em tela cheia.
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-secretaria'].includes(currentView);
return (
<>
@@ -521,7 +599,7 @@ const App: React.FC = () => {
></div>
)}
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 md:relative md:translate-x-0 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Sidebar
activeTab={currentView}
setActiveTab={(t) => handleNavigate(t as ViewKey)}
@@ -530,6 +608,18 @@ const App: React.FC = () => {
</>
)}
{/* Botão flutuante para reexibir a sidebar (desktop, quando recolhida).
Na agenda NÃO aparece ela tem o próprio toggle ao lado do título. */}
{!isStandaloneView && isSidebarCollapsed && currentView !== 'agenda' && (
<button
onClick={() => toggleSidebarCollapsed(false)}
title="Mostrar menu"
className="hidden md:flex fixed top-3 left-3 z-40 items-center justify-center w-9 h-9 bg-white border border-gray-200 rounded-lg text-gray-500 hover:text-gray-800 hover:border-gray-300 shadow-sm transition-colors"
>
<PanelLeftOpen size={18} />
</button>
)}
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
{HybridBackend.isPreviewMode() && (
<div className="bg-red-600 text-white px-4 py-2 flex items-center justify-between gap-3 shadow-md z-30">
+9 -1
View File
@@ -6,7 +6,7 @@ import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
import {
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
Search, UserPlus, Link2, Trash2, ClipboardList
Search, UserPlus, Link2, Trash2, ClipboardList, MessageCircle
} from 'lucide-react';
interface Props {
@@ -17,6 +17,7 @@ interface Props {
onChanged: () => void; // recarrega a agenda
onEditar: () => void; // abre o modal de edição/reagendar (mover de horário)
onAtender?: () => void;
onVerWhats?: (info: { pacienteId: string; pacienteNome: string }) => void; // abre o drawer de WhatsApp do paciente
podeAtender?: boolean;
dentistaRestrito?: boolean; // dentista linkado por clínica: modal simples, sem dados sensíveis/agendamento
}
@@ -492,6 +493,13 @@ const DetailPage: React.FC<Props> = (p) => {
{/* Navegação / ações principais */}
<div className="space-y-2">
{p.onVerWhats && pacienteId && (
<button onClick={() => p.onVerWhats?.({ pacienteId, pacienteNome: nome })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 transition-colors text-xs font-black uppercase text-emerald-700">
<span className="flex items-center gap-2"><MessageCircle size={15} /> Ver WhatsApp</span>
<span className="text-emerald-300"></span>
</button>
)}
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors text-xs font-black uppercase text-gray-600">
<span className="flex items-center gap-2"><History size={15} /> Histórico</span>
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
const toast = useToast();
const [open, setOpen] = useState(false);
const [labs, setLabs] = useState<any[]>([]);
const [busca, setBusca] = useState('');
const [addEmail, setAddEmail] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [busy, setBusy] = useState(false);
const preselected = useRef(false);
const carregar = useCallback(async (): Promise<any[]> => {
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
catch { setLabs([]); return []; }
}, []);
useEffect(() => { carregar(); }, [carregar]);
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
useEffect(() => {
if (preselected.current || value) return;
const pad = labs.find(l => l.padrao);
if (pad) { preselected.current = true; onChange(pad.id, pad); }
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
const sel = labs.find(l => l.id === value);
const q = busca.toLowerCase();
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
const toggleFav = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
catch { toast.error('ERRO'); }
};
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
e.stopPropagation();
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
catch { toast.error('ERRO'); }
};
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
const adicionar = async () => {
if (!addEmail.trim()) return;
setBusy(true);
try {
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
if (r?.error) throw new Error(r.error);
toast.success('PROTÉTICO VINCULADO.');
setAddEmail(''); setShowAdd(false);
const novos = await carregar();
const novo = novos.find((x: any) => x.id === r.protetico?.id);
if (novo) selecionar(novo);
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
};
return (
<>
<button type="button" onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
<span className="truncate flex items-center gap-2">
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
</span>
<Search size={16} className="text-gray-400 shrink-0" />
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-4 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
) : filtrados.map(l => (
<div key={l.id} onClick={() => selecionar(l)}
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
</button>
<div className="min-w-0 flex-1">
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
{l.nota != null && <span>· {l.nota}</span>}
{l.padrao && <span className="text-violet-600">· Padrão</span>}
</p>
</div>
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
</div>
))}
</div>
<div className="p-4 border-t border-gray-100">
{showAdd ? (
<div className="flex gap-2">
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
</div>
) : (
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
</button>
)}
</div>
</div>
</div>
)}
</>
);
};
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { Search, X, Check, ChevronDown } from 'lucide-react';
export interface SelectOption { value: string; label: string; hint?: string }
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
export const SearchSelect: React.FC<{
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
title?: string;
className?: string;
disabled?: boolean;
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
const [open, setOpen] = useState(false);
const [busca, setBusca] = useState('');
const sel = options.find(o => o.value === value);
const q = busca.trim().toLowerCase();
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
return (
<>
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
<span className="flex items-center justify-between gap-2">
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
<ChevronDown size={16} className="text-gray-400 shrink-0" />
</span>
</button>
{open && (
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
</div>
<div className="p-3 border-b border-gray-50">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{filtrados.length === 0 ? (
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
) : filtrados.map(o => (
<button key={o.value} onClick={() => pick(o.value)}
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
<div className="min-w-0 flex-1">
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
</div>
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+56 -8
View File
@@ -1,12 +1,12 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import {
Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone
} from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts';
import { getActivePlugins } from '../views/plugins/pluginRegistry.ts';
import { getActivePlugins, isPluginActive } from '../views/plugins/pluginRegistry.ts';
interface SidebarProps {
activeTab: string;
@@ -25,6 +25,19 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').is_tutor === true; } catch { return false; }
})();
// Acesso do usuário às áreas do WhatsApp (Inbox/Secretária). null = ainda carregando
// (fail-open: mostra até saber). Dono e autorizados veem; os demais não.
const [waAccess, setWaAccess] = useState<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean } | null>(null);
useEffect(() => {
let alive = true;
if (isPluginActive('newwhats')) {
HybridBackend.getWaAccess().then(a => { if (alive) setWaAccess(a); }).catch(() => {});
}
return () => { alive = false; };
}, [activeWorkspace?.id]);
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
if (activePluginIds.includes('rx-scoreodonto')) {
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
@@ -37,14 +50,27 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
}
}
// Locação de Salas: capability SEMPRE ativa (sem desativar) p/ todos menos superadmin.
// Dois menus: administrar as próprias salas (locador) e alugar salas de terceiros (locatário).
if (currentRole !== 'superadmin') {
// NewWhats: Inbox + Secretária IA — para TODOS os usuários quando o plugin está
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (isPluginActive('newwhats')) {
if (canSeeInbox) pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox.
if (!waAccess || waAccess.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
if (canSeeSecretaria) pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
// a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não
// a locação. Por isso restringimos a papéis que podem ser titulares de sala.
const PAPEIS_LOCACAO = ['donosala', 'donoclinica', 'donoconsultorio', 'admin', 'dentista', 'biomedico', 'protetico'];
if (PAPEIS_LOCACAO.includes(currentRole)) {
pluginMenuItems.push({ id: 'minhas-salas', label: 'MINHAS SALAS', icon: DoorOpen, color: '#0d9488' });
pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
}
// Marketplace de Profissionais: clínicas contratam, profissionais divulgam perfil/recebem propostas.
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
// O protético DEPENDE desta tela para publicar o perfil no diretório e ser encontrado pelas clínicas
// — por isso aparece sempre para ele, mesmo sem o plugin ativado.
if ((activePluginIds.includes('profissionais-marketplace') || currentRole === 'protetico' || currentRole === 'biomedico' || currentRole === 'dentista') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#0d9488' });
}
// OBS: o catálogo de PLUGINS é EXCLUSIVO do superadmin (gerência/ativação).
@@ -58,6 +84,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
{ id: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
{ id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText },
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
@@ -94,11 +128,23 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
if (['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id);
// Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético —
// painel do lab + bancada de produção, sem as telas clínicas da clínica.
if ((activeWorkspace as any)?.tipo === 'laboratorio') {
// Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono.
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
return ehTecnico
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
}
// plugins e gestão de tutores: exclusivo superadmin
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
@@ -128,7 +174,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
// Protético: opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo acima).
// Este é só o fallback fora desse contexto — sem telas clínicas (agenda/GTO/tratamentos).
if (currentRole === 'protetico') return ['notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
return false;
});

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