- reúne todos os .md de ScoreOdonto numa pasta única (fonte única, dentro do repo versionado pelo Gitea) - arquitetura.md: reescrito p/ o estado REAL (3 VPS, banco compartilhado dev/prod, sem staging/listener/registry fantasmas) - deploy.md: reescrito p/ o estado-ALVO (Gitea CI/CD: push->build->registry->deploy por tag) - DEPLOY-PRODUCAO.md: runbook operacional executável (fluxo manual real de hoje) - VERDADE-HOJE.md: auditoria (codigo/banco/containers/deploy/versao/rollback) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 KiB
ScoreOdonto — Pendências, Tutoria e Deploy (dev)
Documento de handoff. Última atualização: 2026-06-12. Contexto completo da arquitetura de agenda/workspaces: ver memória do projeto
project-agenda-workspace-architecture.md.
1. Deploy no container de dev (IMPORTANTE)
dev.scoreodonto.com NÃO é Vite HMR. É servido pelo container
scoreodontocom-scoreodonto-frontend-1 (projeto compose scoreodontocom,
de docker-compose.yml) rodando nginx sobre um vite build estático.
Edições no fonte não aparecem sozinhas — exigem rebuild + recreate manual.
Banco é de PRODUÇÃO (compartilhado)
- Backend de dev (VPS 4) e produção (VPS 1) usam o MESMO Postgres na VPS 3
(
10.99.0.3:5432/scoreodonto). Não há banco de dev separado. - Migrações aditivas idempotentes rodam no startup (
runMigrations()emserver.js). - Qualquer dado criado "testando" em dev é dado real de produção → sempre limpar.
- A constraint
vinculos_role_checké DROP+ADD a cada boot. Ao deployar na prod, oserver.jsde lá PRECISA incluir'protetico', senão o ADD falha se já houver vínculo protético criado via dev.
Comandos de deploy
cd /home/deploy/stack/scoreodonto.com
# Frontend (server.js NÃO é hot-mount; só plugins/sessions/media são):
docker compose build scoreodonto-frontend && docker compose up -d --force-recreate scoreodonto-frontend
# Backend:
docker compose build scoreodonto-backend && docker compose up -d --force-recreate scoreodonto-backend
- Verificar: o hash do bundle em
https://dev.scoreodonto.com/(/assets/index-*.js) muda. vite buildNÃO faz type-check bloqueante → erros TS não quebram o build, mas podem estourar em runtime. Conferir após deploy.- Logs de migração:
docker logs --tail 20 scoreodontocom-scoreodonto-backend-1 | grep -i migration(esperado:[MIGRATION] all migrations OK).
Efeito colateral relevante
Quando esta sessão começou, o dev servia um build antigo (index-DWdouzmk.js).
Os rebuilds passaram a publicar o working tree atual, que inclui vários arquivos
untracked (WIP de outra sessão): OrthoView, OrthoReports, OrthoPhotoWorkspace,
TutoriaSection, SuperAdminView, GestaoEquipeView, Tutor*View, plugins/, etc.
→ Bugs nesses arquivos só passaram a aparecer agora por causa do deploy, não por edição.
2. TUTORIA no modal de tratamento de orto — ✅ CAUSA RAIZ RESOLVIDA
Relato do usuário: "ferrou com a tutoria no modal que administra o tratamento de orto."
CAUSA RAIZ (confirmada): as telas WIP (TutoriaSection, OrthoPhotoWorkspace,
PhotoAnnotator, TutorPainelView, TutorCandidaturaView, AdminGestaoTutoresView)
chamavam 24 métodos do HybridBackend que não existiam em services/backend.ts
(só getCurrentUserName existia) → crash HybridBackend.X is not a function assim que
o rebuild publicou esses componentes. Além disso o backend não tinha nenhuma tabela
nem endpoint de orto/tutoria.
CORRIGIDO nesta sessão:
services/backend.ts: adicionados os 24 métodos via helper_ortoJson(chama o endpoint real e degrada com fallback seguro se a rota faltar → fim do crash).backend/server.js: 9 tabelas (ortho_photo_session,ortho_photo,ortho_photo_annotation,ortho_tutor_config,ortho_tutor_candidatura,ortho_tutor_profile,ortho_tutoring_request,ortho_tutoring_message,ortho_tutoring_photo_package) + 25 endpoints + upload real de fotos viamulterno volume persistente/app/media, servidas por/api/orto/fotos/:id/raw.backend/package.json: +multer.- Validado: migrations OK, 9 tabelas no banco, endpoints respondendo (200/401/404). Frontend e backend rebuildados → live no dev.
FEITO depois:
OrthoDetailPanelagora renderizaOrthoPhotoWorkspace+TutoriaSection(no lugar doPhotoCompareplaceholder) — a tutoria/fotos vivem no modal do paciente.- Sessões mensais: orto é mensal →
ortho_photo_sessionganhourotulo+data_ref; o workspace cria sessão por mês (inputmonth) + especiais (INICIAL/FINALIZAÇÃO/ PÓS-CONTENÇÃO); tabs dinâmicas ordenadas. Validado end-to-end. - Exportar documentação: botão EXPORTAR → modal seleciona "completa" (tudo) ou fotos específicas (checkbox por foto, agrupado por sessão) → abre view de impressão (navegador salva como PDF). Sem dependência nova.
FEITO depois (cont.):
- Tratamentos de orto persistem: tabelas
ortho_tratamento+ortho_evolucao(triagem/diagnóstico/flags em JSONB); endpoints GET/POST/PUT +/evolucoes+/finalizar.backend.tstroca o mock t1/t2 por chamadas reais (mappers snake↔camel). Botão "NOVO TRATAMENTO" abre modal de criação (paciente/plano/diagnóstico/triagem). Adicionar evolução atualizafio_atual/ultima_consulta; finalizar grava contenção. Validado end-to-end. - Comparador cross-sessão (mesmo slot, sessão A × B / mês a mês) com modos lado a lado / slider / sobreposição.
FEITO depois (cont.):
- MercadoPago real:
POST /tutoria/candidaturas/:id/pagamentocria uma preference (Checkout Pro) com ataxa_inscricaoda config,external_reference=cand:<id>,back_urlsenotification_url→ devolveinit_point.POST /tutoria/mp-webhook(público) consulta o pagamento e, seapproved, ativa o tutor automaticamente (ativarCandidaturaTutor, reaproveitado pelo confirmar-pagamento do admin). Semmp_access_tokenna config → fallback honesto (ativação manual). Admin configura token/taxa emAdminGestaoTutoresView. Caveat: não testado contra a API real do MP (sem credenciais); fallback e webhook-noop validados.
FEITO depois (cont.):
- GestaoEquipeView corrigida (estava roteada em
/gestao-equipee quebrava igual a tutoria — chamava 12 métodos inexistentes). Backend: tabelassetores/funcoes+ colunasvinculos.setor_id/funcao_id; endpoints membros (GET com nomes, POST com setor/função, +PUT, DELETE) e setores/funções CRUD (todostenantGuard).backend.ts: os 12 métodos. Validado end-to-end + isolamento de tenant (403). - PDF nativo (
jspdfadicionado às deps): o EXPORTAR agora gera um PDF real (fotos agrupadas por sessão, 3/linha, legenda do slot) via canvas→jsPDF; botão IMPRIMIR mantém o print→PDF como alternativa. Caveat: geração é client-side (jsPDF+canvas), validei só install/tsc/build, não a renderização no navegador.
FEITO depois (cont.):
- Planejamento visual no
PhotoAnnotator: grupo PLANEJ. com vetor de movimentação (haste + barra de origem + cabeça larga, arrastar da posição atual → destino), marca de extração (X, clique no dente) e linha média (referência vertical tracejada, clique). Persistem como anotações (JSONB) — sem mudança no backend.
FEITO depois (cont.):
- Type-gaps zerados —
tscdo frontend agora passa com 0 erros. Bug real corrigido emFinanceiroView(getPacientesretorna{data,total}→ usava o objeto inteiro como lista, quebrando a busca → agoraresults.data);TutorPainelViewganhou os campos financeiros na interfaceSolicitacao. Login/RxConfig limparam junto.
FEITO depois (cont.):
- Calibração de escala (mm) no
PhotoAnnotator: ferramenta ESCALA (arrastar sobre distância conhecida → informa o valor real em mm) define mm/px; os vetores de movimentação passam a exibir a magnitude em mm. Rótulos renderizados fora do SVG (HTML posicionado porimgDim) p/ não distorcer com o viewBox. Persiste no JSONB. RxConfigViewusava props inexistentes doPageHeader(subtitle/icon) → trocado pordescription(subtítulo agora aparece). Resta 1 erro tsc pré-existente emLoginView:490(comparação morta no fluxo de cadastro/auth) — deixado de propósito: é lógica de auth e mascarar esconderia a intenção do autor; funciona em runtime.- Comparador ganhou modo EVOLUÇÃO: mesmo slot em todas as sessões/meses lado a lado (faixa horizontal cronológica) — além de lado a lado / slider / sobreposição.
- Vínculo com o cadastro de pacientes: o modal de Novo Tratamento tem busca no
cadastro (
getPacientes) que preenchepaciente_id+ nome + nascimento; digitar livre cria paciente avulso (sem vínculo). Frontend-only, validado por tsc/build.
2b. Apuração original (histórico)
Apuração (fatos):
- Não foi editado nenhum arquivo de orto/tutoria nesta sessão. Todos estão untracked
(
git status→??):OrthoView.tsx,OrthoReports.tsx,TutoriaSection.tsx,TutorPainelView.tsx,TutorCandidaturaView.tsx,AdminGestaoTutoresView.tsx. OrthoView.tsxnão tem nenhuma referência a "tutor".TutoriaSection.tsxnão é importada em lugar nenhum (código solto/WIP).- Quem referencia tutor:
OrthoReports.tsx(e os Tutor*View). - Único toque indireto possível:
services/backend.ts(eu altereigetCurrentRole, mas só com fallback p/ superadmin quando não há workspace — não afeta dentista/tutoria;getCurrentUserName, usado pela TutoriaSection, NÃO foi alterado). - Provável causa: o rebuild publicou a versão WIP da tutoria que antes não estava no ar.
PENDENTE — preciso do sintoma para diagnosticar:
- Onde exatamente? (OrthoView do paciente / OrthoReports / "Meus Tratamentos" / outro)
- O que acontece? (crash/tela branca / botão sumiu / dado não carrega / erro console)
- Mensagem de erro no console do navegador?
→ Com o sintoma, achar a causa raiz em OrthoReports.tsx / TutoriaSection.tsx e corrigir.
3. Status das fases (arquitetura agenda/workspaces)
- Fase 0 — Cadastro: 5 cards de perfil com preço (Clínica R$499 separada).
LoginView.tsx. - Fase 1 — Workspace pessoal automático no
/api/register(dentista/protético/paciente);clinicas.tipo+owner_id;proteticoadicionado aovinculos_role_check. Fim do beco "aguardando-convite". - Fase 2 — Identidade global:
dentistas.usuario_id+ backfill por email; criação seta usuario_id. - Fase 3 — Anti-overbooking global:
POST /api/agendamentosresolve identidade do profissional e bloqueia sobreposição em QUALQUER clínica → 409{conflito, ocupado, podeRegistrarInteresse}. - [~] Fase 4 — Fila de interesse:
- Backend: tabela
interesses_agenda,POST/GET /api/interesses, hook no DELETE de agendamento que notifica interessados comoenviado_por='sistema'e marcaliberado. - Frontend Parte 1: AgendaView mostra painel "Horário ocupado" no 409 → registrar interesse (com minutos de espera).
- Frontend Parte 2: dentista vê "Pedidos de interesse" (sino + badge + modal; GET retorna
clinica_nome). POST /api/clinicaspassou a setarowner_id+tipo.- FALTA: endpoint "tentar liberar" (clínica pede → dentista decide mover/cancelar o ocupante).
- FALTA: re-notificação periódica do sistema enquanto o dentista demora (job/cron), sempre rotulada como "notificação do sistema" (impessoal).
- Backend: tabela
- Fase 5 — Odontodelivery: diretório (
disponivel_diretoriojá existe) → pedido de horário ao freelancer.
Regras de negócio confirmadas
- Profissional não pode estar em 2 lugares ao mesmo tempo (invariante: 1 agendamento por slot).
- Conflito não é "não" seco: vira fila de INTERESSE (não trava o slot). Clínica pode: registrar interesse com tempo de espera (expira), escolher outro horário, ou pedir pro dentista liberar.
- Todos têm workspace (pessoal automático no cadastro).
4. Bugs pré-existentes encontrados (não introduzidos nesta sessão)
notificacoes.lidaé smallint (confirmado no banco:int2), mas havia código usando boolean → corrigido (2026-06-12). Locais:POST /api/superadmin/notificacoesinseriafalse(server.js:4385 e 4393) e o marcar-como-lida usavatrue/false(POST /api/notificacoes/read-allserver.js:1584 e.../:id/readserver.js:1601). Todos trocados para1/0. Os demais INSERT (270, 2188) já usavam0.NÃO procede mais. A constraint atual é case-insensitive e aceitaPOST /api/agendamentosdefaultstatus='Agendado'violaagendamentos_status_checkagendado(lower(status) IN ('importado','agendado','confirmado','reagendado','remarcar', 'cancelado','falta','atendido','pendente','concluido','faltou')). O default do código érest.status || 'agendado'→ válido. Há registrosagendadoem uso no banco.NÃO procede. O PUT chamaPUT /api/agendamentos/:idnão passa pelo check de overlapfindOverbookingConflict(...)e retorna 409 em conflito (server.js:~2013).
4c. Integração Plugin RX ↔ servidor dental (2026-06-12) — MODELO GLOBAL
Modelo correto (confirmado pelo usuário): config é GLOBAL, só do superadmin
(não por clínica, não por usuário admin da conta). Hierarquia desejada no RX:
scoreodonto / conta-mestre (consulttclinic@gmail.com) / clinicaID / paciente.
Mapa: conta-mestre = api_token global · clinicaID = images.clinic_name ·
paciente = images.patient_name.
Menu — feito antes: Sidebar.tsx voltou a mostrar PLUGINS ao superadmin
(allowlist linha 67 estava sem 'plugins').
FASE 0 — scoreodonto realinhado p/ GLOBAL (feito, deploy dev):
settings.rx_config_global={ rx_url, api_token }. Sem login/senha, sem cache: o backend usa oapi_tokenestático direto como Bearer (_rxAdminFetch).api_tokensdo RX são aceitos peloauthenticateToken(viram admin, não expiram).PUT /api/rx/configagora ésuperadminGuard(só superadmin grava); token preservado se vier vazio.GET /api/rx/config(authGuard) devolve{rx_url, has_token, configured}(sem token).POST /api/rx/devicescria no RX comclinic_name = clinicaId(e-mail default único por clínica+PC:<clinicaId>.<pcSlug>@rx.scoreodonto.com).GET /api/rx/devices?clinicaId=filtra por clinic_name.- Revertido o que era do modelo errado: bypass do
tenantGuard(removido) e seletor de clínica doRxConfigView(removido). RxConfigView: card CONFIGURAÇÃO GERAL DO RX (URL + token) editável só superadmin; clínica vê status read-only + cadastra seus PCs. Superadmin vê PCs de todas as clínicas.backend.ts:getRxConfig()(0 args,{rx_url,has_token,configured}),putRxConfig({rx_url,api_token}),createRxDevicesem clinicName.RXPlugin.tsx:getRxConfig()sem arg.
FASE 1 — servidor RX (dev.rx, dental-server/routes/images.js) (feito, restart dev):
- Filtro
?clinic=<clinicaId>(=clinic_name) emGET /api/images,/images/patientse/images/by-patient. NovoGET /api/images/unique-clinics(clinic_name + contagens). - Validado:
node --checkok;rx_dev_apireiniciado (porta 3000); endpoints → 401 (auth ok). ⚠️npm run dev=node server.js(SEM nodemon) → mudanças no RX exigemdocker restart rx_dev_api.
Validado geral: tsc 0 erros; scoreodonto backend+frontend rebuildados no dev (bundle govSqkSI).
FASE 2/3 — leitura por proxy (feito, deploy dev bundle BVGGJsCu):
- Backend:
GET /api/rx/patients?clinicaId=&search=eGET /api/rx/images?clinicaId=&paciente=→ proxy p/ RX/images/patientse/images/by-patientcomclinic=<clinicaId>+ api_token. Isolamento_rxClinicScopeOk: superadmin vê tudo; demais só clínicas com vínculo (senão 403). backend.ts:getRxPatients,getRxPatientImages.RXPluginconsome esses (lista de pacientes + imagens do paciente). Thumbnails seguem públicos (/api/images/:id/thumbnail).
PENDENTE:
- Falta o
api_tokencolado em CONFIGURAÇÃO GERAL DO RX (superadmin) → sem ele, criar PC dá 400 e a leitura volta vazia. Gerar viaPOST /api/auth/tokensdo RX (usuáriois_admin). - Imagem em tamanho cheio (
/images/:id/file-url) ainda é autenticada → falta proxyGET /api/rx/image/:id/file(galeria mostra thumbnail público; full-res é follow-up). - GTOs / upload / trigger-sync do RXPlugin ainda usam
rxFetchdireto (token vazio) — já estavam quebrados antes; migrar p/ proxy se forem usados. - Ativação do plugin ainda é localStorage + toggle só superadmin (PluginsView) → rever p/ ativação por clínica no backend.
- Promover FASES 1–3 p/ prod (rx.scoreodonto.com + scoreodonto prod) após validar no dev.
4d. Plugins: Locação de Salas + Marketplace de Profissionais (2026-06-12) — deploy dev
Dois plugins novos no padrão do RX (catálogo PluginsView + pluginRegistry localStorage
pluginMenuItemsno Sidebar). Bundleindex-BylSJWKo.js;tsc0 erros; migrations OK.
locacao-salas (teal, view /salas, views/plugins/SalasPlugin.tsx):
- Tabelas
salas+sala_reservas. CRUD do locador, marketplace (tipo/cidade/UF), valores por hora/turno/diária/semanal/mensal. - Reserva em transação com anti-conflito TRIPLO → 409: sala ocupada, reserva do próprio
solicitante em outra sala, e agenda global (
findOverbookingConflictviadentistas.usuario_id) — sala entra na invariante "1 lugar por vez". - Fluxo: pendente → confirmar/recusar (locador) / cancelar (ambos). Notificações
(
notificarUsuario) +audit_log(entidade='sala'|'sala_reserva').
profissionais-marketplace (laranja, view /marketplace-profissionais,
views/plugins/ProfissionaisPlugin.tsx) — decisão: Delivery (nível 8) virou "Profissionais":
usuarios+cep,raio_atuacao_km,bio_profissional(raio é informativo, sem geodistância).GET /api/profissionais/marketplaceinclui biomédico (o diretório antigo não incluía) e filtra por CEP-prefixo.GET/PUT /api/profissionais/perfil(opt-indisponivel_diretorio).- Tabela
propostas_profissional: clínica→profissional (POST /api/propostascomtenantGuard, anti-duplicata pendente), aceitar/recusar (profissional) / cancelar (clínica) + notificações. - Consolidado com o Diretório antigo (bundle
DZJWcMDu): com o plugin ativo, o item "DIRETÓRIO PROF." some do menu e a rota/diretorio-profissionaisrenderiza o plugin; com o plugin inativo, oDiretorioProfissionaisViewantigo continua intacto (sem regressão).DiretorioProfissionaisView//api/profissionais-disponiveispodem ser removidos quando o plugin virar padrão.
Como testar: superadmin → PLUGINS → ativar (lembrete: ativação é localStorage por navegador — pendência 4c vale aqui também) → itens aparecem no menu dos papéis operacionais. Propostas exigem workspace ativo (clínica/consultório); aba MEU PERFIL só para dentista/biomedico/protetico.
⚠️ LIMPEZA pós-teste (banco é de PRODUÇÃO): dados de teste ficam em salas,
sala_reservas, propostas_profissional, colunas novas de usuarios e respingos em
notificacoes/audit_log. Limpar com:
DELETE FROM sala_reservas; DELETE FROM salas; DELETE FROM propostas_profissional;
-- e reverter perfis de teste:
-- UPDATE usuarios SET disponivel_diretorio=false, raio_atuacao_km=NULL, bio_profissional=NULL WHERE id='...';
Fluxos institucionais de locação (2026-06-13, bundle CvP3Q2X_):
sala_reservas+clinica_id/clinica_nome/profissional_usuario_id/profissional_nome.- Clínica aluga sala: ReservaModal ganhou "RESERVAR COMO: PESSOAL | ";
como unidade, pode designar o profissional (
GET /api/dentistas?clinicaId=, só os comusuario_id). O anti-conflito roda sobre o OCUPANTE (designado ou solicitante) —COALESCE(profissional_usuario_id, solicitante_id)nas reservas + agenda global. Designado é notificado. Membros da unidade veem/cancelam as reservas dela (GET /api/sala-reservas/minhas?clinicaId=, vínculo validado). - Consultório/clínica anuncia a própria sala: checkbox "ANUNCIAR PELA UNIDADE: "
no cadastro/edição (grava
salas.clinica_id; PUT aceita desvincular). Marketplace e cards mostram "POR " (joinclinicas). - Dentista aluga sala: fluxo pessoal original, inalterado.
Alinhado com /superadmin/financeiro (bundle CXf4eiwZ): a Tabela de Preços ganhou a
seção PLUGINS (ADD-ONS) — chaves plugin:locacao-salas e plugin:profissionais-marketplace
no mesmo settings.pricing (0 = incluído no plano). GET /api/superadmin/pricing agora mescla
defaults (chaves novas aparecem em configs antigas) e ganhou a linha biomédico que faltava.
Os cards da Área de Plugins exibem o preço configurado (chip "R$ X/MÊS" / "INCLUÍDO NO PLANO").
A COBRANÇA efetiva do add-on (bloquear plugin sem pagamento) segue o modelo manual atual de
pagamentos por usuário — não implementada.
PENDENTE (roadmap acordado):
- Cobrança efetiva dos add-ons (ativação por clínica no backend + verificação de pagamento — junto com a pendência 4c de tirar a ativação do localStorage).
- Score Professional (motor de pontuação — alimentado por reservas/propostas/faltas).
- Geodistância real (CEP→lat/lng; raio passa a filtrar de verdade).
- Fotos das salas (upload via
/app/media, como as fotos de orto). - Pagamento da locação (MercadoPago já existe na tutoria — reaproveitar).
- Proposta aceita → criar
vinculosautomaticamente (hoje o aceite é só status+notificação).
5. Estado de versionamento
- NADA foi commitado nesta sessão. Tudo está no working tree, rodando contra o banco de prod.
- Regra do projeto: commit/push só com autorização explícita — o push dispara o pipeline de produção via webhook do Gitea (pode sobrescrever trabalho em andamento).
- Arquivos que ESTA sessão editou:
backend/server.js,frontend/App.tsx,frontend/components/Sidebar.tsx,frontend/services/backend.ts,frontend/views/AgendaView.tsx,frontend/views/LoginView.tsx,frontend/views/LandingPage.tsx. (Demais arquivos modificados/untracked são WIP de sessões anteriores.)