feat: cron automático de thumbnails + lista de pacientes em tabela + CRUD
continuous-integration/webhook Deploy concluído (VPS4)

Backend:
- server.js: cron thumb-backfill a cada 10min com rastreio de estado
  (lastRun, nextRun, running, lastResult). GET /api/cron/status e
  POST /api/cron/run/:key para consulta e disparo manual via painel.
- routes/images.js: PUT /api/images/patients/:name — edita patient_name,
  doctor e remark em todas as imagens do paciente de uma vez.

Frontend:
- PatientsPage.jsx (/patients): tabela com colunas Nome (+ miniatura),
  Dentista, Imagens, Última atualização, Observações, Ações. Suporta
  busca, paginação infinita, editar e excluir por linha.
- SettingsPage.jsx (/settings): seção de Cron Jobs com status, resultado
  e botão "Executar agora"; seção de informações do servidor.
- EditPatientModal.jsx: edita nome, dentista e observações do paciente.
- Sidebar.jsx: add "Pacientes" (todos usuários) e "Configurações" (admin).
- App.jsx: rotas /patients (protegida) e /settings (admin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-31 06:44:45 +02:00
parent 003e568318
commit 67accb56aa
7 changed files with 687 additions and 6 deletions
+14
View File
@@ -11,6 +11,8 @@ import ClientsPage from './pages/ClientsPage';
import AdminClinicsPage from './pages/AdminClinicsPage';
import ResetPage from './pages/ResetPage';
import DownloadPage from './pages/DownloadPage';
import PatientsPage from './pages/PatientsPage';
import SettingsPage from './pages/SettingsPage';
export default function App() {
return (
@@ -55,6 +57,18 @@ export default function App() {
</AdminRoute>
} />
<Route path="/patients" element={
<ProtectedRoute>
<PatientsPage />
</ProtectedRoute>
} />
<Route path="/settings" element={
<AdminRoute>
<SettingsPage />
</AdminRoute>
} />
{/* Catch-all: redireciona para home */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -0,0 +1,81 @@
import { useState, useEffect } from 'react';
import Modal from './Modal';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
export default function EditPatientModal({ isOpen, onClose, patient, onSaved }) {
const { showToast } = useToast();
const [form, setForm] = useState({ newName: '', doctor: '', remark: '' });
const [saving, setSaving] = useState(false);
useEffect(() => {
if (patient) {
setForm({
newName: patient.patient_name || '',
doctor: patient.doctor || '',
remark: patient.remark || '',
});
}
}, [patient]);
const set = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.newName.trim()) return showToast('Nome é obrigatório.', 'error');
setSaving(true);
try {
await api.put(`/images/patients/${encodeURIComponent(patient.patient_name)}`, form);
showToast('Paciente atualizado com sucesso!', 'success');
onSaved?.();
onClose();
} catch (err) {
showToast(err.response?.data?.error || 'Erro ao salvar.', 'error');
} finally {
setSaving(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Editar Paciente">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Nome completo *</label>
<input
type="text"
required
value={form.newName}
onChange={set('newName')}
placeholder="Nome do paciente"
/>
</div>
<div className="form-group">
<label>Dentista</label>
<input
type="text"
value={form.doctor}
onChange={set('doctor')}
placeholder="Nome do dentista responsável"
/>
</div>
<div className="form-group">
<label>Observações</label>
<textarea
rows={3}
value={form.remark}
onChange={set('remark')}
placeholder="Informações adicionais..."
/>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
{saving ? 'Salvando...' : 'Salvar'}
</button>
</div>
</form>
</Modal>
);
}
@@ -38,6 +38,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
<i className="fa-solid fa-images" /> Galeria
</NavLink>
<NavLink
to="/patients"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<i className="fa-solid fa-users" /> Pacientes
</NavLink>
<button className="nav-item" onClick={onNewPatient}>
<i className="fa-solid fa-user-plus" /> Novo Paciente
</button>
@@ -90,6 +97,13 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
<button className="nav-item" onClick={onPlugins}>
<i className="fa-solid fa-plug" /> Plugins / Wasabi
</button>
<NavLink
to="/settings"
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
>
<i className="fa-solid fa-sliders" /> Configurações
</NavLink>
</>
)}
</nav>
@@ -0,0 +1,252 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import Sidebar from '../components/Sidebar';
import CreatePatientModal from '../components/CreatePatientModal';
import EditPatientModal from '../components/EditPatientModal';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
function formatDate(d) {
if (!d) return '—';
return new Date(d).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
export default function PatientsPage() {
const { showToast } = useToast();
const { isAdmin } = useAuth();
const [patients, setPatients] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [version, setVersion] = useState('');
const isFetchingRef = useRef(false);
// Modals
const [showCreate, setShowCreate] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [syncTab, setSyncTab] = useState('devices');
useEffect(() => {
fetch('/version.txt').then(r => r.text()).then(v => setVersion(v.trim())).catch(() => {});
}, []);
const loadPatients = useCallback(async (reset = true) => {
if (isFetchingRef.current) return;
if (!reset && !hasMore) return;
isFetchingRef.current = true;
const currentPage = reset ? 1 : page;
if (reset) { setPage(1); setHasMore(true); setLoading(true); }
try {
const { data } = await api.get(
`/images/patients?search=${encodeURIComponent(search)}&page=${currentPage}&limit=50`
);
if (data.length < 50) setHasMore(false);
if (reset) {
setPatients(data);
setPage(2);
} else {
setPatients(prev => [...prev, ...data]);
setPage(p => p + 1);
}
} catch {
showToast('Erro ao carregar pacientes', 'error');
} finally {
setLoading(false);
isFetchingRef.current = false;
}
}, [search, page, hasMore, showToast]);
useEffect(() => { loadPatients(true); }, [search]);
const handleScroll = (e) => {
const el = e.target;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 200) loadPatients(false);
};
const deletePatient = async (patientName) => {
if (!confirm(`⚠️ Excluir permanentemente todas as imagens de "${patientName}"?\n\nEsta ação não pode ser desfeita.`)) return;
try {
await api.delete(`/images/by-patient?name=${encodeURIComponent(patientName)}`);
showToast('Paciente excluído com sucesso!', 'success');
loadPatients(true);
} catch (e) {
showToast(e.response?.data?.error || 'Falha ao excluir.', 'error');
}
};
return (
<>
<div className="app-container">
<Sidebar
version={version}
onNewPatient={() => setShowCreate(true)}
onUpload={() => { setSyncTab('upload'); setShowSync(true); }}
onSettings={() => setShowSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={(tab) => { setSyncTab(tab || 'devices'); setShowSync(true); }}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
<header className="header">
<div className="header-content">
<div className="header-left">
<h1>Pacientes</h1>
</div>
<div className="header-actions">
<input
type="search"
className="search-input"
placeholder="🔍 Pesquisar paciente..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ height: 42, minWidth: 220 }}
/>
<button className="btn btn-primary" style={{ height: 42 }} onClick={() => setShowCreate(true)}>
+ Novo Paciente
</button>
<button className="btn btn-secondary" style={{ height: 42, padding: '10px 14px' }} onClick={() => loadPatients(true)}>
🔄
</button>
</div>
</div>
</header>
<div className="content-scroll" onScroll={handleScroll}>
{loading && (
<div className="loading"><div className="spinner" /><p>Carregando...</p></div>
)}
{!loading && patients.length === 0 && (
<div className="empty-state">
<div className="empty-icon">👥</div>
<h2>Nenhum paciente encontrado</h2>
<p>As imagens enviadas pelos clientes aparecerão aqui agrupadas por paciente.</p>
</div>
)}
{!loading && patients.length > 0 && (
<div style={{ padding: '0 24px 24px' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.875rem',
background: 'var(--surface)',
borderRadius: 12,
overflow: 'hidden',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
}}>
<thead>
<tr style={{ background: 'var(--surface-hover, rgba(0,0,0,0.04))' }}>
{['Paciente', 'Dentista', 'Imagens', 'Última atualização', 'Observações', 'Ações'].map(col => (
<th key={col} style={{
padding: '12px 16px',
textAlign: 'left',
fontWeight: 600,
color: 'var(--text-secondary)',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderBottom: '1px solid var(--border)',
whiteSpace: 'nowrap',
}}>{col}</th>
))}
</tr>
</thead>
<tbody>
{patients.map((p, i) => (
<tr key={`${p.patient_name}-${i}`} style={{
borderBottom: '1px solid var(--border)',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-hover, rgba(0,0,0,0.025))'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<td style={{ padding: '12px 16px', fontWeight: 600, maxWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{p.thumb_url
? <img src={p.thumb_url} alt="" style={{ width: 36, height: 36, borderRadius: 6, objectFit: 'cover', flexShrink: 0, background: '#e0e7ff' }} loading="lazy" />
: <div style={{ width: 36, height: 36, borderRadius: 6, background: 'var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, flexShrink: 0 }}>🦷</div>
}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={p.patient_name}>
{p.patient_name || 'Sem nome'}
</span>
</div>
</td>
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.doctor || '—'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(59,130,246,0.1)', color: '#3b82f6',
borderRadius: 20, padding: '2px 10px', fontWeight: 700, fontSize: '0.8rem',
}}>
{p.image_count || 0}
</span>
</td>
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
{formatDate(p.last_date)}
</td>
<td style={{ padding: '12px 16px', color: 'var(--text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.remark || '—'}
</td>
<td style={{ padding: '12px 16px', whiteSpace: 'nowrap' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn btn-small btn-secondary"
onClick={() => setEditTarget(p)}
title="Editar paciente"
style={{ padding: '5px 10px' }}
> Editar</button>
{isAdmin && (
<button
className="btn btn-small"
onClick={() => deletePatient(p.patient_name)}
title="Excluir paciente"
style={{ padding: '5px 10px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)' }}
>🗑</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{!hasMore && patients.length > 0 && (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>
{patients.length} paciente{patients.length !== 1 ? 's' : ''} no total
</div>
)}
</div>
</div>
)}
</div>
</main>
</div>
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreated={() => loadPatients(true)} />
<EditPatientModal isOpen={!!editTarget} patient={editTarget} onClose={() => setEditTarget(null)} onSaved={() => loadPatients(true)} />
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncTab} />
</>
);
}
@@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import { useToast } from '../contexts/ToastContext';
import Sidebar from '../components/Sidebar';
import CreatePatientModal from '../components/CreatePatientModal';
import SettingsModal from '../components/SettingsModal';
import UserManagementModal from '../components/UserManagementModal';
import PluginsModal from '../components/PluginsModal';
import SyncModal from '../components/SyncModal';
function fmtDate(d) {
if (!d) return '—';
return new Date(d).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function StatusBadge({ running, lastResult }) {
if (running) return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(234,179,8,0.12)', color: '#ca8a04', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}> Executando</span>;
if (!lastResult) return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(107,114,128,0.12)', color: '#6b7280', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}> Aguardando</span>;
if (lastResult.status === 'error') return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(239,68,68,0.12)', color: '#ef4444', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}> Erro</span>;
return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'rgba(34,197,94,0.12)', color: '#16a34a', borderRadius: 20, padding: '3px 12px', fontWeight: 600, fontSize: '0.78rem' }}> OK</span>;
}
export default function SettingsPage() {
const { showToast } = useToast();
const [cronJobs, setCronJobs] = useState([]);
const [loadingCron, setLoadingCron] = useState(true);
const [runningKey, setRunningKey] = useState(null);
const [version, setVersion] = useState('');
// sidebar modals
const [showCreate, setShowCreate] = useState(false);
const [showAccountSettings, setShowAccountSettings] = useState(false);
const [showUsers, setShowUsers] = useState(false);
const [showPlugins, setShowPlugins] = useState(false);
const [showSync, setShowSync] = useState(false);
const [syncTab, setSyncTab] = useState('devices');
useEffect(() => {
fetch('/version.txt').then(r => r.text()).then(v => setVersion(v.trim())).catch(() => {});
loadCronStatus();
// Atualiza o status a cada 15s para refletir execuções em andamento
const interval = setInterval(loadCronStatus, 15000);
return () => clearInterval(interval);
}, []);
const loadCronStatus = async () => {
try {
const { data } = await api.get('/cron/status');
setCronJobs(data);
} catch {
// silencia — pode não ter permissão
} finally {
setLoadingCron(false);
}
};
const runCron = async (key) => {
setRunningKey(key);
try {
await api.post(`/cron/run/${key}`);
showToast('Cron iniciado em background.', 'info');
setTimeout(loadCronStatus, 2000);
} catch (e) {
showToast(e.response?.data?.error || 'Falha ao iniciar.', 'error');
} finally {
setRunningKey(null);
}
};
return (
<>
<div className="app-container">
<Sidebar
version={version}
onNewPatient={() => setShowCreate(true)}
onUpload={() => { setSyncTab('upload'); setShowSync(true); }}
onSettings={() => setShowAccountSettings(true)}
onUsers={() => setShowUsers(true)}
onSync={(tab) => { setSyncTab(tab || 'devices'); setShowSync(true); }}
onPlugins={() => setShowPlugins(true)}
/>
<main className="main-content">
<header className="header">
<div className="header-content">
<div className="header-left">
<h1>Configurações</h1>
</div>
</div>
</header>
<div className="content-scroll">
<div style={{ padding: '24px', maxWidth: 900, display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* ── Cron Jobs ─────────────────────────────────────── */}
<section style={{ background: 'var(--surface)', borderRadius: 12, border: '1px solid var(--border)', overflow: 'hidden' }}>
<div style={{ padding: '18px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: '1rem', fontWeight: 700, margin: 0 }}> Cron Jobs</h2>
<p style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
Tarefas agendadas que rodam automaticamente em background.
</p>
</div>
<button
className="btn btn-secondary"
style={{ fontSize: '0.8rem', padding: '7px 14px' }}
onClick={loadCronStatus}
>
🔄 Atualizar
</button>
</div>
{loadingCron && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-secondary)' }}>
<div className="spinner" style={{ margin: '0 auto 8px' }} />
Carregando...
</div>
)}
{!loadingCron && cronJobs.length === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-secondary)' }}>
Nenhum cron job encontrado.
</div>
)}
{!loadingCron && cronJobs.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr>
{['Nome', 'Descrição', 'Intervalo', 'Status', 'Última execução', 'Próxima execução', 'Resultado', ''].map(col => (
<th key={col} style={{
padding: '10px 16px', textAlign: 'left', fontWeight: 600,
color: 'var(--text-secondary)', fontSize: '0.72rem',
textTransform: 'uppercase', letterSpacing: '0.05em',
borderBottom: '1px solid var(--border)', whiteSpace: 'nowrap',
background: 'var(--surface-hover, rgba(0,0,0,0.025))',
}}>{col}</th>
))}
</tr>
</thead>
<tbody>
{cronJobs.map(job => (
<tr key={job.key} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '14px 16px', fontWeight: 600, whiteSpace: 'nowrap' }}>{job.name}</td>
<td style={{ padding: '14px 16px', color: 'var(--text-secondary)', maxWidth: 240, fontSize: '0.82rem' }}>{job.description}</td>
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
A cada {job.intervalMinutes} min
</td>
<td style={{ padding: '14px 16px' }}>
<StatusBadge running={job.running} lastResult={job.lastResult} />
</td>
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
{fmtDate(job.lastRun)}
</td>
<td style={{ padding: '14px 16px', whiteSpace: 'nowrap', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
{fmtDate(job.nextRun)}
</td>
<td style={{ padding: '14px 16px', fontSize: '0.82rem', color: 'var(--text-secondary)' }}>
{job.lastResult ? (
job.lastResult.status === 'error'
? <span style={{ color: '#ef4444' }}>{job.lastResult.error}</span>
: `${job.lastResult.total} imagem(ns) processada(s)`
) : '—'}
</td>
<td style={{ padding: '14px 16px' }}>
<button
className="btn btn-small btn-secondary"
style={{ whiteSpace: 'nowrap', padding: '6px 12px', fontSize: '0.8rem' }}
disabled={job.running || runningKey === job.key}
onClick={() => runCron(job.key)}
>
{job.running || runningKey === job.key ? '⏳ Rodando...' : '▶ Executar agora'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── Servidor ──────────────────────────────────────── */}
<section style={{ background: 'var(--surface)', borderRadius: 12, border: '1px solid var(--border)', padding: '18px 20px' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, margin: '0 0 12px' }}>🖥 Informações do Servidor</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
{[
{ label: 'Versão', value: version ? `v${version}` : '—' },
{ label: 'Ambiente', value: 'Produção' },
].map(({ label, value }) => (
<div key={label} style={{ background: 'var(--surface-hover, rgba(0,0,0,0.03))', borderRadius: 8, padding: '12px 14px' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{label}</div>
<div style={{ fontWeight: 600, fontSize: '0.95rem' }}>{value}</div>
</div>
))}
</div>
</section>
</div>
</div>
</main>
</div>
<CreatePatientModal isOpen={showCreate} onClose={() => setShowCreate(false)} />
<SettingsModal isOpen={showAccountSettings} onClose={() => setShowAccountSettings(false)} />
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
<SyncModal isOpen={showSync} onClose={() => setShowSync(false)} defaultTab={syncTab} />
</>
);
}
+43
View File
@@ -207,6 +207,49 @@ router.get('/by-patient', async (req, res) => {
}
});
// ================================================================
// PUT /api/images/patients/:name - Editar dados de um paciente
// Atualiza patient_name, doctor e remark em todas as imagens do paciente.
// ================================================================
router.put('/patients/:name', async (req, res) => {
try {
const oldName = decodeURIComponent(req.params.name);
const { newName, doctor, remark } = req.body;
if (!oldName) return res.status(400).json({ error: 'Nome do paciente obrigatório.' });
const updates = [];
const params = [];
let i = 1;
if (newName !== undefined && newName.trim() !== '') {
updates.push(`patient_name = $${i++}`);
params.push(newName.trim());
}
if (doctor !== undefined) {
updates.push(`doctor = $${i++}`);
params.push(doctor);
}
if (remark !== undefined) {
updates.push(`remark = $${i++}`);
params.push(remark);
}
if (updates.length === 0) return res.status(400).json({ error: 'Nenhum campo para atualizar.' });
params.push(oldName);
const result = await db.run(
`UPDATE images SET ${updates.join(', ')} WHERE patient_name = $${i}`,
params
);
res.json({ success: true, updated: result.changes });
} catch (error) {
console.error('Erro ao editar paciente:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// DELETE /api/images/by-patient - Excluir todas imagens de um paciente
// ================================================================
+68 -6
View File
@@ -1286,9 +1286,29 @@ async function start() {
// Inicializar cliente DragonflyDB/Redis
redis.initRedis();
// Iniciar servidor
// Backfill de thumbnails ausentes: roda em background após o boot
setTimeout(async () => {
// ================================================================
// CRON — Geração automática de thumbnails ausentes
// ================================================================
const THUMB_CRON_INTERVAL_MS = 10 * 60 * 1000; // 10 minutos
const cronState = {
thumbBackfill: {
name: 'Geração de Thumbnails',
description: 'Gera e envia ao Wasabi as miniaturas de RX que ainda não possuem thumbnail.',
intervalMs: THUMB_CRON_INTERVAL_MS,
running: false,
lastRun: null,
nextRun: null,
lastResult: null,
}
};
async function runThumbCron() {
if (cronState.thumbBackfill.running) return;
cronState.thumbBackfill.running = true;
cronState.thumbBackfill.lastRun = new Date().toISOString();
cronState.thumbBackfill.nextRun = new Date(Date.now() + THUMB_CRON_INTERVAL_MS).toISOString();
console.log('[cron:thumbs] Iniciando varredura de thumbnails ausentes...');
try {
const { runThumbBackfill } = require('./routes/images');
const missing = await db.all(
@@ -1296,11 +1316,53 @@ async function start() {
WHERE thumb_filename IS NULL AND enabled = true ORDER BY created_at DESC`
);
if (missing.length > 0) {
console.log(`[boot] Iniciando backfill de thumbnails para ${missing.length} imagem(ns) sem thumb...`);
runThumbBackfill(missing);
console.log(`[cron:thumbs] ${missing.length} imagem(ns) sem thumbnail — processando...`);
await runThumbBackfill(missing);
cronState.thumbBackfill.lastResult = { total: missing.length, status: 'ok' };
} else {
console.log('[cron:thumbs] Nenhuma imagem pendente.');
cronState.thumbBackfill.lastResult = { total: 0, status: 'ok' };
}
} catch (e) { /* silencia erro de backfill para não afetar o boot */ }
} catch (e) {
console.error('[cron:thumbs] Erro:', e.message);
cronState.thumbBackfill.lastResult = { total: 0, status: 'error', error: e.message };
} finally {
cronState.thumbBackfill.running = false;
}
}
// Rodar na inicialização após 5s e depois a cada intervalo
setTimeout(() => {
cronState.thumbBackfill.nextRun = new Date(Date.now() + THUMB_CRON_INTERVAL_MS).toISOString();
runThumbCron();
}, 5000);
setInterval(runThumbCron, THUMB_CRON_INTERVAL_MS);
// GET /api/cron/status — estado dos cron jobs (admin)
app.get('/api/cron/status', authenticateToken, (req, res) => {
res.json(
Object.entries(cronState).map(([key, job]) => ({
key,
name: job.name,
description: job.description,
intervalMinutes: Math.round(job.intervalMs / 60000),
running: job.running,
lastRun: job.lastRun,
nextRun: job.nextRun,
lastResult: job.lastResult,
}))
);
});
// POST /api/cron/run/:key — dispara um cron job manualmente (admin)
app.post('/api/cron/run/:key', authenticateToken, async (req, res) => {
if (req.params.key === 'thumbBackfill') {
res.json({ success: true, message: 'Cron iniciado em background.' });
runThumbCron();
} else {
res.status(404).json({ error: 'Cron job não encontrado.' });
}
});
server.listen(PORT, () => {
console.log('═══════════════════════════════════════════════════════');