diff --git a/backend/server.js b/backend/server.js index 36166a8..2d6aa39 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4174,6 +4174,19 @@ app.post('/api/protese/os/:id/link/revogar', authGuard, async (req, res) => { res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); +// Status do link ativo (para a UI da clínica gerir: copiar/revogar, ver acessos/expiração). +app.get('/api/protese/os/:id/link/status', 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 } = await pool.query( + "SELECT token, expires_at, last_access_at, acessos 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]); + if (!rows.length) return res.json({ ativo: false }); + const base = process.env.APP_PUBLIC_URL || 'https://scoreodonto.com'; + res.json({ ativo: true, url: `${base}/p/${rows[0].token}`, expires_at: rows[0].expires_at, last_access_at: rows[0].last_access_at, acessos: rows[0].acessos }); + } 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) => { diff --git a/doc/MODO1-LINK-SEGURO-PROTESE.md b/doc/MODO1-LINK-SEGURO-PROTESE.md index e5fbc1a..82127ad 100644 --- a/doc/MODO1-LINK-SEGURO-PROTESE.md +++ b/doc/MODO1-LINK-SEGURO-PROTESE.md @@ -94,7 +94,15 @@ marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o `" (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 (conversão):** herança de histórico ao criar conta + revogação/expiração na UI. +- **✅ 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).** > 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/services/backend.ts b/frontend/services/backend.ts index 3174f9f..1527c55 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1130,6 +1130,15 @@ export const HybridBackend = { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link'); return await res.json(); }, + getProteseLinkStatus: async (osId: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`); + return await res.json().catch(() => ({ ativo: false })); + }, + revogarProteseLink: async (osId: string) => { + const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/revogar`, { method: 'POST' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar'); + 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)}`); diff --git a/frontend/views/ProteseView.tsx b/frontend/views/ProteseView.tsx index d028066..f0d932d 100644 --- a/frontend/views/ProteseView.tsx +++ b/frontend/views/ProteseView.tsx @@ -558,31 +558,41 @@ 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 confirm = useConfirm(); + const [st, setSt] = useState(null); // null=carregando | {ativo,...} const [busy, setBusy] = useState(false); - const gerar = async () => { + const carregar = useCallback(() => HybridBackend.getProteseLinkStatus(os.id).then(setSt).catch(() => setSt({ ativo: false })), [os.id]); + useEffect(() => { carregar(); }, [carregar]); + const acao = async (fn: () => Promise, ok: string) => { setBusy(true); - try { const r = await HybridBackend.gerarProteseLink(os.id); setUrl(r.url); } + try { await fn(); await carregar(); toast.success(ok); } 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}`); + const copiar = async () => { try { await navigator.clipboard.writeText(st.url); toast.success('LINK COPIADO.'); } catch { toast.error('NÃO FOI POSSÍVEL COPIAR.'); } }; + const revogar = async () => { if (await confirm('REVOGAR O LINK? O PROTÉTICO PERDE O ACESSO.')) acao(() => HybridBackend.revogarProteseLink(os.id), 'LINK REVOGADO.'); }; + const waText = st?.url ? encodeURIComponent(`Acompanhe a prótese (${os.tipo}) do paciente ${os.paciente_nome || ''}: ${st.url}`) : ''; return (

Acompanhamento do protético

- {!url ? ( + {!st ? ( +

Carregando…

+ ) : !st.ativo ? ( <>

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" /> + 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.

+
+ {st.acessos || 0} acesso(s){st.last_access_at ? ` · último ${dataBR(st.last_access_at)}` : ' · ainda não aberto'} + +
+

{st.expires_at ? `Expira em ${dataBR(st.expires_at)} · ` : ''}sem CPF nem valores do paciente.

)}