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 (!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(`
O Google Agenda foi integrado com sucesso.
Você já pode fechar esta janela.