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>
This commit is contained in:
VPS 4 Builder
2026-06-24 06:53:02 +02:00
parent 5f4460e37d
commit 672c3b01e5
6 changed files with 322 additions and 5 deletions
+74
View File
@@ -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)