Files
scoreodonto.com/backend/server.js
T
VPS 4 Builder 435176c0c3 fix(backend): add camelCase response mapping for all GET endpoints
PG stores columns lowercase but frontend expects camelCase. Each GET endpoint
now maps the response to include camelCase aliases alongside the lowercase fields.

- agendamentos: dentistaId, pacienteNome, pacienteCelular
- dentistas: corAgenda
- procedimentos: especialidadeId, especialidadeNome, valorParticular, codigoInterno;
  fix valoresPlanos filter (v.procedimentoid) and map planoId, planoNome, codigoPlano
- guias: full camelCase map (dataSolicitacao, beneficiarioNome, tipoTratamento, etc.)
- gto-builder: map items (builderId, procedimentoId, codigoTUSS, valorTotal, etc.)
  and builder fields (pacienteId, dentistaId, credenciadaId)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 04:26:03 +02:00

1268 lines
51 KiB
JavaScript

import 'dotenv/config';
import express from 'express';
import pg from 'pg';
import Redis from 'ioredis';
import cors from 'cors';
import { google } from 'googleapis';
import jwt from 'jsonwebtoken';
const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
// =============================================================================
// DATABASE — PostgreSQL nativo (pg Pool)
// =============================================================================
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost')
? { rejectUnauthorized: false } : false,
max: 10,
idleTimeoutMillis: 30000
});
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
// Helper: transaction wrapper
async function withTransaction(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Helper: build INSERT query from plain object (PG native)
// Keys are lowercased to match PG's case-folded column storage.
function buildInsert(table, obj) {
const allowed = Object.entries(obj).filter(([, v]) => v !== undefined && v !== null || typeof v === 'boolean' || v === 0);
const keys = allowed.map(([k]) => k.toLowerCase());
const vals = allowed.map(([, v]) => v);
const cols = keys.join(', ');
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
return { text: `INSERT INTO ${table} (${cols}) VALUES (${placeholders})`, values: vals };
}
// Helper: build UPDATE SET clause from plain object (PG native)
// Keys are lowercased to match PG's case-folded column storage.
function buildUpdate(table, obj, whereCol, whereVal) {
const skip = new Set(['id', 'createdat', 'created_at']);
const entries = Object.entries(obj).filter(([k, v]) => !skip.has(k.toLowerCase()) && v !== undefined);
const set = entries.map(([k], i) => `${k.toLowerCase()} = $${i + 1}`).join(', ');
const values = [...entries.map(([, v]) => v), whereVal];
return { text: `UPDATE ${table} SET ${set} WHERE ${whereCol.toLowerCase()} = $${entries.length + 1}`, values };
}
// Helper: convert PostgreSQL error codes to friendly messages
function pgErrCode(err) { return err.code; } // '23505' = unique violation
// =============================================================================
// DRAGONFLY / REDIS CACHE
// =============================================================================
let redis = null;
try {
const DRAGONFLY_URL = process.env.DRAGONFLY_URL || 'redis://localhost:6379';
redis = new Redis(DRAGONFLY_URL, { lazyConnect: true });
redis.on('connect', () => console.log('[DRAGONFLY] Connected'));
redis.on('error', (err) => console.error('[DRAGONFLY] Error:', err.message));
await redis.connect().catch(() => { redis = null; });
} catch (e) {
console.error('[DRAGONFLY] Failed to initialize:', e.message);
redis = null;
}
// =============================================================================
// EXPRESS
// =============================================================================
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}));
// --- HEALTH CHECK ---
app.get('/api/ping', (req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));
// =============================================================================
// SECURITY MIDDLEWARE: Tenant Guard
// =============================================================================
async function tenantGuard(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'TOKEN DE AUTENTICAÇÃO NÃO ENVIADO.' });
}
let payload;
try {
payload = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
} catch {
return res.status(401).json({ success: false, message: 'TOKEN INVÁLIDO OU EXPIRADO. FAÇA LOGIN NOVAMENTE.' });
}
const clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId;
if (clinicaId) {
try {
const { rows } = await pool.query(
'SELECT id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2',
[payload.userId, clinicaId]
);
if (rows.length === 0) {
console.warn(`[TENANT VIOLATION] User ${payload.userId} -> clinica ${clinicaId}`);
return res.status(403).json({ success: false, message: 'ACESSO NEGADO: VOCÊ NÃO TEM VÍNCULO COM ESTA UNIDADE.' });
}
} catch (err) {
return res.status(500).json({ success: false, message: 'ERRO INTERNO DE AUTENTICAÇÃO.' });
}
}
req.authUser = payload;
next();
}
// --- CLINICAS ---
app.put('/api/clinicas/:id/color', async (req, res) => {
const { cor } = req.body;
if (!cor) return res.status(400).json({ error: 'Campo cor obrigatório.' });
try {
await pool.query('UPDATE clinicas SET cor = $1 WHERE id = $2', [cor, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LOGIN ROUTE ---
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ success: false, message: 'EMAIL E SENHA SÃO OBRIGATÓRIOS.' });
console.log(`[LOGIN ATTEMPT] Email: ${email}`);
try {
const { rows: users } = await pool.query(
'SELECT id, nome, email FROM usuarios WHERE email = $1 AND senha = $2',
[email.toLowerCase().trim(), password]
);
if (users.length === 0) {
console.log(`[LOGIN FAILED] Invalid credentials for: ${email}`);
return res.status(401).json({ success: false, message: 'CREDENCIAIS INVÁLIDAS' });
}
const user = users[0];
const { rows: workspaces } = await pool.query(`
SELECT v.clinica_id as id, c.nome_fantasia as nome, c.cor, v.role
FROM vinculos v
JOIN clinicas c ON v.clinica_id = c.id
WHERE v.usuario_id = $1
`, [user.id]);
if (workspaces.length === 0) {
console.log(`[LOGIN FORBIDDEN] No workspaces for User ID: ${user.id}`);
return res.status(403).json({ success: false, message: 'USUÁRIO SEM VÍNCULO COM CLÍNICAS' });
}
console.log(`[LOGIN SUCCESS] User: ${user.email}, Workspaces: ${workspaces.length}`);
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '12h' });
res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email }, workspaces });
} catch (err) {
console.error(`[LOGIN ERROR]: ${err.message}`);
res.status(500).json({ success: false, error: 'ERRO INTERNO DO SERVIDOR.' });
}
});
const PORT = process.env.PORT || 3005;
// --- GOOGLE OAUTH2 SETUP ---
const createOAuth2Client = () => new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || `http://localhost:${PORT}/api/oauth2callback`
);
// --- GOOGLE AUTH ROUTES ---
app.get('/api/auth/google/url', (req, res) => {
const { dentistId } = req.query;
const oauth2Client = createOAuth2Client();
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/calendar.readonly'
],
state: dentistId || 'admin',
prompt: 'consent'
});
res.json({ url });
});
app.get('/api/oauth2callback', async (req, res) => {
const { code, state } = req.query;
if (!code) return res.status(400).send('Código não fornecido');
try {
const oauth2Client = createOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
let ownerId = state || 'admin';
if (ownerId === 'admin') {
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const { data } = await oauth2.userinfo.get();
ownerId = data.email;
}
await pool.query(`
INSERT INTO google_tokens (owner_id, refresh_token, access_token, expiry_date)
VALUES ($1, $2, $3, $4)
ON CONFLICT (owner_id) DO UPDATE SET
refresh_token = EXCLUDED.refresh_token,
access_token = EXCLUDED.access_token,
expiry_date = EXCLUDED.expiry_date
`, [ownerId, tokens.refresh_token, tokens.access_token, tokens.expiry_date]);
res.send(`<div style="font-family:sans-serif;text-align:center;padding-top:50px">
<h1 style="color:#10b981">Agenda Conectada!</h1>
<p>O Google Agenda foi integrado com sucesso.</p>
<p>Você já pode fechar esta janela.</p>
<script>setTimeout(() => window.close(), 3000);<\/script></div>`);
} catch (err) {
console.error('OAuth Error:', err.message);
res.status(500).send('Erro na autorização com o Google.');
}
});
app.get('/api/auth/google/status', async (req, res) => {
try {
const { rows } = await pool.query('SELECT owner_id, updated_at FROM google_tokens');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/auth/google/:ownerId', async (req, res) => {
try {
await pool.query('DELETE FROM google_tokens WHERE owner_id = $1', [req.params.ownerId]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- SETTINGS ---
app.get('/api/settings', async (req, res) => {
try {
const { rows } = await pool.query("SELECT * FROM settings WHERE id = 'main'");
res.json(rows[0] ? JSON.parse(rows[0].data) : {});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/settings', async (req, res) => {
try {
const data = JSON.stringify(req.body);
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('main', 'general', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[data]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- MAGIC LINK & DENTIST SELF-REGISTRATION ---
app.post('/api/dentistas/magic-link', async (req, res) => {
const { clinicaId, dentistName } = req.body;
try {
const token = Math.random().toString(36).substring(2, 15);
const appUrl = process.env.APP_URL || `http://localhost:${process.env.FRONTEND_PORT || 3000}`;
const magicLink = `${appUrl}/#/cadastro-dentista?token=${token}&clinica=${clinicaId}&nome=${encodeURIComponent(dentistName)}`;
res.json({ success: true, link: magicLink });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas/register', async (req, res) => {
const { id, nome, email, senha, cro, cro_uf, celular, especialidade, clinicaId } = req.body;
try {
await withTransaction(async (client) => {
// 1. Create or update User
await client.query(`
INSERT INTO usuarios (id, nome, email, senha, cro, cro_uf, celular, especialidade)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf,
celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade
`, [id, nome, email, senha, cro, cro_uf, celular, especialidade]);
// 2. Create or update link to clinic
const vinculoId = `v_${id}_${clinicaId}`;
await client.query(`
INSERT INTO vinculos (id, usuario_id, clinica_id, role)
VALUES ($1, $2, $3, 'dentista')
ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = 'dentista'
`, [vinculoId, id, clinicaId]);
// 3. Create or update Dentist record for agenda
await client.query(`
INSERT INTO dentistas (id, clinica_id, nome, email, telefone, especialidade, coragenda)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade
`, [id, clinicaId, nome, email, celular, especialidade, '#2563eb']);
});
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/google/events', async (req, res) => {
try {
// 1. Check for connected accounts in google_tokens
const { rows: tokenRows } = await pool.query('SELECT * FROM google_tokens');
const allEvents = [];
const now = new Date();
const timeMin = now.toISOString();
for (const tokenData of tokenRows) {
try {
const oauth2Client = createOAuth2Client();
oauth2Client.setCredentials({
refresh_token: tokenData.refresh_token,
access_token: tokenData.access_token,
expiry_date: tokenData.expiry_date
});
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.list({
calendarId: 'primary',
timeMin: timeMin,
singleEvents: true,
orderBy: 'startTime',
maxResults: 100
});
if (response.data.items) {
// Try to resolve owner name
let ownerName = tokenData.owner_id;
const { rows: dent } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [tokenData.owner_id]);
if (dent[0]) ownerName = dent[0].nome;
const mapped = response.data.items.map(item => ({
id: `google_${item.id}`,
title: `[${ownerName}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date,
end: item.end.dateTime || item.end.date,
isGoogle: true,
owner: ownerName,
backgroundColor: '#3b82f6',
borderColor: '#3b82f6'
}));
allEvents.push(...mapped);
}
} catch (err) {
console.error(`Error fetching calendar for ${tokenData.owner_id}:`, err.message);
}
}
// 2. Legacy Support: Check Settings for Public Calendars (API KEY)
const { rows: settingsRows } = await pool.query("SELECT data FROM settings WHERE id = 'main'");
if (settingsRows[0]) {
const settings = JSON.parse(settingsRows[0].data);
const apiKey = settings.googleApiKey;
if (apiKey) {
const legacyIds = [];
if (settings.clinicCalendarId) legacyIds.push({ id: settings.clinicCalendarId, owner: 'CLÍNICA' });
// We only add dentists that DON'T have a token yet to avoid duplication
if (settings.googleCalendarIds) {
for (const [dentistId, calId] of Object.entries(settings.googleCalendarIds)) {
if (calId && !tokenRows.find(t => t.owner_id === dentistId)) {
const { rows: dName } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [dentistId]);
legacyIds.push({ id: calId, owner: dName[0]?.nome || 'DENTISTA' });
}
}
}
for (const { id, owner } of legacyIds) {
try {
const cleanId = id.trim().toLowerCase();
const response = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(cleanId)}/events?key=${apiKey}&timeMin=${timeMin}&singleEvents=true&orderBy=startTime`);
const data = await response.json();
if (data.items) {
const mapped = data.items.map(item => ({
id: `google_legacy_${item.id}`,
title: `[${owner}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date,
end: item.end.dateTime || item.end.date,
isGoogle: true,
owner: owner,
backgroundColor: owner === 'CLÍNICA' ? '#10b981' : '#6b7280',
borderColor: owner === 'CLÍNICA' ? '#10b981' : '#6b7280'
}));
allEvents.push(...mapped);
}
} catch (err) { console.error(`Legacy API Error for ${id}:`, err.message); }
}
}
}
res.json(allEvents);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- DASHBOARD STATS ---
app.get('/api/dashboard/stats', async (req, res) => {
try {
const cacheKey = 'dashboard:stats';
if (redis) {
const cachedStats = await redis.get(cacheKey);
if (cachedStats) {
console.log('[CACHE HIT] Serving dashboard stats from DragonflyDB');
return res.json(JSON.parse(cachedStats));
}
}
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos');
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads');
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'RECEITA' OR tipo IS NULL");
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA'");
// Growth data (last 6 months)
const { rows: growth } = await pool.query(`
SELECT
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
FROM financeiro
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
ORDER BY mes ASC
`);
// Recent appointments with patient and dentist names
const { rows: recent } = await pool.query(`
SELECT a.id, a.pacientenome, a.start_time, a.status,
d.nome as "dentistaNome"
FROM agendamentos a
LEFT JOIN dentistas d ON a.dentistaid = d.id
ORDER BY a.start_time DESC
LIMIT 5
`);
const responseData = {
stats: {
totalPacientes: parseInt(pacientes[0].count),
totalAgendamentos: parseInt(agendamentos[0].count),
totalLeads: parseInt(leads[0].count),
faturamentoTotal: parseFloat(faturamento[0].total),
despesasTotal: parseFloat(pendente[0].total)
},
growth: growth,
recentAgendamentos: recent
};
if (redis) {
await redis.setex(cacheKey, 60, JSON.stringify(responseData)); // Cache for 60s
console.log('[CACHE MISS] Cached dashboard stats in DragonflyDB');
}
res.json(responseData);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- NOTIFICACOES ---
app.get('/api/notificacoes', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM notificacoes ORDER BY data DESC LIMIT 100');
res.json(rows.map(n => ({ ...n, lida: !!n.lida })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/notificacoes/read-all', async (req, res) => {
try {
await pool.query('UPDATE notificacoes SET lida = true WHERE lida = false');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/notificacoes/:id', async (req, res) => {
try {
await pool.query('DELETE FROM notificacoes WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/notificacoes/:id/read', async (req, res) => {
try {
await pool.query('UPDATE notificacoes SET lida = true WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PACIENTES ---
app.get('/api/pacientes', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM pacientes');
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/pacientes', async (req, res) => {
try {
const p = req.body;
const ins = buildInsert('pacientes', p);
await pool.query(ins.text, ins.values);
res.json({ success: true, data: p });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/pacientes/:id', async (req, res) => {
try {
const upd = buildUpdate('pacientes', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- DENTISTAS ---
app.get('/api/dentistas', async (req, res) => {
try {
const { clinicaId } = req.query;
let query = 'SELECT * FROM dentistas';
const params = [];
if (clinicaId) {
query += ' WHERE clinica_id = $1';
params.push(clinicaId);
}
query += ' ORDER BY ordem ASC, nome ASC';
const { rows } = await pool.query(query, params);
res.json(rows.map(d => ({ ...d, ativo: !!d.ativo, corAgenda: d.coragenda })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas', async (req, res) => {
try {
const d = req.body;
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM dentistas');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('dentistas', { ...d, ordem });
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/dentistas/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE dentistas SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/dentistas/:id', async (req, res) => {
try {
const upd = buildUpdate('dentistas', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/:id', async (req, res) => {
try {
const { id } = req.params;
await withTransaction(async (client) => {
// 1. Remove Google Tokens associated with this dentist
await client.query('DELETE FROM google_tokens WHERE owner_id = $1', [id]);
// 2. Remove clinical links (if any)
await client.query('DELETE FROM vinculos WHERE usuario_id = $1', [id]);
// 3. Set to NULL in agendamentos if it exists
await client.query('UPDATE agendamentos SET dentistaid = NULL WHERE dentistaid = $1', [id]);
// 4. Delete from dentistas table
await client.query('DELETE FROM dentistas WHERE id = $1', [id]);
});
res.json({ success: true });
} catch (err) {
console.error('Error deleting dentist:', err);
res.status(500).json({ error: err.message });
}
});
// --- GESTÃO DE HORÁRIOS ---
app.get('/api/dentistas/:dentistaId/horarios', async (req, res) => {
try {
const { dentistaId } = req.params;
const { clinicaId } = req.query;
let query = 'SELECT * FROM dentistas_horarios WHERE dentista_id = $1';
const params = [dentistaId];
if (clinicaId) {
query += ' AND clinica_id = $2';
params.push(clinicaId);
}
query += ' ORDER BY dia_semana ASC, hora_inicio ASC';
const { rows } = await pool.query(query, params);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas/horarios', async (req, res) => {
try {
const { id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo } = req.body;
const horaId = id || `h_${Math.random().toString(36).substring(2, 9)}`;
await pool.query(`
INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio,
hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo
`, [horaId, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo ?? 1]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/horarios/:id', async (req, res) => {
try {
await pool.query('DELETE FROM dentistas_horarios WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- RELATÓRIOS ---
app.get('/api/reports/productivity', async (req, res) => {
try {
const { clinicaId, start, end } = req.query;
if (!clinicaId) return res.status(400).json({ error: "Missing clinicaId" });
// Financial Totals
const { rows: finRows } = await pool.query(`
SELECT
SUM(CASE WHEN tipo = 'RECEITA' AND status = 'Pago' THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' AND status = 'Pago' THEN valor ELSE 0 END) as despesa,
COUNT(*) as totalTransacoes
FROM financeiro
WHERE clinica_id = $1 AND datavencimento BETWEEN $2 AND $3
`, [clinicaId, start || '2000-01-01', end || '2100-01-01']);
// Appointments by Dentist
const { rows: agRows } = await pool.query(`
SELECT
d.nome as dentista,
COUNT(a.id) as totalAgendamentos,
SUM(CASE WHEN a.status = 'Concluido' THEN 1 ELSE 0 END) as concluidos,
SUM(CASE WHEN a.status = 'Faltou' THEN 1 ELSE 0 END) as faltas
FROM dentistas d
LEFT JOIN agendamentos a ON d.id = a.dentistaid AND a.start_time BETWEEN $1 AND $2
WHERE d.clinica_id = $3
GROUP BY d.id, d.nome
`, [start || '2000-01-01', end || '2100-01-01', clinicaId]);
res.json({
financeiro: finRows[0],
producao: agRows
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- AGENDAMENTOS ---
app.get('/api/agendamentos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM agendamentos');
res.json(rows.map(r => ({
...r,
dentistaId: r.dentistaid,
pacienteNome: r.pacientenome,
pacienteCelular: r.pacientecelular,
start: r.start_time,
end: r.end_time
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST with concurrency lock: prevents double-booking the same dentist+time slot
app.post('/api/agendamentos', async (req, res) => {
try {
const { start, end, ...rest } = req.body;
const { dentistaId } = rest;
await withTransaction(async (client) => {
// Lock check: SELECT the conflicting row FOR UPDATE to prevent race conditions
if (dentistaId && start) {
const { rows: conflict } = await client.query(
`SELECT id FROM agendamentos
WHERE dentistaid = $1 AND start_time = $2
LIMIT 1 FOR UPDATE`,
[dentistaId, start]
);
if (conflict.length > 0) {
throw { code: 'CONF_ERROR', message: 'ESTE HORÁRIO ACABOU DE SER RESERVADO POR OUTRA PESSOA. POR FAVOR, ESCOLHA OUTRO HORÁRIO.' };
}
}
const id = rest.id || `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
// Map keys from camelCase/old attributes to correct lowercase PG columns if necessary
const payload = {
id,
pacientenome: rest.pacienteNome,
pacientecelular: rest.pacienteCelular,
pacienteid: rest.pacienteId,
dentistaid: rest.dentistaId,
start_time: start,
end_time: end,
procedimento: rest.procedimento,
observacoes: rest.observacoes,
status: rest.status || 'Agendado',
clinica_id: rest.clinica_id
};
const insertQuery = buildInsert('agendamentos', payload);
await client.query(insertQuery.text, insertQuery.values);
});
res.json({ success: true });
} catch (err) {
if (err.code === 'CONF_ERROR') {
return res.status(409).json({ success: false, message: err.message });
}
// PostgreSQL unique violation code is '23505'
if (err.code === '23505') {
return res.status(409).json({
success: false,
message: 'ESTE HORÁRIO ACABOU DE SER RESERVADO POR OUTRA PESSOA. POR FAVOR, ESCOLHA OUTRO HORÁRIO.'
});
}
res.status(500).json({ error: err.message });
}
});
app.put('/api/agendamentos/:id', async (req, res) => {
try {
const { start, end, ...rest } = req.body;
const updateData = {};
if (start) updateData.start_time = start;
if (end) updateData.end_time = end;
if (rest.status) updateData.status = rest.status;
if (rest.observacoes !== undefined) updateData.observacoes = rest.observacoes;
if (rest.procedimento) updateData.procedimento = rest.procedimento;
if (rest.dentistaId) updateData.dentistaid = rest.dentistaId;
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
if (updateQuery) {
await pool.query(updateQuery.text, updateQuery.values);
}
res.json({ success: true });
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({
success: false,
message: 'ESTE HORÁRIO JÁ ESTÁ OCUPADO PARA ESTE DENTISTA.'
});
}
res.status(500).json({ error: err.message });
}
});
app.delete('/api/agendamentos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM agendamentos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- ESPECIALIDADES ---
app.get('/api/especialidades', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM especialidades ORDER BY ordem ASC, nome ASC');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/especialidades', async (req, res) => {
try {
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM especialidades');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('especialidades', { ...req.body, ordem });
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/especialidades/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE especialidades SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/especialidades/:id', async (req, res) => {
try {
const upd = buildUpdate('especialidades', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/especialidades/:id', async (req, res) => {
try {
await pool.query('DELETE FROM especialidades WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PLANOS ---
app.get('/api/planos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM planos');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/planos', async (req, res) => {
try {
const ins = buildInsert('planos', req.body);
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/planos/:id', async (req, res) => {
try {
const upd = buildUpdate('planos', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/planos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM planos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PROCEDIMENTOS ---
app.get('/api/procedimentos', async (req, res) => {
try {
const { rows: procs } = await pool.query('SELECT * FROM procedimentos ORDER BY ordem ASC, nome ASC');
const { rows: valores } = await pool.query('SELECT * FROM procedimentos_valores_planos');
const data = procs.map(p => ({
...p,
especialidadeId: p.especialidadeid,
especialidadeNome: p.especialidadenome,
valorParticular: parseFloat(p.valorparticular || 0),
codigoInterno: p.codigointerno,
valoresPlanos: valores
.filter(v => v.procedimentoid === p.id)
.map(v => ({
...v,
planoId: v.planoid,
planoNome: v.planonome,
codigoPlano: v.codigoplano,
valor: parseFloat(v.valor || 0)
}))
}));
res.json(data);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/procedimentos', async (req, res) => {
try {
const { valoresPlanos, ...proc } = req.body;
// Auto-generate codigoInterno for PARTICULAR
let codigoInternoVal = proc.codigoInterno;
if (proc.categoria === 'Particular' && (!codigoInternoVal || codigoInternoVal === '')) {
const { rows: partCount } = await pool.query("SELECT COUNT(*) as count FROM procedimentos WHERE categoria = 'Particular'");
codigoInternoVal = (parseInt(partCount[0].count) + 1).toString();
}
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM procedimentos');
const ordemValue = parseInt(counts[0].count);
const payload = {
...proc,
codigoInterno: codigoInternoVal,
ordem: ordemValue,
tipo_regiao: proc.tipo_regiao || 'GERAL',
exige_face: proc.exige_face ? 1 : 0
};
if (!payload.id) payload.id = Math.random().toString(36).substring(2, 10).toUpperCase();
await withTransaction(async (client) => {
const ins = buildInsert('procedimentos', payload);
await client.query(ins.text, ins.values);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
const vIns = buildInsert('procedimentos_valores_planos', { ...v, procedimentoId: payload.id });
await client.query(vIns.text, vIns.values);
}
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/procedimentos/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE procedimentos SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/procedimentos/:id', async (req, res) => {
try {
const { valoresPlanos, ...proc } = req.body;
const updateData = { ...proc, exige_face: proc.exige_face ? 1 : 0 };
await withTransaction(async (client) => {
const upd = buildUpdate('procedimentos', updateData, 'id', req.params.id);
await client.query(upd.text, upd.values);
await client.query('DELETE FROM procedimentos_valores_planos WHERE procedimentoid = $1', [req.params.id]);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
const vIns = buildInsert('procedimentos_valores_planos', { ...v, procedimentoId: req.params.id });
await client.query(vIns.text, vIns.values);
}
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/procedimentos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM procedimentos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- FINANCEIRO ---
app.get('/api/financeiro', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM financeiro');
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/financeiro', async (req, res) => {
try {
const ins = buildInsert('financeiro', req.body);
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/financeiro/:id', async (req, res) => {
try {
const upd = buildUpdate('financeiro', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/financeiro/:id', async (req, res) => {
try {
await pool.query('DELETE FROM financeiro WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LEADS ---
app.get('/api/leads', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM leads');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/leads', async (req, res) => {
try {
const ins = buildInsert('leads', req.body);
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- GUIAS ODONTOLÓGICAS (TRATAMENTOS) ---
app.get('/api/guias', async (req, res) => {
try {
const {
page = 1,
pageSize = 10,
search = '',
status = '',
dataInicio = '',
dataFim = '',
sortField = 'dataSolicitacao',
sortOrder = 'DESC',
gto = ''
} = req.query;
const offset = (page - 1) * pageSize;
const limit = parseInt(pageSize);
let where = ' WHERE 1=1';
const params = [];
let paramIdx = 1;
if (gto) {
where += ` AND (numeroguiaprestador LIKE $${paramIdx} OR numeroguiaoperadora LIKE $${paramIdx + 1})`;
params.push(`%${gto}%`, `%${gto}%`);
paramIdx += 2;
}
if (search) {
where += ` AND (beneficiarionome LIKE $${paramIdx} OR beneficiarioidentificacao LIKE $${paramIdx + 1})`;
params.push(`%${search}%`, `%${search}%`);
paramIdx += 2;
}
if (status) {
where += ` AND status = $${paramIdx}`;
params.push(status);
paramIdx++;
}
if (dataInicio) {
where += ` AND datasolicitacao >= $${paramIdx}`;
params.push(dataInicio);
paramIdx++;
}
if (dataFim) {
where += ` AND datasolicitacao <= $${paramIdx}`;
params.push(dataFim);
paramIdx++;
}
// Validate sortField and sortOrder to prevent SQL injection
const allowedSortFields = ['datasolicitacao', 'beneficiarionome', 'status', 'numeroguiaprestador'];
const normalizedSort = sortField.toLowerCase();
const finalSortField = allowedSortFields.includes(normalizedSort) ? normalizedSort : 'datasolicitacao';
const finalSortOrder = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const query = `
SELECT * FROM guias_odontologicas
${where}
ORDER BY ${finalSortField} ${finalSortOrder}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
const countQuery = `SELECT COUNT(*) as total FROM guias_odontologicas ${where}`;
const { rows } = await pool.query(query, [...params, limit, offset]);
const { rows: totalRows } = await pool.query(countQuery, params);
const mapGuia = (g) => ({
...g,
numeroGuiaPrestador: g.numeroguiaprestador,
numeroGuiaOperadora: g.numeroguiaoperadora,
dataSolicitacao: g.datasolicitacao,
tipoTratamento: g.tipotratamento,
beneficiarioId: g.beneficiarioid,
beneficiarioNome: g.beneficiarionome,
beneficiarioIdentificacao: g.beneficiarioidentificacao,
createdAt: g.createdat
});
res.json({
data: rows.map(mapGuia),
total: totalRows[0].total,
page: parseInt(page),
pageSize: limit
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- GTO BUILDER (Lançar GTO) ---
app.post('/api/gto-builder', async (req, res) => {
try {
const { pacienteId, dentistaId, credenciadaId } = req.body;
const id = Math.random().toString(36).substring(2, 9).toUpperCase();
await pool.query(
"INSERT INTO gto_builders (id, pacienteid, dentistaid, credenciadaid, status, total) VALUES ($1, $2, $3, $4, 'RASCUNHO', 0)",
[id, pacienteId, dentistaId, credenciadaId]
);
res.json({ id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/gto-builder/:id', async (req, res) => {
try {
const { rows: builder } = await pool.query('SELECT * FROM gto_builders WHERE id = $1', [req.params.id]);
const { rows: items } = await pool.query('SELECT * FROM gto_items WHERE builderid = $1', [req.params.id]);
const mappedItems = items.map(i => ({
...i,
builderId: i.builderid,
procedimentoId: i.procedimentoid,
codigoTUSS: i.codigotuss,
codigoInterno: i.codigointerno,
valorUnitario: parseFloat(i.valorunitario || 0),
valorTotal: parseFloat(i.valortotal || 0),
anexosCount: i.anexoscount
}));
const b = builder[0];
res.json({
...b,
pacienteId: b?.pacienteid,
dentistaId: b?.dentistaid,
credenciadaId: b?.credenciadaid,
items: mappedItems
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/gto-builder/:id/item', async (req, res) => {
try {
const itemId = Math.random().toString(36).substring(2, 9).toUpperCase();
const item = { ...req.body, id: itemId, builderId: req.params.id };
const ins = buildInsert('gto_items', item);
await pool.query(ins.text, ins.values);
// Update total
await pool.query(
'UPDATE gto_builders SET total = (SELECT SUM(valortotal) FROM gto_items WHERE builderid = $1) WHERE id = $2',
[req.params.id, req.params.id]
);
res.json({ success: true, item });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/gto-builder/:id/item/:itemId', async (req, res) => {
try {
await pool.query('DELETE FROM gto_items WHERE id = $1', [req.params.itemId]);
// Update total
await pool.query(
'UPDATE gto_builders SET total = COALESCE((SELECT SUM(valortotal) FROM gto_items WHERE builderid = $1), 0) WHERE id = $2',
[req.params.id, req.params.id]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/gto-builder/:id/finalizar', async (req, res) => {
try {
await pool.query("UPDATE gto_builders SET status = 'FINALIZADA' WHERE id = $1", [req.params.id]);
// Logic to migrate to guias_odontologicas would go here in a real app
// For now, we just mark as finished.
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// initTables() was removed as database is native PostgreSQL and schema DDL is maintained externally.
async function generateIntelligentNotifications() {
if (!pool) return;
try {
// 1. Check for upcoming appointments (next 2 hours)
const { rows: upcoming } = await pool.query(`
SELECT a.*, p.nome as "pacienteNome"
FROM agendamentos a
LEFT JOIN pacientes p ON a.pacientenome = p.nome
WHERE a.start_time BETWEEN NOW() AND NOW() + INTERVAL '2 hours'
AND a.status = 'Pendente'
`);
for (const appt of upcoming) {
const id = `notif_appt_${appt.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'PRÓXIMO ATENDIMENTO',
`Paciente ${appt.pacientenome} às ${new Date(appt.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
'agenda'
]);
}
}
// 2. Check for overdue bills
const { rows: overdue } = await pool.query(`
SELECT * FROM financeiro
WHERE datavencimento < CURRENT_DATE AND status = 'Pendente'
`);
for (const bill of overdue) {
const id = `notif_fin_overdue_${bill.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'FATURA ATRASADA',
`A conta "${bill.descricao}" de R$ ${bill.valor} venceu em ${new Date(bill.datavencimento).toLocaleDateString('pt-BR')}`,
'financeiro'
]);
}
}
// 3. Check for bills due today
const { rows: dueToday } = await pool.query(`
SELECT * FROM financeiro
WHERE datavencimento = CURRENT_DATE AND status = 'Pendente'
`);
for (const bill of dueToday) {
const id = `notif_fin_today_${bill.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'VENCIMENTO HOJE',
`A conta "${bill.descricao}" de R$ ${bill.valor} vence hoje!`,
'financeiro'
]);
}
}
// 4. System Events (to Dev) - Mocking a system event if table counts are low
const { rows: pCount } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
if (parseInt(pCount[0].count) < 5) {
const id = 'notif_sys_setup';
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'CONFIGURAÇÃO DO SISTEMA',
'O sistema possui poucos pacientes cadastrados. Considere importar dados.',
'sistema'
]);
}
}
} catch (err) {
console.error('Error generating notifications:', err);
}
}
// Start server after all routes are registered
app.listen(PORT, '0.0.0.0', () => {
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
// Run immediately on startup, then every 5 minutes
generateIntelligentNotifications();
setInterval(generateIntelligentNotifications, 5 * 60 * 1000);
});