Files
scoreodonto.com/backend/server.js
T
VPS 4 Builder 6cf18c049f fix(sync): propagate real tab name in pushTableToSheet and pullTableFromSheet
Ensures values.update and values.get use the actual tab title returned by
ensureSheetTab (case-insensitive match) so writes don't fail when existing
Apps Script tabs have different casing than our expected names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:46:30 +02:00

1586 lines
67 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 });
}
// Returns the actual tab title as it exists in the sheet (case-preserving).
async function ensureSheetTab(sheets, tabName) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
const existing = meta.data.sheets.find(s =>
s.properties.title.toLowerCase() === tabName.toLowerCase()
);
if (existing) return existing.properties.title; // already exists, return real name
try {
await sheets.spreadsheets.batchUpdate({
spreadsheetId: SHEETS_BACKUP_ID,
requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] }
});
} catch (e) {
if (!e.message?.includes('already exists') && !e.message?.includes('Já existe')) throw e;
// Re-fetch to get the real name after race-condition create
const meta2 = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
const found = meta2.data.sheets.find(s => s.properties.title.toLowerCase() === tabName.toLowerCase());
if (found) return found.properties.title;
}
return tabName;
}
async function pushTableToSheet(tableDef, sheets) {
const { rows } = await pool.query(tableDef.query);
const realTab = await ensureSheetTab(sheets, tableDef.tab);
await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!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: `${realTab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: sheetRows }
});
return rows.length;
}
async function pullTableFromSheet(tableDef, sheets) {
const realTab = await ensureSheetTab(sheets, tableDef.tab);
const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!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 (!googleCreds.clientId || !googleCreds.clientSecret) {
return res.status(503).json({ error: 'Credenciais OAuth não configuradas. Acesse Configurações → Integrações Google e salve o Client ID e Client Secret.' });
}
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);
});