1302 lines
54 KiB
JavaScript
1302 lines
54 KiB
JavaScript
import 'dotenv/config';
|
|
import express from 'express';
|
|
import pg from 'pg';
|
|
import Redis from 'ioredis';
|
|
import cors from 'cors';
|
|
import { google } from 'googleapis';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
const { Pool } = pg;
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
|
|
|
// =============================================================================
|
|
// DATABASE — PostgreSQL nativo (pg Pool)
|
|
// =============================================================================
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL,
|
|
ssl: process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost')
|
|
? { rejectUnauthorized: false } : false,
|
|
max: 10,
|
|
idleTimeoutMillis: 30000
|
|
});
|
|
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
|
|
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
|
|
|
|
// =============================================================================
|
|
// PostgreSQL Translator & Compatibility Wrapper
|
|
// =============================================================================
|
|
function translateQuery(query, params) {
|
|
if (typeof query !== 'string') return { sql: query, pgParams: params };
|
|
let sql = query;
|
|
let pgParams = params ? (Array.isArray(params) ? [...params] : [params]) : [];
|
|
|
|
sql = sql.replace(/`([^`]+)`/g, '"$1"');
|
|
|
|
if (sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)) {
|
|
const table = sql.match(/INSERT\s+INTO\s+([^\s]+)\s+SET\s+\?/i)[1].replace(/"/g, '');
|
|
const dataObj = pgParams[0];
|
|
if (dataObj && typeof dataObj === 'object') {
|
|
const keys = Object.keys(dataObj);
|
|
const cols = keys.map(k => `"${k}"`).join(', ');
|
|
const valuesPlaceholder = keys.map((_, idx) => `$${idx + 1}`).join(', ');
|
|
sql = `INSERT INTO "${table}" (${cols}) VALUES (${valuesPlaceholder})`;
|
|
pgParams = keys.map(k => dataObj[k]);
|
|
}
|
|
}
|
|
|
|
if (sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i)) {
|
|
const match = sql.match(/UPDATE\s+([^\s]+)\s+SET\s+\?\s+WHERE\s+(.+)/i);
|
|
const table = match[1].replace(/"/g, '');
|
|
const whereClause = match[2];
|
|
const dataObj = pgParams[0];
|
|
const extraParams = pgParams.slice(1);
|
|
if (dataObj && typeof dataObj === 'object') {
|
|
const keys = Object.keys(dataObj);
|
|
let paramIdx = 1;
|
|
const setClause = keys.map(k => `"${k}" = $${paramIdx++}`).join(', ');
|
|
let postgresWhere = whereClause;
|
|
let tempIdx = paramIdx;
|
|
postgresWhere = postgresWhere.replace(/\?/g, () => `$${tempIdx++}`);
|
|
sql = `UPDATE "${table}" SET ${setClause} WHERE ${postgresWhere}`;
|
|
pgParams = [...keys.map(k => dataObj[k]), ...extraParams];
|
|
}
|
|
}
|
|
|
|
let count = 1;
|
|
sql = sql.replace(/\?/g, () => `$${count++}`);
|
|
|
|
if (sql.match(/ON\s+DUPLICATE\s+KEY\s+UPDATE/i)) {
|
|
if (sql.toLowerCase().includes('settings')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*VALUES\(data\)|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*\$4|ON\s+DUPLICATE\s+KEY\s+UPDATE\s+data\s*=\s*EXCLUDED\.data/i, 'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data');
|
|
else if (sql.toLowerCase().includes('google_tokens')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (owner_id) DO UPDATE SET refresh_token = EXCLUDED.refresh_token, access_token = EXCLUDED.access_token, expiry_date = EXCLUDED.expiry_date');
|
|
else if (sql.toLowerCase().includes('usuarios')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf, celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade');
|
|
else if (sql.toLowerCase().includes('vinculos')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = EXCLUDED.role');
|
|
else if (sql.toLowerCase().includes('dentistas_horarios')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio, hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo');
|
|
else if (sql.toLowerCase().includes('dentistas')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade');
|
|
else if (sql.toLowerCase().includes('procedimentos_valores_planos')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET valor = EXCLUDED.valor');
|
|
else if (sql.toLowerCase().includes('procedimentos')) sql = sql.replace(/ON\s+DUPLICATE\s+KEY\s+UPDATE[\s\S]+/i, 'ON CONFLICT (id) DO UPDATE SET nome = EXCLUDED.nome, "especialidadeId" = EXCLUDED."especialidadeId", "especialidadeNome" = EXCLUDED."especialidadeNome", "valorParticular" = EXCLUDED."valorParticular", "codigoInterno" = EXCLUDED."codigoInterno", categoria = EXCLUDED.categoria, tipo_regiao = EXCLUDED.tipo_regiao, exige_face = EXCLUDED.exige_face, codigo = EXCLUDED.codigo');
|
|
}
|
|
|
|
sql = sql.replace(/DATE_FORMAT\(([^,]+),\s*['"]%Y-%m['"]\)/gi, "TO_CHAR($1, 'YYYY-MM')");
|
|
sql = sql.replace(/DATE_SUB\(CURDATE\(\),\s*INTERVAL\s+(\d+)\s+MONTH\)/gi, "CURRENT_DATE - INTERVAL '$1 MONTH'");
|
|
if (sql.match(/SET\s+FOREIGN_KEY_CHECKS\s*=\s*\d+/i)) sql = "SELECT 1";
|
|
|
|
return { sql, pgParams };
|
|
}
|
|
|
|
const originalPoolQuery = pool.query.bind(pool);
|
|
pool.query = async function (queryText, params) {
|
|
const { sql, pgParams } = translateQuery(queryText, params);
|
|
const result = await originalPoolQuery(sql, pgParams);
|
|
const resArray = [result.rows, result.fields];
|
|
resArray.rows = result.rows;
|
|
resArray.rowCount = result.rowCount;
|
|
return resArray;
|
|
};
|
|
|
|
pool.getConnection = async function () {
|
|
const client = await pool.connect();
|
|
const originalClientQuery = client.query.bind(client);
|
|
client.query = async function (queryText, params) {
|
|
const { sql, pgParams } = translateQuery(queryText, params);
|
|
const result = await originalClientQuery(sql, pgParams);
|
|
const resArray = [result.rows, result.fields];
|
|
resArray.rows = result.rows;
|
|
resArray.rowCount = result.rowCount;
|
|
return resArray;
|
|
};
|
|
client.beginTransaction = async () => await originalClientQuery('BEGIN');
|
|
client.commit = async () => await originalClientQuery('COMMIT');
|
|
client.rollback = async () => await originalClientQuery('ROLLBACK');
|
|
return client;
|
|
};
|
|
|
|
// 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 { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
|
|
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos');
|
|
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads');
|
|
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'RECEITA' OR tipo IS NULL");
|
|
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA'");
|
|
|
|
// Growth data (last 6 months)
|
|
const { rows: growth } = await pool.query(`
|
|
SELECT
|
|
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
|
|
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
|
|
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
|
|
FROM financeiro
|
|
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months'
|
|
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
|
|
ORDER BY mes ASC
|
|
`);
|
|
|
|
// Recent appointments with patient and dentist names
|
|
const { rows: recent } = await pool.query(`
|
|
SELECT a.*, d.nome as dentistanome
|
|
FROM agendamentos a
|
|
LEFT JOIN dentistas d ON a.dentistaid = d.id
|
|
ORDER BY a.start_time DESC
|
|
LIMIT 5
|
|
`);
|
|
|
|
const responseData = {
|
|
stats: {
|
|
totalPacientes: parseInt(pacientes[0].count),
|
|
totalAgendamentos: parseInt(agendamentos[0].count),
|
|
totalLeads: parseInt(leads[0].count),
|
|
faturamentoTotal: parseFloat(faturamento[0].total),
|
|
despesasTotal: parseFloat(pendente[0].total)
|
|
},
|
|
growth: growth,
|
|
recentAgendamentos: recent
|
|
};
|
|
|
|
if (redis) {
|
|
await redis.setex(cacheKey, 60, JSON.stringify(responseData)); // Cache for 60s
|
|
console.log('[CACHE MISS] Cached dashboard stats in DragonflyDB');
|
|
}
|
|
|
|
res.json(responseData);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// --- NOTIFICACOES ---
|
|
app.get('/api/notificacoes', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query('SELECT * FROM notificacoes ORDER BY data DESC LIMIT 100');
|
|
res.json(rows.map(n => ({ ...n, lida: !!n.lida })));
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
app.post('/api/notificacoes/read-all', async (req, res) => {
|
|
try {
|
|
await pool.query('UPDATE notificacoes SET lida = true WHERE lida = false');
|
|
res.json({ success: true });
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
app.delete('/api/notificacoes/:id', async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM notificacoes WHERE id = $1', [req.params.id]);
|
|
res.json({ success: true });
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
app.put('/api/notificacoes/:id/read', async (req, res) => {
|
|
try {
|
|
await pool.query('UPDATE notificacoes SET lida = true WHERE id = $1', [req.params.id]);
|
|
res.json({ success: true });
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// --- PACIENTES ---
|
|
app.get('/api/pacientes', async (req, res) => {
|
|
try {
|
|
const [rows] = await pool.query('SELECT * FROM pacientes');
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/pacientes', async (req, res) => {
|
|
try {
|
|
const p = req.body;
|
|
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 { rows: finRows } = await pool.query(`
|
|
SELECT
|
|
SUM(CASE WHEN tipo = 'RECEITA' AND status = 'Pago' THEN valor ELSE 0 END) as receita,
|
|
SUM(CASE WHEN tipo = 'DESPESA' AND status = 'Pago' THEN valor ELSE 0 END) as despesa,
|
|
COUNT(*) as totalTransacoes
|
|
FROM financeiro
|
|
WHERE clinica_id = $1 AND datavencimento BETWEEN $2 AND $3
|
|
`, [clinicaId, start || '2000-01-01', end || '2100-01-01']);
|
|
|
|
// Appointments by Dentist
|
|
const { rows: agRows } = await pool.query(`
|
|
SELECT
|
|
d.nome as dentista,
|
|
COUNT(a.id) as totalAgendamentos,
|
|
SUM(CASE WHEN a.status = 'Concluido' THEN 1 ELSE 0 END) as concluidos,
|
|
SUM(CASE WHEN a.status = 'Faltou' THEN 1 ELSE 0 END) as faltas
|
|
FROM dentistas d
|
|
LEFT JOIN agendamentos a ON d.id = a.dentistaid AND a.start_time BETWEEN $1 AND $2
|
|
WHERE d.clinica_id = $3
|
|
GROUP BY d.id, d.nome
|
|
`, [start || '2000-01-01', end || '2100-01-01', clinicaId]);
|
|
|
|
res.json({
|
|
financeiro: finRows[0],
|
|
producao: agRows
|
|
});
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// --- AGENDAMENTOS ---
|
|
app.get('/api/agendamentos', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query('SELECT * FROM agendamentos');
|
|
res.json(rows.map(r => ({ ...r, start: r.start_time, end: r.end_time })));
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// POST with concurrency lock: prevents double-booking the same dentist+time slot
|
|
app.post('/api/agendamentos', async (req, res) => {
|
|
try {
|
|
const { start, end, ...rest } = req.body;
|
|
const { dentistaId } = rest;
|
|
|
|
await withTransaction(async (client) => {
|
|
// Lock check: SELECT the conflicting row FOR UPDATE to prevent race conditions
|
|
if (dentistaId && start) {
|
|
const { rows: conflict } = await client.query(
|
|
`SELECT id FROM agendamentos
|
|
WHERE dentistaid = $1 AND start_time = $2
|
|
LIMIT 1 FOR UPDATE`,
|
|
[dentistaId, start]
|
|
);
|
|
if (conflict.length > 0) {
|
|
throw { code: 'CONF_ERROR', message: 'ESTE HORÁRIO ACABOU DE SER RESERVADO POR OUTRA PESSOA. POR FAVOR, ESCOLHA OUTRO HORÁRIO.' };
|
|
}
|
|
}
|
|
|
|
const id = rest.id || `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
|
|
// Map keys from camelCase/old attributes to correct lowercase PG columns if necessary
|
|
const payload = {
|
|
id,
|
|
pacientenome: rest.pacienteNome,
|
|
pacientecelular: rest.pacienteCelular,
|
|
pacienteid: rest.pacienteId,
|
|
dentistaid: rest.dentistaId,
|
|
start_time: start,
|
|
end_time: end,
|
|
procedimento: rest.procedimento,
|
|
observacoes: rest.observacoes,
|
|
status: rest.status || 'Agendado',
|
|
clinica_id: rest.clinica_id
|
|
};
|
|
|
|
const insertQuery = buildInsert('agendamentos', payload);
|
|
await client.query(insertQuery.text, insertQuery.values);
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
if (err.code === 'CONF_ERROR') {
|
|
return res.status(409).json({ success: false, message: err.message });
|
|
}
|
|
// PostgreSQL unique violation code is '23505'
|
|
if (err.code === '23505') {
|
|
return res.status(409).json({
|
|
success: false,
|
|
message: 'ESTE HORÁRIO ACABOU DE SER RESERVADO POR OUTRA PESSOA. POR FAVOR, ESCOLHA OUTRO HORÁRIO.'
|
|
});
|
|
}
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/agendamentos/:id', async (req, res) => {
|
|
try {
|
|
const { start, end, ...rest } = req.body;
|
|
const updateData = {};
|
|
if (start) updateData.start_time = start;
|
|
if (end) updateData.end_time = end;
|
|
if (rest.status) updateData.status = rest.status;
|
|
if (rest.observacoes !== undefined) updateData.observacoes = rest.observacoes;
|
|
if (rest.procedimento) updateData.procedimento = rest.procedimento;
|
|
if (rest.dentistaId) updateData.dentistaid = rest.dentistaId;
|
|
|
|
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
|
if (updateQuery) {
|
|
await pool.query(updateQuery.text, updateQuery.values);
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
if (err.code === '23505') {
|
|
return res.status(409).json({
|
|
success: false,
|
|
message: 'ESTE HORÁRIO JÁ ESTÁ OCUPADO PARA ESTE DENTISTA.'
|
|
});
|
|
}
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/agendamentos/:id', async (req, res) => {
|
|
try {
|
|
await pool.query('DELETE FROM agendamentos WHERE id = ?', [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 }); }
|
|
});
|
|
|
|
// initTables() was removed as database is native PostgreSQL and schema DDL is maintained externally.
|
|
async function generateIntelligentNotifications() {
|
|
if (!pool) return;
|
|
try {
|
|
// 1. Check for upcoming appointments (next 2 hours)
|
|
const { rows: upcoming } = await pool.query(`
|
|
SELECT a.*, p.nome as "pacienteNome"
|
|
FROM agendamentos a
|
|
LEFT JOIN pacientes p ON a.pacientenome = p.nome
|
|
WHERE a.start_time BETWEEN NOW() AND NOW() + INTERVAL '2 hours'
|
|
AND a.status = 'Pendente'
|
|
`);
|
|
|
|
for (const appt of upcoming) {
|
|
const id = `notif_appt_${appt.id}`;
|
|
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
|
|
if (exists.length === 0) {
|
|
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
|
|
id,
|
|
'PRÓXIMO ATENDIMENTO',
|
|
`Paciente ${appt.pacientenome} às ${new Date(appt.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
|
|
'agenda'
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 2. Check for overdue bills
|
|
const { rows: overdue } = await pool.query(`
|
|
SELECT * FROM financeiro
|
|
WHERE datavencimento < CURRENT_DATE AND status = 'Pendente'
|
|
`);
|
|
|
|
for (const bill of overdue) {
|
|
const id = `notif_fin_overdue_${bill.id}`;
|
|
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
|
|
if (exists.length === 0) {
|
|
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
|
|
id,
|
|
'FATURA ATRASADA',
|
|
`A conta "${bill.descricao}" de R$ ${bill.valor} venceu em ${new Date(bill.datavencimento).toLocaleDateString('pt-BR')}`,
|
|
'financeiro'
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 3. Check for bills due today
|
|
const { rows: dueToday } = await pool.query(`
|
|
SELECT * FROM financeiro
|
|
WHERE datavencimento = CURRENT_DATE AND status = 'Pendente'
|
|
`);
|
|
|
|
for (const bill of dueToday) {
|
|
const id = `notif_fin_today_${bill.id}`;
|
|
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
|
|
if (exists.length === 0) {
|
|
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
|
|
id,
|
|
'VENCIMENTO HOJE',
|
|
`A conta "${bill.descricao}" de R$ ${bill.valor} vence hoje!`,
|
|
'financeiro'
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 4. System Events (to Dev) - Mocking a system event if table counts are low
|
|
const { rows: pCount } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
|
|
if (parseInt(pCount[0].count) < 5) {
|
|
const id = 'notif_sys_setup';
|
|
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
|
|
if (exists.length === 0) {
|
|
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
|
|
id,
|
|
'CONFIGURAÇÃO DO SISTEMA',
|
|
'O sistema possui poucos pacientes cadastrados. Considere importar dados.',
|
|
'sistema'
|
|
]);
|
|
}
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error('Error generating notifications:', err);
|
|
}
|
|
}
|
|
|
|
|
|
|