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'; import multer from 'multer'; import { WebSocketServer } from 'ws'; import fs from 'fs'; import path from 'path'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; const { Pool } = pg; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; // URL assinada para imagens clínicas servidas em (sem header de auth). // Token = "."; expira em 12h. Reemitido a cada listagem de fotos. const MEDIA_TTL_MS = 12 * 60 * 60 * 1000; function signMedia(id) { const exp = Date.now() + MEDIA_TTL_MS; const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${id}.${exp}`).digest('hex').slice(0, 32); return `${exp}.${sig}`; } function verifyMedia(id, token) { if (!token || typeof token !== 'string' || !token.includes('.')) return false; const i = token.indexOf('.'); const exp = Number(token.slice(0, i)); if (!exp || Date.now() > exp) return false; const good = crypto.createHmac('sha256', JWT_SECRET).update(`${id}.${exp}`).digest('hex').slice(0, 32); try { return crypto.timingSafeEqual(Buffer.from(token.slice(i + 1)), Buffer.from(good)); } catch { return false; } } // Documentação fotográfica de ortodontia: uploads vão para o volume persistente // /app/media (montado de /home/deploy/scoreodonto-media), servidos via /api/orto/fotos/:id/raw. const MEDIA_ROOT = process.env.MEDIA_PATH || '/app/media'; const ortoUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } }); // ============================================================================= // DATABASE — PostgreSQL nativo (pg Pool) // ============================================================================= const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_URL && !/localhost|@pgdev/.test(process.env.DATABASE_URL) ? { 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 // ============================================================================= // SENHAS (bcrypt) + provisionamento de contas // ============================================================================= const SENHA_ROUNDS = 10; async function hashSenha(plain) { return bcrypt.hash(String(plain), SENHA_ROUNDS); } function isHashed(s) { return typeof s === 'string' && /^\$2[aby]\$/.test(s); } // Verifica senha; migra senhas legadas em texto puro para hash de forma preguiçosa. async function verificarSenha(userId, plainInput, stored) { if (isHashed(stored)) return bcrypt.compare(String(plainInput), stored); if (stored != null && String(plainInput) === String(stored)) { try { await pool.query('UPDATE usuarios SET senha = $1 WHERE id = $2', [await hashSenha(plainInput), userId]); } catch { /* ignore */ } return true; } return false; } // Senha temporária legível (sem caracteres ambíguos). function gerarSenhaTemp() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let s = ''; for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)]; return s; } // Encontra (ou provisiona) a conta de um membro pelo e-mail. Se não existe, // cria conta FREE com o papel dado, senha temporária (hash) e troca obrigatória. async function ensureUsuarioPorEmail(email, nome, role) { const e = String(email).toLowerCase().trim(); const { rows } = await pool.query('SELECT id, nome FROM usuarios WHERE lower(email) = $1', [e]); if (rows.length) return { id: rows[0].id, nome: rows[0].nome, created: false, tempPassword: null }; const id = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); const temp = gerarSenhaTemp(); await pool.query( 'INSERT INTO usuarios (id, nome, email, senha, role, must_change_password) VALUES ($1,$2,$3,$4,$5,true)', [id, (nome || e).toUpperCase(), e, await hashSenha(temp), role] ); return { id, nome: (nome || e).toUpperCase(), created: true, tempPassword: temp }; } // Nível hierárquico do usuário NA clínica (dono/admin = topo). Usado para "só gerencia abaixo". async function nivelNaClinica(userId, clinicaId) { const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]); if (cl[0]?.owner_id === userId) return 100000; const { rows } = await pool.query( 'SELECT v.role, f.nivel FROM vinculos v LEFT JOIN funcoes f ON f.id = v.funcao_id WHERE v.usuario_id = $1 AND v.clinica_id = $2', [userId, clinicaId]); if (!rows.length) return -1; // não é membro if (['donoclinica', 'donoconsultorio', 'admin'].includes(rows[0].role)) return 99999; return rows[0].nivel ?? 0; } async function nivelDaFuncao(funcaoId) { if (!funcaoId) return 0; const { rows } = await pool.query('SELECT nivel FROM funcoes WHERE id = $1', [funcaoId]); return rows[0]?.nivel ?? 0; } // ============================================================================= // 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() })); // --- VERSION / BUILD INFO (carimbado no build via ARGs do Dockerfile) --- const BUILD_INFO = { name: 'ScoreOdonto API', version: process.env.APP_VERSION || 'dev', commit: process.env.GIT_COMMIT || 'dev', build: process.env.BUILD_TIME || '', environment: (process.env.APP_ENV || 'DEV').toUpperCase(), }; app.get('/api/version', (req, res) => res.json(BUILD_INFO)); // Health check (público, sem auth) — status do backend + dependências. // Banco é crítico (down => HTTP 503); cache é opcional (down => "degraded", mas 200). app.get('/api/health', async (req, res) => { const checks = {}; try { const t = Date.now(); await pool.query('SELECT 1'); checks.database = { status: 'ok', latency_ms: Date.now() - t }; } catch { checks.database = { status: 'down' }; } try { if (redis) { const t = Date.now(); await redis.ping(); checks.cache = { status: 'ok', latency_ms: Date.now() - t }; } else { checks.cache = { status: 'disabled' }; } } catch { checks.cache = { status: 'down' }; } const dbOk = checks.database.status === 'ok'; const cacheDegraded = checks.cache.status === 'down'; const status = !dbOk ? 'down' : (cacheDegraded ? 'degraded' : 'ok'); res.status(dbOk ? 200 : 503).json({ status, version: BUILD_INFO.version, commit: BUILD_INFO.commit, environment: BUILD_INFO.environment, uptime_s: Math.round(process.uptime()), checks, }); }); // ============================================================================= // 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; req.isSala = false; 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) { // Workspace pode ser uma SALA própria (locação): valida pela posse, não por vínculo. const { rows: sl } = await pool.query('SELECT id FROM salas WHERE id = $1 AND owner_usuario_id = $2', [clinicaId, payload.userId]); if (sl.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.' }); } req.isSala = true; } } catch (err) { return res.status(500).json({ success: false, message: 'ERRO INTERNO DE AUTENTICAÇÃO.' }); } } req.authUser = payload; req.clinicaId = clinicaId || null; next(); } function authGuard(req, res, next) { const authHeader = req.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Não autenticado.' }); try { req.authUser = jwt.verify(authHeader.split(' ')[1], JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Token inválido.' }); } } // ── RBAC intra-clínica ─────────────────────────────────────────────────────── // Exige que o usuário tenha a "capability" (chave de view do frontend) no workspace // ATIVO. Espelha o isViewAllowed do frontend: superadmin global → ok; dono // (donoclinica/donoconsultorio/admin) → ok; cargo com permissões explícitas → ok se // inclui a capability; senão, mapa padrão por papel. DEVE rodar APÓS o tenantGuard. const CAP_OWNER_ROLES = ['donoclinica', 'donoconsultorio', 'admin']; const CAP_DEFAULTS = { paciente: ['tratamentos', 'notificacoes', 'configuracoes'], dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'], biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], }; // Núcleo da decisão de capability (req.clinicaId já resolvido). async function capabilityCore(cap, req, res, next) { const userId = req.authUser && req.authUser.userId; if (!userId) return res.status(401).json({ error: 'Não autenticado.' }); const { rows: ur } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [userId]); if (ur[0] && ur[0].role === 'superadmin') return next(); const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); const { rows } = await pool.query( `SELECT v.role, f.permissoes FROM vinculos v LEFT JOIN funcoes f ON f.id = v.funcao_id WHERE v.usuario_id = $1 AND v.clinica_id = $2`, [userId, clinicaId]); if (!rows.length) return res.status(403).json({ error: 'ACESSO NEGADO: sem vínculo com esta unidade.' }); const role = rows[0].role; if (CAP_OWNER_ROLES.includes(role)) return next(); let perms = rows[0].permissoes; if (typeof perms === 'string') { try { perms = JSON.parse(perms); } catch { perms = null; } } if (Array.isArray(perms) && perms.length > 0) { if (perms.includes(cap)) return next(); return res.status(403).json({ error: `ACESSO NEGADO: seu cargo não inclui '${cap}'.` }); } if ((CAP_DEFAULTS[role] || []).includes(cap)) return next(); return res.status(403).json({ error: `ACESSO NEGADO: seu cargo não permite '${cap}'.` }); } // Capability pela clínica da REQUISIÇÃO (param/body/query). Valida vínculo + cargo. function requireCapability(cap) { return async function (req, res, next) { try { if (!req.clinicaId) req.clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId || null; return await capabilityCore(cap, req, res, next); } catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); } }; } // Capability pela clínica da LINHA do recurso (tabela + req.params.id) — para PUT/DELETE /:id // quando o clinicaId não vem na requisição. Itens globais (clinica_id NULL) → só superadmin. function requireCapabilityRow(cap, table) { return async function (req, res, next) { try { const { rows } = await pool.query(`SELECT clinica_id AS cid FROM ${table} WHERE id = $1`, [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'Recurso não encontrado.' }); req.clinicaId = rows[0].cid || null; return await capabilityCore(cap, req, res, next); } catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); } }; } // Capability para reordenação: clinicaId vem do 1º item de req.body.orders ([{id,ordem}]). function requireCapabilityFromOrders(cap, table) { return async function (req, res, next) { try { const orders = Array.isArray(req.body?.orders) ? req.body.orders : []; const firstId = orders.length ? orders[0].id : null; if (!firstId) return res.status(400).json({ error: 'Lista de ordenação vazia.' }); const { rows } = await pool.query(`SELECT clinica_id AS cid FROM ${table} WHERE id = $1`, [firstId]); if (!rows.length) return res.status(404).json({ error: 'Item não encontrado.' }); req.clinicaId = rows[0].cid || null; return await capabilityCore(cap, req, res, next); } catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); } }; } // ============================================================================= // AUDITORIA GENÉRICA + PENDÊNCIAS DE AGENDA (Fase 1) // ============================================================================= async function actorNome(userId) { if (!userId) return null; try { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]); return rows[0]?.nome || null; } catch { return null; } } // Registra um evento IMUTÁVEL de auditoria. Passe o client da transação quando houver. // ── WebSocket: tempo real por clínica ─────────────────────────────────────── // Registro de conexões vivas agrupadas por clínica. wsBroadcast() empurra um // aviso de mudança para todos os clientes daquela clínica → refetch instantâneo. const wsClients = new Map(); // clinicaId -> Set function wsBroadcast(clinicaId, type, payload = {}) { if (!clinicaId) return; const set = wsClients.get(clinicaId); const n = set ? set.size : 0; console.log(`[WS] broadcast type=${type} clinica=${String(clinicaId).slice(0, 12)} -> ${n} cliente(s)`); if (!n) return; const msg = JSON.stringify({ type, ...payload, at: Date.now() }); for (const ws of set) { if (ws.readyState === 1) { try { ws.send(msg); } catch { /* ignore */ } } } } // Entidades cujas mudanças interessam à agenda em tempo real. const WS_AGENDA_ENTIDADES = new Set(['agendamento', 'avaliacao']); async function registrarAudit(q, { clinicaId, entidade, entidadeId, acao, actorId, actorNome, detalhes }) { const run = q || pool; await run.query( `INSERT INTO audit_log (id, clinica_id, entidade, entidade_id, acao, actor_id, actor_nome, detalhes, at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())`, ['au_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), clinicaId || null, entidade, entidadeId || null, acao, actorId || null, actorNome || null, JSON.stringify(detalhes || {})] ); // Avisa os clientes conectados que a agenda mudou (chokepoint único de mutações). if (WS_AGENDA_ENTIDADES.has(entidade)) wsBroadcast(clinicaId, 'agenda', { entidade, entidadeId, acao }); } // Abre uma pendência "a reagendar" (motivo: falta | cancelado | remarcar). async function abrirPendencia(q, { clinicaId, pacienteId, pacienteNome, origemId, motivo }) { const run = q || pool; await run.query( `INSERT INTO agenda_pendencias (id, clinica_id, paciente_id, paciente_nome, origem_agendamento_id, motivo, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,'aberta',NOW())`, ['pend_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), clinicaId || null, pacienteId || null, pacienteNome || null, origemId || null, motivo] ); } // Resolve pendências abertas de um paciente (ex.: ao surgir um novo agendamento futuro). async function resolverPendenciasPaciente(q, clinicaId, pacienteId, { by, byNome, resolucao }) { if (!pacienteId) return; const run = q || pool; await run.query( `UPDATE agenda_pendencias SET status='resolvida', resolved_at=NOW(), resolved_by=$3, resolved_by_nome=$4, resolucao=$5 WHERE clinica_id=$1 AND paciente_id=$2 AND status='aberta'`, [clinicaId || null, pacienteId, by || null, byNome || null, resolucao || 'reagendado'] ); } // Slot liberado (cancel/falta/remarcar) → notifica (como SISTEMA) interesses sobrepostos (Fase 4). async function notificarInteressesSlotLivre(a) { try { const { rows: dr } = await pool.query('SELECT usuario_id FROM dentistas WHERE id = $1', [a.dentistaid]); const profUid = dr[0]?.usuario_id || null; if (!profUid) return; const fim = a.end_time || a.start_time; const { rows: ints } = await pool.query( `SELECT id, solicitante_usuario_id FROM interesses_agenda WHERE profissional_usuario_id = $1 AND status = 'aguardando' AND start_time < $3 AND COALESCE(end_time, start_time) > $2`, [profUid, a.start_time, fim]); for (const it of ints) { if (it.solicitante_usuario_id) { await pool.query( `INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por) VALUES (gen_random_uuid(), $1, $2, 'sucesso', 0, NOW(), $3, 'sistema')`, ['Horário liberado', 'Um horário que você aguardava ficou livre. Você já pode agendar.', it.solicitante_usuario_id]); } await pool.query(`UPDATE interesses_agenda SET status = 'liberado' WHERE id = $1`, [it.id]); } } catch (e) { console.error('[interesses]', e.message); } } // --- GOOGLE CALENDAR como BACKUP + NOTIFICAÇÃO (sentido único: sistema → Google) --- // Best-effort: nunca quebra o agendamento. Eventos levam marcador extendedProperties // private.scoreodonto p/ não duplicarem na leitura (getGoogleEvents filtra esses). async function getClinicOAuth(clinicaId) { const { rows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1 ORDER BY updated_at DESC NULLS LAST LIMIT 1', [clinicaId]); if (!rows.length) return null; const tk = rows[0]; const c = createOAuth2Client(); c.setCredentials({ refresh_token: tk.refresh_token, access_token: tk.access_token, expiry_date: tk.expiry_date }); return c; } // action: 'create' | 'update' | 'delete'. `ag` usa colunas do banco (start_time, pacienteid, etc.). async function pushGoogleEvent(action, ag) { try { if (!ag || !ag.clinica_id) return; const auth = await getClinicOAuth(ag.clinica_id); if (!auth) return; // clínica sem Google conectado → sem backup/notificação const calendar = google.calendar({ version: 'v3', auth }); if (action === 'delete') { if (ag.google_event_id) { await calendar.events.delete({ calendarId: 'primary', eventId: ag.google_event_id, sendUpdates: 'all' }).catch(() => { }); await pool.query('UPDATE agendamentos SET google_event_id = NULL WHERE id = $1', [ag.id]).catch(() => { }); } return; } // Convidado: paciente (se tiver e-mail) → Google envia convite + lembretes. let attendees; let pacEmail; let pacCelular; if (ag.pacienteid) { const { rows: pr } = await pool.query('SELECT email, celular FROM pacientes WHERE id = $1', [ag.pacienteid]); pacEmail = pr[0]?.email; pacCelular = pr[0]?.celular; if (pacEmail && /@/.test(pacEmail)) attendees = [{ email: pacEmail }]; } // Nome do profissional para a descrição. let dentNome; if (ag.dentistaid) { const { rows: dn } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [ag.dentistaid]); dentNome = dn[0]?.nome; } // Descrição rica: dados do agendamento (antes ficava só "cópia de backup"). const linhas = []; if (ag.pacientenome) linhas.push(`👤 Paciente: ${ag.pacientenome}`); if (ag.procedimento) linhas.push(`🦷 Procedimento: ${ag.procedimento}`); if (dentNome) linhas.push(`🧑‍⚕️ Profissional: ${dentNome}`); if (pacCelular) linhas.push(`📞 Telefone: ${pacCelular}`); if (ag.observacoes) linhas.push(`📝 Obs.: ${ag.observacoes}`); linhas.push('', '— Agendamento ScoreOdonto (cópia de backup).'); const resource = { summary: `${ag.pacientenome || 'Paciente'}${ag.procedimento ? ' — ' + ag.procedimento : ''}`, description: linhas.join('\n'), start: { dateTime: new Date(ag.start_time).toISOString() }, end: { dateTime: new Date(ag.end_time || ag.start_time).toISOString() }, attendees, reminders: { useDefault: false, overrides: [{ method: 'email', minutes: 1440 }, { method: 'popup', minutes: 60 }] }, extendedProperties: { private: { scoreodonto: ag.id } }, }; if (action === 'update' && ag.google_event_id) { try { await calendar.events.patch({ calendarId: 'primary', eventId: ag.google_event_id, sendUpdates: 'all', requestBody: resource }); } catch { const ev = await calendar.events.insert({ calendarId: 'primary', sendUpdates: 'all', requestBody: resource }); await pool.query('UPDATE agendamentos SET google_event_id = $1 WHERE id = $2', [ev.data.id, ag.id]); } } else { const ev = await calendar.events.insert({ calendarId: 'primary', sendUpdates: 'all', requestBody: resource }); await pool.query('UPDATE agendamentos SET google_event_id = $1 WHERE id = $2', [ev.data.id, ag.id]); } } catch (e) { console.error('[google-push]', e.message); } } // Área padrão a partir do papel do usuário (default ao criar especialidade/procedimento). function roleToArea(role) { if (role === 'biomedico') return 'biomedicina'; if (role === 'protetico') return 'protese'; return 'odontologia'; } // Workspaces do usuário = clínicas (via vínculos) + salas que ele é dono (tipo='sala'). // Cada sala é um workspace próprio, isolado da clínica (tem endereço próprio). async function listarWorkspaces(userId, userRole) { const { rows: clinicas } = await pool.query(` SELECT v.clinica_id AS id, c.nome_fantasia AS nome, c.cor, v.role, COALESCE(c.tipo, 'clinica') AS tipo, v.funcao_id, f.nome AS funcao_nome, f.permissoes, f.nivel FROM vinculos v JOIN clinicas c ON v.clinica_id = c.id LEFT JOIN funcoes f ON f.id = v.funcao_id WHERE v.usuario_id = $1 `, [userId]); const { rows: salas } = await pool.query( `SELECT id, nome, cidade, estado FROM salas WHERE owner_usuario_id = $1 ORDER BY nome`, [userId]); const salaWs = salas.map(s => ({ id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala', cidade: s.cidade, estado: s.estado, funcao_id: null, funcao_nome: null, permissoes: null, nivel: null, })); // Salas das clínicas em que o usuário tem vínculo (mas NÃO é dono): acesso OPERACIONAL — // ver a agenda da sala e bloquear horário para manutenção, sem locação/financeiro. const { rows: salasOp } = await pool.query(` SELECT DISTINCT s.id, s.nome, s.cidade, s.estado FROM salas s JOIN vinculos v ON v.clinica_id = s.clinica_id AND v.usuario_id = $1 WHERE s.clinica_id IS NOT NULL AND s.owner_usuario_id <> $1 ORDER BY s.nome`, [userId]); const salaOpWs = salasOp.map(s => ({ id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'funcionario', tipo: 'sala', cidade: s.cidade, estado: s.estado, papel_sala: 'operador', funcao_id: null, funcao_nome: null, permissoes: null, nivel: null, })); return [...clinicas, ...salaWs, ...salaOpWs]; } // Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido). const BIOMED_ESPECIALIDADES = [ 'BIOMEDICINA ESTÉTICA', 'HARMONIZAÇÃO OROFACIAL', 'ESTÉTICA FACIAL', 'ESTÉTICA CORPORAL', 'PROCEDIMENTOS INJETÁVEIS', ]; const BIOMED_PROCEDIMENTOS = [ { nome: 'TOXINA BOTULÍNICA (BOTOX)', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 800 }, { nome: 'PREENCHIMENTO COM ÁCIDO HIALURÔNICO', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 1200 }, { nome: 'BIOESTIMULADOR DE COLÁGENO', esp: 'ESTÉTICA FACIAL', valor: 1500 }, { nome: 'SKINBOOSTER', esp: 'ESTÉTICA FACIAL', valor: 900 }, { nome: 'PEELING QUÍMICO', esp: 'ESTÉTICA FACIAL', valor: 300 }, { nome: 'MICROAGULHAMENTO', esp: 'ESTÉTICA FACIAL', valor: 400 }, { nome: 'LIMPEZA DE PELE', esp: 'ESTÉTICA FACIAL', valor: 150 }, { nome: 'FIOS DE SUSTENTAÇÃO (PDO)', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2000 }, { nome: 'HARMONIZAÇÃO FACIAL', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2500 }, { nome: 'ESCLEROTERAPIA (VASINHOS)', esp: 'ESTÉTICA CORPORAL', valor: 350 }, { nome: 'INTRADERMOTERAPIA / MESOTERAPIA', esp: 'ESTÉTICA CORPORAL', valor: 400 }, { nome: 'LIPÓLISE ENZIMÁTICA', esp: 'ESTÉTICA CORPORAL', valor: 500 }, ]; // Popula o catálogo de biomedicina de uma clínica/consultório (idempotente: só se vazio). async function seedBiomedicina(clinicaId) { if (!clinicaId) return false; const { rows } = await pool.query('SELECT 1 FROM especialidades WHERE clinica_id = $1 LIMIT 1', [clinicaId]); if (rows.length) return false; const espId = {}; for (let i = 0; i < BIOMED_ESPECIALIDADES.length; i++) { const id = `esp_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`; await pool.query("INSERT INTO especialidades (id, nome, clinica_id, ordem, ativo, area) VALUES ($1,$2,$3,$4,true,'biomedicina')", [id, BIOMED_ESPECIALIDADES[i], clinicaId, i]); espId[BIOMED_ESPECIALIDADES[i]] = id; } for (let i = 0; i < BIOMED_PROCEDIMENTOS.length; i++) { const pr = BIOMED_PROCEDIMENTOS[i]; const id = `proc_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`; await pool.query( "INSERT INTO procedimentos (id, nome, especialidadeid, especialidadenome, valorparticular, clinica_id, ordem, ativo, area) VALUES ($1,$2,$3,$4,$5,$6,$7,true,'biomedicina')", [id, pr.nome, espId[pr.esp] || null, pr.esp, pr.valor, clinicaId, i]); } // marca a clínica como área de biomedicina await pool.query("INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,'biomedicina') ON CONFLICT DO NOTHING", [clinicaId]); return true; } // Catálogo geral de produtos de prótese (preço fixo, SEM especialidade — o protético é geral). // Garantia padrão de 12 meses por produto. const PROTESE_PRODUTOS_PADRAO = [ { nome: 'Coroa de Zircônia', preco: 350 }, { nome: 'Coroa Metalocerâmica', preco: 250 }, { nome: 'Coroa Provisória', preco: 80 }, { nome: 'Faceta / Laminado de Porcelana', preco: 400 }, { nome: 'Onlay / Inlay Cerâmico', preco: 300 }, { nome: 'Núcleo Metálico Fundido', preco: 90 }, { nome: 'Prótese Total (Dentadura)', preco: 600 }, { nome: 'Prótese Parcial Removível (PPR)', preco: 500 }, { nome: 'Coroa sobre Implante', preco: 600 }, { nome: 'Protocolo sobre Implante (arco)', preco: 1800 }, { nome: 'Placa de Bruxismo / Miorrelaxante',preco: 150 }, { nome: 'Provisório em Resina (unidade)', preco: 60 }, ]; // Idempotente: só popula se o protético ainda não tem nenhum produto. async function seedProteseProdutos(proteticoId) { if (!proteticoId) return false; const { rows } = await pool.query('SELECT 1 FROM protese_produtos WHERE protetico_id = $1 LIMIT 1', [proteticoId]); if (rows.length) return false; for (let i = 0; i < PROTESE_PRODUTOS_PADRAO.length; i++) { const p = PROTESE_PRODUTOS_PADRAO[i]; await pool.query( "INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,12,true,$5)", [`ppr_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, proteticoId, p.nome, p.preco, i]); } return true; } // --- REGISTRO DE USUÁRIOS --- app.post('/api/register', async (req, res) => { const { nome, email, senha, role, workspaceNome, especialidade } = req.body; if (!nome || !email || !senha) return res.status(400).json({ success: false, message: 'Nome, e-mail e senha são obrigatórios.' }); const VALID_ROLES = ['donoconsultorio', 'donoclinica', 'donosala', 'dentista', 'biomedico', 'protetico', 'paciente']; const userRole = VALID_ROLES.includes(role) ? role : 'donoclinica'; try { const existing = await pool.query('SELECT id FROM usuarios WHERE email = $1', [email.toLowerCase().trim()]); if (existing.rows.length > 0) return res.status(409).json({ success: false, message: 'E-mail já cadastrado.' }); const userId = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); const nomeUpper = nome.trim().toUpperCase(); // padrão do sistema: tudo em maiúsculas const esp = (Array.isArray(especialidade) ? especialidade[0] : especialidade) || null; await pool.query( 'INSERT INTO usuarios (id, nome, email, senha, role, especialidade) VALUES ($1, $2, $3, $4, $5, $6)', [userId, nomeUpper, email.toLowerCase().trim(), await hashSenha(senha), userRole, esp] ); // Fase 1 — workspace pessoal automático para profissionais e paciente. // (donoconsultorio/donoclinica criam a clínica-empresa no passo seguinte.) let workspaces = []; if (['dentista', 'biomedico', 'protetico', 'paciente'].includes(userRole)) { const wsId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; // Nome do estabelecimento informado pelo usuário; senão, default por papel // (evita a unidade nascer com o NOME DA PESSOA sem querer). const defaultNome = userRole === 'dentista' ? `CONSULTÓRIO DE ${nome.trim()}` : userRole === 'biomedico' ? `CONSULTÓRIO DE ${nome.trim()}` : userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}` : nome.trim(); const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase(); // Fase 2: o workspace do protético é um LABORATÓRIO (área de gestão de 1ª classe). const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal'; await pool.query( `INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, $3, $4)`, [wsId, wsNome, wsTipo, userId] ); await pool.query( 'INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)', [`v_${userId}_${wsId}`, userId, wsId, userRole] ); workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }]; // Biomédico já nasce com o catálogo de biomedicina estética no consultório dele. if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } } // Protético já nasce com o catálogo geral de produtos de prótese (preço fixo + garantia). if (userRole === 'protetico') { try { await seedProteseProdutos(userId); } catch (e) { console.error('[seedProteseProdutos]', e.message); } } } const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' }); console.log(`[REGISTER] New user: ${email}, role: ${userRole}, workspaces: ${workspaces.length}`); res.json({ success: true, token, user: { id: userId, nome: nomeUpper, email: email.toLowerCase().trim(), role: userRole }, workspaces, noWorkspace: workspaces.length === 0, userRole }); } catch (err) { console.error('[REGISTER]', err.message); res.status(500).json({ success: false, message: err.message }); } }); // --- CRIAÇÃO E GERENCIAMENTO DE CLÍNICAS --- app.post('/api/clinicas', async (req, res) => { const authHeader = req.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ success: false, message: 'Não autenticado.' }); let payload; try { payload = jwt.verify(authHeader.split(' ')[1], JWT_SECRET); } catch { return res.status(401).json({ success: false, message: 'Token inválido.' }); } const { nome_fantasia, documento } = req.body; if (!nome_fantasia) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' }); try { const { rows: userRows } = await pool.query('SELECT email, nome, role FROM usuarios WHERE id = $1', [payload.userId]); if (!userRows.length) return res.status(404).json({ success: false, message: 'Usuário não encontrado.' }); const { role: ownerRole } = userRows[0]; const vinculoRole = ['donoclinica', 'donoconsultorio'].includes(ownerRole) ? ownerRole : 'donoclinica'; const clinicaId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const tipoWs = vinculoRole === 'donoconsultorio' ? 'consultorio' : 'clinica'; await pool.query('INSERT INTO clinicas (id, nome_fantasia, documento, owner_id, tipo) VALUES ($1, $2, $3, $4, $5)', [clinicaId, nome_fantasia.toUpperCase(), documento || null, payload.userId, tipoWs]); const vinculoId = `v_${payload.userId}_${clinicaId}`; await pool.query('INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)', [vinculoId, payload.userId, clinicaId, vinculoRole]); console.log(`[CLINICA] Created: ${nome_fantasia} by ${userRows[0].email}`); res.json({ success: true, clinica: { id: clinicaId, nome_fantasia: nome_fantasia.toUpperCase() } }); } catch (err) { console.error('[POST /api/clinicas]', err.message); res.status(500).json({ success: false, message: err.message }); } }); app.get('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { const { rows } = await pool.query(` SELECT u.id, u.nome, u.email, v.role, v.setor_id, v.funcao_id, s.nome AS setor_nome, f.nome AS funcao_nome, f.nivel AS funcao_nivel FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id LEFT JOIN setores s ON s.id = v.setor_id LEFT JOIN funcoes f ON f.id = v.funcao_id WHERE v.clinica_id = $1 ORDER BY u.nome `, [req.params.clinicaId]); res.json({ success: true, membros: rows }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { email, role, setor_id, funcao_id, nome } = req.body; if (!email || !role) return res.status(400).json({ success: false, message: 'E-mail e função são obrigatórios.' }); try { // Hierarquia: só pode adicionar/atribuir cargo ABAIXO do seu próprio nível. const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId); if (reqNivel <= await nivelDaFuncao(funcao_id)) { return res.status(403).json({ success: false, message: 'Você só pode gerenciar cargos de nível inferior ao seu.' }); } // Provisiona a conta se o e-mail ainda não tem login (dentista/secretária/funcionário). const u = await ensureUsuarioPorEmail(email, nome, role); const vinculoId = `v_${u.id}_${req.params.clinicaId}`; await pool.query(` INSERT INTO vinculos (id, usuario_id, clinica_id, role, setor_id, funcao_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = $4, setor_id = $5, funcao_id = $6 `, [vinculoId, u.id, req.params.clinicaId, role, setor_id || null, funcao_id || null]); res.json({ success: true, membro: { id: u.id, nome: u.nome, email: String(email).toLowerCase().trim(), role }, created: u.created, tempPassword: u.tempPassword }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { role, setor_id, funcao_id } = req.body; try { const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId); const alvoNivel = await nivelNaClinica(req.params.userId, req.params.clinicaId); if (reqNivel <= alvoNivel || reqNivel <= await nivelDaFuncao(funcao_id)) { return res.status(403).json({ success: false, message: 'Você só pode gerenciar membros/cargos de nível inferior ao seu.' }); } await pool.query('UPDATE vinculos SET role = COALESCE($1, role), setor_id = $2, funcao_id = $3 WHERE usuario_id = $4 AND clinica_id = $5', [role || null, setor_id || null, funcao_id || null, req.params.userId, req.params.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // Reset de senha pelo dono/admin da clínica → gera nova senha temporária (resolve // contas criadas sem senha ou esquecidas). Alvo precisa ser membro desta unidade. app.post('/api/clinicas/:clinicaId/membros/:userId/reset-senha', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const clinicaId = req.params.clinicaId, alvo = req.params.userId, requester = req.authUser.userId; try { const { rows: tv } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [alvo, clinicaId]); if (!tv.length) return res.status(404).json({ success: false, message: 'Membro não encontrado nesta unidade.' }); // Hierarquia: só reseta a senha de quem está ABAIXO do seu nível. if (await nivelNaClinica(requester, clinicaId) <= await nivelNaClinica(alvo, clinicaId)) { return res.status(403).json({ success: false, message: 'Você só pode resetar a senha de membros de nível inferior ao seu.' }); } const { rows: u } = await pool.query('SELECT email FROM usuarios WHERE id = $1', [alvo]); if (!u.length) return res.status(404).json({ success: false, message: 'Usuário não encontrado.' }); const temp = gerarSenhaTemp(); await pool.query('UPDATE usuarios SET senha = $1, must_change_password = true WHERE id = $2', [await hashSenha(temp), alvo]); res.json({ success: true, email: u[0].email, tempPassword: temp }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.delete('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { if (await nivelNaClinica(req.authUser.userId, req.params.clinicaId) <= await nivelNaClinica(req.params.userId, req.params.clinicaId)) { return res.status(403).json({ success: false, message: 'Você só pode remover membros de nível inferior ao seu.' }); } await pool.query('DELETE FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [req.params.userId, req.params.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // --- SETORES (por clínica) --- app.get('/api/clinicas/:clinicaId/setores', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { const { rows } = await pool.query('SELECT id, nome, descricao, cor FROM setores WHERE clinica_id = $1 ORDER BY nome', [req.params.clinicaId]); res.json({ success: true, setores: rows }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/clinicas/:clinicaId/setores', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, cor } = req.body; if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' }); try { const id = _oid('set'); await pool.query('INSERT INTO setores (id, clinica_id, nome, descricao, cor) VALUES ($1,$2,$3,$4,$5)', [id, req.params.clinicaId, nome, descricao || null, cor || '#2563eb']); res.json({ success: true, id }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.put('/api/clinicas/:clinicaId/setores/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, cor } = req.body; try { await pool.query('UPDATE setores SET nome = COALESCE($1, nome), descricao = $2, cor = COALESCE($3, cor) WHERE id = $4 AND clinica_id = $5', [nome || null, descricao || null, cor || null, req.params.id, req.params.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/clinicas/:clinicaId/setores/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { await pool.query('UPDATE vinculos SET setor_id = NULL WHERE setor_id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); await pool.query('DELETE FROM setores WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // --- FUNÇÕES (por clínica) --- app.get('/api/clinicas/:clinicaId/funcoes', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { const { rows } = await pool.query('SELECT id, nome, descricao, permissoes, nivel FROM funcoes WHERE clinica_id = $1 ORDER BY nivel DESC, nome', [req.params.clinicaId]); res.json({ success: true, funcoes: rows }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/clinicas/:clinicaId/funcoes', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, permissoes, nivel } = req.body; if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' }); try { const id = _oid('fnc'); await pool.query('INSERT INTO funcoes (id, clinica_id, nome, descricao, permissoes, nivel) VALUES ($1,$2,$3,$4,$5,$6)', [id, req.params.clinicaId, nome, descricao || null, JSON.stringify(Array.isArray(permissoes) ? permissoes : []), Number.isFinite(nivel) ? nivel : 1]); res.json({ success: true, id }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.put('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { const { nome, descricao, permissoes, nivel } = req.body; try { await pool.query( `UPDATE funcoes SET nome = COALESCE($1, nome), descricao = $2, permissoes = COALESCE($3, permissoes), nivel = COALESCE($4, nivel) WHERE id = $5 AND clinica_id = $6`, [nome || null, descricao || null, permissoes !== undefined ? JSON.stringify(permissoes) : null, Number.isFinite(nivel) ? nivel : null, req.params.id, req.params.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { try { await pool.query('UPDATE vinculos SET funcao_id = NULL WHERE funcao_id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); await pool.query('DELETE FROM funcoes WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // --- CLINICAS --- app.put('/api/clinicas/:id/color', authGuard, async (req, res) => { const { cor } = req.body; if (!cor || !/^#[0-9a-fA-F]{6}$/.test(cor)) return res.status(400).json({ error: 'Cor inválida (use #RRGGBB).' }); // Só dono/admin da unidade altera a identidade visual. const { rows: _own } = await pool.query( `SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2 UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`, [req.params.id, req.authUser?.userId]); if (!_own.length) return res.status(403).json({ error: 'Apenas o dono/admin pode alterar a cor da unidade.' }); 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 }); } }); // Renomear a unidade (clínica/consultório/laboratório). Só o dono (ou vínculo dono/admin). app.put('/api/clinicas/:id/nome', authGuard, async (req, res) => { const nome = (req.body?.nome_fantasia || '').trim(); if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' }); try { const { rows } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [req.params.id]); if (!rows.length) return res.status(404).json({ success: false, message: 'Unidade não encontrada.' }); let allowed = rows[0].owner_id === req.authUser.userId; if (!allowed) { const { rows: v } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, req.params.id]); allowed = v.length > 0 && ['donoclinica', 'donoconsultorio', 'admin'].includes(v[0].role); } if (!allowed) return res.status(403).json({ success: false, message: 'Apenas o dono pode renomear a unidade.' }); const finalNome = nome.toUpperCase(); await pool.query('UPDATE clinicas SET nome_fantasia = $1 WHERE id = $2', [finalNome, req.params.id]); res.json({ success: true, nome: finalNome }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Perfil completo da unidade (leitura: qualquer membro; edição: só dono/admin). app.get('/api/clinicas/:clinicaId', tenantGuard, async (req, res) => { try { const { rows } = await pool.query( `SELECT id, nome_fantasia, documento, telefone, tipo, cor, logradouro, numero, bairro, cidade, estado, cep, owner_id FROM clinicas WHERE id = $1`, [req.params.clinicaId]); if (!rows.length) return res.status(404).json({ success: false, message: 'Unidade não encontrada.' }); res.json({ success: true, clinica: rows[0] }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.put('/api/clinicas/:clinicaId', authGuard, async (req, res) => { const COLS = ['nome_fantasia', 'documento', 'telefone', 'cor', 'logradouro', 'numero', 'bairro', 'cidade', 'estado', 'cep']; const UPPER = new Set(['nome_fantasia', 'documento', 'logradouro', 'bairro', 'cidade', 'estado']); try { const { rows } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [req.params.clinicaId]); if (!rows.length) return res.status(404).json({ success: false, message: 'Unidade não encontrada.' }); let allowed = rows[0].owner_id === req.authUser.userId; if (!allowed) { const { rows: v } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, req.params.clinicaId]); allowed = v.length > 0 && ['donoclinica', 'donoconsultorio', 'admin'].includes(v[0].role); } if (!allowed) return res.status(403).json({ success: false, message: 'Apenas o dono pode editar a unidade.' }); const b = req.body || {}; const keys = COLS.filter(c => b[c] !== undefined); if (!keys.length) return res.json({ success: true }); const val = (c) => (UPPER.has(c) && typeof b[c] === 'string') ? b[c].trim().toUpperCase() : b[c]; const set = keys.map((c, i) => `${c} = $${i + 1}`).join(', '); await pool.query(`UPDATE clinicas SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(val), req.params.clinicaId]); if (b.cep !== undefined) await setGeoByCep('clinicas', req.params.clinicaId, b.cep); res.json({ success: true, nome: b.nome_fantasia ? String(b.nome_fantasia).trim().toUpperCase() : undefined }); } catch (err) { res.status(500).json({ success: false, message: 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, role, senha, must_change_password FROM usuarios WHERE email = $1 AND (ativo IS NULL OR ativo = true)', [email.toLowerCase().trim()] ); const user = users[0]; if (!user || !(await verificarSenha(user.id, password, user.senha))) { console.log(`[LOGIN FAILED] Invalid credentials for: ${email}`); return res.status(401).json({ success: false, message: 'CREDENCIAIS INVÁLIDAS' }); } const userRole = user.role || 'dentista'; const mustChangePassword = !!user.must_change_password; const workspaces = await listarWorkspaces(user.id, userRole); const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '12h' }); if (workspaces.length === 0) { console.log(`[LOGIN NO-WORKSPACE] User: ${user.email}, role: ${userRole}`); res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email, role: userRole }, workspaces: [], noWorkspace: true, userRole, mustChangePassword }); return; } console.log(`[LOGIN SUCCESS] User: ${user.email}, Workspaces: ${workspaces.length}`); res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email, role: userRole }, workspaces, mustChangePassword }); } 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 // ============================================================================= let SHEETS_BACKUP_ID = process.env.SHEETS_BACKUP_ID || '1q1sf1GOji2u3qIJEToWfbLCb4rzb_pKLOX_AylMDT4g'; async function loadSheetsConfig() { try { const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_config'"); if (rows[0]) { const cfg = JSON.parse(rows[0].data); if (cfg.spreadsheetId) SHEETS_BACKUP_ID = cfg.spreadsheetId; } } catch {} } // 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, clinicaId) { // Multi-tenant: exporta SOMENTE os dados da clínica que está fazendo o backup // (todas as SYNC_TABLES têm coluna clinica_id). Sem isso, vazava tudo de todas. const q = clinicaId ? tableDef.query.replace(/ORDER BY/i, 'WHERE clinica_id = $1 ORDER BY') : tableDef.query; const { rows } = await pool.query(q, clinicaId ? [clinicaId] : []); 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)] ); } // Auto-push GLOBAL desativado: vazava dados de todas as clínicas para a planilha/token // do primeiro usuário. O backup agora é MANUAL e POR CLÍNICA (ver /api/clinica-sync/*). // Mantido como no-op para não quebrar as ~8 chamadas espalhadas nas rotas de escrita. function schedulePush(_tabName) { /* desativado — backup por clínica é manual */ } // ============================================================================= // BACKUP POR CLÍNICA → Google Sheets (planilha própria, na conta Google da unidade) // Cada workspace (clínica/consultório/laboratório/dentista) tem sua planilha, // criada automaticamente na conta Google conectada, com SÓ os dados daquela clínica. // ============================================================================= const CLINIC_SYNC_TABLES = [ { tab: 'pacientes', query: `SELECT id, nome, cpf, telefone, email, ultimotratamento AS "ultimoTratamento", convenio, datanascimento AS "dataNascimento" FROM pacientes WHERE clinica_id = $1 ORDER BY nome`, headers: ['id', 'nome', 'cpf', 'telefone', 'email', 'ultimoTratamento', 'convenio', 'dataNascimento'], upsert: `INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,ultimotratamento=EXCLUDED.ultimotratamento,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`, params: (r, cid) => [r.id, cid, r.nome, r.cpf, r.telefone, r.email, r.ultimoTratamento, r.convenio, r.dataNascimento], }, { tab: 'agendamentos', query: `SELECT id, pacientenome AS "pacienteNome", dentistaid AS "dentistaId", start_time::text, end_time::text, status, procedimento, observacoes FROM agendamentos WHERE clinica_id = $1 ORDER BY start_time DESC`, headers: ['id', 'pacienteNome', 'dentistaId', 'start_time', 'end_time', 'status', 'procedimento', 'observacoes'], upsert: `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 clinica_id=EXCLUDED.clinica_id,pacientenome=EXCLUDED.pacientenome,dentistaid=EXCLUDED.dentistaid,start_time=EXCLUDED.start_time,end_time=EXCLUDED.end_time,status=EXCLUDED.status,procedimento=EXCLUDED.procedimento,observacoes=EXCLUDED.observacoes`, params: (r, cid) => [r.id, cid, r.pacienteNome, r.dentistaId, r.start_time || null, r.end_time || null, r.status, r.procedimento, r.observacoes], }, { tab: 'financeiro', query: `SELECT id, pacientenome AS "pacienteNome", descricao, valor::text, datavencimento::text AS "dataVencimento", status, formapagamento AS "formaPagamento", tipo FROM financeiro WHERE clinica_id = $1 ORDER BY datavencimento DESC`, headers: ['id', 'pacienteNome', 'descricao', 'valor', 'dataVencimento', 'status', 'formaPagamento', 'tipo'], upsert: `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 clinica_id=EXCLUDED.clinica_id,pacientenome=EXCLUDED.pacientenome,descricao=EXCLUDED.descricao,valor=EXCLUDED.valor,datavencimento=EXCLUDED.datavencimento,status=EXCLUDED.status,formapagamento=EXCLUDED.formapagamento,tipo=EXCLUDED.tipo`, params: (r, cid) => [r.id, cid, r.pacienteNome, r.descricao, r.valor || null, r.dataVencimento || null, r.status, r.formaPagamento, r.tipo], }, { tab: 'leads', query: `SELECT id, nome, sobrenome, cpf, whatsapp, tipointeresse AS "tipoInteresse", datacadastro::text AS "dataCadastro", status FROM leads WHERE clinica_id = $1 ORDER BY datacadastro DESC`, headers: ['id', 'nome', 'sobrenome', 'cpf', 'whatsapp', 'tipoInteresse', 'dataCadastro', 'status'], upsert: `INSERT INTO leads (id,clinica_id,nome,sobrenome,cpf,whatsapp,tipointeresse,datacadastro,status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,sobrenome=EXCLUDED.sobrenome,cpf=EXCLUDED.cpf,whatsapp=EXCLUDED.whatsapp,tipointeresse=EXCLUDED.tipointeresse,status=EXCLUDED.status`, params: (r, cid) => [r.id, cid, r.nome, r.sobrenome, r.cpf, r.whatsapp, r.tipoInteresse, r.dataCadastro || null, r.status], }, ]; const clinicSheetUrl = (id) => `https://docs.google.com/spreadsheets/d/${id}`; async function getClinicSheetsClient(clinicaId) { const { rows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1 ORDER BY updated_at DESC NULLS LAST LIMIT 1', [clinicaId]); if (!rows.length) 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 ensureClinicSpreadsheet(clinicaId, sheets) { const { rows } = await pool.query('SELECT sheets_id, nome_fantasia FROM clinicas WHERE id = $1', [clinicaId]); if (!rows.length) throw new Error('CLINICA_NOT_FOUND'); let id = rows[0].sheets_id; if (id) { try { await sheets.spreadsheets.get({ spreadsheetId: id, fields: 'spreadsheetId' }); return { id, created: false }; } catch { id = null; } // sumiu/sem acesso → recria } const title = `ScoreOdonto — ${rows[0].nome_fantasia || 'Unidade'}`; const r = await sheets.spreadsheets.create({ requestBody: { properties: { title } } }); id = r.data.spreadsheetId; await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [id, clinicaId]); return { id, created: true }; } async function ensureClinicTab(sheets, spreadsheetId, tabName) { const meta = await sheets.spreadsheets.get({ spreadsheetId }); const ex = meta.data.sheets.find(s => s.properties.title.toLowerCase() === tabName.toLowerCase()); if (ex) return ex.properties.title; await sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] } }); return tabName; } async function pushClinicTable(def, sheets, spreadsheetId, clinicaId) { const { rows } = await pool.query(def.query, [clinicaId]); const tab = await ensureClinicTab(sheets, spreadsheetId, def.tab); await sheets.spreadsheets.values.clear({ spreadsheetId, range: `${tab}!A:ZZ` }); const values = [def.headers, ...rows.map(row => def.headers.map(h => { const v = row[h] ?? row[h.toLowerCase()]; return v == null ? '' : String(v); }))]; await sheets.spreadsheets.values.update({ spreadsheetId, range: `${tab}!A1`, valueInputOption: 'RAW', requestBody: { values } }); return rows.length; } async function pullClinicTable(def, sheets, spreadsheetId, clinicaId) { const tab = await ensureClinicTab(sheets, spreadsheetId, def.tab); const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A:ZZ` }); const sheetRows = resp.data.values || []; if (sheetRows.length < 2) return 0; const headers = sheetRows[0]; let n = 0; for (const row of sheetRows.slice(1)) { if (!row.some(c => c !== '')) continue; const obj = {}; headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; }); if (!obj.id) continue; try { await pool.query(def.upsert, def.params(obj, clinicaId)); n++; } catch (e) { console.error(`[CLINIC-SYNC pull] ${def.tab} ${obj.id}:`, e.message); } } return n; } async function saveClinicSyncStatus(clinicaId, data) { await pool.query(`INSERT INTO settings (id,category,data) VALUES ($1,'sync',$2) ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`, [`sync:${clinicaId}`, JSON.stringify(data)]); } app.get('/api/clinica-sync/status', tenantGuard, async (req, res) => { try { const clinicaId = req.query.clinicaId; if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' }); const { rows: cl } = await pool.query('SELECT sheets_id FROM clinicas WHERE id = $1', [clinicaId]); const { rows: tk } = await pool.query('SELECT owner_id FROM google_tokens WHERE clinica_id = $1 LIMIT 1', [clinicaId]); const counts = {}; for (const d of CLINIC_SYNC_TABLES) { const { rows } = await pool.query(`SELECT COUNT(*) n FROM ${d.tab} WHERE clinica_id = $1`, [clinicaId]); counts[d.tab] = parseInt(rows[0].n); } const { rows: st } = await pool.query('SELECT data FROM settings WHERE id = $1', [`sync:${clinicaId}`]); const sheetsId = cl[0]?.sheets_id || null; res.json({ success: true, connected: tk.length > 0, googleEmail: tk[0]?.owner_id || null, spreadsheetId: sheetsId, spreadsheetUrl: sheetsId ? clinicSheetUrl(sheetsId) : null, counts, lastSync: st[0] ? JSON.parse(st[0].data) : null }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/clinica-sync/push', tenantGuard, async (req, res) => { const clinicaId = req.body?.clinicaId || req.query.clinicaId; const logs = []; try { if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' }); const sheets = await getClinicSheetsClient(clinicaId); const { id, created } = await ensureClinicSpreadsheet(clinicaId, sheets); if (created) logs.push('[OK] PLANILHA CRIADA NO SEU GOOGLE DRIVE'); const counts = {}; for (const d of CLINIC_SYNC_TABLES) { try { const c = await pushClinicTable(d, sheets, id, clinicaId); logs.push(`[OK] ${d.tab.toUpperCase()}: ${c} REGISTROS`); counts[d.tab] = c; } catch (e) { logs.push(`[ERRO] ${d.tab}: ${e.message}`); } } await saveClinicSyncStatus(clinicaId, { lastPush: new Date().toISOString(), counts }); res.json({ success: true, spreadsheetUrl: clinicSheetUrl(id), logs }); } catch (err) { const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE (AGENDA → CONFIGURAÇÕES) ANTES DE FAZER BACKUP.' : err.message; res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] }); } }); app.post('/api/clinica-sync/pull', tenantGuard, async (req, res) => { const clinicaId = req.body?.clinicaId || req.query.clinicaId; const logs = []; try { if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' }); const sheets = await getClinicSheetsClient(clinicaId); const { rows: cl } = await pool.query('SELECT sheets_id FROM clinicas WHERE id = $1', [clinicaId]); if (!cl[0]?.sheets_id) return res.status(400).json({ success: false, message: 'Nenhuma planilha ainda. Faça um backup (enviar) primeiro.' }); const id = cl[0].sheets_id; for (const d of CLINIC_SYNC_TABLES) { try { const c = await pullClinicTable(d, sheets, id, clinicaId); logs.push(`[OK] ${d.tab.toUpperCase()}: ${c} IMPORTADOS`); } catch (e) { logs.push(`[ERRO] ${d.tab}: ${e.message}`); } } await saveClinicSyncStatus(clinicaId, { lastPull: new Date().toISOString() }); res.json({ success: true, logs }); } catch (err) { const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE.' : err.message; res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] }); } }); // --- IMPORT/EXPORT DE PACIENTES (CSV) --- // Normaliza cabeçalho (minúsculo, sem acento, só letras/números) e dicionário de sinônimos. const _normHdr = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]/g, ''); const PAC_FIELD_SYNS = { id: ['id'], nome: ['nome', 'name', 'paciente', 'nomecompleto', 'nomepaciente'], sobrenome: ['sobrenome', 'surname'], cpf: ['cpf', 'documento'], telefone: ['telefone', 'tefefoneprincipal', 'telefoneprincipal', 'whatsapp', 'celular', 'fone', 'contato', 'telefone1'], email: ['email'], convenio: ['convenio', 'credenciada', 'plano', 'planos', 'planosaude'], datanascimento: ['datanascimento', 'datanasci', 'nascimento', 'dtnasc', 'dn'], }; function mapPacHeaders(headers) { const norm = (headers || []).map(_normHdr); const map = {}; for (const [field, syns] of Object.entries(PAC_FIELD_SYNS)) { map[field] = norm.findIndex(h => syns.includes(h)); } return map; } // Acha a melhor aba "pacientes" (mais colunas casadas) e importa só os campos do sistema. async function smartImportPacientes(sheets, spreadsheetId, clinicaId) { const meta = await sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets.properties.title' }); const tabs = (meta.data.sheets || []).map(s => s.properties.title); const cands = tabs.filter(t => _normHdr(t).includes('paciente')); if (!cands.length) throw new Error('Nenhuma aba com "pacientes" no nome foi encontrada na planilha.'); let best = null; for (const tab of cands) { const r = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A1:ZZ1` }); const headers = (r.data.values || [])[0] || []; const map = mapPacHeaders(headers); const score = Object.values(map).filter(i => i >= 0).length; if (!best || score > best.score) best = { tab, headers, map, score }; } if (best.map.nome < 0) throw new Error(`Aba "${best.tab}": não encontrei a coluna NOME.`); const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${best.tab}!A:ZZ` }); const rows = resp.data.values || []; const get = (row, i) => (i >= 0 && i < row.length ? String(row[i] || '').trim() : ''); let imported = 0, skipped = 0; for (const row of rows.slice(1)) { if (!row.some(c => String(c || '').trim() !== '')) continue; let nome = get(row, best.map.nome); if (best.map.sobrenome >= 0) { const sn = get(row, best.map.sobrenome); if (sn) nome = `${nome} ${sn}`.trim(); } nome = nome.toUpperCase(); if (!nome) { skipped++; continue; } const cpf = get(row, best.map.cpf); const cpfDigits = cpf.replace(/\D/g, ''); let id = get(row, best.map.id) || cpfDigits || ('imp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7)); try { await pool.query( `INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,convenio,datanascimento) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`, [id.substring(0, 50), clinicaId, nome.substring(0, 255), cpf.substring(0, 20) || null, get(row, best.map.telefone).substring(0, 30) || null, get(row, best.map.email).substring(0, 150) || null, get(row, best.map.convenio).substring(0, 100) || null, get(row, best.map.datanascimento).substring(0, 20) || null] ); imported++; } catch { skipped++; } } const matched = Object.entries(best.map).filter(([, i]) => i >= 0).map(([f]) => f.toUpperCase()); return { tab: best.tab, matched, imported, skipped }; } // Importa pacientes de uma planilha EXISTENTE (ID/URL). NÃO altera o sheets_id nem a planilha fonte. app.post('/api/clinica-sync/import-pacientes', tenantGuard, async (req, res) => { const clinicaId = req.body?.clinicaId || req.query.clinicaId; let sheetsId = String(req.body?.sheetsId || '').trim(); const logs = []; try { if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' }); const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); if (m) sheetsId = m[1]; const sheets = await getClinicSheetsClient(clinicaId); try { await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'spreadsheetId' }); } catch { return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade.' }); } const r = await smartImportPacientes(sheets, sheetsId, clinicaId); logs.push(`[←] ABA "${r.tab}" — colunas casadas: ${r.matched.join(', ')}`); logs.push(`[✓] ${r.imported} PACIENTES IMPORTADOS${r.skipped ? ` (${r.skipped} ignorados)` : ''}`); res.json({ success: true, imported: r.imported, skipped: r.skipped, tab: r.tab, matched: r.matched, logs }); } catch (err) { const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message; res.status(400).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] }); } }); // Export CSV dos pacientes (o bem mais precioso). Sem "ultimo tratamento". Não precisa de Google. app.get('/api/pacientes/export', authGuard, requireCapability('agenda'), async (req, res) => { try { const clinicaId = req.query.clinicaId; if (!clinicaId) return res.status(400).send('clinicaId obrigatório'); const cols = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'datanascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes']; const header = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'dataNascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes']; const { rows } = await pool.query(`SELECT ${cols.join(',')} FROM pacientes WHERE clinica_id = $1 ORDER BY nome`, [clinicaId]); const esc = v => { v = v == null ? '' : String(v); return /[",\n;]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }; const csv = '' + [header.join(',')].concat(rows.map(r => cols.map(c => esc(r[c])).join(','))).join('\n'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="pacientes.csv"`); res.send(csv); } catch (err) { res.status(500).send('erro ao exportar'); } }); // Define a planilha (ID/URL) a ser usada por esta clínica → habilita IMPORTAR de uma // planilha EXISTENTE (ex.: migração de pacientes). Valida o acesso com a conta da unidade. app.post('/api/clinica-sync/set-id', tenantGuard, async (req, res) => { const clinicaId = req.body?.clinicaId || req.query.clinicaId; let sheetsId = String(req.body?.sheetsId || '').trim(); try { if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' }); const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); // aceita URL completa if (m) sheetsId = m[1]; const sheets = await getClinicSheetsClient(clinicaId); let title, abas = []; try { const meta = await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'properties.title,sheets.properties.title' }); title = meta.data.properties.title; abas = (meta.data.sheets || []).map(s => s.properties.title); } catch { return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade (' + sheetsId + ').' }); } await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [sheetsId, clinicaId]); res.json({ success: true, spreadsheetId: sheetsId, spreadsheetUrl: clinicSheetUrl(sheetsId), title, abas }); } catch (err) { const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message; res.status(400).json({ success: false, message: msg }); } }); // --- 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, clinicaId, ownerId } = req.query; const resolvedOwner = ownerId || dentistId || 'admin'; const resolvedClinica = clinicaId || ''; const oauth2Client = createOAuth2Client(); const url = oauth2Client.generateAuthUrl({ access_type: 'offline', scope: [ 'https://www.googleapis.com/auth/userinfo.email', // leitura+escrita de EVENTOS (criar/editar/convidar) → backup + notificação 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/spreadsheets' ], state: `${resolvedOwner}|${resolvedClinica}`, 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); // Decodifica defensivamente (o '|' pode voltar como %7C / %257C no round-trip do Google), // senão o split falha e o owner_id fica colado com a clínica (clinica_id nulo). let rawState = String(state || 'admin|'); for (let i = 0; i < 2 && /%[0-9a-f]{2}/i.test(rawState); i++) { try { rawState = decodeURIComponent(rawState); } catch { break; } } let [rawOwner, clinicaId] = rawState.split('|'); let ownerId = rawOwner || 'admin'; // Salvaguarda: nunca grava owner colado com a clínica. if (ownerId.includes('|')) { const pp = ownerId.split('|'); ownerId = pp[0]; clinicaId = clinicaId || pp[1] || null; } 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, clinica_id, refresh_token, access_token, expiry_date) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (owner_id) DO UPDATE SET clinica_id = EXCLUDED.clinica_id, refresh_token = EXCLUDED.refresh_token, access_token = EXCLUDED.access_token, expiry_date = EXCLUDED.expiry_date `, [ownerId, clinicaId || null, tokens.refresh_token, tokens.access_token, tokens.expiry_date]); // Conta-clínica: UMA por clínica. Ao (re)conectar uma conta de e-mail, remove // e-mails antigos desta mesma clínica (evita acúmulo/confusão de várias contas). if (clinicaId && String(ownerId).includes('@')) { await pool.query("DELETE FROM google_tokens WHERE clinica_id = $1 AND owner_id LIKE '%@%' AND owner_id <> $2", [clinicaId, ownerId]); } res.send(`

Agenda Conectada!

O Google Agenda foi integrado com sucesso.

Você já pode fechar esta janela.