Files
scoreodonto.com/backend/server.js.bak
T

1584 lines
68 KiB
Plaintext

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: native PG query wrapper returns [rows, fields] like MySQL adapter did
async function query(text, params) {
const result = await pool.query(text, params);
return [result.rows, result.fields];
}
// 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)
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);
const vals = allowed.map(([, v]) => v);
const cols = keys.map(k => `"${k}"`).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)
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) && v !== undefined);
const set = entries.map(([k], i) => `"${k}" = $${i + 1}`).join(', ');
const values = [...entries.map(([, v]) => v), whereVal];
return { text: `UPDATE ${table} SET ${set} WHERE "${whereCol}" = $${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`
);
app.listen(PORT, '0.0.0.0', () => console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`));
// --- 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 = ?', [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", ?) ON DUPLICATE KEY UPDATE data = ?', [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);
// Em um sistema real, salvaríamos esse token em uma tabela de convites
const magicLink = `http://localhost:3000/#/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 pool.query('SET FOREIGN_KEY_CHECKS = 0');
// 1. Create User
await pool.query(`
INSERT INTO usuarios (id, nome, email, senha, cro, cro_uf, celular, especialidade)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
nome = VALUES(nome), cro = VALUES(cro), cro_uf = VALUES(cro_uf),
celular = VALUES(celular), especialidade = VALUES(especialidade)
`, [id, nome, email, senha, cro, cro_uf, celular, especialidade]);
// 2. Create Link to clinic
const vinculoId = `v_${id}_${clinicaId}`;
await pool.query(`
INSERT INTO vinculos (id, usuario_id, clinica_id, role)
VALUES (?, ?, ?, 'dentista')
ON DUPLICATE KEY UPDATE role = 'dentista'
`, [vinculoId, id, clinicaId]);
// 3. Create Dentist record for agenda
await pool.query(`
INSERT INTO dentistas (id, clinica_id, nome, email, telefone, especialidade, corAgenda)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
nome = VALUES(nome), especialidade = VALUES(especialidade)
`, [id, clinicaId, nome, email, celular, especialidade, '#2563eb']);
await pool.query('SET FOREIGN_KEY_CHECKS = 1');
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 [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 [dent] = await pool.query('SELECT nome FROM dentistas WHERE id = ?', [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] = await pool.query('SELECT data FROM settings WHERE id = "main"');
if (rows[0]) {
const settings = JSON.parse(rows[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 [dName] = await pool.query('SELECT nome FROM dentistas WHERE id = ?', [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 [pacientes] = await pool.query('SELECT COUNT(*) as count FROM pacientes');
const [agendamentos] = await pool.query('SELECT COUNT(*) as count FROM agendamentos');
const [leads] = await pool.query('SELECT COUNT(*) as count FROM leads');
const [faturamento] = await pool.query('SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = "RECEITA" OR tipo IS NULL');
const [pendente] = await pool.query('SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = "DESPESA"');
// Growth data (last 6 months)
// PostgreSQL DATE_FORMAT converted dynamically via adapter, but adapter fallback handles date operations perfectly.
const [growth] = await pool.query(`
SELECT
DATE_FORMAT(dataVencimento, '%Y-%m') 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 >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
GROUP BY mes
ORDER BY mes ASC
`);
// Recent appointments with patient and dentist names
const [recent] = await pool.query(`
SELECT a.*, 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: pacientes[0].count,
totalAgendamentos: agendamentos[0].count,
totalLeads: leads[0].count,
faturamentoTotal: faturamento[0].total,
despesasTotal: 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 = 1 WHERE lida = 0');
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 = ?', [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 = 1 WHERE id = ?', [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;
await pool.query('INSERT INTO pacientes SET ?', p);
res.json({ success: true, data: p });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/pacientes/:id', async (req, res) => {
try {
await pool.query('UPDATE pacientes SET ? WHERE id = ?', [req.body, req.params.id]);
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 = ?';
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 })));
} 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 [counts] = await pool.query('SELECT COUNT(*) as count FROM dentistas');
const ordem = counts[0].count;
await pool.query('INSERT INTO dentistas SET ?', { ...d, ordem });
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}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
for (const item of orders) {
await connection.query('UPDATE dentistas SET ordem = ? WHERE id = ?', [item.ordem, item.id]);
}
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally { connection.release(); }
});
app.put('/api/dentistas/:id', async (req, res) => {
try {
await pool.query('UPDATE dentistas SET ? WHERE id = ?', [req.body, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/:id', async (req, res) => {
const connection = await pool.getConnection();
try {
const { id } = req.params;
await connection.beginTransaction();
// 1. Remove Google Tokens associated with this dentist
await connection.query('DELETE FROM google_tokens WHERE owner_id = ?', [id]);
// 2. Remove clinical links (if any)
await connection.query('DELETE FROM vinculos WHERE usuario_id = ?', [id]);
// 3. Set to NULL in agendamentos if it exists
await connection.query('UPDATE agendamentos SET dentistaId = NULL WHERE dentistaId = ?', [id]);
// 4. Delete from dentistas table
await connection.query('DELETE FROM dentistas WHERE id = ?', [id]);
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
console.error('Error deleting dentist:', err);
res.status(500).json({ error: err.message });
} finally {
connection.release();
}
});
// --- 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 = ?';
const params = [dentistaId];
if (clinicaId) {
query += ' AND clinica_id = ?';
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;
await pool.query(`
INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
dia_semana = VALUES(dia_semana), hora_inicio = VALUES(hora_inicio),
hora_fim = VALUES(hora_fim), ativo = VALUES(ativo)
`, [id || `h_${Math.random().toString(36).substring(2, 9)}`, 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 = ?', [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 [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 = ? AND dataVencimento BETWEEN ? AND ?
`, [clinicaId, start || '2000-01-01', end || '2100-01-01']);
// Appointments by Dentist
const [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 ? AND ?
WHERE d.clinica_id = ?
GROUP BY d.id
`, [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, 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) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { start, end, ...rest } = req.body;
const { dentistaId } = rest;
// Lock check: SELECT the conflicting row FOR UPDATE to prevent race conditions
if (dentistaId && start) {
const [conflict] = await connection.query(
`SELECT id FROM agendamentos
WHERE dentistaId = ? AND start_time = ?
LIMIT 1 FOR UPDATE`,
[dentistaId, start]
);
if (conflict.length > 0) {
await connection.rollback();
return res.status(409).json({
success: false,
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)}`;
await connection.query(
'INSERT INTO agendamentos SET ?',
{ ...rest, id, start_time: start, end_time: end }
);
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
// MySQL error 1062 = duplicate entry (UNIQUE KEY violation)
if (err.code === 'ER_DUP_ENTRY') {
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 });
} finally { connection.release(); }
});
app.put('/api/agendamentos/:id', async (req, res) => {
try {
const { start, end, ...rest } = req.body;
const updateData = { ...rest };
if (start) updateData.start_time = start;
if (end) updateData.end_time = end;
await pool.query('UPDATE agendamentos SET ? WHERE id = ?', [updateData, req.params.id]);
res.json({ success: true });
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
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 = ?', [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 [counts] = await pool.query('SELECT COUNT(*) as count FROM especialidades');
const ordem = counts[0].count;
await pool.query('INSERT INTO especialidades SET ?', { ...req.body, ordem });
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}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
for (const item of orders) {
await connection.query('UPDATE especialidades SET ordem = ? WHERE id = ?', [item.ordem, item.id]);
}
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally { connection.release(); }
});
app.put('/api/especialidades/:id', async (req, res) => {
try {
await pool.query('UPDATE especialidades SET ? WHERE id = ?', [req.body, req.params.id]);
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 = ?', [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 {
await pool.query('INSERT INTO planos SET ?', req.body);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/planos/:id', async (req, res) => {
try {
await pool.query('UPDATE planos SET ? WHERE id = ?', [req.body, req.params.id]);
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 = ?', [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 [procs] = await pool.query('SELECT * FROM procedimentos ORDER BY ordem ASC, nome ASC');
const [valores] = await pool.query('SELECT * FROM procedimentos_valores_planos');
const data = procs.map(p => ({
...p,
valorParticular: parseFloat(p.valorParticular || 0),
valoresPlanos: valores
.filter(v => v.procedimentoId === p.id)
.map(v => ({ ...v, valor: parseFloat(v.valor || 0) }))
}));
res.json(data);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/procedimentos', async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { valoresPlanos, ...proc } = req.body;
// Auto-generate codigoInterno for PARTICULAR
let codigoInternoVal = proc.codigoInterno;
if (proc.categoria === 'Particular' && (!codigoInternoVal || codigoInternoVal === '')) {
const [rows] = await connection.query('SELECT COUNT(*) as count FROM procedimentos WHERE categoria = "Particular"');
codigoInternoVal = (rows[0].count + 1).toString();
}
// Automatically set order to the end
const [counts] = await connection.query('SELECT COUNT(*) as count FROM procedimentos');
const ordemValue = 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 connection.query('INSERT INTO procedimentos SET ?', payload);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
await connection.query('INSERT INTO procedimentos_valores_planos SET ?', { ...v, procedimentoId: payload.id });
}
}
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally { connection.release(); }
});
app.put('/api/procedimentos/:id', async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const { valoresPlanos, ...proc } = req.body;
const updateData = {
...proc,
exige_face: proc.exige_face ? 1 : 0
};
await connection.query('UPDATE procedimentos SET ? WHERE id = ?', [updateData, req.params.id]);
await connection.query('DELETE FROM procedimentos_valores_planos WHERE procedimentoId = ?', [req.params.id]);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
await connection.query('INSERT INTO procedimentos_valores_planos SET ?', { ...v, procedimentoId: req.params.id });
}
}
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally { connection.release(); }
});
app.put('/api/procedimentos/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
for (const item of orders) {
await connection.query('UPDATE procedimentos SET ordem = ? WHERE id = ?', [item.ordem, item.id]);
}
await connection.commit();
res.json({ success: true });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally { connection.release(); }
});
app.delete('/api/procedimentos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM procedimentos WHERE id = ?', [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 {
await pool.query('INSERT INTO financeiro SET ?', req.body);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/financeiro/:id', async (req, res) => {
try {
await pool.query('UPDATE financeiro SET ? WHERE id = ?', [req.body, req.params.id]);
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 = ?', [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 {
await pool.query('INSERT INTO leads SET ?', req.body);
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 = [];
if (gto) {
where += ' AND (numeroGuiaPrestador LIKE ? OR numeroGuiaOperadora LIKE ?)';
params.push(`%${gto}%`, `%${gto}%`);
}
if (search) {
where += ' AND (beneficiarioNome LIKE ? OR beneficiarioIdentificacao LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
if (status) {
where += ' AND status = ?';
params.push(status);
}
if (dataInicio) {
where += ' AND dataSolicitacao >= ?';
params.push(dataInicio);
}
if (dataFim) {
where += ' AND dataSolicitacao <= ?';
params.push(dataFim);
}
// Validate sortField and sortOrder to prevent SQL injection
const allowedSortFields = ['dataSolicitacao', 'beneficiarioNome', 'status', 'numeroGuiaPrestador'];
const finalSortField = allowedSortFields.includes(sortField) ? sortField : 'dataSolicitacao';
const finalSortOrder = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const query = `
SELECT * FROM guias_odontologicas
${where}
ORDER BY ${finalSortField} ${finalSortOrder}
LIMIT ? OFFSET ?
`;
const countQuery = `SELECT COUNT(*) as total FROM guias_odontologicas ${where}`;
const [rows] = await pool.query(query, [...params, limit, offset]);
const [totalRows] = await pool.query(countQuery, params);
res.json({
data: rows,
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 (?, ?, ?, ?, "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 [builder] = await pool.query('SELECT * FROM gto_builders WHERE id = ?', [req.params.id]);
const [items] = await pool.query('SELECT * FROM gto_items WHERE builderId = ?', [req.params.id]);
res.json({ ...builder[0], items });
} 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 };
await pool.query('INSERT INTO gto_items SET ?', item);
// Update total
await pool.query(
'UPDATE gto_builders SET total = (SELECT SUM(valorTotal) FROM gto_items WHERE builderId = ?) WHERE id = ?',
[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 = ?', [req.params.itemId]);
// Update total
await pool.query(
'UPDATE gto_builders SET total = COALESCE((SELECT SUM(valorTotal) FROM gto_items WHERE builderId = ?), 0) WHERE id = ?',
[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 = ?', [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 }); }
});
// Initialization: Create tables if not exist
async function initTables() {
try {
// Ensure session uses UTF-8 and ignore FK checks during initialization
await pool.query('SET NAMES utf8mb4');
await pool.query('SET CHARACTER SET utf8mb4');
await pool.query('SET FOREIGN_KEY_CHECKS = 0');
// 1. Clinics
await pool.query(`
CREATE TABLE IF NOT EXISTS clinicas (
id VARCHAR(50) PRIMARY KEY,
nome_fantasia VARCHAR(255) NOT NULL,
documento VARCHAR(50),
cor VARCHAR(20) DEFAULT '#2563eb',
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
// 2. Users (Global identity)
await pool.query(`
CREATE TABLE IF NOT EXISTS usuarios (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
senha VARCHAR(255) NOT NULL,
cro VARCHAR(20),
cro_uf VARCHAR(2),
celular VARCHAR(20),
especialidade VARCHAR(100),
cor_agenda VARCHAR(20) DEFAULT '#2563eb',
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
// 3. Links (Many-to-Many pivô)
await pool.query(`
CREATE TABLE IF NOT EXISTS vinculos (
id VARCHAR(50) PRIMARY KEY,
usuario_id VARCHAR(50) NOT NULL,
clinica_id VARCHAR(50) NOT NULL,
role ENUM('paciente', 'dentista', 'funcionario', 'donoclinica', 'admin') DEFAULT 'admin',
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY user_clinica (usuario_id, clinica_id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
// Initial Data Migration/Seed
const [clinCount] = await pool.query('SELECT COUNT(*) as cnt FROM clinicas');
if (clinCount[0].cnt === 0) {
await pool.query('INSERT INTO clinicas (id, nome_fantasia, documento, cor) VALUES ("c1", "SCOREODONTO MATRIZ", "00.000.000/0001-00", "#2563eb")');
await pool.query('INSERT INTO clinicas (id, nome_fantasia, documento, cor) VALUES ("c2", "SCOREODONTO FILIAL SUL", "00.000.000/0002-00", "#059669")');
}
const [userCount] = await pool.query('SELECT COUNT(*) as cnt FROM usuarios');
if (userCount[0].cnt === 0) {
const mockUsers = [
['u1', 'RUI CESAR', 'admin@scoreodonto.com', 'admin'],
['u2', 'DENTISTA TESTE', 'dentista@scoreodonto.com', '123456'],
['u3', 'FUNCIONARIO TESTE', 'funcionario@scoreodonto.com', 'cassems'],
['u4', 'DONO CLINICA', 'dono@scoreodonto.com', 'Rc362514'],
['u5', 'PACIENTE TESTE', 'paciente@scoreodonto.com', '14253636'],
];
for (const [id, nome, email, senha] of mockUsers) {
await pool.query('INSERT IGNORE INTO usuarios (id, nome, email, senha) VALUES (?, ?, ?, ?)', [id, nome, email, senha]);
// Default links to Matrix clinic
let role = 'admin';
if (email.includes('dentista')) role = 'dentista';
if (email.includes('funcionario')) role = 'funcionario';
if (email.includes('dono')) role = 'donoclinica';
if (email.includes('paciente')) role = 'paciente';
await pool.query('INSERT IGNORE INTO vinculos (id, usuario_id, clinica_id, role) VALUES (?, ?, ?, ?)', [`v_${id}_c1`, id, 'c1', role]);
// Add an extra link for Admin and Dono for testing multi-clinic
if (id === 'u1' || id === 'u4') {
await pool.query('INSERT IGNORE INTO vinculos (id, usuario_id, clinica_id, role) VALUES (?, ?, ?, ?)', [`v_${id}_c2`, id, 'c2', id === 'u1' ? 'admin' : 'donoclinica']);
}
}
console.log('Multi-clinic Database Seeded.');
}
await pool.query(`
CREATE TABLE IF NOT EXISTS especialidades (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
descricao TEXT,
ordem INT DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS procedimentos (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
codigo VARCHAR(50),
codigoInterno VARCHAR(50),
descricao TEXT,
especialidadeId VARCHAR(50),
especialidadeNome VARCHAR(100),
valorParticular DECIMAL(10,2),
categoria VARCHAR(50) DEFAULT 'Particular',
tipo_regiao ENUM('DENTE', 'ARCO', 'GERAL') DEFAULT 'GERAL',
exige_face TINYINT(1) DEFAULT 0,
ordem INT DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS procedimentos_valores_planos (
id INT AUTO_INCREMENT PRIMARY KEY,
procedimentoId VARCHAR(50),
planoId VARCHAR(50),
planoNome VARCHAR(100),
valor DECIMAL(10,2),
codigoPlano VARCHAR(50)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS pacientes (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
cpf VARCHAR(20),
telefone VARCHAR(20),
email VARCHAR(100),
ultimoTratamento VARCHAR(255),
convenio VARCHAR(100),
dataNascimento DATE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS dentistas (
id VARCHAR(50) PRIMARY KEY,
clinica_id VARCHAR(50),
nome VARCHAR(255) NOT NULL,
email VARCHAR(100),
telefone VARCHAR(20),
corAgenda VARCHAR(20),
especialidade VARCHAR(100),
ativo TINYINT(1) DEFAULT 1,
ordem INT DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS settings (
id VARCHAR(50) PRIMARY KEY,
category VARCHAR(50) NOT NULL,
data TEXT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS agendamentos (
id VARCHAR(50) PRIMARY KEY,
clinica_id VARCHAR(50),
pacienteNome VARCHAR(255) NOT NULL,
dentistaId VARCHAR(50),
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
status ENUM('Confirmado', 'Pendente', 'Concluido', 'Faltou') DEFAULT 'Pendente',
procedimento VARCHAR(255),
observacoes TEXT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS financeiro (
id VARCHAR(50) PRIMARY KEY,
clinica_id VARCHAR(50),
pacienteNome VARCHAR(255),
descricao VARCHAR(255),
valor DECIMAL(10,2),
dataVencimento DATE,
status ENUM('Pago', 'Pendente', 'Atrasado') DEFAULT 'Pendente',
formaPagamento ENUM('Boleto', 'Pix', 'Cartão', 'Dinheiro') DEFAULT 'Dinheiro',
tipo ENUM('RECEITA', 'DESPESA') DEFAULT 'RECEITA',
ordem INT DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS dentistas_horarios (
id VARCHAR(50) PRIMARY KEY,
dentista_id VARCHAR(50) NOT NULL,
clinica_id VARCHAR(50) NOT NULL,
dia_semana INT NOT NULL,
hora_inicio TIME NOT NULL,
hora_fim TIME NOT NULL,
ativo TINYINT(1) DEFAULT 1
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS leads (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100),
sobrenome VARCHAR(100),
cpf VARCHAR(20),
whatsapp VARCHAR(20),
tipoInteresse ENUM('Particular', 'Plano'),
dataCadastro DATETIME DEFAULT CURRENT_TIMESTAMP,
status ENUM('Novo', 'Convertido', 'Arquivado') DEFAULT 'Novo'
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS planos (
id VARCHAR(50) PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
tipo ENUM('Convenio', 'Particular', 'Parceria'),
descontoPadrao DECIMAL(5,2),
corCartao VARCHAR(20)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS notificacoes (
id VARCHAR(50) PRIMARY KEY,
titulo VARCHAR(255) NOT NULL,
mensagem TEXT,
tipo ENUM('lead', 'sistema', 'agenda', 'financeiro') DEFAULT 'sistema',
lida TINYINT(1) DEFAULT 0,
data DATETIME DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS google_tokens (
owner_id VARCHAR(255) PRIMARY KEY,
refresh_token TEXT NOT NULL,
access_token TEXT,
expiry_date BIGINT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
// Force existing tables to UTF-8 and add missing columns
try {
await pool.query('SET FOREIGN_KEY_CHECKS = 0');
await pool.query('ALTER TABLE especialidades CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await pool.query('ALTER TABLE procedimentos CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await pool.query('ALTER TABLE procedimentos_valores_planos CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await pool.query('ALTER TABLE guias_odontologicas CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await pool.query('ALTER TABLE gto_builders CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await pool.query('ALTER TABLE gto_items CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
// Ensure columns exist
const [usrCro] = await pool.query('SHOW COLUMNS FROM usuarios LIKE "cro"');
if (usrCro.length === 0) {
await pool.query('ALTER TABLE usuarios ADD COLUMN cro VARCHAR(20) AFTER senha');
await pool.query('ALTER TABLE usuarios ADD COLUMN cro_uf VARCHAR(2) AFTER cro');
await pool.query('ALTER TABLE usuarios ADD COLUMN celular VARCHAR(20) AFTER cro_uf');
await pool.query('ALTER TABLE usuarios ADD COLUMN especialidade VARCHAR(100) AFTER celular');
await pool.query('ALTER TABLE usuarios ADD COLUMN cor_agenda VARCHAR(20) DEFAULT "#2563eb" AFTER especialidade');
}
const [clinCor] = await pool.query('SHOW COLUMNS FROM clinicas LIKE "cor"');
if (clinCor.length === 0) await pool.query('ALTER TABLE clinicas ADD COLUMN cor VARCHAR(20) DEFAULT "#2563eb" AFTER documento');
const [dentClin] = await pool.query('SHOW COLUMNS FROM dentistas LIKE "clinica_id"');
if (dentClin.length === 0) await pool.query('ALTER TABLE dentistas ADD COLUMN clinica_id VARCHAR(50) AFTER id');
const [agClin] = await pool.query('SHOW COLUMNS FROM agendamentos LIKE "clinica_id"');
if (agClin.length === 0) await pool.query('ALTER TABLE agendamentos ADD COLUMN clinica_id VARCHAR(50) AFTER id');
const [finClin] = await pool.query('SHOW COLUMNS FROM financeiro LIKE "clinica_id"');
if (finClin.length === 0) await pool.query('ALTER TABLE financeiro ADD COLUMN clinica_id VARCHAR(50) AFTER id');
const [finTipo] = await pool.query('SHOW COLUMNS FROM financeiro LIKE "tipo"');
if (finTipo.length === 0) await pool.query('ALTER TABLE financeiro ADD COLUMN tipo ENUM("RECEITA", "DESPESA") DEFAULT "RECEITA" AFTER formaPagamento');
const [procRegiao] = await pool.query('SHOW COLUMNS FROM procedimentos LIKE "tipo_regiao"');
if (procRegiao.length === 0) {
await pool.query('ALTER TABLE procedimentos ADD COLUMN tipo_regiao ENUM("DENTE", "ARCO", "GERAL") DEFAULT "GERAL" AFTER categoria');
await pool.query('ALTER TABLE procedimentos ADD COLUMN exige_face TINYINT(1) DEFAULT 0 AFTER tipo_regiao');
}
// SECURITY: Add UNIQUE KEY to prevent concurrent double-booking
try {
await pool.query('ALTER TABLE agendamentos ADD UNIQUE KEY unique_dentista_slot (dentistaId, start_time)');
console.log('[DB] Unique key unique_dentista_slot created on agendamentos.');
} catch (e) {
if (!e.message.includes('Duplicate key name')) {
console.log('[DB] unique_dentista_slot already exists or non-critical error:', e.message);
}
}
// Re-enable FK checks
await pool.query('SET FOREIGN_KEY_CHECKS = 1');
console.log('Database schema and multi-tenant structure updated!');
} catch (e) {
console.error('Error during ALTER TABLE operations:', e.message);
await pool.query('SET FOREIGN_KEY_CHECKS = 1');
}
await pool.query(`
CREATE TABLE IF NOT EXISTS guias_odontologicas (
id VARCHAR(50) PRIMARY KEY,
numeroGuiaPrestador VARCHAR(50) NOT NULL,
numeroGuiaOperadora VARCHAR(50),
dataSolicitacao DATE NOT NULL,
tipoTratamento VARCHAR(100) NOT NULL,
status VARCHAR(50) NOT NULL,
beneficiarioId VARCHAR(50) NOT NULL,
beneficiarioNome VARCHAR(255) NOT NULL,
beneficiarioIdentificacao VARCHAR(50) NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS gto_builders (
id VARCHAR(50) PRIMARY KEY,
pacienteId VARCHAR(50) NOT NULL,
dentistaId VARCHAR(50) NOT NULL,
credenciadaId VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT "RASCUNHO",
total DECIMAL(10,2) DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS gto_items (
id VARCHAR(50) PRIMARY KEY,
builderId VARCHAR(50) NOT NULL,
procedimentoId VARCHAR(50) NOT NULL,
codigoTUSS VARCHAR(50) NOT NULL,
codigoInterno VARCHAR(50),
descricao VARCHAR(255) NOT NULL,
dente VARCHAR(10),
face VARCHAR(10),
quantidade INT DEFAULT 1,
valorUnitario DECIMAL(10,2) NOT NULL,
valorTotal DECIMAL(10,2) NOT NULL,
observacao TEXT,
anexosCount INT DEFAULT 0
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
`);
// Check if empty and add mock data
const [count] = await pool.query('SELECT COUNT(*) as cnt FROM guias_odontologicas');
if (count[0].cnt === 0) {
const mockData = [
['1', '177903', 'O-001', '2026-02-25', 'Tratamento Odontologico', 'AUTORIZADO', 'B1', 'RONEY OTTONI DE SOUZA', '181442'],
['2', '178056', 'O-002', '2026-02-25', 'Tratamento Odontologico', 'AUTORIZADO', 'B2', 'MILENA LIMA DIAS OTTONI DE SOUZA', '157320'],
['3', '178179', 'O-003', '2026-02-25', 'Tratamento Odontologico', 'AUTORIZADO', 'B3', 'EVANDRO MACHADO AZEMAN', '2025100002341'],
['4', '177208', 'O-004', '2026-02-24', 'Tratamento Odontologico', 'AUTORIZADO', 'B4', 'MARIA JULIA MIRANDA DA SILVA', '145805'],
['5', '177335', 'O-005', '2026-02-24', 'Tratamento Odontologico', 'AUTORIZADO_PARCIAL', 'B5', 'JOAO ITALO CORREA DE AMORIM SANT ANNA', '443079'],
['6', '176468', 'O-006', '2026-02-23', 'Urgencia / Emergencia', 'AUTORIZADO', 'B6', 'EDUARDO STENIO GONCALVES DOS SANTOS', '435593'],
['7', '176572', 'O-007', '2026-02-23', 'Tratamento Odontologico', 'AUTORIZADO', 'B7', 'LUCIA MARIA DA SILVA JULIO', '127311'],
['8', '176666', 'O-008', '2026-02-23', 'Tratamento Odontologico', 'AUTORIZADO', 'B8', 'JAIRO HIDEKI NAGAO', '96512'],
['9', '176721', 'O-009', '2026-02-23', 'Tratamento Odontologico', 'AUTORIZADO', 'B8', 'JAIRO HIDEKI NAGAO', '96512'],
];
for (const row of mockData) {
await pool.query('INSERT INTO guias_odontologicas (id, numeroGuiaPrestador, numeroGuiaOperadora, dataSolicitacao, tipoTratamento, status, beneficiarioId, beneficiarioNome, beneficiarioIdentificacao) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', row);
}
}
setInterval(generateIntelligentNotifications, 60000);
generateIntelligentNotifications();
// Check if planos empty and add mock data
const [planCount] = await pool.query('SELECT COUNT(*) as cnt FROM planos');
if (planCount[0].cnt === 0) {
await pool.query("INSERT INTO planos (id, nome, tipo, descontoPadrao, corCartao) VALUES ('p1', 'PARTICULAR', 'Particular', 0, '#1f2937')");
}
// Re-enable FK checks
await pool.query('SET FOREIGN_KEY_CHECKS = 1');
} catch (err) {
await pool.query('SET FOREIGN_KEY_CHECKS = 1');
console.error('Error initializing tables:', err);
}
}
async function generateIntelligentNotifications() {
if (!pool) return;
try {
// 1. Check for upcoming appointments (next 2 hours)
const [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 DATE_ADD(NOW(), INTERVAL 2 HOUR)
AND a.status = 'Pendente'
`);
for (const appt of upcoming) {
const id = `notif_appt_${appt.id} `;
const [exists] = await pool.query('SELECT id FROM notificacoes WHERE id = ?', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES (?, ?, ?, ?)', [
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 [overdue] = await pool.query(`
SELECT * FROM financeiro
WHERE dataVencimento < CURDATE() AND status = 'Pendente'
`);
for (const bill of overdue) {
const id = `notif_fin_overdue_${bill.id} `;
const [exists] = await pool.query('SELECT id FROM notificacoes WHERE id = ?', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES (?, ?, ?, ?)', [
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 [dueToday] = await pool.query(`
SELECT * FROM financeiro
WHERE dataVencimento = CURDATE() AND status = 'Pendente'
`);
for (const bill of dueToday) {
const id = `notif_fin_today_${bill.id} `;
const [exists] = await pool.query('SELECT id FROM notificacoes WHERE id = ?', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES (?, ?, ?, ?)', [
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 [pCount] = await pool.query('SELECT COUNT(*) as count FROM pacientes');
if (pCount[0].count < 5) {
const id = 'notif_sys_setup';
const [exists] = await pool.query('SELECT id FROM notificacoes WHERE id = ?', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES (?, ?, ?, ?)', [
id,
'CONFIGURAÇÃO DO SISTEMA',
'O sistema possui poucos pacientes cadastrados. Considere importar dados.',
'sistema'
]);
}
}
} catch (err) {
console.error('Error generating notifications:', err);
}
}
// initTables(); removed from here