From 672c3b01e5edc10fb0314a5e3b85f9e439bdf6af Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 24 Jun 2026 06:53:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(protese):=20Modo=201=20fatia=201a=20?= =?UTF-8?q?=E2=80=94=20leitura=20da=20OS=20por=20link=20seguro=20/p/TOKEN?= =?UTF-8?q?=20(sem=20conta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 74 +++++++++++ doc/MODO1-LINK-SEGURO-PROTESE.md | 10 +- frontend/index.tsx | 5 +- frontend/services/backend.ts | 12 ++ frontend/views/ProtesePublicView.tsx | 183 +++++++++++++++++++++++++++ frontend/views/ProteseView.tsx | 43 ++++++- 6 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 frontend/views/ProtesePublicView.tsx diff --git a/backend/server.js b/backend/server.js index 9d55d2c..215ee8b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4146,6 +4146,67 @@ app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab). +app.post('/api/protese/os/:id/link', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const acc = await carregaOSComAcesso(req.params.id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + const { rows: ex } = await pool.query( + "SELECT token FROM protese_os_link WHERE os_id=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1", [acc.os.id]); + let token = ex[0]?.token; + if (!token) { + token = crypto.randomBytes(24).toString('base64url'); + await pool.query("INSERT INTO protese_os_link (token, os_id, protetico_id, created_by, expires_at) VALUES ($1,$2,$3,$4, NOW() + INTERVAL '60 days')", + [token, acc.os.id, acc.os.protetico_id, uid]); + await logEventoOS(acc.os, uid, 'Link de acompanhamento gerado para o protético', 'link'); + } + const base = process.env.APP_PUBLIC_URL || 'https://scoreodonto.com'; + res.json({ success: true, token, url: `${base}/p/${token}` }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); +app.post('/api/protese/os/:id/link/revogar', authGuard, async (req, res) => { + try { + const uid = req.authUser.userId; + const acc = await carregaOSComAcesso(req.params.id, uid); + if (acc.error) return res.status(acc.status).json({ error: acc.error }); + await pool.query("UPDATE protese_os_link SET revoked_at=NOW() WHERE os_id=$1 AND revoked_at IS NULL", [acc.os.id]); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// MODO 1 — leitura PÚBLICA da OS pelo token (sem login). Blindagem: sem CPF, sem valor do paciente. +app.get('/api/p/:token', async (req, res) => { + try { + const { rows: lk } = await pool.query( + "SELECT * FROM protese_os_link WHERE token=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())", [req.params.token]); + if (!lk.length) return res.status(404).json({ error: 'Link inválido ou expirado.' }); + const { rows } = await pool.query("SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id=protese_os.clinica_id) AS clinica_nome FROM protese_os WHERE id=$1 AND deleted_at IS NULL", [lk[0].os_id]); + if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' }); + const os = rows[0]; + await pool.query("UPDATE protese_os_link SET last_access_at=NOW(), acessos=acessos+1 WHERE token=$1", [req.params.token]); + const [ev, ft, cm, oc, cu] = await Promise.all([ + pool.query("SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at", [os.id]), + pool.query("SELECT id, legenda, uploaded_nome, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL ORDER BY created_at", [os.id]), + pool.query("SELECT id, nome, quantidade, COALESCE(direcao,'enviado') AS direcao, COALESCE(status_conferencia,'pendente') AS status_conferencia, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at", [os.id]), + pool.query("SELECT id, categoria, descricao, status, autor_nome, resposta, resposta_nome, created_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at", [os.id]), + pool.query("SELECT de_local, para_local, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at", [os.id]), + ]); + // Payload curado: o protético vê o trabalho e o próprio custo, NUNCA CPF nem valor ao paciente. + res.json({ + id: os.id, tipo: os.tipo, tipo_os: os.tipo_os, status: os.status, + clinica_nome: os.clinica_nome, dentista_nome: os.dentista_nome, paciente_nome: os.paciente_nome, + dentes: os.dentes, material: os.material, cor: os.cor, observacoes: os.observacoes, + prazo_entrega: os.prazo_entrega, garantia_ate: os.garantia_ate, garantia_meses: os.garantia_meses, + custo: parseFloat(os.custo || 0), local_atual: os.local_atual || 'clinica', + eventos: ev.rows, + fotos: ft.rows.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })), + componentes: cm.rows, ocorrencias: oc.rows, custodia: cu.rows, + somente_leitura: true, + }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // Detalhe + histórico de eventos + fotos. app.get('/api/protese/os/:id', authGuard, async (req, res) => { try { @@ -6276,6 +6337,19 @@ async function runMigrations() { `CREATE INDEX IF NOT EXISTS idx_protese_os_ocor ON protese_os_ocorrencia (os_id, created_at)`, // Fotos de evidência da ocorrência reusam protese_os_foto (com ocorrencia_id). `ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS ocorrencia_id TEXT`, + // Modo 1 — link seguro /p/TOKEN: o protético acompanha a OS sem conta. Token por OS, revogável. + `CREATE TABLE IF NOT EXISTS protese_os_link ( + token TEXT PRIMARY KEY, + os_id TEXT NOT NULL, + protetico_id TEXT, + created_by TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + last_access_at TIMESTAMPTZ, + acessos INTEGER DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id)`, `CREATE TABLE IF NOT EXISTS protese_produtos ( id TEXT PRIMARY KEY, protetico_id TEXT NOT NULL, -- dono (usuário protético) diff --git a/doc/MODO1-LINK-SEGURO-PROTESE.md b/doc/MODO1-LINK-SEGURO-PROTESE.md index 8e553f0..787e877 100644 --- a/doc/MODO1-LINK-SEGURO-PROTESE.md +++ b/doc/MODO1-LINK-SEGURO-PROTESE.md @@ -81,9 +81,15 @@ histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele). 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):** tabela + `GET /api/p/:token` (leitura) + página pública read-only + botão "enviar". +- **✅ 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):** custódia + status + foto pelo link (escrita), com auditoria. -- **1c (conversão):** CTA de cadastro com herança de histórico + revogação/expiração na UI. +- **1c (conversão):** herança de histórico ao criar conta + revogação/expiração na UI. > Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a > camada de token público. diff --git a/frontend/index.tsx b/frontend/index.tsx index 99cf660..397c80b 100644 --- a/frontend/index.tsx +++ b/frontend/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App.tsx'; +import { ProtesePublicView } from './views/ProtesePublicView.tsx'; import { ErrorBoundary } from './components/ErrorBoundary.tsx'; import { ToastProvider } from './contexts/ToastContext.tsx'; import { ConfirmProvider } from './contexts/ConfirmContext.tsx'; @@ -34,13 +35,15 @@ window.addEventListener('vite:preloadError', () => { const rootElement = document.getElementById('root'); if (rootElement) { const root = createRoot(rootElement); + // Modo 1 — link seguro: /p/TOKEN é página PÚBLICA (sem login), fora do app autenticado. + const publicMatch = window.location.pathname.match(/^\/p\/(.+)$/); root.render( - + {publicMatch ? : } diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index aef0cb5..4a5183e 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1124,6 +1124,18 @@ export const HybridBackend = { const res = await apiFetch(`${API_URL}/protese/lab/indicadores`); return await res.json().catch(() => ({})); }, + // Modo 1: link seguro de acompanhamento da OS + gerarProteseLink: async (osId: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link'); + return await res.json(); + }, + // Leitura PÚBLICA via token (sem login — não usa apiFetch/Authorization) + getProtesePublic: async (token: string) => { + const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}`); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Link inválido ou expirado.'); + return await res.json(); + }, // Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade. getSalaReservas: async (salaId: string): Promise => { diff --git a/frontend/views/ProtesePublicView.tsx b/frontend/views/ProtesePublicView.tsx new file mode 100644 index 0000000..5d7d8fa --- /dev/null +++ b/frontend/views/ProtesePublicView.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect } from 'react'; +import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2 } from 'lucide-react'; +import { HybridBackend } from '../services/backend.ts'; + +const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); +const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/'); +const dataHoraBR = (d: string) => { if (!d) return ''; const x = new Date(d); return isNaN(x.getTime()) ? dataBR(d) : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); }; + +const STATUS_LABEL: Record = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado' }; +const STATUS_COR: Record = { solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700', prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700', entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600' }; +const FLUXO = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue']; +const OCOR_CATS: Record = { nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível', parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça incorreta', ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho', outro: 'Outro' }; + +const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }> = ({ titulo, Icon, children }) => ( +
+

{titulo}

+ {children} +
+); + +export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => { + const [os, setOs] = useState(null); + const [erro, setErro] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')).finally(() => setLoading(false)); + }, [token]); + + if (loading) return
; + if (erro || !os) return ( +
+ +

{erro || 'Link inválido'}

+

Peça um novo link à clínica.

+
+ ); + + const noLab = (os.local_atual || 'clinica') === 'laboratorio'; + const idxAtual = FLUXO.indexOf(os.status); + const cancelado = os.status === 'cancelado'; + + return ( +
+ {/* Topo / marca */} +
+ +
+

ScoreOdonto

+

Acompanhamento de prótese

+
+ Somente leitura +
+ +
+ {/* Cabeçalho da OS */} +
+
+
+

{os.tipo}

+

OS {os.id}

+
+ {STATUS_LABEL[os.status] || os.status} +
+
+ {os.clinica_nome && {os.clinica_nome}} + {noLab ? 'Com o laboratório' : 'Na clínica'} + {os.tipo_os === 'garantia' && Garantia} +
+
+ + {/* Etapas */} + + {cancelado ? ( +
OS cancelada
+ ) : ( +
+ {FLUXO.map((s, i) => { + const done = i < idxAtual, atual = i === idxAtual; + const ev = (os.eventos || []).find((e: any) => e.status === s); + return ( + +
+ {done ? : atual ?
: } + {STATUS_LABEL[s]} + {ev ? dataBR(ev.created_at) : ''} +
+ {i < FLUXO.length - 1 &&
} + + ); + })} +
+ )} + + + {/* Dados */} + +
+ {os.paciente_nome &&
Paciente{os.paciente_nome}
} + {os.dentista_nome &&
Dentista{os.dentista_nome}
} + {os.dentes &&
Dentes{os.dentes}
} + {os.material &&
Material{os.material}
} + {os.cor &&
Cor{os.cor}
} + {os.prazo_entrega &&
Prazo{dataBR(os.prazo_entrega)}
} + {Number(os.custo) > 0 &&
Valor do lab{fmtBRL(os.custo)}
} +
+ {os.observacoes &&

{os.observacoes}

} + {os.garantia_ate &&
Garantia até {dataBR(os.garantia_ate)}
} +
+ + {/* Componentes */} + {(os.componentes || []).length > 0 && ( + +
+ {os.componentes.map((c: any) => ( +
+ {c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'} + {c.quantidade}× + {c.nome} +
+ ))} +
+
+ )} + + {/* Fotos */} + {(os.fotos || []).length > 0 && ( + +
+ {os.fotos.map((f: any) => ( + + + {f.legenda &&

{f.legenda}

} +
+ ))} +
+
+ )} + + {/* Ocorrências */} + {(os.ocorrencias || []).length > 0 && ( + +
+ {os.ocorrencias.map((o: any) => ( +
+
+ {(o.status || '').replace('_', ' ')} + {OCOR_CATS[o.categoria] || o.categoria} +
+

{o.descricao}

+ {o.resposta &&

Resposta: {o.resposta}

} +
+ ))} +
+
+ )} + + {/* Histórico */} + +
+ {(os.eventos || []).map((ev: any, i: number) => ( +
+ +
+ {ev.observacao || STATUS_LABEL[ev.status] || ev.status} + {dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''} +
+
+ ))} +
+
+ + {/* CTA de conversão */} + +

Gostou de acompanhar por aqui?

+

Crie sua conta grátis e gerencie seu laboratório: bancada, etapas, financeiro e score.

+ Criar conta grátis +
+

ScoreOdonto · Provedor de Prótese

+
+
+ ); +}; diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index 91fdb0d..d028066 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2 } from 'lucide-react'; +import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2 } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { useToast } from '../contexts/ToastContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; @@ -383,7 +383,12 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose )} - {tab === 'monitor' && } + {tab === 'monitor' && ( +
+ + {modo === 'clinica' && } +
+ )} {tab === 'ocorrencias' && } @@ -550,6 +555,40 @@ const OcorrenciasSecao: React.FC<{ os: any; onChanged: () => void }> = ({ os, on ); }; +// ── Seção: Compartilhar link de acompanhamento com o protético (Modo 1) ───── +const CompartilharLinkSecao: React.FC<{ os: any }> = ({ os }) => { + const toast = useToast(); + const [url, setUrl] = useState(''); + const [busy, setBusy] = useState(false); + const gerar = async () => { + setBusy(true); + try { const r = await HybridBackend.gerarProteseLink(os.id); setUrl(r.url); } + catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); } + }; + const copiar = async () => { try { await navigator.clipboard.writeText(url); toast.success('LINK COPIADO.'); } catch { toast.error('NÃO FOI POSSÍVEL COPIAR.'); } }; + const waText = encodeURIComponent(`Acompanhe a prótese (${os.tipo}) do paciente ${os.paciente_nome || ''}: ${url}`); + return ( +
+

Acompanhamento do protético

+ {!url ? ( + <> +

Gere um link para o protético acompanhar esta OS sem precisar de conta.

+ + + ) : ( +
+ e.currentTarget.select()} className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] text-gray-600 outline-none" /> +
+ WhatsApp + +
+

Somente leitura · expira em 60 dias · sem CPF nem valores do paciente.

+
+ )} +
+ ); +}; + // ── Seção: Monitoramento de custódia (onde o trabalho está) ───────────────── const MonitoramentoSecao: React.FC<{ os: any; podeEditar: boolean; busy: boolean; onMover: (p: 'clinica' | 'laboratorio') => void }> = ({ os, podeEditar, busy, onMover }) => { const noLab = (os.local_atual || 'clinica') === 'laboratorio';