feat(protese): Modo 1 fatia 1a — leitura da OS por link seguro /p/TOKEN (sem conta)
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user