feat(protese): Modo 1 fatia 1b — ações do protético pelo link (sem conta)
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 49s

Pelo link /p/TOKEN o protético (sem conta) passa a ATUAR na OS, tudo auditado como "<nome> (link)":
- POST /api/p/:token/custodia — confirmar recebimento / devolução (clínica↔lab)
- POST /api/p/:token/status — avançar a produção (recebido…finalizado); entregue/cancelado
  seguem exclusivos da clínica (400 pelo link)
- POST /api/p/:token/foto — anexar foto do trabalho
Página pública ganha a seção "Atualizar status" (custódia/etapa/foto) enquanto a OS não está
finalizada; header alterna Acompanhamento/Somente leitura.

Validado em DEV: recebimento, avanço de etapa, foto; entregue pelo link → 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-24 07:03:52 +02:00
parent f0b7a45d86
commit 28dc27a289
4 changed files with 136 additions and 8 deletions
+65 -1
View File
@@ -4202,11 +4202,75 @@ app.get('/api/p/:token', async (req, res) => {
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,
finalizada: os.status === 'entregue' || os.status === 'cancelado',
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Modo 1 (1b) — ações do protético PELO LINK (sem conta). Auditadas como "<nome> (link)".
async function resolveLinkToken(token) {
const { rows } = 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())", [token]);
if (!rows.length) return null;
const { rows: os } = await pool.query("SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL", [rows[0].os_id]);
if (!os.length) return null;
return { link: rows[0], os: os[0] };
}
async function logEventoLink(os, link, status, observacao) {
const nome = `${await actorNome(link.protetico_id)} (link)`;
await pool.query("INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)",
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, status, observacao, link.protetico_id, nome]);
return nome;
}
app.post('/api/p/:token/custodia', async (req, res) => {
try {
const ctx = await resolveLinkToken(req.params.token);
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
const { os, link } = ctx;
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
if (!para) return res.status(400).json({ error: 'para_local inválido.' });
const de = os.local_atual || 'clinica';
if (de === para) return res.status(409).json({ error: `Já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
const nome = `${await actorNome(link.protetico_id)} (link)`;
await pool.query("INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
[`pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, os.id, os.clinica_id, de, para, os.status, link.protetico_id, nome]);
await pool.query("UPDATE protese_os SET local_atual=$1 WHERE id=$2", [para, os.id]);
await logEventoLink(os, link, 'custodia', para === 'laboratorio' ? 'Protético recebeu o trabalho (via link)' : 'Trabalho devolvido à clínica (via link)');
res.json({ success: true, local_atual: para });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/p/:token/status', async (req, res) => {
try {
const ctx = await resolveLinkToken(req.params.token);
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
const { os, link } = ctx;
const PROD = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
if (!PROD.includes(req.body?.status)) return res.status(400).json({ error: 'Etapa inválida (somente produção pelo link).' });
if (os.status === 'entregue' || os.status === 'cancelado') return res.status(409).json({ error: `OS já está "${os.status}".` });
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW() WHERE id=$2', [req.body.status, os.id]);
await logEventoLink(os, link, req.body.status, null);
res.json({ success: true, status: req.body.status });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/p/:token/foto', ortoUpload.single('foto'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
const ctx = await resolveLinkToken(req.params.token);
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
const { os, link } = ctx;
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const nome = `${await actorNome(link.protetico_id)} (link)`;
await pool.query("INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, link.protetico_id, nome]);
await logEventoLink(os, link, 'foto', 'Foto anexada (via link)');
res.json({ success: true, id: fid });
} 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 {