import 'dotenv/config'; import express from 'express'; import pg from 'pg'; import Redis from 'ioredis'; import cors from 'cors'; import { google } from 'googleapis'; import jwt from 'jsonwebtoken'; const { Pool } = pg; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; // ============================================================================= // DATABASE — PostgreSQL nativo (pg Pool) // ============================================================================= const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost') ? { rejectUnauthorized: false } : false, max: 10, idleTimeoutMillis: 30000 }); pool.on('connect', () => console.log('[PG] Connected to PostgreSQL')); pool.on('error', (err) => console.error('[PG] Pool error:', err.message)); // Helper: native PG query wrapper returns [rows, fields] like MySQL adapter did async function query(text, params) { const result = await pool.query(text, params); return [result.rows, result.fields]; } // Helper: transaction wrapper async function withTransaction(fn) { const client = await pool.connect(); try { await client.query('BEGIN'); const result = await fn(client); await client.query('COMMIT'); return result; } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } // Helper: build INSERT query from plain object (PG native) function buildInsert(table, obj) { const allowed = Object.entries(obj).filter(([, v]) => v !== undefined && v !== null || typeof v === 'boolean' || v === 0); const keys = allowed.map(([k]) => k); const vals = allowed.map(([, v]) => v); const cols = keys.map(k => `"${k}"`).join(', '); const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); return { text: `INSERT INTO ${table} (${cols}) VALUES (${placeholders})`, values: vals }; } // Helper: build UPDATE SET clause from plain object (PG native) function buildUpdate(table, obj, whereCol, whereVal) { const skip = new Set(['id', 'createdAt', 'created_at']); const entries = Object.entries(obj).filter(([k, v]) => !skip.has(k) && v !== undefined); const set = entries.map(([k], i) => `"${k}" = $${i + 1}`).join(', '); const values = [...entries.map(([, v]) => v), whereVal]; return { text: `UPDATE ${table} SET ${set} WHERE "${whereCol}" = $${entries.length + 1}`, values }; } // Helper: convert PostgreSQL error codes to friendly messages function pgErrCode(err) { return err.code; } // '23505' = unique violation // ============================================================================= // DRAGONFLY / REDIS CACHE // ============================================================================= let redis = null; try { const DRAGONFLY_URL = process.env.DRAGONFLY_URL || 'redis://localhost:6379'; redis = new Redis(DRAGONFLY_URL, { lazyConnect: true }); redis.on('connect', () => console.log('[DRAGONFLY] Connected')); redis.on('error', (err) => console.error('[DRAGONFLY] Error:', err.message)); await redis.connect().catch(() => { redis = null; }); } catch (e) { console.error('[DRAGONFLY] Failed to initialize:', e.message); redis = null; } // ============================================================================= // EXPRESS // ============================================================================= const app = express(); app.use(express.json({ limit: '2mb' })); app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); // --- HEALTH CHECK --- app.get('/api/ping', (req, res) => res.json({ status: 'ok', ts: new Date().toISOString() })); // ============================================================================= // SECURITY MIDDLEWARE: Tenant Guard // ============================================================================= async function tenantGuard(req, res, next) { const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ success: false, message: 'TOKEN DE AUTENTICAÇÃO NÃO ENVIADO.' }); } let payload; try { payload = jwt.verify(authHeader.split(' ')[1], JWT_SECRET); } catch { return res.status(401).json({ success: false, message: 'TOKEN INVÁLIDO OU EXPIRADO. FAÇA LOGIN NOVAMENTE.' }); } const clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId; if (clinicaId) { try { const { rows } = await pool.query( 'SELECT id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [payload.userId, clinicaId] ); if (rows.length === 0) { console.warn(`[TENANT VIOLATION] User ${payload.userId} -> clinica ${clinicaId}`); return res.status(403).json({ success: false, message: 'ACESSO NEGADO: VOCÊ NÃO TEM VÍNCULO COM ESTA UNIDADE.' }); } } catch (err) { return res.status(500).json({ success: false, message: 'ERRO INTERNO DE AUTENTICAÇÃO.' }); } } req.authUser = payload; next(); } // --- CLINICAS --- app.put('/api/clinicas/:id/color', async (req, res) => { const { cor } = req.body; if (!cor) return res.status(400).json({ error: 'Campo cor obrigatório.' }); try { await pool.query('UPDATE clinicas SET cor = $1 WHERE id = $2', [cor, req.params.id]); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- LOGIN ROUTE --- app.post('/api/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ success: false, message: 'EMAIL E SENHA SÃO OBRIGATÓRIOS.' }); console.log(`[LOGIN ATTEMPT] Email: ${email}`); try { const { rows: users } = await pool.query( 'SELECT id, nome, email FROM usuarios WHERE email = $1 AND senha = $2', [email.toLowerCase().trim(), password] ); if (users.length === 0) { console.log(`[LOGIN FAILED] Invalid credentials for: ${email}`); return res.status(401).json({ success: false, message: 'CREDENCIAIS INVÁLIDAS' }); } const user = users[0]; const { rows: workspaces } = await pool.query(` SELECT v.clinica_id as id, c.nome_fantasia as nome, c.cor, v.role FROM vinculos v JOIN clinicas c ON v.clinica_id = c.id WHERE v.usuario_id = $1 `, [user.id]); if (workspaces.length === 0) { console.log(`[LOGIN FORBIDDEN] No workspaces for User ID: ${user.id}`); return res.status(403).json({ success: false, message: 'USUÁRIO SEM VÍNCULO COM CLÍNICAS' }); } console.log(`[LOGIN SUCCESS] User: ${user.email}, Workspaces: ${workspaces.length}`); const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '12h' }); res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email }, workspaces }); } catch (err) { console.error(`[LOGIN ERROR]: ${err.message}`); res.status(500).json({ success: false, error: 'ERRO INTERNO DO SERVIDOR.' }); } }); const PORT = process.env.PORT || 3005; // --- GOOGLE OAUTH2 SETUP --- const createOAuth2Client = () => new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, process.env.GOOGLE_REDIRECT_URI || `http://localhost:${PORT}/api/oauth2callback` ); app.listen(PORT, '0.0.0.0', () => console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`)); // --- GOOGLE AUTH ROUTES --- app.get('/api/auth/google/url', (req, res) => { const { dentistId } = req.query; const oauth2Client = createOAuth2Client(); const url = oauth2Client.generateAuthUrl({ access_type: 'offline', scope: [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/calendar.readonly' ], state: dentistId || 'admin', prompt: 'consent' }); res.json({ url }); }); app.get('/api/oauth2callback', async (req, res) => { const { code, state } = req.query; if (!code) return res.status(400).send('Código não fornecido'); try { const oauth2Client = createOAuth2Client(); const { tokens } = await oauth2Client.getToken(code); let ownerId = state || 'admin'; if (ownerId === 'admin') { oauth2Client.setCredentials(tokens); const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client }); const { data } = await oauth2.userinfo.get(); ownerId = data.email; } await pool.query(` INSERT INTO google_tokens (owner_id, refresh_token, access_token, expiry_date) VALUES ($1, $2, $3, $4) ON CONFLICT (owner_id) DO UPDATE SET refresh_token = EXCLUDED.refresh_token, access_token = EXCLUDED.access_token, expiry_date = EXCLUDED.expiry_date `, [ownerId, tokens.refresh_token, tokens.access_token, tokens.expiry_date]); res.send(`
O Google Agenda foi integrado com sucesso.
Você já pode fechar esta janela.