feat(protese): Modo 1 fatia 1c — gestão do link na UI da clínica (status/acessos/revogar)
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>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -94,7 +94,15 @@ marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o
|
||||
`"<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 (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.
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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<any>(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<any>, 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 (
|
||||
<div className="border-t border-gray-100 pt-4 space-y-2">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase flex items-center gap-1.5"><Share2 size={12} /> Acompanhamento do protético</p>
|
||||
{!url ? (
|
||||
{!st ? (
|
||||
<p className="text-[11px] text-gray-300 uppercase font-bold">Carregando…</p>
|
||||
) : !st.ativo ? (
|
||||
<>
|
||||
<p className="text-[11px] text-gray-400">Gere um link para o protético acompanhar esta OS <b>sem precisar de conta</b>.</p>
|
||||
<button disabled={busy} onClick={gerar} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Share2 size={13} /> Gerar link de acompanhamento</button>
|
||||
<button disabled={busy} onClick={() => acao(() => HybridBackend.gerarProteseLink(os.id), 'LINK GERADO.')} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Share2 size={13} /> Gerar link de acompanhamento</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<input readOnly value={url} onClick={e => e.currentTarget.select()} className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] text-gray-600 outline-none" />
|
||||
<input readOnly value={st.url} onClick={e => e.currentTarget.select()} className="w-full p-2 bg-gray-50 border border-gray-100 rounded-lg text-[11px] text-gray-600 outline-none" />
|
||||
<div className="flex gap-2">
|
||||
<a href={`https://wa.me/?text=${waText}`} target="_blank" rel="noreferrer" className="flex-1 py-2 rounded-lg bg-green-600 text-white text-[11px] font-black uppercase text-center">WhatsApp</a>
|
||||
<button onClick={copiar} className="flex-1 py-2 rounded-lg border border-gray-200 text-gray-600 text-[11px] font-black uppercase">Copiar</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-400 uppercase">Somente leitura · expira em 60 dias · sem CPF nem valores do paciente.</p>
|
||||
<div className="flex items-center justify-between text-[9px] font-bold text-gray-400 uppercase">
|
||||
<span>{st.acessos || 0} acesso(s){st.last_access_at ? ` · último ${dataBR(st.last_access_at)}` : ' · ainda não aberto'}</span>
|
||||
<button disabled={busy} onClick={revogar} className="text-red-500 hover:text-red-600 disabled:opacity-50">Revogar</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-gray-400 uppercase">{st.expires_at ? `Expira em ${dataBR(st.expires_at)} · ` : ''}sem CPF nem valores do paciente.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user