1584 lines
68 KiB
Plaintext
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
|
|
|
|
|
|
|