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