Files
scoreodonto.com/backend/server.js
T
VPS 4 Builder f084656712 feat(agenda): auditoria/autoria, faltas, a-reagendar, presença + RBAC por cargo
Agenda (multi-secretária):
- audit_log genérico + autoria (created/updated/canceled_by) + soft-delete + version (lock otimista)
- status novo vocabulário + índice único PARCIAL (libera slot ao cancelar/faltar)
- endpoints: falta, remarcar, restaurar, historico, pendencias (a reagendar), faltas, familia
- presença: heartbeat (DB última visita + Dragonfly ao vivo, janela 45s)
- frontend: StackModal, AgendaDetailModal, AgendaPresence; lista a-reagendar; pacienteId no fluxo de criação; chips de família + badge de faltas no modal

RBAC por cargo: funcoes.permissoes/nivel; gate de menu por cargo (array vazio herda role); hierarquia só-gerencia-abaixo no backend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:10:58 +02:00

4170 lines
213 KiB
JavaScript

import 'dotenv/config';
import express from 'express';
import pg from 'pg';
import Redis from 'ioredis';
import cors from 'cors';
import { google } from 'googleapis';
import jwt from 'jsonwebtoken';
import multer from 'multer';
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
// 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 && !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
// =============================================================================
// 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() }));
// =============================================================================
// 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;
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.' });
}
}
// =============================================================================
// 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.
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 || {})]
);
}
// 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); }
}
// --- REGISTRO DE USUÁRIOS ---
app.post('/api/register', async (req, res) => {
const { nome, email, senha, role, workspaceNome } = 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', 'dentista', '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
await pool.query(
'INSERT INTO usuarios (id, nome, email, senha, role) VALUES ($1, $2, $3, $4, $5)',
[userId, nomeUpper, email.toLowerCase().trim(), await hashSenha(senha), userRole]
);
// Fase 1 — workspace pessoal automático para profissionais e paciente.
// (donoconsultorio/donoclinica criam a clínica-empresa no passo seguinte.)
let workspaces = [];
if (['dentista', '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 === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}`
: nome.trim();
const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase();
await pool.query(
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, 'pessoal', $3)`,
[wsId, wsNome, 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 }];
}
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, 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, 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, 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', authGuard, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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', 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 }); }
});
// 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]);
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 { rows: workspaces } = await pool.query(`
SELECT v.clinica_id as id, c.nome_fantasia as nome, c.cor, v.role,
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
`, [user.id]);
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) {
const { rows } = await pool.query(tableDef.query);
const realTab = await ensureSheetTab(sheets, tableDef.tab);
await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
const sheetRows = [tableDef.headers, ...rows.map(row =>
tableDef.headers.map(h => {
const v = row[h] ?? row[h.toLowerCase()];
return v === null || v === undefined ? '' : String(v);
})
)];
await sheets.spreadsheets.values.update({
spreadsheetId: SHEETS_BACKUP_ID,
range: `${realTab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: sheetRows }
});
return rows.length;
}
async function pullTableFromSheet(tableDef, sheets) {
const realTab = await ensureSheetTab(sheets, tableDef.tab);
const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
const sheetRows = resp.data.values || [];
if (sheetRows.length < 2) return 0;
const headers = sheetRows[0];
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c !== ''));
let upserted = 0;
for (const row of dataRows) {
const obj = {};
headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
if (!obj.id) continue;
try {
await pool.query(tableDef.upsertSql, tableDef.upsertParams(obj));
upserted++;
} catch (e) {
console.error(`[SHEETS PULL] ${tableDef.tab} row ${obj.id}:`, e.message);
}
}
return upserted;
}
async function saveSyncStatus(updates) {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
const current = rows[0] ? JSON.parse(rows[0].data) : {};
const merged = { ...current, ...updates };
await pool.query(
`INSERT INTO settings (id,category,data) VALUES ('sheets_sync_status','sync',$1)
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
[JSON.stringify(merged)]
);
}
// 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}`] });
}
});
// --- 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',
'https://www.googleapis.com/auth/calendar.readonly',
'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);
const [rawOwner, clinicaId] = (state || 'admin|').split('|');
let ownerId = rawOwner || '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, 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(`<div style="font-family:sans-serif;text-align:center;padding-top:50px">
<h1 style="color:#10b981">Agenda Conectada!</h1>
<p>O Google Agenda foi integrado com sucesso.</p>
<p>Você já pode fechar esta janela.</p>
<script>setTimeout(() => window.close(), 3000);<\/script></div>`);
} catch (err) {
console.error('OAuth Error:', err.message);
res.status(500).send('Erro na autorização com o Google.');
}
});
app.get('/api/auth/google/status', async (req, res) => {
try {
const { clinicaId } = req.query;
const sql = clinicaId
? 'SELECT owner_id, clinica_id, updated_at FROM google_tokens WHERE clinica_id = $1'
: 'SELECT owner_id, clinica_id, updated_at FROM google_tokens';
const { rows } = await pool.query(sql, clinicaId ? [clinicaId] : []);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/auth/google/:ownerId', async (req, res) => {
try {
await pool.query('DELETE FROM google_tokens WHERE owner_id = $1', [req.params.ownerId]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- SETTINGS ---
app.get('/api/settings', async (req, res) => {
try {
// Escopo por workspace/clínica: cada clínica tem suas próprias configs
// (clinicEmail, etc.). 'main' é só o fallback legado/global.
const id = req.query.clinicaId || 'main';
const { rows } = await pool.query('SELECT data FROM settings WHERE id = $1', [id]);
res.json(rows[0] ? JSON.parse(rows[0].data) : {});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/settings', async (req, res) => {
try {
const id = req.query.clinicaId || 'main';
const data = JSON.stringify(req.body);
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ($1, 'general', $2)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[id, data]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/settings/google-credentials — returns configured status + masked clientId only
app.get('/api/settings/google-credentials', tenantGuard, async (req, res) => {
const fromEnv = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const configured = !!(googleCreds.clientId && googleCreds.clientSecret);
const clientIdHint = googleCreds.clientId
? googleCreds.clientId.substring(0, 12) + '...' + googleCreds.clientId.slice(-8)
: null;
res.json({ configured, clientIdHint, fromEnv });
});
// POST /api/settings/google-credentials — save to DB + update in-memory cache
app.post('/api/settings/google-credentials', tenantGuard, async (req, res) => {
const { clientId, clientSecret } = req.body;
if (!clientId || !clientSecret) return res.status(400).json({ error: 'clientId e clientSecret são obrigatórios.' });
try {
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('google_credentials', 'oauth', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[JSON.stringify({ clientId, clientSecret })]
);
// Update in-memory cache immediately (no restart needed)
googleCreds.clientId = clientId;
googleCreds.clientSecret = clientSecret;
console.log('[GOOGLE] Credentials updated via UI');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- MAGIC LINK & DENTIST SELF-REGISTRATION ---
app.post('/api/dentistas/magic-link', async (req, res) => {
const { clinicaId, dentistName } = req.body;
try {
const token = Math.random().toString(36).substring(2, 15);
const appUrl = process.env.APP_URL || `http://localhost:${process.env.FRONTEND_PORT || 3000}`;
const magicLink = `${appUrl}/cadastro-dentista?token=${token}&clinica=${clinicaId}&nome=${encodeURIComponent(dentistName)}`;
res.json({ success: true, link: magicLink });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas/register', async (req, res) => {
const { id, nome, email, senha, cro, cro_uf, celular, especialidade, clinicaId } = req.body;
try {
await withTransaction(async (client) => {
// 1. Create or update User
await client.query(`
INSERT INTO usuarios (id, nome, email, senha, cro, cro_uf, celular, especialidade)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf,
celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade
`, [id, nome, email, senha, cro, cro_uf, celular, especialidade]);
// 2. Create or update link to clinic
const vinculoId = `v_${id}_${clinicaId}`;
await client.query(`
INSERT INTO vinculos (id, usuario_id, clinica_id, role)
VALUES ($1, $2, $3, 'dentista')
ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = 'dentista'
`, [vinculoId, id, clinicaId]);
// 3. Create or update Dentist record for agenda
await client.query(`
INSERT INTO dentistas (id, clinica_id, nome, email, telefone, especialidade, coragenda, usuario_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $1)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade, usuario_id = EXCLUDED.usuario_id
`, [id, clinicaId, nome, email, celular, especialidade, '#2563eb']);
});
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/google/events', async (req, res) => {
try {
const { clinicaId } = req.query;
// Sem clínica definida não dá pra escopar → não vaza agenda de outras contas.
if (!clinicaId) return res.json([]);
// Tokens só desta clínica (cada conta Google é vinculada à sua clínica).
const { rows: tokenRows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1', [clinicaId]);
const allEvents = [];
const now = new Date();
const timeMin = now.toISOString();
for (const tokenData of tokenRows) {
try {
const oauth2Client = createOAuth2Client();
oauth2Client.setCredentials({
refresh_token: tokenData.refresh_token,
access_token: tokenData.access_token,
expiry_date: tokenData.expiry_date
});
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.list({
calendarId: 'primary',
timeMin: timeMin,
singleEvents: true,
orderBy: 'startTime',
maxResults: 100
});
if (response.data.items) {
// Rótulo amigável: se o dono do token for um dentista, usa o nome dele;
// se for a conta da clínica (owner_id = e-mail), usa o NOME DA CLÍNICA
// (nunca o e-mail/identificador cru, que poluía todos os eventos).
let ownerName = 'AGENDA GOOGLE';
const { rows: dent } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [tokenData.owner_id]);
if (dent[0]) {
ownerName = dent[0].nome;
} else if (tokenData.clinica_id) {
const { rows: cl } = await pool.query('SELECT nome_fantasia FROM clinicas WHERE id = $1', [tokenData.clinica_id]);
if (cl[0]?.nome_fantasia) ownerName = cl[0].nome_fantasia;
}
const mapped = response.data.items.map(item => ({
id: `google_${item.id}`,
title: `[${ownerName}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date,
end: item.end.dateTime || item.end.date,
isGoogle: true,
owner: ownerName,
backgroundColor: '#3b82f6',
borderColor: '#3b82f6'
}));
allEvents.push(...mapped);
}
} catch (err) {
console.error(`Error fetching calendar for ${tokenData.owner_id}:`, err.message);
}
}
// 2. Legacy Support: agendas públicas (API KEY) — configs DESTA clínica (não globais).
const { rows: settingsRows } = await pool.query('SELECT data FROM settings WHERE id = $1', [clinicaId]);
if (settingsRows[0]) {
const settings = JSON.parse(settingsRows[0].data);
const apiKey = settings.googleApiKey;
if (apiKey) {
const legacyIds = [];
if (settings.clinicCalendarId) legacyIds.push({ id: settings.clinicCalendarId, owner: 'CLÍNICA' });
// We only add dentists that DON'T have a token yet to avoid duplication
if (settings.googleCalendarIds) {
for (const [dentistId, calId] of Object.entries(settings.googleCalendarIds)) {
if (calId && !tokenRows.find(t => t.owner_id === dentistId)) {
const { rows: dName } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [dentistId]);
legacyIds.push({ id: calId, owner: dName[0]?.nome || 'DENTISTA' });
}
}
}
for (const { id, owner } of legacyIds) {
try {
const cleanId = id.trim().toLowerCase();
const response = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(cleanId)}/events?key=${apiKey}&timeMin=${timeMin}&singleEvents=true&orderBy=startTime`);
const data = await response.json();
if (data.items) {
const mapped = data.items.map(item => ({
id: `google_legacy_${item.id}`,
title: `[${owner}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date,
end: item.end.dateTime || item.end.date,
isGoogle: true,
owner: owner,
backgroundColor: owner === 'CLÍNICA' ? '#10b981' : '#6b7280',
borderColor: owner === 'CLÍNICA' ? '#10b981' : '#6b7280'
}));
allEvents.push(...mapped);
}
} catch (err) { console.error(`Legacy API Error for ${id}:`, err.message); }
}
}
}
res.json(allEvents);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- DASHBOARD STATS ---
app.get('/api/dashboard/stats', async (req, res) => {
try {
const cacheKey = 'dashboard:stats';
if (redis) {
const cachedStats = await redis.get(cacheKey);
if (cachedStats) {
console.log('[CACHE HIT] Serving dashboard stats from DragonflyDB');
return res.json(JSON.parse(cachedStats));
}
}
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos');
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads');
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'RECEITA' OR tipo IS NULL");
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA'");
// Growth data (last 6 months)
const { rows: growth } = await pool.query(`
SELECT
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
FROM financeiro
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
ORDER BY mes ASC
`);
// Recent appointments with patient and dentist names
const { rows: recent } = await pool.query(`
SELECT a.id, a.pacientenome, a.start_time, a.status,
d.nome as "dentistaNome"
FROM agendamentos a
LEFT JOIN dentistas d ON a.dentistaid = d.id
ORDER BY a.start_time DESC
LIMIT 5
`);
const responseData = {
stats: {
totalPacientes: parseInt(pacientes[0].count),
totalAgendamentos: parseInt(agendamentos[0].count),
totalLeads: parseInt(leads[0].count),
faturamentoTotal: parseFloat(faturamento[0].total),
despesasTotal: parseFloat(pendente[0].total)
},
growth: growth,
recentAgendamentos: recent
};
if (redis) {
await redis.setex(cacheKey, 60, JSON.stringify(responseData)); // Cache for 60s
console.log('[CACHE MISS] Cached dashboard stats in DragonflyDB');
}
res.json(responseData);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- NOTIFICACOES ---
app.get('/api/notificacoes', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM notificacoes ORDER BY data DESC LIMIT 100');
res.json(rows.map(n => ({ ...n, lida: !!n.lida })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/notificacoes/read-all', async (req, res) => {
try {
await pool.query('UPDATE notificacoes SET lida = true WHERE lida = false');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/notificacoes/:id', async (req, res) => {
try {
await pool.query('DELETE FROM notificacoes WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/notificacoes/:id/read', async (req, res) => {
try {
await pool.query('UPDATE notificacoes SET lida = true WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PACIENTES ---
app.get('/api/pacientes', authGuard, async (req, res) => {
try {
const { q, sort, clinicaId } = req.query;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const orderMap = { az: 'p.nome ASC', za: 'p.nome DESC', convenio: 'p.convenio ASC NULLS LAST, p.nome ASC', recentes: 'p.nome ASC' };
const orderBy = orderMap[sort] || 'p.nome ASC';
const base = `SELECT p.id, p.nome, p.cpf, p.telefone, p.email,
p.ultimotratamento AS "ultimoTratamento",
p.convenio, p.datanascimento AS "dataNascimento",
p.grupofamiliarid AS "grupoFamiliarId",
pf.nome AS "grupoFamiliarNome",
p.grupofamiliarrelacao AS "grupoFamiliarRelacao",
p.indicadorporid AS "indicadoPorId",
pi.nome AS "indicadoPorNome"
FROM pacientes p
LEFT JOIN pacientes pf ON pf.id = p.grupofamiliarid
LEFT JOIN pacientes pi ON pi.id = p.indicadorporid
WHERE p.clinica_id = $1`;
let text, params;
if (q && q.trim()) {
const raw = q.trim();
const term = `%${raw.toUpperCase()}%`;
const digits = raw.replace(/\D/g, '');
const digitsLike = digits ? `%${digits}%` : null;
// Busca por NOME, CPF ou TELEFONE — com ou sem formatação (pontos/traços/parênteses).
text = `${base} AND (
UPPER(p.nome) LIKE $2
OR p.cpf LIKE $2
OR p.telefone LIKE $2
OR ($3::text IS NOT NULL AND regexp_replace(COALESCE(p.cpf,''), '\\D', '', 'g') LIKE $3)
OR ($3::text IS NOT NULL AND regexp_replace(COALESCE(p.telefone,''), '\\D', '', 'g') LIKE $3)
) ORDER BY ${orderBy} LIMIT 100`;
params = [clinicaId, term, digitsLike];
} else {
text = `${base} ORDER BY ${orderBy} LIMIT 50`;
params = [clinicaId];
}
const { rows } = await pool.query(text, params);
const { rows: total } = await pool.query('SELECT COUNT(*) as total FROM pacientes WHERE clinica_id = $1', [clinicaId]);
res.json({ data: rows, total: parseInt(total[0].total) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/pacientes', authGuard, async (req, res) => {
try {
const clinicaId = req.body.clinica_id || req.query.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório.' });
const p = { ...req.body, clinica_id: clinicaId };
const ins = buildInsert('pacientes', p);
await pool.query(ins.text, ins.values);
schedulePush('pacientes');
res.json({ success: true, data: p });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/pacientes/:id', authGuard, async (req, res) => {
try {
const upd = buildUpdate('pacientes', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
schedulePush('pacientes');
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- DENTISTAS ---
app.get('/api/dentistas', async (req, res) => {
try {
const { clinicaId } = req.query;
let query = 'SELECT * FROM dentistas';
const params = [];
if (clinicaId) {
query += ' WHERE clinica_id = $1';
params.push(clinicaId);
}
query += ' ORDER BY ordem ASC, nome ASC';
const { rows } = await pool.query(query, params);
res.json(rows.map(d => ({ ...d, ativo: !!d.ativo, corAgenda: d.coragenda })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas', async (req, res) => {
try {
const d = req.body;
let tempPassword = null, contaCriada = false;
// Dentista com e-mail: provisiona a conta (se não existir) e vincula à clínica,
// pra ele ter acesso à agenda. Conta "free" — só vínculo com a clínica.
if (d.email && !d.usuario_id) {
const u = await ensureUsuarioPorEmail(d.email, d.nome, 'dentista');
d.usuario_id = u.id;
tempPassword = u.tempPassword;
contaCriada = u.created;
if (d.clinica_id) {
await pool.query(
`INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'dentista')
ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = 'dentista'`,
[`v_${u.id}_${d.clinica_id}`, u.id, d.clinica_id]
);
}
}
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM dentistas');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('dentistas', { ...d, ordem });
await pool.query(ins.text, ins.values);
res.json({ success: true, tempPassword, contaCriada });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/dentistas/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE dentistas SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/dentistas/:id', async (req, res) => {
try {
const upd = buildUpdate('dentistas', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/:id', async (req, res) => {
try {
const { id } = req.params;
await withTransaction(async (client) => {
// 1. Remove Google Tokens associated with this dentist
await client.query('DELETE FROM google_tokens WHERE owner_id = $1', [id]);
// 2. Remove clinical links (if any)
await client.query('DELETE FROM vinculos WHERE usuario_id = $1', [id]);
// 3. Set to NULL in agendamentos if it exists
await client.query('UPDATE agendamentos SET dentistaid = NULL WHERE dentistaid = $1', [id]);
// 4. Delete from dentistas table
await client.query('DELETE FROM dentistas WHERE id = $1', [id]);
});
res.json({ success: true });
} catch (err) {
console.error('Error deleting dentist:', err);
res.status(500).json({ error: err.message });
}
});
// --- GESTÃO DE HORÁRIOS ---
app.get('/api/dentistas/:dentistaId/horarios', async (req, res) => {
try {
const { dentistaId } = req.params;
const { clinicaId } = req.query;
let query = 'SELECT * FROM dentistas_horarios WHERE dentista_id = $1';
const params = [dentistaId];
if (clinicaId) {
query += ' AND clinica_id = $2';
params.push(clinicaId);
}
query += ' ORDER BY dia_semana ASC, hora_inicio ASC';
const { rows } = await pool.query(query, params);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas/horarios', async (req, res) => {
try {
const { id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo } = req.body;
const horaId = id || `h_${Math.random().toString(36).substring(2, 9)}`;
await pool.query(`
INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio,
hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo
`, [horaId, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo ?? 1]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/horarios/:id', async (req, res) => {
try {
await pool.query('DELETE FROM dentistas_horarios WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- RELATÓRIOS ---
app.get('/api/reports/productivity', async (req, res) => {
try {
const { clinicaId, start, end } = req.query;
if (!clinicaId) return res.status(400).json({ error: "Missing clinicaId" });
// Financial Totals
const { rows: finRows } = await pool.query(`
SELECT
SUM(CASE WHEN tipo = 'RECEITA' AND status = 'Pago' THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' AND status = 'Pago' THEN valor ELSE 0 END) as despesa,
COUNT(*) as totalTransacoes
FROM financeiro
WHERE clinica_id = $1 AND datavencimento BETWEEN $2 AND $3
`, [clinicaId, start || '2000-01-01', end || '2100-01-01']);
// Appointments by Dentist
const { rows: agRows } = await pool.query(`
SELECT
d.nome as dentista,
COUNT(a.id) as totalAgendamentos,
SUM(CASE WHEN a.status = 'Concluido' THEN 1 ELSE 0 END) as concluidos,
SUM(CASE WHEN a.status = 'Faltou' THEN 1 ELSE 0 END) as faltas
FROM dentistas d
LEFT JOIN agendamentos a ON d.id = a.dentistaid AND a.start_time BETWEEN $1 AND $2
WHERE d.clinica_id = $3
GROUP BY d.id, d.nome
`, [start || '2000-01-01', end || '2100-01-01', clinicaId]);
res.json({
financeiro: finRows[0],
producao: agRows
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- AGENDAMENTOS ---
app.get('/api/agendamentos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM agendamentos');
res.json(rows.map(r => ({
...r,
dentistaId: r.dentistaid,
pacienteNome: r.pacientenome,
pacienteCelular: r.pacientecelular,
start: r.start_time,
end: r.end_time
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST with concurrency lock: prevents double-booking the same dentist+time slot
app.post('/api/agendamentos', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { start, end, ...rest } = req.body;
const { dentistaId } = rest;
const id = rest.id || `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
await withTransaction(async (client) => {
// Fase 3 — anti-overbooking GLOBAL. Resolve a identidade do profissional
// (dentistas.usuario_id) e bloqueia SOBREPOSIÇÃO de horário em QUALQUER
// clínica → garante "não pode estar em 2 lugares ao mesmo tempo".
// Ignora status INATIVOS (cancelado/falta/remarcar), que liberam o slot.
if (dentistaId && start) {
let dentistaIds = [dentistaId];
const { rows: dr } = await client.query('SELECT usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
const profUid = dr[0]?.usuario_id || null;
if (profUid) {
const { rows: ids } = await client.query('SELECT id FROM dentistas WHERE usuario_id = $1', [profUid]);
if (ids.length) dentistaIds = ids.map(r => r.id);
}
const fim = end || start;
// sobreposição de intervalos: existente.start < novo.end AND existente.end > novo.start
const { rows: conflict } = await client.query(
`SELECT id, clinica_id, start_time::text AS start_time, end_time::text AS end_time
FROM agendamentos
WHERE dentistaid = ANY($1)
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
AND start_time < $3
AND COALESCE(end_time, start_time) > $2
LIMIT 1 FOR UPDATE`,
[dentistaIds, start, fim]
);
if (conflict.length > 0) {
throw { code: 'CONF_OVERLAP', conflito: conflict[0] };
}
}
// Map keys from camelCase/old attributes to correct lowercase PG columns if necessary
const payload = {
id,
pacientenome: rest.pacienteNome,
pacientecelular: rest.pacienteCelular,
pacienteid: rest.pacienteId,
dentistaid: rest.dentistaId,
start_time: start,
end_time: end,
procedimento: rest.procedimento,
observacoes: rest.observacoes,
status: rest.status || 'agendado',
clinica_id: rest.clinica_id,
created_by: actor,
created_by_nome: actorNm,
version: 1
};
const insertQuery = buildInsert('agendamentos', payload);
await client.query(insertQuery.text, insertQuery.values);
await registrarAudit(client, {
clinicaId: rest.clinica_id, entidade: 'agendamento', entidadeId: id, acao: 'criou',
actorId: actor, actorNome: actorNm,
detalhes: { paciente: rest.pacienteNome, start, end, procedimento: rest.procedimento }
});
// Um novo agendamento FUTURO resolve as pendências "a reagendar" do paciente.
if (rest.pacienteId && start && new Date(start) >= new Date()) {
await resolverPendenciasPaciente(client, rest.clinica_id, rest.pacienteId, { by: actor, byNome: actorNm, resolucao: 'reagendado' });
}
});
schedulePush('agendamentos');
res.json({ success: true, id });
} catch (err) {
if (err.code === 'CONF_OVERLAP') {
return res.status(409).json({
success: false,
conflito: true,
podeRegistrarInteresse: true, // gancho p/ Fase 4 (interesse/fila)
ocupado: err.conflito, // { id, clinica_id, start_time, end_time }
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). REGISTRE INTERESSE OU ESCOLHA OUTRO HORÁRIO.'
});
}
if (err.code === 'CONF_ERROR') {
return res.status(409).json({ success: false, message: err.message });
}
if (err.code === '23505') {
return res.status(409).json({
success: false,
message: 'ESTE HORÁRIO ACABOU DE SER RESERVADO POR OUTRA PESSOA. POR FAVOR, ESCOLHA OUTRO HORÁRIO.'
});
}
res.status(500).json({ error: err.message });
}
});
// PUT = editar / REAGENDAR (mover pra novo slot). Lock otimista por `version`.
app.put('/api/agendamentos/:id', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { start, end, version, ...rest } = req.body;
const { rows: cur } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!cur.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const before = cur[0];
// Lock otimista: se o cliente mandou version e ela não bate, alguém editou antes.
if (version != null && Number(version) !== Number(before.version || 1)) {
return res.status(409).json({ success: false, stale: true, message: 'ESTE AGENDAMENTO FOI ALTERADO POR OUTRA PESSOA. RECARREGUE PARA VER A VERSÃO ATUAL.' });
}
const updateData = {};
if (start) updateData.start_time = start;
if (end) updateData.end_time = end;
if (rest.status) updateData.status = rest.status;
if (rest.observacoes !== undefined) updateData.observacoes = rest.observacoes;
if (rest.procedimento) updateData.procedimento = rest.procedimento;
if (rest.dentistaId) updateData.dentistaid = rest.dentistaId;
if (rest.pacienteNome) updateData.pacientenome = rest.pacienteNome;
if (rest.pacienteId) updateData.pacienteid = rest.pacienteId;
updateData.updated_by = actor;
updateData.updated_by_nome = actorNm;
updateData.updated_at = new Date().toISOString();
updateData.version = Number(before.version || 1) + 1;
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
if (updateQuery) await pool.query(updateQuery.text, updateQuery.values);
// remarcou (mudou horário) vs editou (outros campos)
const movido = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time));
await registrarAudit(pool, {
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
detalhes: movido
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
});
schedulePush('agendamentos');
res.json({ success: true, version: updateData.version });
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({
success: false,
message: 'ESTE HORÁRIO JÁ ESTÁ OCUPADO PARA ESTE DENTISTA.'
});
}
res.status(500).json({ error: err.message });
}
});
// DELETE = cancelamento SOFT (status 'cancelado'). NUNCA remove a linha — preserva
// histórico/autoria ("quem cancelou") e gera pendência "a reagendar".
app.delete('/api/agendamentos/:id', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!ag.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const a = ag[0];
await pool.query(
`UPDATE agendamentos SET status='cancelado', canceled_by=$2, canceled_by_nome=$3, canceled_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`,
[req.params.id, actor, actorNm]);
await registrarAudit(pool, {
clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'cancelou',
actorId: actor, actorNome: actorNm, detalhes: { motivo: req.body?.motivo || null, start: a.start_time }
});
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'cancelado' });
await notificarInteressesSlotLivre(a);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Marcar FALTA (no-show) → contabiliza falta do paciente + pendência "a reagendar".
app.post('/api/agendamentos/:id/falta', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!ag.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const a = ag[0];
await pool.query(`UPDATE agendamentos SET status='falta', updated_by=$2, updated_by_nome=$3, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`, [req.params.id, actor, actorNm]);
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'faltou', actorId: actor, actorNome: actorNm, detalhes: { start: a.start_time } });
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'falta' });
await notificarInteressesSlotLivre(a);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// REMARCAR (precisa nova data, SEM horário definido) → pendência "a reagendar".
app.post('/api/agendamentos/:id/remarcar', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!ag.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const a = ag[0];
await pool.query(`UPDATE agendamentos SET status='remarcar', updated_by=$2, updated_by_nome=$3, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`, [req.params.id, actor, actorNm]);
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'remarcou', actorId: actor, actorNome: actorNm, detalhes: { motivo: req.body?.motivo || null, start: a.start_time } });
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' });
await notificarInteressesSlotLivre(a);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// RESTAURAR (desfaz cancelamento/falta/remarcar) → volta a 'agendado' e baixa a pendência gerada.
app.post('/api/agendamentos/:id/restaurar', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!ag.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const a = ag[0];
await pool.query(`UPDATE agendamentos SET status='agendado', canceled_by=NULL, canceled_by_nome=NULL, canceled_at=NULL, updated_by=$2, updated_by_nome=$3, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`, [req.params.id, actor, actorNm]);
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'restaurou', actorId: actor, actorNome: actorNm, detalhes: {} });
await pool.query(`UPDATE agenda_pendencias SET status='resolvida', resolved_at=NOW(), resolved_by=$2, resolved_by_nome=$3, resolucao='restaurado' WHERE origem_agendamento_id=$1 AND status='aberta'`, [a.id, actor, actorNm]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Histórico (auditoria) de um agendamento.
app.get('/api/agendamentos/:id/historico', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT acao, actor_nome, detalhes, at FROM audit_log WHERE entidade='agendamento' AND entidade_id=$1 ORDER BY at ASC`,
[req.params.id]);
res.json({ success: true, historico: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Pendências "a reagendar" da clínica (default: abertas).
app.get('/api/agenda/pendencias', authGuard, async (req, res) => {
try {
const { clinicaId, status } = req.query;
if (!clinicaId) return res.json({ success: true, pendencias: [] });
const { rows } = await pool.query(
`SELECT * FROM agenda_pendencias WHERE clinica_id=$1 AND status=$2 ORDER BY created_at DESC`,
[clinicaId, status || 'aberta']);
res.json({ success: true, pendencias: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Baixa manual de pendência ("não vai reagendar" / dispensar).
app.post('/api/agenda/pendencias/:id/resolver', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
await pool.query(
`UPDATE agenda_pendencias SET status='resolvida', resolved_at=NOW(), resolved_by=$2, resolved_by_nome=$3, resolucao=$4 WHERE id=$1`,
[req.params.id, actor, actorNm, req.body?.resolucao || 'dispensado']);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Contagem de faltas de um paciente.
app.get('/api/pacientes/:id/faltas', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(`SELECT count(*)::int AS faltas FROM agendamentos WHERE pacienteid=$1 AND lower(status)='falta'`, [req.params.id]);
res.json({ success: true, faltas: rows[0]?.faltas || 0 });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- PRESENÇA NA AGENDA (Fase 2) ---
// Heartbeat: registra a última visita (DB, persistente) + presença AO VIVO (Dragonfly, TTL).
app.post('/api/agenda/presenca', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const { clinicaId } = req.body || {};
if (!clinicaId || !actor) return res.json({ success: true });
const nome = (await actorNome(actor)) || 'Usuário';
await pool.query(
`INSERT INTO agenda_presenca (clinica_id, usuario_id, usuario_nome, last_view_at)
VALUES ($1,$2,$3,NOW())
ON CONFLICT (clinica_id, usuario_id) DO UPDATE SET usuario_nome=EXCLUDED.usuario_nome, last_view_at=NOW()`,
[clinicaId, actor, nome]);
if (redis) {
const key = `presence:agenda:${clinicaId}`;
await redis.zadd(key, Date.now(), `${actor}|${nome}`);
await redis.expire(key, 86400);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Quem está ONLINE agora (Dragonfly, janela de 45s) + últimas visitas (DB).
app.get('/api/agenda/presenca', authGuard, async (req, res) => {
try {
const { clinicaId } = req.query;
if (!clinicaId) return res.json({ success: true, online: [], ultimas: [] });
const me = req.authUser?.userId || null;
let online = [];
if (redis) {
const key = `presence:agenda:${clinicaId}`;
await redis.zremrangebyscore(key, 0, Date.now() - 45000);
const membros = await redis.zrange(key, 0, -1, 'WITHSCORES');
for (let i = 0; i < membros.length; i += 2) {
const parts = membros[i].split('|');
const uid = parts.shift();
online.push({ usuario_id: uid, nome: parts.join('|'), ts: Number(membros[i + 1]) });
}
online = online.filter(o => o.usuario_id !== me);
}
const { rows: ultimas } = await pool.query(
`SELECT usuario_id, usuario_nome, last_view_at FROM agenda_presenca WHERE clinica_id=$1 ORDER BY last_view_at DESC LIMIT 20`,
[clinicaId]);
res.json({ success: true, online, ultimas });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Grupo familiar do paciente (para chips compactos no modal de agenda).
app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => {
try {
const { rows: pr } = await pool.query('SELECT grupofamiliarid FROM pacientes WHERE id = $1', [req.params.id]);
const gid = pr[0]?.grupofamiliarid;
if (!gid) return res.json({ success: true, grupo: null, membros: [] });
const { rows: g } = await pool.query('SELECT id, nome, responsavel_id FROM grupos_familiares WHERE id = $1', [gid]);
const { rows: m } = await pool.query(
'SELECT id, nome, grupofamiliarrelacao, telefone FROM pacientes WHERE grupofamiliarid = $1 ORDER BY nome', [gid]);
res.json({ success: true, grupo: g[0] || { id: gid }, membros: m });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- INTERESSES DE AGENDA (Fase 4) ---
// Fila de interesse quando o horário do profissional está ocupado (não trava o slot).
app.post('/api/interesses', async (req, res) => {
try {
const { dentistaId, profissionalUsuarioId, clinicaInteressadaId, solicitanteUsuarioId, start, end, tempoEsperaMin, observacoes } = req.body;
if (!clinicaInteressadaId || !start) return res.status(400).json({ success: false, message: 'clinicaInteressadaId e start são obrigatórios.' });
// identidade global do profissional
let profUid = profissionalUsuarioId || null;
if (!profUid && dentistaId) {
const { rows } = await pool.query('SELECT usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
profUid = rows[0]?.usuario_id || null;
}
if (!profUid) return res.status(400).json({ success: false, message: 'Profissional não identificado.' });
// quem notificar: o solicitante informado ou o dono da clínica interessada
let solicitante = solicitanteUsuarioId || null;
if (!solicitante) {
const { rows } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaInteressadaId]);
solicitante = rows[0]?.owner_id || null;
}
const id = `int_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
// expira_em calculado no SQL (NOW()+interval) p/ ficar na mesma base de tempo
// que NOW()/created_at e evitar descompasso de fuso com strings UTC do JS.
const minutos = tempoEsperaMin != null ? Number(tempoEsperaMin) : null;
const { rows: ins } = await pool.query(
`INSERT INTO interesses_agenda (id, profissional_usuario_id, clinica_interessada_id, solicitante_usuario_id, start_time, end_time, expira_em, observacoes)
VALUES ($1,$2,$3,$4,$5,$6,
CASE WHEN $7::int IS NULL THEN NULL ELSE NOW() + ($7::int * interval '1 minute') END,
$8)
RETURNING expira_em`,
[id, profUid, clinicaInteressadaId, solicitante, start, end || null, minutos, observacoes || null]
);
res.json({ success: true, interesse: { id, profissional_usuario_id: profUid, status: 'aguardando', expira_em: ins[0].expira_em } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.get('/api/interesses', async (req, res) => {
try {
const { profissionalId, clinicaId } = req.query;
// expira preguiçosamente os vencidos
await pool.query(`UPDATE interesses_agenda SET status = 'expirado' WHERE status = 'aguardando' AND expira_em IS NOT NULL AND expira_em < NOW()`);
let sql = `SELECT i.*, c.nome_fantasia AS clinica_nome
FROM interesses_agenda i
LEFT JOIN clinicas c ON c.id = i.clinica_interessada_id
WHERE 1=1`;
const params = []; let i = 1;
if (profissionalId) { sql += ` AND i.profissional_usuario_id = $${i++}`; params.push(profissionalId); }
if (clinicaId) { sql += ` AND i.clinica_interessada_id = $${i++}`; params.push(clinicaId); }
sql += ' ORDER BY i.created_at ASC';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- ESPECIALIDADES ---
app.get('/api/especialidades', authGuard, async (req, res) => {
try {
const { clinicaId } = req.query;
const sql = clinicaId
? 'SELECT * FROM especialidades WHERE clinica_id IS NULL OR clinica_id = $1 ORDER BY ordem ASC, nome ASC'
: 'SELECT * FROM especialidades WHERE clinica_id IS NULL ORDER BY ordem ASC, nome ASC';
const params = clinicaId ? [clinicaId] : [];
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/especialidades', authGuard, async (req, res) => {
try {
const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
const isSuperAdmin = userRows[0]?.role === 'superadmin';
const clinicaId = isSuperAdmin ? null : (req.body.clinica_id || req.query.clinicaId || null);
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM especialidades');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('especialidades', { ...req.body, clinica_id: clinicaId, ordem });
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/especialidades/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE especialidades SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/especialidades/:id', async (req, res) => {
try {
const upd = buildUpdate('especialidades', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/especialidades/:id', async (req, res) => {
try {
await pool.query('DELETE FROM especialidades WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PLANOS ---
app.get('/api/planos', authGuard, async (req, res) => {
try {
const { clinicaId } = req.query;
const sql = clinicaId
? 'SELECT * FROM planos WHERE clinica_id IS NULL OR clinica_id = $1 ORDER BY nome ASC'
: 'SELECT * FROM planos WHERE clinica_id IS NULL ORDER BY nome ASC';
const { rows } = await pool.query(sql, clinicaId ? [clinicaId] : []);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/planos', authGuard, async (req, res) => {
try {
const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
const isSuperAdmin = userRows[0]?.role === 'superadmin';
const clinicaId = isSuperAdmin ? null : (req.body.clinica_id || req.query.clinicaId || null);
const ins = buildInsert('planos', { ...req.body, clinica_id: clinicaId });
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/planos/:id', async (req, res) => {
try {
const upd = buildUpdate('planos', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/planos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM planos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PROCEDIMENTOS ---
app.get('/api/procedimentos', authGuard, async (req, res) => {
try {
const { clinicaId } = req.query;
const sql = clinicaId
? 'SELECT * FROM procedimentos WHERE clinica_id IS NULL OR clinica_id = $1 ORDER BY ordem ASC, nome ASC'
: 'SELECT * FROM procedimentos WHERE clinica_id IS NULL ORDER BY ordem ASC, nome ASC';
const { rows: procs } = await pool.query(sql, clinicaId ? [clinicaId] : []);
const { rows: valores } = await pool.query('SELECT * FROM procedimentos_valores_planos');
const data = procs.map(p => ({
...p,
especialidadeId: p.especialidadeid,
especialidadeNome: p.especialidadenome,
valorParticular: parseFloat(p.valorparticular || 0),
codigoInterno: p.codigointerno,
valoresPlanos: valores
.filter(v => v.procedimentoid === p.id)
.map(v => ({
...v,
planoId: v.planoid,
planoNome: v.planonome,
codigoPlano: v.codigoplano,
valor: parseFloat(v.valor || 0)
}))
}));
res.json(data);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/procedimentos', authGuard, async (req, res) => {
try {
const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
const isSuperAdmin = userRows[0]?.role === 'superadmin';
const clinicaId = isSuperAdmin ? null : (req.body.clinica_id || req.query.clinicaId || null);
const { valoresPlanos, ...proc } = req.body;
// Auto-generate codigoInterno for PARTICULAR
let codigoInternoVal = proc.codigoInterno;
if (proc.categoria === 'Particular' && (!codigoInternoVal || codigoInternoVal === '')) {
const { rows: partCount } = await pool.query("SELECT COUNT(*) as count FROM procedimentos WHERE categoria = 'Particular'");
codigoInternoVal = (parseInt(partCount[0].count) + 1).toString();
}
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM procedimentos');
const ordemValue = parseInt(counts[0].count);
const payload = {
...proc,
clinica_id: clinicaId,
codigoInterno: codigoInternoVal,
ordem: ordemValue,
tipo_regiao: proc.tipo_regiao || 'GERAL',
exige_face: proc.exige_face ? 1 : 0
};
if (!payload.id) payload.id = Math.random().toString(36).substring(2, 10).toUpperCase();
await withTransaction(async (client) => {
const ins = buildInsert('procedimentos', payload);
await client.query(ins.text, ins.values);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
const vIns = buildInsert('procedimentos_valores_planos', { ...v, procedimentoId: payload.id });
await client.query(vIns.text, vIns.values);
}
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/procedimentos/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE procedimentos SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/procedimentos/:id', async (req, res) => {
try {
const { valoresPlanos, ...proc } = req.body;
const updateData = { ...proc, exige_face: proc.exige_face ? 1 : 0 };
await withTransaction(async (client) => {
const upd = buildUpdate('procedimentos', updateData, 'id', req.params.id);
await client.query(upd.text, upd.values);
await client.query('DELETE FROM procedimentos_valores_planos WHERE procedimentoid = $1', [req.params.id]);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
const vIns = buildInsert('procedimentos_valores_planos', { ...v, procedimentoId: req.params.id });
await client.query(vIns.text, vIns.values);
}
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/procedimentos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM procedimentos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- FINANCEIRO ---
app.get('/api/financeiro', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM financeiro');
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/financeiro', async (req, res) => {
try {
const ins = buildInsert('financeiro', req.body);
await pool.query(ins.text, ins.values);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/financeiro/:id', async (req, res) => {
try {
const upd = buildUpdate('financeiro', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/financeiro/:id', async (req, res) => {
try {
await pool.query('DELETE FROM financeiro WHERE id = $1', [req.params.id]);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- CONTRATOS ---
app.get('/api/contratos', async (req, res) => {
try {
const { paciente_id, status } = req.query;
let where = 'WHERE 1=1';
const vals = [];
if (paciente_id) { vals.push(paciente_id); where += ` AND c.paciente_id = $${vals.length}`; }
if (status) { vals.push(status); where += ` AND c.status = $${vals.length}`; }
const { rows: contratos } = await pool.query(
`SELECT c.*, json_agg(ci.* ORDER BY ci.ordem) FILTER (WHERE ci.id IS NOT NULL) AS items
FROM contratos c
LEFT JOIN contrato_itens ci ON ci.contrato_id = c.id
${where} GROUP BY c.id ORDER BY c.created_at DESC`, vals);
res.json(contratos.map(c => ({
...c,
valorTotal: parseFloat(c.valor_total || 0),
entrada: parseFloat(c.entrada || 0),
parcelas: parseInt(c.parcelas || 1),
pacienteId: c.paciente_id,
pacienteNome: c.paciente_nome,
dentistaId: c.dentista_id,
dentistaNome: c.dentista_nome,
especialidadeId: c.especialidade_id,
especialidadeNome: c.especialidade_nome,
dataInicio: c.data_inicio,
dataFim: c.data_fim,
formaPagamento: c.forma_pagamento,
createdAt: c.created_at,
items: (c.items || []).map(i => ({
...i,
contratoId: i.contrato_id,
procedimentoNome: i.procedimento_nome,
valorUnitario: parseFloat(i.valor_unitario || 0),
valorTotal: parseFloat(i.valor_total || 0),
}))
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/contratos', async (req, res) => {
try {
const { items, ...contrato } = req.body;
if (!contrato.id) contrato.id = Math.random().toString(36).substring(2, 12).toUpperCase();
await withTransaction(async (client) => {
const ins = buildInsert('contratos', contrato);
await client.query(ins.text, ins.values);
if (items?.length) {
for (const item of items) {
if (!item.id) item.id = Math.random().toString(36).substring(2, 12);
const iIns = buildInsert('contrato_itens', { ...item, contrato_id: contrato.id });
await client.query(iIns.text, iIns.values);
}
}
});
res.json({ id: contrato.id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/contratos/:id', async (req, res) => {
try {
const { items, ...contrato } = req.body;
await withTransaction(async (client) => {
const upd = buildUpdate('contratos', contrato, 'id', req.params.id);
await client.query(upd.text, upd.values);
await client.query('DELETE FROM contrato_itens WHERE contrato_id = $1', [req.params.id]);
if (items?.length) {
for (const item of items) {
if (!item.id) item.id = Math.random().toString(36).substring(2, 12);
const iIns = buildInsert('contrato_itens', { ...item, contrato_id: req.params.id });
await client.query(iIns.text, iIns.values);
}
}
});
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/contratos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM contrato_itens WHERE contrato_id = $1', [req.params.id]);
await pool.query('DELETE FROM contratos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LEADS ---
app.get('/api/leads', authGuard, async (req, res) => {
try {
const { clinicaId } = req.query;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const { rows } = await pool.query('SELECT * FROM leads WHERE clinica_id = $1 ORDER BY datacadastro DESC NULLS LAST', [clinicaId]);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/leads', authGuard, async (req, res) => {
try {
const clinicaId = req.body.clinica_id || req.query.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório.' });
const ins = buildInsert('leads', { ...req.body, clinica_id: clinicaId });
await pool.query(ins.text, ins.values);
schedulePush('leads');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- GUIAS ODONTOLÓGICAS (TRATAMENTOS) ---
app.get('/api/guias', async (req, res) => {
try {
const {
page = 1,
pageSize = 10,
search = '',
status = '',
dataInicio = '',
dataFim = '',
sortField = 'dataSolicitacao',
sortOrder = 'DESC',
gto = ''
} = req.query;
const offset = (page - 1) * pageSize;
const limit = parseInt(pageSize);
let where = ' WHERE 1=1';
const params = [];
let paramIdx = 1;
if (gto) {
where += ` AND (numeroguiaprestador LIKE $${paramIdx} OR numeroguiaoperadora LIKE $${paramIdx + 1})`;
params.push(`%${gto}%`, `%${gto}%`);
paramIdx += 2;
}
if (search) {
where += ` AND (beneficiarionome LIKE $${paramIdx} OR beneficiarioidentificacao LIKE $${paramIdx + 1})`;
params.push(`%${search}%`, `%${search}%`);
paramIdx += 2;
}
if (status) {
where += ` AND status = $${paramIdx}`;
params.push(status);
paramIdx++;
}
if (dataInicio) {
where += ` AND datasolicitacao >= $${paramIdx}`;
params.push(dataInicio);
paramIdx++;
}
if (dataFim) {
where += ` AND datasolicitacao <= $${paramIdx}`;
params.push(dataFim);
paramIdx++;
}
// Validate sortField and sortOrder to prevent SQL injection
const allowedSortFields = ['datasolicitacao', 'beneficiarionome', 'status', 'numeroguiaprestador'];
const normalizedSort = sortField.toLowerCase();
const finalSortField = allowedSortFields.includes(normalizedSort) ? normalizedSort : 'datasolicitacao';
const finalSortOrder = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const query = `
SELECT * FROM guias_odontologicas
${where}
ORDER BY ${finalSortField} ${finalSortOrder}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
const countQuery = `SELECT COUNT(*) as total FROM guias_odontologicas ${where}`;
const { rows } = await pool.query(query, [...params, limit, offset]);
const { rows: totalRows } = await pool.query(countQuery, params);
const mapGuia = (g) => ({
...g,
numeroGuiaPrestador: g.numeroguiaprestador,
numeroGuiaOperadora: g.numeroguiaoperadora,
dataSolicitacao: g.datasolicitacao,
tipoTratamento: g.tipotratamento,
beneficiarioId: g.beneficiarioid,
beneficiarioNome: g.beneficiarionome,
beneficiarioIdentificacao: g.beneficiarioidentificacao,
createdAt: g.createdat
});
res.json({
data: rows.map(mapGuia),
total: totalRows[0].total,
page: parseInt(page),
pageSize: limit
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- GTO BUILDER (Lançar GTO) ---
app.post('/api/gto-builder', async (req, res) => {
try {
const { pacienteId, dentistaId, credenciadaId } = req.body;
const id = Math.random().toString(36).substring(2, 9).toUpperCase();
await pool.query(
"INSERT INTO gto_builders (id, pacienteid, dentistaid, credenciadaid, status, total) VALUES ($1, $2, $3, $4, 'RASCUNHO', 0)",
[id, pacienteId, dentistaId, credenciadaId]
);
res.json({ id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/gto-builder/:id', async (req, res) => {
try {
const { rows: builder } = await pool.query('SELECT * FROM gto_builders WHERE id = $1', [req.params.id]);
const { rows: items } = await pool.query('SELECT * FROM gto_items WHERE builderid = $1', [req.params.id]);
const mappedItems = items.map(i => ({
...i,
builderId: i.builderid,
procedimentoId: i.procedimentoid,
codigoTUSS: i.codigotuss,
codigoInterno: i.codigointerno,
valorUnitario: parseFloat(i.valorunitario || 0),
valorTotal: parseFloat(i.valortotal || 0),
anexosCount: i.anexoscount
}));
const b = builder[0];
res.json({
...b,
pacienteId: b?.pacienteid,
dentistaId: b?.dentistaid,
credenciadaId: b?.credenciadaid,
items: mappedItems
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/gto-builder/:id/item', async (req, res) => {
try {
const itemId = Math.random().toString(36).substring(2, 9).toUpperCase();
const item = { ...req.body, id: itemId, builderId: req.params.id };
const ins = buildInsert('gto_items', item);
await pool.query(ins.text, ins.values);
// Update total
await pool.query(
'UPDATE gto_builders SET total = (SELECT SUM(valortotal) FROM gto_items WHERE builderid = $1) WHERE id = $2',
[req.params.id, req.params.id]
);
res.json({ success: true, item });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/gto-builder/:id/item/:itemId', async (req, res) => {
try {
await pool.query('DELETE FROM gto_items WHERE id = $1', [req.params.itemId]);
// Update total
await pool.query(
'UPDATE gto_builders SET total = COALESCE((SELECT SUM(valortotal) FROM gto_items WHERE builderid = $1), 0) WHERE id = $2',
[req.params.id, req.params.id]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/gto-builder/:id/finalizar', async (req, res) => {
try {
await pool.query("UPDATE gto_builders SET status = 'FINALIZADA' WHERE id = $1", [req.params.id]);
// Logic to migrate to guias_odontologicas would go here in a real app
// For now, we just mark as finished.
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// =============================================================================
// SYNC ROUTES — Google Sheets ↔ PostgreSQL
// =============================================================================
app.get('/api/sync/status', tenantGuard, async (req, res) => {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
const syncStatus = rows[0] ? JSON.parse(rows[0].data) : {};
const counts = {};
for (const t of SYNC_TABLES) {
const { rows: c } = await pool.query(`SELECT COUNT(*) as n FROM ${t.pgTable}`);
counts[t.tab] = parseInt(c[0].n);
}
res.json({ syncStatus, counts, spreadsheetId: SHEETS_BACKUP_ID });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/sync/push — PG → Google Sheets (backup)
app.post('/api/sync/push', tenantGuard, async (req, res) => {
const logs = [];
try {
const sheets = await getSheetsClient();
logs.push(`[OK] AUTENTICADO NO GOOGLE`);
const statusUpdate = {};
for (const tableDef of SYNC_TABLES) {
logs.push(`[→] ENVIANDO: ${tableDef.tab.toUpperCase()}...`);
try {
const count = await pushTableToSheet(tableDef, sheets);
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS`);
statusUpdate[tableDef.tab] = { lastPush: new Date().toISOString(), count };
} catch (e) {
logs.push(`[ERRO] ${tableDef.tab}: ${e.message}`);
}
}
await saveSyncStatus(statusUpdate);
logs.push(`[✓] BACKUP CONCLUÍDO — ${new Date().toLocaleString('pt-BR')}`);
res.json({ success: true, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA. VÁ EM CONFIGURAÇÕES E CONECTE O GOOGLE.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// POST /api/sync/pull — Google Sheets → PG (restaurar backup)
app.post('/api/sync/pull', tenantGuard, async (req, res) => {
const logs = [];
const clinicaId = req.clinicaId;
try {
const sheets = await getSheetsClient();
logs.push(`[OK] AUTENTICADO NO GOOGLE`);
for (const tableDef of SYNC_TABLES) {
logs.push(`[←] IMPORTANDO: ${tableDef.tab.toUpperCase()}...`);
try {
const count = await pullTableFromSheet(tableDef, sheets);
if (clinicaId) {
await pool.query(
`UPDATE ${tableDef.pgTable} SET clinica_id = $1 WHERE clinica_id IS NULL`,
[clinicaId]
);
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS → clinica_id vinculado`);
} else {
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS IMPORTADOS`);
}
} catch (e) {
logs.push(`[ERRO] ${tableDef.tab}: ${e.message}`);
}
}
logs.push(`[✓] RESTAURAÇÃO CONCLUÍDA — ${new Date().toLocaleString('pt-BR')}`);
res.json({ success: true, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA. VÁ EM CONFIGURAÇÕES E CONECTE O GOOGLE.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// Import patients from Apps Script legacy sheet (PACIENTES-CONSULTT-CLINIC tab)
app.post('/api/sync/import-legacy', tenantGuard, async (req, res) => {
const logs = [];
try {
const sheets = await getSheetsClient();
logs.push('[←] LENDO ABA PACIENTES-CONSULTT-CLINIC...');
const realTab = await ensureSheetTab(sheets, 'PACIENTES-CONSULTT-CLINIC');
const resp = await sheets.spreadsheets.values.get({
spreadsheetId: SHEETS_BACKUP_ID,
range: `${realTab}!A:ZZ`
});
const sheetRows = resp.data.values || [];
if (sheetRows.length < 2) {
logs.push('[AVISO] ABA VAZIA OU SEM DADOS.');
return res.json({ success: true, logs, imported: 0 });
}
const headers = sheetRows[0].map(h => h.trim());
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c && c.trim() !== ''));
logs.push(`[OK] ${dataRows.length} LINHAS ENCONTRADAS`);
const idx = h => headers.indexOf(h);
const iNome = idx('Nome'); const iSobre = idx('SobreNome');
const iCpf = idx('CPF'); const iCred = idx('Credenciada');
const iNasci = idx('DataNasci'); const iTel = idx('TefefonePrincipal');
let imported = 0, skipped = 0;
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
const get = j => (j >= 0 && j < row.length ? (row[j] || '').trim() : '');
const nome = [get(iNome), get(iSobre)].filter(Boolean).join(' ').toUpperCase();
if (!nome) { skipped++; continue; }
const cpfRaw = get(iCpf);
const cpfDigits = cpfRaw.replace(/\D/g, '');
const id = cpfDigits || `legacy_${i + 1}`;
try {
const trunc = (v, n) => v ? v.substring(0, n) : null;
await pool.query(
`INSERT INTO pacientes (id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento,clinica_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,
convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento,
clinica_id=EXCLUDED.clinica_id`,
[trunc(id,50), trunc(nome,255), trunc(cpfRaw,20)||null, trunc(get(iTel),20)||null, null, null, trunc(get(iCred),100)||null, trunc(get(iNasci),20)||null, req.clinicaId]
);
imported++;
} catch (e) {
skipped++;
if (skipped <= 3) logs.push(`[AVISO] linha ${i + 2}: ${e.message}`);
}
}
logs.push(`[✓] ${imported} PACIENTES IMPORTADOS, ${skipped} IGNORADOS`);
logs.push(`[!] PLANILHA NÃO FOI ALTERADA. Use "Backup Agora" se quiser sincronizar.`);
if (imported > 0) {
await saveSyncStatus({ pacientes: { lastPush: new Date().toISOString(), count: imported, source: 'legacy' } });
}
res.json({ success: true, logs, imported, skipped });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// initTables() was removed as database is native PostgreSQL and schema DDL is maintained externally.
async function generateIntelligentNotifications() {
if (!pool) return;
try {
// 1. Check for upcoming appointments (next 2 hours)
const { rows: upcoming } = await pool.query(`
SELECT a.*, p.nome as "pacienteNome"
FROM agendamentos a
LEFT JOIN pacientes p ON a.pacientenome = p.nome
WHERE a.start_time BETWEEN NOW() AND NOW() + INTERVAL '2 hours'
AND a.status = 'Pendente'
`);
for (const appt of upcoming) {
const id = `notif_appt_${appt.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'PRÓXIMO ATENDIMENTO',
`Paciente ${appt.pacientenome} às ${new Date(appt.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
'agenda'
]);
}
}
// 2. Check for overdue bills
const { rows: overdue } = await pool.query(`
SELECT * FROM financeiro
WHERE datavencimento < CURRENT_DATE AND status = 'Pendente'
`);
for (const bill of overdue) {
const id = `notif_fin_overdue_${bill.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'FATURA ATRASADA',
`A conta "${bill.descricao}" de R$ ${bill.valor} venceu em ${new Date(bill.datavencimento).toLocaleDateString('pt-BR')}`,
'financeiro'
]);
}
}
// 3. Check for bills due today
const { rows: dueToday } = await pool.query(`
SELECT * FROM financeiro
WHERE datavencimento = CURRENT_DATE AND status = 'Pendente'
`);
for (const bill of dueToday) {
const id = `notif_fin_today_${bill.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'VENCIMENTO HOJE',
`A conta "${bill.descricao}" de R$ ${bill.valor} vence hoje!`,
'financeiro'
]);
}
}
// 4. System Events (to Dev) - Mocking a system event if table counts are low
const { rows: pCount } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
if (parseInt(pCount[0].count) < 5) {
const id = 'notif_sys_setup';
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'CONFIGURAÇÃO DO SISTEMA',
'O sistema possui poucos pacientes cadastrados. Considere importar dados.',
'sistema'
]);
}
}
} catch (err) {
console.error('Error generating notifications:', err);
}
}
// --- MODELOS DE RECEITA ---
app.get('/api/modelos-receita', async (req, res) => {
try {
const { especialidadeId } = req.query;
let q = `SELECT m.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items
FROM modelos_receita m LEFT JOIN items_receita i ON i.modelo_id = m.id`;
const params = [];
if (especialidadeId) { q += ` WHERE m.especialidade_id = $1`; params.push(especialidadeId); }
q += ` GROUP BY m.id ORDER BY m.nome`;
const { rows } = await pool.query(q, params);
res.json(rows.map(r => ({
id: r.id, nome: r.nome,
especialidadeId: r.especialidade_id, especialidadeNome: r.especialidade_nome,
instrucoes: r.instrucoes, items: r.items || []
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/modelos-receita', async (req, res) => {
try {
const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body;
const id = `mr_${Date.now()}`;
await pool.query(
`INSERT INTO modelos_receita (id, nome, especialidade_id, especialidade_nome, instrucoes) VALUES ($1,$2,$3,$4,$5)`,
[id, nome, especialidadeId || null, especialidadeNome || null, instrucoes || null]
);
for (let i = 0; i < items.length; i++) {
const it = items[i];
await pool.query(
`INSERT INTO items_receita (id, modelo_id, medicamento, dose, via, frequencia, duracao, instrucoes, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[`ir_${Date.now()}_${i}`, id, it.medicamento, it.dose || '', it.via || 'VO', it.frequencia || '', it.duracao || '', it.instrucoes || null, i]
);
}
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/modelos-receita/:id', async (req, res) => {
try {
const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body;
await pool.query(
`UPDATE modelos_receita SET nome=$1, especialidade_id=$2, especialidade_nome=$3, instrucoes=$4 WHERE id=$5`,
[nome, especialidadeId || null, especialidadeNome || null, instrucoes || null, req.params.id]
);
await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]);
for (let i = 0; i < items.length; i++) {
const it = items[i];
await pool.query(
`INSERT INTO items_receita (id, modelo_id, medicamento, dose, via, frequencia, duracao, instrucoes, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[`ir_${Date.now()}_${i}`, req.params.id, it.medicamento, it.dose || '', it.via || 'VO', it.frequencia || '', it.duracao || '', it.instrucoes || null, i]
);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/modelos-receita/:id', async (req, res) => {
try {
await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]);
await pool.query(`DELETE FROM modelos_receita WHERE id = $1`, [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PEDIDOS DE EXAME ---
app.get('/api/pedidos-exame', async (req, res) => {
try {
const { pacienteId } = req.query;
let q = `SELECT p.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items
FROM pedidos_exame p LEFT JOIN items_pedido_exame i ON i.pedido_id = p.id`;
const params = [];
if (pacienteId) { q += ` WHERE p.paciente_id = $1`; params.push(pacienteId); }
q += ` GROUP BY p.id ORDER BY p.created_at DESC LIMIT 50`;
const { rows } = await pool.query(q, params);
res.json(rows.map(r => ({
id: r.id, pacienteId: r.paciente_id, pacienteNome: r.paciente_nome,
profissionalId: r.profissional_id, profissionalNome: r.profissional_nome,
data: r.data, observacoes: r.observacoes, items: r.items || []
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/pedidos-exame', async (req, res) => {
try {
const { pacienteId, pacienteNome, profissionalId, profissionalNome, data, observacoes, items = [] } = req.body;
const id = `pe_${Date.now()}`;
await pool.query(
`INSERT INTO pedidos_exame (id, paciente_id, paciente_nome, profissional_id, profissional_nome, data, observacoes) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, pacienteId || null, pacienteNome, profissionalId || null, profissionalNome || null, data, observacoes || null]
);
for (let i = 0; i < items.length; i++) {
const it = items[i];
await pool.query(
`INSERT INTO items_pedido_exame (id, pedido_id, nome, categoria, regiao, observacao, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[`ie_${Date.now()}_${i}`, id, it.nome, it.categoria, it.regiao || null, it.observacao || null, i]
);
}
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
async function runMigrations() {
const migrations = [
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarid TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarrelacao TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS indicadorporid TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS observacoes TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS logradouro TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS numero TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS complemento TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS bairro TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cidade TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS estado TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cep TEXT`,
`CREATE TABLE IF NOT EXISTS modelos_receita (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
especialidade_id TEXT,
especialidade_nome TEXT,
instrucoes TEXT,
clinica_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS items_receita (
id TEXT PRIMARY KEY,
modelo_id TEXT NOT NULL,
medicamento TEXT NOT NULL,
dose TEXT NOT NULL DEFAULT '',
via TEXT NOT NULL DEFAULT 'VO',
frequencia TEXT NOT NULL DEFAULT '',
duracao TEXT NOT NULL DEFAULT '',
instrucoes TEXT,
ordem INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS pedidos_exame (
id TEXT PRIMARY KEY,
paciente_id TEXT,
paciente_nome TEXT,
profissional_id TEXT,
profissional_nome TEXT,
data TEXT NOT NULL,
observacoes TEXT,
clinica_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS items_pedido_exame (
id TEXT PRIMARY KEY,
pedido_id TEXT NOT NULL,
nome TEXT NOT NULL,
categoria TEXT NOT NULL,
regiao TEXT,
observacao TEXT,
ordem INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS contratos (
id TEXT PRIMARY KEY,
clinica_id TEXT,
paciente_id TEXT,
paciente_nome TEXT NOT NULL,
dentista_id TEXT,
dentista_nome TEXT,
especialidade_id TEXT,
especialidade_nome TEXT,
titulo TEXT NOT NULL,
status TEXT DEFAULT 'Rascunho',
data_inicio TEXT,
data_fim TEXT,
valor_total NUMERIC DEFAULT 0,
entrada NUMERIC DEFAULT 0,
parcelas INTEGER DEFAULT 1,
forma_pagamento TEXT DEFAULT 'Pix',
clausulas TEXT,
observacoes TEXT,
created_at TIMESTAMP DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS contrato_itens (
id TEXT PRIMARY KEY,
contrato_id TEXT,
procedimento_nome TEXT NOT NULL,
quantidade INTEGER DEFAULT 1,
valor_unitario NUMERIC DEFAULT 0,
valor_total NUMERIC DEFAULT 0,
descricao TEXT,
ordem INTEGER DEFAULT 0
)`,
// Usuarios: role, ativo e diretório de profissionais
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'dentista'`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`,
// Provisionamento de equipe: força troca da senha temporária no 1º login.
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT false`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS estado TEXT`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cidade TEXT`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS bairro TEXT`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS especialidade_dir TEXT`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS disponivel_diretorio BOOLEAN DEFAULT false`,
// Clinicas: cor padrão
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS cor TEXT DEFAULT '#2563eb'`,
// Clinicas → workspaces (Fase 1): tipo = pessoal | consultorio | clinica; owner_id = dono
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS tipo TEXT DEFAULT 'clinica'`,
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS owner_id TEXT`,
// Backup por clínica: ID da planilha Google Sheets própria da unidade.
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS sheets_id TEXT`,
// Dentistas → identidade global do profissional (Fase 2): liga o registro local da
// clínica ao usuario global. Backfill idempotente por email (mesma pessoa em várias
// clínicas → mesmo usuario_id). É a base do anti-overbooking (Fase 3).
`ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS usuario_id TEXT`,
`UPDATE dentistas d SET usuario_id = u.id FROM usuarios u WHERE d.usuario_id IS NULL AND d.email IS NOT NULL AND lower(d.email) = lower(u.email)`,
// Interesses de agenda (Fase 4): fila de interesse quando o slot está ocupado.
// NÃO trava o slot (só 1 agendamento por horário mantém o invariante).
`CREATE TABLE IF NOT EXISTS interesses_agenda (
id TEXT PRIMARY KEY,
profissional_usuario_id TEXT NOT NULL,
clinica_interessada_id TEXT NOT NULL,
solicitante_usuario_id TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
status TEXT DEFAULT 'aguardando',
expira_em TIMESTAMP,
observacoes TEXT,
created_at TIMESTAMP DEFAULT NOW()
)`,
// Vinculos: constraint role (inclui protetico — necessário p/ workspace pessoal)
`ALTER TABLE vinculos DROP CONSTRAINT IF EXISTS vinculos_role_check`,
`ALTER TABLE vinculos ADD CONSTRAINT vinculos_role_check CHECK (role = ANY(ARRAY['paciente','dentista','protetico','funcionario','donoclinica','donoconsultorio','admin']))`,
`ALTER TABLE google_tokens ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE leads ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE especialidades ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE planos ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE procedimentos ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE guias_odontologicas ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
// ===================================================================
// ORTODONTIA · DOCUMENTAÇÃO FOTOGRÁFICA · TUTORIA
// tratamento_id é TEXT livre (os tratamentos ainda são mock no front).
// ===================================================================
`CREATE TABLE IF NOT EXISTS ortho_photo_session (
id TEXT PRIMARY KEY,
tratamento_id TEXT NOT NULL,
clinica_id TEXT,
tipo TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (tratamento_id, tipo)
)`,
// Sessões mensais: orto é mensal, então além das sessões especiais
// (inicial/finalizacao/pos_contencao) há uma sessão por mês para a evolução.
`ALTER TABLE ortho_photo_session ADD COLUMN IF NOT EXISTS rotulo TEXT`,
`ALTER TABLE ortho_photo_session ADD COLUMN IF NOT EXISTS data_ref DATE`,
`CREATE TABLE IF NOT EXISTS ortho_photo (
id TEXT PRIMARY KEY,
sessao_id TEXT NOT NULL,
slot TEXT NOT NULL,
filename TEXT NOT NULL,
mimetype TEXT,
original_name TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (sessao_id, slot)
)`,
`CREATE TABLE IF NOT EXISTS ortho_photo_annotation (
id TEXT PRIMARY KEY,
foto_id TEXT NOT NULL,
dados JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_tutor_config (
id TEXT PRIMARY KEY,
taxa_inscricao NUMERIC DEFAULT 0,
chave_pix TEXT,
instrucoes_pagamento TEXT,
mp_access_token TEXT,
mp_public_key TEXT,
comissao_pct NUMERIC DEFAULT 20,
texto_responsabilidade TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_tutor_candidatura (
id TEXT PRIMARY KEY,
usuario_id TEXT,
nome TEXT NOT NULL,
email TEXT,
telefone TEXT,
cro TEXT,
cro_uf TEXT,
titulacao TEXT,
instituicao TEXT,
ano_conclusao INTEGER,
especialidade_principal TEXT,
especialidades_adicionais JSONB,
anos_experiencia INTEGER,
bio TEXT,
preco_por_consulta NUMERIC,
portfolio_url TEXT,
linkedin_url TEXT,
status TEXT DEFAULT 'PENDENTE',
observacao_admin TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_tutor_profile (
id TEXT PRIMARY KEY,
usuario_id TEXT,
nome TEXT NOT NULL,
especialidade TEXT,
especialidades_adicionais JSONB,
bio TEXT,
preco_base NUMERIC,
sla_horas INTEGER DEFAULT 48,
cro TEXT,
cro_uf TEXT,
titulacao TEXT,
instituicao TEXT,
anos_experiencia INTEGER,
portfolio_url TEXT,
linkedin_url TEXT,
pix_key TEXT,
mp_email TEXT,
aceite_split_pct NUMERIC,
aceite_split_at TIMESTAMPTZ,
status TEXT DEFAULT 'ATIVO',
disponivel BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_tutoring_request (
id TEXT PRIMARY KEY,
tratamento_id TEXT,
clinica_id TEXT,
tutor_id TEXT,
paciente_nome TEXT,
tipo TEXT,
descricao TEXT,
status TEXT DEFAULT 'AGUARDANDO',
aceite_responsabilidade BOOLEAN DEFAULT false,
aceite_nome TEXT,
aceite_texto_versao TEXT,
valor_consultoria NUMERIC,
comissao_pct NUMERIC,
valor_tutor NUMERIC,
status_repasse TEXT DEFAULT 'PENDENTE',
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_tutoring_message (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
autor_tipo TEXT NOT NULL,
autor_nome TEXT,
conteudo TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_tutoring_photo_package (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
foto_id TEXT NOT NULL,
slot TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Tratamentos ortodônticos (CRM) + evoluções mensais
`CREATE TABLE IF NOT EXISTS ortho_tratamento (
id TEXT PRIMARY KEY,
clinica_id TEXT,
paciente_id TEXT,
paciente_nome TEXT NOT NULL,
data_nascimento TEXT,
responsavel TEXT,
tipo_paciente TEXT,
triagem JSONB,
diagnostico JSONB,
data_inicio TEXT,
tipo_aparelho TEXT,
extracoes TEXT,
sequencia_fios TEXT,
elasticos TEXT,
ipr_planejado TEXT,
tempo_estimado TEXT,
valor NUMERIC,
forma_pagamento TEXT,
fase_atual TEXT,
fio_atual TEXT,
classificacao TEXT,
cooperacao TEXT,
percentual_concluido INTEGER,
ultima_consulta TEXT,
proxima_consulta TEXT,
meses_restantes INTEGER,
status TEXT DEFAULT 'Em Tratamento',
flags JSONB,
contencao_tipo TEXT,
contencao_superior BOOLEAN,
contencao_inferior BOOLEAN,
tempo_uso TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS ortho_evolucao (
id TEXT PRIMARY KEY,
tratamento_id TEXT NOT NULL,
data TEXT,
tipo TEXT,
procedimento TEXT,
proximo_retorno TEXT,
fio TEXT,
elastico TEXT,
procedimentos JSONB,
higiene INTEGER,
cooperacao TEXT,
observacoes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Gestão de equipe: setores + funções por clínica; vínculos ganham setor/função.
`CREATE TABLE IF NOT EXISTS setores (
id TEXT PRIMARY KEY,
clinica_id TEXT,
nome TEXT NOT NULL,
descricao TEXT,
cor TEXT DEFAULT '#2563eb',
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS funcoes (
id TEXT PRIMARY KEY,
clinica_id TEXT,
nome TEXT NOT NULL,
descricao TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`ALTER TABLE vinculos ADD COLUMN IF NOT EXISTS setor_id TEXT`,
`ALTER TABLE vinculos ADD COLUMN IF NOT EXISTS funcao_id TEXT`,
// Gestão de acessos por cargo (RBAC): páginas permitidas + nível hierárquico.
`ALTER TABLE funcoes ADD COLUMN IF NOT EXISTS permissoes JSONB DEFAULT '[]'::jsonb`,
`ALTER TABLE funcoes ADD COLUMN IF NOT EXISTS nivel INTEGER DEFAULT 1`,
// ── Agenda Fase 1: autoria, soft-delete, faltas, auditoria, pendências ──
// Novo vocabulário de status (o CHECK antigo barrava 'agendado'/'cancelado').
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS agendamentos_status_check`,
`ALTER TABLE agendamentos ADD CONSTRAINT agendamentos_status_check CHECK (status IS NULL OR lower(status) IN ('agendado','confirmado','reagendado','remarcar','cancelado','falta','atendido','pendente','concluido','faltou'))`,
// Slot único só vale para status ATIVOS — assim cancelar/faltar libera o slot p/ reagendar.
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS unique_dentista_slot`,
`CREATE UNIQUE INDEX IF NOT EXISTS unique_dentista_slot_active ON agendamentos (dentistaid, start_time) WHERE lower(status) NOT IN ('cancelado','falta','remarcar')`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacienteid TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacientecelular TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by_nome TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW()`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS updated_by TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS updated_by_nome TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS canceled_by TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS canceled_by_nome TEXT`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS canceled_at TIMESTAMPTZ`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`,
`CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
clinica_id TEXT,
entidade TEXT NOT NULL,
entidade_id TEXT,
acao TEXT NOT NULL,
actor_id TEXT,
actor_nome TEXT,
detalhes JSONB DEFAULT '{}'::jsonb,
at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_entidade ON audit_log (clinica_id, entidade, entidade_id, at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_audit_clinica ON audit_log (clinica_id, at DESC)`,
`CREATE TABLE IF NOT EXISTS agenda_pendencias (
id TEXT PRIMARY KEY,
clinica_id TEXT,
paciente_id TEXT,
paciente_nome TEXT,
origem_agendamento_id TEXT,
motivo TEXT,
status TEXT DEFAULT 'aberta',
created_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by TEXT,
resolved_by_nome TEXT,
resolucao TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_pend_clinica ON agenda_pendencias (clinica_id, status, created_at DESC)`,
`CREATE TABLE IF NOT EXISTS agenda_presenca (
clinica_id TEXT,
usuario_id TEXT,
usuario_nome TEXT,
last_view_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (clinica_id, usuario_id)
)`,
];
for (const sql of migrations) {
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
}
console.log('[MIGRATION] all migrations OK');
}
// --- DIRETÓRIO DE PROFISSIONAIS ---
// Perfil do sistema (usuário logado): leitura e edição dos próprios dados.
app.get('/api/usuarios/me', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT id, nome, email, celular, cro, cro_uf, especialidade, cidade, bairro, estado, cor_agenda, role
FROM usuarios WHERE id = $1`, [req.authUser.userId]);
if (!rows.length) return res.status(404).json({ success: false, message: 'Usuário não encontrado.' });
res.json({ success: true, usuario: rows[0] });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/usuarios/me', authGuard, async (req, res) => {
const COLS = ['nome', 'email', 'celular', 'cro', 'cro_uf', 'especialidade', 'cidade', 'bairro', 'estado', 'cor_agenda'];
const UPPER = new Set(['nome', 'especialidade', 'cro_uf', 'cidade', 'bairro', 'estado']);
try {
const b = req.body || {};
if (b.email) {
const email = String(b.email).toLowerCase().trim();
const { rows } = await pool.query('SELECT id FROM usuarios WHERE email = $1 AND id <> $2', [email, req.authUser.userId]);
if (rows.length) return res.status(409).json({ success: false, message: 'E-mail já usado por outra conta.' });
}
const keys = COLS.filter(c => b[c] !== undefined);
if (!keys.length) return res.json({ success: true });
const val = (c) => c === 'email' ? String(b[c]).toLowerCase().trim()
: (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 usuarios SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(val), req.authUser.userId]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Trocar senha (inclui o caso de troca obrigatória no 1º login).
app.post('/api/usuarios/me/senha', authGuard, async (req, res) => {
const { atual, nova } = req.body || {};
if (!nova || String(nova).length < 6) return res.status(400).json({ success: false, message: 'A NOVA SENHA DEVE TER NO MÍNIMO 6 CARACTERES.' });
try {
const { rows } = await pool.query('SELECT senha, must_change_password FROM usuarios WHERE id = $1', [req.authUser.userId]);
if (!rows.length) return res.status(404).json({ success: false, message: 'Usuário não encontrado.' });
// Fora do fluxo de troca obrigatória, exige a senha atual.
if (!rows[0].must_change_password) {
if (!atual || !(await verificarSenha(req.authUser.userId, atual, rows[0].senha))) {
return res.status(400).json({ success: false, message: 'SENHA ATUAL INCORRETA.' });
}
}
await pool.query('UPDATE usuarios SET senha = $1, must_change_password = false WHERE id = $2', [await hashSenha(nova), req.authUser.userId]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.put('/api/usuarios/localidade', 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 }); }
const { estado, cidade, bairro, especialidade, disponivel_diretorio } = req.body;
if (!estado || !cidade) return res.status(400).json({ success: false, message: 'Estado e cidade são obrigatórios.' });
try {
await pool.query(
`UPDATE usuarios SET estado=$1, cidade=$2, bairro=$3, especialidade_dir=$4, disponivel_diretorio=$5 WHERE id=$6`,
[estado.toUpperCase(), cidade.toUpperCase(), bairro?.toUpperCase() || null, especialidade?.toUpperCase() || null, disponivel_diretorio ?? true, payload.userId]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.get('/api/profissionais-disponiveis', async (req, res) => {
const authHeader = req.headers['authorization'];
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Não autenticado.' });
try { jwt.verify(authHeader.split(' ')[1], JWT_SECRET); } catch { return res.status(401).json({ error: 'Token inválido.' }); }
const { tipo, estado, cidade, especialidade } = req.query;
const TIPOS = ['dentista', 'protetico'];
let where = `WHERE disponivel_diretorio = true AND role = ANY($1::text[])`;
const params = [tipo && TIPOS.includes(tipo) ? [tipo] : TIPOS];
if (estado) { params.push(estado.toUpperCase()); where += ` AND estado = $${params.length}`; }
if (cidade) { params.push(`%${cidade.toUpperCase()}%`); where += ` AND cidade ILIKE $${params.length}`; }
if (especialidade){ params.push(`%${especialidade.toUpperCase()}%`); where += ` AND especialidade_dir ILIKE $${params.length}`; }
try {
const { rows } = await pool.query(
`SELECT id, nome, email, role, estado, cidade, bairro, especialidade_dir as especialidade FROM usuarios ${where} ORDER BY estado, cidade, nome LIMIT 200`,
params
);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LIMPEZA DE DADOS LEGADOS (sem clinica_id) ---
app.delete('/api/sync/limpar-legado', tenantGuard, async (req, res) => {
try {
const { rows } = await pool.query('DELETE FROM pacientes WHERE clinica_id IS NULL RETURNING id');
res.json({ success: true, removed: rows.length });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/sync/limpar-agendamentos', tenantGuard, async (req, res) => {
try {
const { rows } = await pool.query('DELETE FROM agendamentos WHERE clinica_id IS NULL RETURNING id');
res.json({ success: true, removed: rows.length });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.get('/api/sync/spreadsheet-id', tenantGuard, async (req, res) => {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_config'").catch(() => ({ rows: [] }));
const dbId = rows[0] ? JSON.parse(rows[0].data).spreadsheetId : null;
res.json({ spreadsheetId: SHEETS_BACKUP_ID, fromDb: !!dbId, envDefault: process.env.SHEETS_BACKUP_ID || '1q1sf1GOji2u3qIJEToWfbLCb4rzb_pKLOX_AylMDT4g' });
});
app.put('/api/sync/spreadsheet-id', tenantGuard, async (req, res) => {
const { spreadsheetId } = req.body;
if (!spreadsheetId || !spreadsheetId.trim()) return res.status(400).json({ error: 'spreadsheetId obrigatório.' });
await pool.query(
`INSERT INTO settings (id,category,data) VALUES ('sheets_config','sync',$1) ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
[JSON.stringify({ spreadsheetId: spreadsheetId.trim() })]
);
SHEETS_BACKUP_ID = spreadsheetId.trim();
res.json({ success: true, spreadsheetId: SHEETS_BACKUP_ID });
});
app.delete('/api/sync/spreadsheet-id', tenantGuard, async (req, res) => {
await pool.query("DELETE FROM settings WHERE id = 'sheets_config'");
SHEETS_BACKUP_ID = process.env.SHEETS_BACKUP_ID || '1q1sf1GOji2u3qIJEToWfbLCb4rzb_pKLOX_AylMDT4g';
res.json({ success: true, spreadsheetId: SHEETS_BACKUP_ID });
});
app.delete('/api/sync/reset-status', tenantGuard, async (req, res) => {
try {
await pool.query("DELETE FROM settings WHERE id = 'sheets_sync_status'");
res.json({ success: true });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/sync/truncate-all', tenantGuard, async (req, res) => {
try {
await pool.query(`
TRUNCATE TABLE pacientes, agendamentos, financeiro, leads, guias_odontologicas RESTART IDENTITY
`);
await pool.query("DELETE FROM settings WHERE id = 'sheets_sync_status'");
res.json({ success: true });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.get('/api/sync/duplicatas', tenantGuard, async (req, res) => {
function groupRows(rows, keyField, criterion, pick) {
const groups = {};
for (const r of rows) {
const k = r[keyField];
if (!k) continue;
if (!groups[k]) groups[k] = { criterion, key: k, records: [] };
groups[k].records.push(pick(r));
}
return Object.values(groups);
}
try {
const result = {};
// pacientes — por CPF
const { rows: pacCpf } = await pool.query(`
SELECT id, nome, cpf, telefone, email, clinica_id
FROM pacientes
WHERE cpf IS NOT NULL AND TRIM(cpf) != ''
AND cpf IN (SELECT cpf FROM pacientes WHERE cpf IS NOT NULL AND TRIM(cpf) != '' GROUP BY cpf HAVING count(*) > 1)
ORDER BY cpf, nome
`);
result.pacientes = groupRows(pacCpf, 'cpf', 'cpf', r => ({ id: r.id, nome: r.nome, cpf: r.cpf, telefone: r.telefone, email: r.email, clinica_id: r.clinica_id }));
// leads — por CPF
const { rows: leadCpf } = await pool.query(`
SELECT id, nome, sobrenome, cpf, whatsapp, clinica_id
FROM leads
WHERE cpf IS NOT NULL AND TRIM(cpf) != ''
AND cpf IN (SELECT cpf FROM leads WHERE cpf IS NOT NULL AND TRIM(cpf) != '' GROUP BY cpf HAVING count(*) > 1)
ORDER BY cpf, nome
`);
result.leads = groupRows(leadCpf, 'cpf', 'cpf', r => ({ id: r.id, nome: r.nome, sobrenome: r.sobrenome, cpf: r.cpf, whatsapp: r.whatsapp, clinica_id: r.clinica_id }));
// agendamentos — por pacientenome + start_time (ambos não nulos)
const { rows: agRows } = await pool.query(`
SELECT id, pacientenome, start_time::text, status, clinica_id,
pacientenome || '|' || start_time::text AS dup_key
FROM agendamentos
WHERE pacientenome IS NOT NULL AND start_time IS NOT NULL
AND (pacientenome || '|' || start_time::text) IN (
SELECT pacientenome || '|' || start_time::text FROM agendamentos
WHERE pacientenome IS NOT NULL AND start_time IS NOT NULL
GROUP BY 1 HAVING count(*) > 1
)
ORDER BY dup_key
`);
result.agendamentos = groupRows(agRows, 'dup_key', 'paciente+horário', r => ({ id: r.id, pacientenome: r.pacientenome, start_time: r.start_time, status: r.status, clinica_id: r.clinica_id }));
// financeiro — por pacientenome + descricao + valor + datavencimento
const { rows: finRows } = await pool.query(`
SELECT id, pacientenome, descricao, valor::text, datavencimento::text AS datavencimento, status, clinica_id,
pacientenome || '|' || COALESCE(descricao,'') || '|' || COALESCE(valor::text,'') || '|' || COALESCE(datavencimento::text,'') AS dup_key
FROM financeiro
WHERE pacientenome IS NOT NULL
AND (pacientenome || '|' || COALESCE(descricao,'') || '|' || COALESCE(valor::text,'') || '|' || COALESCE(datavencimento::text,'')) IN (
SELECT pacientenome || '|' || COALESCE(descricao,'') || '|' || COALESCE(valor::text,'') || '|' || COALESCE(datavencimento::text,'')
FROM financeiro WHERE pacientenome IS NOT NULL GROUP BY 1 HAVING count(*) > 1
)
ORDER BY dup_key
`);
result.financeiro = groupRows(finRows, 'dup_key', 'paciente+descrição+valor+data', r => ({ id: r.id, pacientenome: r.pacientenome, descricao: r.descricao, valor: r.valor, datavencimento: r.datavencimento, status: r.status, clinica_id: r.clinica_id }));
// guias — por numeroguiaprestador
const { rows: guiaRows } = await pool.query(`
SELECT id, numeroguiaprestador, beneficiarionome, status, COALESCE(clinica_id, NULL) AS clinica_id
FROM guias_odontologicas
WHERE numeroguiaprestador IS NOT NULL AND TRIM(numeroguiaprestador) != ''
AND numeroguiaprestador IN (
SELECT numeroguiaprestador FROM guias_odontologicas
WHERE numeroguiaprestador IS NOT NULL AND TRIM(numeroguiaprestador) != ''
GROUP BY numeroguiaprestador HAVING count(*) > 1
)
ORDER BY numeroguiaprestador
`);
result.guias = groupRows(guiaRows, 'numeroguiaprestador', 'nº guia prestador', r => ({ id: r.id, numeroguiaprestador: r.numeroguiaprestador, beneficiarionome: r.beneficiarionome, status: r.status, clinica_id: r.clinica_id }));
res.json(result);
} catch (err) {
console.error('[DUPLICATAS]', err.message);
res.status(500).json({ error: err.message });
}
});
const DUP_TABLE_MAP = {
pacientes: 'pacientes',
leads: 'leads',
agendamentos: 'agendamentos',
financeiro: 'financeiro',
guias: 'guias_odontologicas'
};
app.delete('/api/sync/duplicatas', tenantGuard, async (req, res) => {
const { ids, tabela } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0 || !tabela) {
return res.status(400).json({ error: 'ids[] e tabela são obrigatórios.' });
}
const pgTable = DUP_TABLE_MAP[tabela];
if (!pgTable) return res.status(400).json({ error: 'Tabela inválida.' });
try {
const { rowCount } = await pool.query(`DELETE FROM ${pgTable} WHERE id = ANY($1)`, [ids]);
res.json({ success: true, removed: rowCount });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- SUPERADMIN ---
async function superadminGuard(req, res, next) {
const auth = req.headers['authorization'];
if (!auth?.startsWith('Bearer ')) return res.status(401).json({ error: 'Não autenticado.' });
try {
const payload = jwt.verify(auth.split(' ')[1], JWT_SECRET);
const { rows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [payload.userId]);
if (!rows[0] || rows[0].role !== 'superadmin') return res.status(403).json({ error: 'Acesso negado.' });
req.authUser = payload;
next();
} catch (e) {
if (e.name === 'JsonWebTokenError' || e.name === 'TokenExpiredError') return res.status(401).json({ error: 'Token inválido.' });
res.status(500).json({ error: e.message });
}
}
app.get('/api/superadmin/stats', superadminGuard, async (req, res) => {
try {
const [usersRes, clinicasRes, loginsRes, semAssRes] = await Promise.all([
pool.query(`SELECT role, COUNT(*) AS total, COUNT(*) FILTER (WHERE ativo = false) AS suspensos FROM usuarios GROUP BY role`),
pool.query(`SELECT COUNT(*) AS n FROM clinicas`),
pool.query(`SELECT COUNT(*) AS n FROM login_log WHERE created_at >= CURRENT_DATE AND status = 'ok'`),
pool.query(`SELECT COUNT(*) AS n FROM usuarios u WHERE u.ativo = true AND NOT EXISTS (SELECT 1 FROM assinaturas a WHERE a.usuario_id = u.id AND a.status = 'ativo')`),
]);
res.json({
usuarios: usersRes.rows,
clinicas: parseInt(clinicasRes.rows[0].n),
logins_hoje: parseInt(loginsRes.rows[0].n),
sem_assinatura: parseInt(semAssRes.rows[0].n),
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/superadmin/clinicas', superadminGuard, async (req, res) => {
const { search = '', estado = '' } = req.query;
try {
const { rows } = await pool.query(`
SELECT c.id, c.nome_fantasia, c.documento, c.estado, c.cidade, c.ativo, c.createdat,
(SELECT COUNT(*) FROM vinculos v WHERE v.clinica_id = c.id) AS membros,
(SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
WHERE v.clinica_id = c.id AND v.role IN ('donoclinica','donoconsultorio') LIMIT 1) AS dono_nome,
(SELECT v.role FROM vinculos v
WHERE v.clinica_id = c.id AND v.role IN ('donoclinica','donoconsultorio') LIMIT 1) AS dono_role
FROM clinicas c
WHERE ($1 = '' OR c.nome_fantasia ILIKE '%' || $1 || '%' OR c.cidade ILIKE '%' || $1 || '%' OR c.documento ILIKE '%' || $1 || '%')
AND ($2 = '' OR c.estado = $2)
ORDER BY c.nome_fantasia
`, [search, estado]);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/superadmin/clinicas/:id', superadminGuard, async (req, res) => {
try {
await pool.query('DELETE FROM vinculos WHERE clinica_id = $1', [req.params.id]);
await pool.query('DELETE FROM clinicas WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/superadmin/usuarios', superadminGuard, async (req, res) => {
const { page = '1', pageSize = '20', clinicaId = '', search = '' } = req.query;
const limit = Math.min(parseInt(String(pageSize)) || 20, 100);
const offset = (Math.max(parseInt(String(page)) || 1, 1) - 1) * limit;
try {
const baseWhere = clinicaId
? `JOIN vinculos v ON v.usuario_id = u.id AND v.clinica_id = $3`
: '';
const searchCond = search ? `AND (u.nome ILIKE '%' || $${clinicaId ? 4 : 3} || '%' OR u.email ILIKE '%' || $${clinicaId ? 4 : 3} || '%')` : '';
const params = clinicaId
? (search ? [limit, offset, clinicaId, search] : [limit, offset, clinicaId])
: (search ? [limit, offset, search] : [limit, offset]);
const { rows } = await pool.query(`
SELECT u.id, u.nome, u.email, u.role, u.ativo, u.createdat, u.celular,
a.status AS assinatura_status, a.validade AS assinatura_validade, a.valor AS assinatura_valor
FROM usuarios u
${baseWhere}
LEFT JOIN assinaturas a ON a.usuario_id = u.id
WHERE u.role != 'superadmin' ${searchCond}
ORDER BY u.nome
LIMIT $1 OFFSET $2
`, params);
const { rows: ct } = await pool.query(`
SELECT COUNT(*) AS n FROM usuarios u ${baseWhere}
WHERE u.role != 'superadmin' ${searchCond}
`, params.slice(2));
res.json({ data: rows, total: parseInt(ct[0].n) });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/superadmin/usuarios/:id/status', superadminGuard, async (req, res) => {
try {
const { ativo } = req.body;
await pool.query('UPDATE usuarios SET ativo = $1 WHERE id = $2', [!!ativo, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/superadmin/usuarios/:id', superadminGuard, async (req, res) => {
try {
await pool.query('DELETE FROM vinculos WHERE usuario_id = $1', [req.params.id]);
await pool.query('DELETE FROM usuarios WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/superadmin/assinaturas/:userId', superadminGuard, async (req, res) => {
const { status, meses, valor } = req.body;
const validade = meses > 0 ? new Date(Date.now() + meses * 30 * 24 * 60 * 60 * 1000) : null;
try {
const { rowCount } = await pool.query(
`UPDATE assinaturas SET status=$2, valor=$3, validade=$4 WHERE usuario_id=$1`,
[req.params.userId, status, valor || 0, validade]
);
if (rowCount === 0) {
await pool.query(
`INSERT INTO assinaturas (id, usuario_id, status, valor, validade, created_at) VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW())`,
[req.params.userId, status, valor || 0, validade]
);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/superadmin/login-log', superadminGuard, async (req, res) => {
const { page = '1', pageSize = '50' } = req.query;
const limit = Math.min(parseInt(String(pageSize)) || 50, 200);
const offset = (Math.max(parseInt(String(page)) || 1, 1) - 1) * limit;
try {
const { rows } = await pool.query(`
SELECT l.id, l.email, l.ip, l.status, l.created_at,
u.nome, u.role
FROM login_log l
LEFT JOIN usuarios u ON u.email = l.email
ORDER BY l.created_at DESC
LIMIT $1 OFFSET $2
`, [limit, offset]);
const { rows: ct } = await pool.query(`SELECT COUNT(*) AS n FROM login_log`);
res.json({ data: rows, total: parseInt(ct[0].n) });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/superadmin/pricing', superadminGuard, async (req, res) => {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'pricing'");
if (rows[0]) return res.json(JSON.parse(rows[0].data));
res.json({ donoconsultorio: 49.90, donoclinica: 499.00, dentista: 49.90, protetico: 49.90, paciente: 0 });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/superadmin/pricing', superadminGuard, async (req, res) => {
try {
await pool.query(
`INSERT INTO settings (id,category,data) VALUES ('pricing','superadmin',$1) ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
[JSON.stringify(req.body)]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/superadmin/notificacoes-history', superadminGuard, async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT n.*, u.nome AS dest_nome FROM notificacoes n
LEFT JOIN usuarios u ON u.id = n.usuario_id
ORDER BY n.data DESC LIMIT 100
`);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/superadmin/notificacoes', superadminGuard, async (req, res) => {
const { titulo, mensagem, tipo, usuario_id } = req.body;
if (!titulo?.trim() || !mensagem?.trim()) return res.status(400).json({ error: 'Título e mensagem obrigatórios.' });
try {
if (usuario_id) {
await pool.query(
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por)
VALUES (gen_random_uuid(), $1, $2, $3, false, NOW(), $4, 'superadmin')`,
[titulo.trim(), mensagem.trim(), tipo || 'info', usuario_id]
);
} else {
const { rows: users } = await pool.query(`SELECT id FROM usuarios WHERE role != 'superadmin' AND ativo = true`);
for (const u of users) {
await pool.query(
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por)
VALUES (gen_random_uuid(), $1, $2, $3, false, NOW(), $4, 'superadmin')`,
[titulo.trim(), mensagem.trim(), tipo || 'info', u.id]
);
}
}
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- RX SCOREODONTO PLUGIN ---
app.get('/api/rx/config', authGuard, async (req, res) => {
const { clinicaId } = req.query;
const key = `rx_config_${clinicaId || 'global'}`;
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = $1", [key]);
if (rows[0]) return res.json(JSON.parse(rows[0].data));
res.json({ rx_url: process.env.RX_URL || 'https://rx.scoreodonto.com', rx_token: process.env.RX_TOKEN || '' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/rx/config', tenantGuard, async (req, res) => {
const { clinicaId, rx_url, rx_token } = req.body;
const key = `rx_config_${clinicaId || 'global'}`;
try {
await pool.query(
`INSERT INTO settings (id,category,data) VALUES ($1,'rx',$2) ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
[key, JSON.stringify({ rx_url: (rx_url || '').trim(), rx_token: (rx_token || '').trim() })]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/rx/devices', authGuard, async (req, res) => {
const { clinicaId } = req.query;
const key = `rx_config_${clinicaId || 'global'}`;
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = $1", [key]);
const cfg = rows[0] ? JSON.parse(rows[0].data) : {};
if (!cfg.rx_url || !cfg.rx_token) return res.json([]);
const r = await fetch(`${cfg.rx_url}/api/admin/clinics`, {
headers: { 'Authorization': `Bearer ${cfg.rx_token}` }
});
const data = await r.json();
res.json(data.clinics || []);
} catch (err) { res.json([]); }
});
app.post('/api/rx/devices', tenantGuard, async (req, res) => {
const { clinicaId, pcName, clinicName, email, password } = req.body;
const key = `rx_config_${clinicaId || 'global'}`;
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = $1", [key]);
const cfg = rows[0] ? JSON.parse(rows[0].data) : {};
if (!cfg.rx_url || !cfg.rx_token) return res.status(400).json({ success: false, message: 'RX não configurado para esta clínica.' });
const r = await fetch(`${cfg.rx_url}/api/admin/clinics`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${cfg.rx_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ clinic_name: clinicName || clinicaId, email: email || `${clinicaId}@rx.local`, password: password || clinicaId, pc_name: pcName })
});
res.json(await r.json());
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// --- RECUPERAÇÃO DE SENHA ---
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body;
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
try {
const { rows } = await pool.query('SELECT id FROM usuarios WHERE email = $1', [email.toLowerCase().trim()]);
// Responde sempre OK para não revelar se o e-mail existe
res.json({ success: true, message: 'Se o e-mail estiver cadastrado, você receberá as instruções em breve.' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Start server after all routes are registered
// =============================================================================
// ORTODONTIA · DOCUMENTAÇÃO FOTOGRÁFICA · TUTORIA
// =============================================================================
const _oid = (p) => `${p}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
async function getTutorConfig() {
const { rows } = await pool.query(`SELECT * FROM ortho_tutor_config WHERE id = 'default'`);
return rows[0] || { id: 'default', comissao_pct: 20, taxa_inscricao: 0, texto_responsabilidade: '' };
}
// Ativa a candidatura e cria/atualiza o perfil de tutor. Reusado pelo admin
// (confirmar pagamento manual) e pelo webhook do MercadoPago. Idempotente.
async function ativarCandidaturaTutor(client, candId) {
const { rows } = await client.query('SELECT * FROM ortho_tutor_candidatura WHERE id = $1', [candId]);
if (!rows.length) throw new Error('Candidatura não encontrada.');
const c = rows[0];
await client.query(`UPDATE ortho_tutor_candidatura SET status = 'ATIVO' WHERE id = $1`, [candId]);
const { rows: ex } = await client.query('SELECT id FROM ortho_tutor_profile WHERE usuario_id = $1', [c.usuario_id]);
if (ex.length) {
await client.query(`UPDATE ortho_tutor_profile SET nome=$1, especialidade=$2, bio=$3, preco_base=$4, status='ATIVO', disponivel=true WHERE usuario_id=$5`,
[c.nome, c.especialidade_principal, c.bio, c.preco_por_consulta, c.usuario_id]);
} else {
await client.query(`INSERT INTO ortho_tutor_profile
(id, usuario_id, nome, especialidade, especialidades_adicionais, bio, preco_base, sla_horas, cro, cro_uf, titulacao, instituicao, anos_experiencia, portfolio_url, linkedin_url, status, disponivel)
VALUES ($1,$2,$3,$4,$5,$6,$7,48,$8,$9,$10,$11,$12,$13,$14,'ATIVO',true)`,
[_oid('tut'), c.usuario_id, c.nome, c.especialidade_principal, JSON.stringify(c.especialidades_adicionais || []), c.bio, c.preco_por_consulta,
c.cro, c.cro_uf, c.titulacao, c.instituicao, c.anos_experiencia, c.portfolio_url, c.linkedin_url]);
}
}
// --- TRATAMENTOS ORTODÔNTICOS (CRM) ---
const TRAT_COLS = ['clinica_id', 'paciente_id', 'paciente_nome', 'data_nascimento', 'responsavel', 'tipo_paciente', 'triagem', 'diagnostico', 'data_inicio', 'tipo_aparelho', 'extracoes', 'sequencia_fios', 'elasticos', 'ipr_planejado', 'tempo_estimado', 'valor', 'forma_pagamento', 'fase_atual', 'fio_atual', 'classificacao', 'cooperacao', 'percentual_concluido', 'ultima_consulta', 'proxima_consulta', 'meses_restantes', 'status', 'flags', 'contencao_tipo', 'contencao_superior', 'contencao_inferior', 'tempo_uso'];
const TRAT_JSON_COLS = new Set(['triagem', 'diagnostico', 'flags']);
const tratVal = (col, v) => (TRAT_JSON_COLS.has(col) && v != null && typeof v !== 'string') ? JSON.stringify(v) : v;
app.get('/api/orto/tratamentos', authGuard, async (req, res) => {
try {
const { clinicaId } = req.query;
const { rows: trats } = clinicaId
? await pool.query('SELECT * FROM ortho_tratamento WHERE clinica_id = $1 ORDER BY created_at DESC', [clinicaId])
: await pool.query('SELECT * FROM ortho_tratamento ORDER BY created_at DESC');
const byT = {};
if (trats.length) {
const { rows: evos } = await pool.query('SELECT * FROM ortho_evolucao WHERE tratamento_id = ANY($1) ORDER BY data ASC, created_at ASC', [trats.map(t => t.id)]);
for (const e of evos) (byT[e.tratamento_id] ||= []).push(e);
}
res.json({ success: true, tratamentos: trats.map(t => ({ ...t, evolucoes: byT[t.id] || [] })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/orto/tratamentos', authGuard, async (req, res) => {
try {
const b = req.body || {};
if (!b.paciente_nome) return res.status(400).json({ success: false, message: 'Nome do paciente é obrigatório.' });
const id = _oid('otr');
const keys = TRAT_COLS.filter(c => b[c] !== undefined);
const cols = ['id', ...keys];
const vals = [id, ...keys.map(c => tratVal(c, b[c]))];
const ph = cols.map((_, i) => `$${i + 1}`).join(',');
await pool.query(`INSERT INTO ortho_tratamento (${cols.join(',')}) VALUES (${ph})`, vals);
res.json({ success: true, id });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/orto/tratamentos/:id', authGuard, async (req, res) => {
try {
const b = req.body || {};
const keys = TRAT_COLS.filter(c => b[c] !== undefined);
if (!keys.length) return res.json({ success: true });
const set = keys.map((c, i) => `${c} = $${i + 1}`).join(', ');
await pool.query(`UPDATE ortho_tratamento SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(c => tratVal(c, b[c])), req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/orto/tratamentos/:id/evolucoes', authGuard, async (req, res) => {
try {
const b = req.body || {};
const id = _oid('oev');
const data = b.data || new Date().toISOString().slice(0, 10);
const { rows } = await pool.query(`INSERT INTO ortho_evolucao (id, tratamento_id, data, tipo, procedimento, proximo_retorno, fio, elastico, procedimentos, higiene, cooperacao, observacoes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`,
[id, req.params.id, data, b.tipo || 'Manutenção', b.procedimento || '', b.proximo_retorno || null, b.fio || null, b.elastico || null,
b.procedimentos != null ? JSON.stringify(b.procedimentos) : null, b.higiene ?? null, b.cooperacao || null, b.observacoes || null]);
// mantém o dashboard do tratamento coerente
await pool.query(`UPDATE ortho_tratamento SET ultima_consulta = $1, proxima_consulta = COALESCE($2, proxima_consulta), fio_atual = COALESCE($3, fio_atual), cooperacao = COALESCE($4, cooperacao) WHERE id = $5`,
[data, b.proximo_retorno || null, b.fio || null, b.cooperacao || null, req.params.id]);
res.json({ success: true, evolucao: rows[0] });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/orto/tratamentos/:id/finalizar', authGuard, async (req, res) => {
try {
const { tipo, superior, inferior, tempoUso } = req.body || {};
await pool.query(`UPDATE ortho_tratamento SET status = 'Finalizado', contencao_tipo = $1, contencao_superior = $2, contencao_inferior = $3, tempo_uso = $4 WHERE id = $5`,
[tipo || null, superior ?? null, inferior ?? null, tempoUso || null, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- DOCUMENTAÇÃO FOTOGRÁFICA: sessões ---
app.get('/api/orto/sessoes', authGuard, async (req, res) => {
try {
const { tratamentoId } = req.query;
const { rows: sess } = await pool.query('SELECT id, tipo, rotulo, data_ref FROM ortho_photo_session WHERE tratamento_id = $1 ORDER BY data_ref ASC NULLS FIRST, created_at ASC', [tratamentoId]);
const byS = {};
if (sess.length) {
const ids = sess.map(s => s.id);
const { rows: fotos } = await pool.query('SELECT id, sessao_id, slot, original_name FROM ortho_photo WHERE sessao_id = ANY($1)', [ids]);
for (const f of fotos) (byS[f.sessao_id] ||= []).push({ id: f.id, slot: f.slot, url: `/api/orto/fotos/${f.id}/raw`, original_name: f.original_name });
}
res.json({ success: true, sessoes: sess.map(s => ({ id: s.id, tipo: s.tipo, rotulo: s.rotulo, data_ref: s.data_ref, fotos: byS[s.id] || [] })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// get-or-create de uma sessão (tratamento + tipo)
app.post('/api/orto/sessoes', authGuard, async (req, res) => {
try {
const { tratamento_id, tipo, clinica_id, rotulo, data_ref } = req.body;
if (!tratamento_id || !tipo) return res.status(400).json({ success: false, message: 'tratamento_id e tipo são obrigatórios.' });
let { rows } = await pool.query('SELECT id, tipo, rotulo, data_ref FROM ortho_photo_session WHERE tratamento_id = $1 AND tipo = $2', [tratamento_id, tipo]);
if (!rows.length) {
const id = _oid('ops');
await pool.query('INSERT INTO ortho_photo_session (id, tratamento_id, clinica_id, tipo, rotulo, data_ref) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (tratamento_id, tipo) DO NOTHING', [id, tratamento_id, clinica_id || null, tipo, rotulo || null, data_ref || null]);
({ rows } = await pool.query('SELECT id, tipo, rotulo, data_ref FROM ortho_photo_session WHERE tratamento_id = $1 AND tipo = $2', [tratamento_id, tipo]));
}
const s = rows[0];
const { rows: fotos } = await pool.query('SELECT id, slot, original_name FROM ortho_photo WHERE sessao_id = $1', [s.id]);
res.json({ success: true, sessao: { id: s.id, tipo: s.tipo, rotulo: s.rotulo, data_ref: s.data_ref, fotos: fotos.map(f => ({ id: f.id, slot: f.slot, url: `/api/orto/fotos/${f.id}/raw`, original_name: f.original_name })) } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- DOCUMENTAÇÃO FOTOGRÁFICA: fotos ---
app.post('/api/orto/fotos', authGuard, ortoUpload.single('foto'), async (req, res) => {
try {
const { sessao_id, slot } = req.body;
if (!sessao_id || !slot || !req.file) return res.status(400).json({ success: false, message: 'sessao_id, slot e foto são obrigatórios.' });
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
const dir = path.join(MEDIA_ROOT, 'orto', sessao_id);
await fs.promises.mkdir(dir, { recursive: true });
// substitui a foto existente no mesmo slot (1 foto por slot)
const { rows: old } = await pool.query('SELECT id, filename FROM ortho_photo WHERE sessao_id = $1 AND slot = $2', [sessao_id, slot]);
for (const o of old) { try { await fs.promises.unlink(path.join(MEDIA_ROOT, o.filename)); } catch {} }
await pool.query('DELETE FROM ortho_photo WHERE sessao_id = $1 AND slot = $2', [sessao_id, slot]);
const id = _oid('oph');
const rel = path.join('orto', sessao_id, `${slot}_${Date.now()}.${ext}`);
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
await pool.query('INSERT INTO ortho_photo (id, sessao_id, slot, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)', [id, sessao_id, slot, rel, req.file.mimetype, req.file.originalname]);
res.json({ success: true, foto: { id, slot, url: `/api/orto/fotos/${id}/raw`, original_name: req.file.originalname } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// stream público da imagem (img tags não enviam Authorization)
app.get('/api/orto/fotos/:id/raw', async (req, res) => {
try {
const { rows } = await pool.query('SELECT filename, mimetype FROM ortho_photo WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).end();
res.type(rows[0].mimetype || 'image/jpeg');
res.sendFile(path.join(MEDIA_ROOT, rows[0].filename), (err) => { if (err && !res.headersSent) res.status(404).end(); });
} catch { res.status(404).end(); }
});
app.delete('/api/orto/fotos/:id', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT filename FROM ortho_photo WHERE id = $1', [req.params.id]);
if (rows.length) { try { await fs.promises.unlink(path.join(MEDIA_ROOT, rows[0].filename)); } catch {} }
await pool.query('DELETE FROM ortho_photo_annotation WHERE foto_id = $1', [req.params.id]);
await pool.query('DELETE FROM ortho_photo WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- DOCUMENTAÇÃO FOTOGRÁFICA: anotações sobre a imagem ---
app.get('/api/orto/fotos/:id/anotacoes', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT id, dados FROM ortho_photo_annotation WHERE foto_id = $1 ORDER BY created_at', [req.params.id]);
res.json({ success: true, anotacoes: rows.map(r => ({ id: r.id, dados: r.dados })) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/orto/fotos/:id/anotacoes', authGuard, async (req, res) => {
try {
const anotacoes = Array.isArray(req.body?.anotacoes) ? req.body.anotacoes : [];
await withTransaction(async (client) => {
await client.query('DELETE FROM ortho_photo_annotation WHERE foto_id = $1', [req.params.id]);
for (const a of anotacoes) {
await client.query('INSERT INTO ortho_photo_annotation (id, foto_id, dados) VALUES ($1,$2,$3)', [_oid('ann'), req.params.id, JSON.stringify(a)]);
}
});
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- TUTORES (perfis ativos) ---
app.get('/api/tutores', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(`SELECT id, nome, especialidade, bio, preco_base, sla_horas FROM ortho_tutor_profile WHERE status = 'ATIVO' AND disponivel = true ORDER BY nome`);
res.json({ success: true, tutores: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.get('/api/tutores/por-usuario/:usuarioId', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM ortho_tutor_profile WHERE usuario_id = $1 LIMIT 1', [req.params.usuarioId]);
res.json({ success: true, tutor: rows[0] || null });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/tutores/:id/credenciais', authGuard, async (req, res) => {
try {
const { pix_key, mp_email, aceitar_split } = req.body;
if (!pix_key) return res.status(400).json({ success: false, message: 'Chave PIX obrigatória.' });
const cfg = await getTutorConfig();
const comissao = cfg.comissao_pct ?? 20;
await pool.query('UPDATE ortho_tutor_profile SET pix_key = $1, mp_email = $2, aceite_split_pct = $3, aceite_split_at = $4 WHERE id = $5',
[pix_key, mp_email || null, aceitar_split ? comissao : null, aceitar_split ? new Date() : null, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- TUTORIA: solicitações ---
app.post('/api/tutoria/solicitacoes', authGuard, async (req, res) => {
try {
const b = req.body;
if (!b.tutor_id || !b.tratamento_id) return res.status(400).json({ success: false, message: 'Tutor e tratamento são obrigatórios.' });
const cfg = await getTutorConfig();
const comissao = cfg.comissao_pct ?? 20;
const { rows: tr } = await pool.query('SELECT preco_base FROM ortho_tutor_profile WHERE id = $1', [b.tutor_id]);
const valor = tr[0]?.preco_base != null ? Number(tr[0].preco_base) : null;
const valorTutor = valor != null ? valor * (1 - comissao / 100) : null;
const id = _oid('treq');
await pool.query(`INSERT INTO ortho_tutoring_request
(id, tratamento_id, clinica_id, tutor_id, paciente_nome, tipo, descricao, status, aceite_responsabilidade, aceite_nome, aceite_texto_versao, valor_consultoria, comissao_pct, valor_tutor, status_repasse)
VALUES ($1,$2,$3,$4,$5,$6,$7,'AGUARDANDO',$8,$9,$10,$11,$12,$13,'PENDENTE')`,
[id, b.tratamento_id, b.clinica_id || null, b.tutor_id, b.paciente_nome || null, b.tipo || null, b.descricao || null,
b.aceite_responsabilidade || false, b.aceite_nome || null, b.aceite_texto_versao || null, valor, comissao, valorTutor]);
// pacote automático: snapshot de todas as fotos do tratamento
let fotosCount = 0;
const { rows: sess } = await pool.query('SELECT id FROM ortho_photo_session WHERE tratamento_id = $1', [b.tratamento_id]);
if (sess.length) {
const { rows: fotos } = await pool.query('SELECT id, slot FROM ortho_photo WHERE sessao_id = ANY($1)', [sess.map(s => s.id)]);
for (const f of fotos) {
await pool.query('INSERT INTO ortho_tutoring_photo_package (id, request_id, foto_id, slot) VALUES ($1,$2,$3,$4)', [_oid('pkg'), id, f.id, f.slot]);
fotosCount++;
}
}
res.json({ success: true, fotos_incluidas: fotosCount });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.get('/api/tutoria/solicitacoes', authGuard, async (req, res) => {
try {
const { clinicaId, tratamentoId } = req.query;
let sql = `SELECT r.*, t.nome AS tutor_nome, t.especialidade AS tutor_especialidade, t.bio AS tutor_bio, t.sla_horas,
(SELECT COUNT(*) FROM ortho_tutoring_message m WHERE m.request_id = r.id) AS total_mensagens
FROM ortho_tutoring_request r LEFT JOIN ortho_tutor_profile t ON t.id = r.tutor_id WHERE 1=1`;
const params = []; let i = 1;
if (clinicaId) { sql += ` AND r.clinica_id = $${i++}`; params.push(clinicaId); }
if (tratamentoId) { sql += ` AND r.tratamento_id = $${i++}`; params.push(tratamentoId); }
sql += ' ORDER BY r.created_at DESC';
const { rows } = await pool.query(sql, params);
res.json({ success: true, solicitacoes: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.get('/api/tutoria/solicitacoes/:id', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(`SELECT r.*, t.nome AS tutor_nome, t.especialidade AS tutor_especialidade, t.bio AS tutor_bio, t.sla_horas
FROM ortho_tutoring_request r LEFT JOIN ortho_tutor_profile t ON t.id = r.tutor_id WHERE r.id = $1`, [req.params.id]);
if (!rows.length) return res.json({ success: false });
const sol = rows[0];
const { rows: msgs } = await pool.query('SELECT * FROM ortho_tutoring_message WHERE request_id = $1 ORDER BY created_at', [req.params.id]);
const { rows: pkg } = await pool.query('SELECT foto_id, slot FROM ortho_tutoring_photo_package WHERE request_id = $1', [req.params.id]);
sol.mensagens = msgs;
sol.fotos = pkg.map(p => ({ id: p.foto_id, url: `/api/orto/fotos/${p.foto_id}/raw`, slot: p.slot }));
res.json({ success: true, solicitacao: sol });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/tutoria/solicitacoes/:id/mensagens', authGuard, async (req, res) => {
try {
const { conteudo, autor_tipo, autor_nome } = req.body;
if (!conteudo) return res.status(400).json({ success: false, message: 'Mensagem vazia.' });
const { rows } = await pool.query(`INSERT INTO ortho_tutoring_message (id, request_id, autor_tipo, autor_nome, conteudo) VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[_oid('tmsg'), req.params.id, autor_tipo || 'CLINICA', autor_nome || null, conteudo]);
res.json({ success: true, mensagem: rows[0] });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/tutoria/solicitacoes/:id/status', authGuard, async (req, res) => {
try {
await pool.query('UPDATE ortho_tutoring_request SET status = $1 WHERE id = $2', [req.body.status, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// --- TUTORIA: candidaturas (lado do candidato) ---
app.get('/api/tutoria/candidaturas/minha', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM ortho_tutor_candidatura WHERE usuario_id = $1 ORDER BY created_at DESC LIMIT 1', [req.query.usuarioId]);
res.json({ success: true, candidatura: rows[0] || null });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/tutoria/candidaturas', authGuard, async (req, res) => {
try {
const b = req.body;
if (!b.nome || !b.cro) return res.status(400).json({ success: false, message: 'Nome e CRO são obrigatórios.' });
const id = _oid('cand');
await pool.query(`INSERT INTO ortho_tutor_candidatura
(id, usuario_id, nome, email, telefone, cro, cro_uf, titulacao, instituicao, ano_conclusao, especialidade_principal, especialidades_adicionais, anos_experiencia, bio, preco_por_consulta, portfolio_url, linkedin_url, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'PENDENTE')`,
[id, b.usuario_id || null, b.nome, b.email || null, b.telefone || null, b.cro, b.cro_uf || null, b.titulacao || null,
b.instituicao || null, b.ano_conclusao || null, b.especialidade_principal || null,
JSON.stringify(b.especialidades_adicionais || []), b.anos_experiencia || null, b.bio || null,
b.preco_por_consulta || null, b.portfolio_url || null, b.linkedin_url || null]);
res.json({ success: true, id });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/tutoria/candidaturas/:id/pagamento', authGuard, async (req, res) => {
try {
const cfg = await getTutorConfig();
const token = cfg.mp_access_token;
const taxa = Number(cfg.taxa_inscricao) || 0;
if (!token) return res.json({ success: false, message: 'PAGAMENTO ONLINE AINDA NÃO CONFIGURADO. AGUARDE A CONFIRMAÇÃO MANUAL DA EQUIPE SCOREODONTO.' });
if (taxa <= 0) return res.json({ success: false, message: 'TAXA DE ATIVAÇÃO NÃO CONFIGURADA PELA EQUIPE.' });
const { rows } = await pool.query('SELECT id, nome, email FROM ortho_tutor_candidatura WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ success: false, message: 'Candidatura não encontrada.' });
const c = rows[0];
const appUrl = process.env.APP_URL || `http://localhost:${process.env.FRONTEND_PORT || 3000}`;
const pref = {
items: [{ title: 'Taxa de ativação — Tutor Ortodôntico Scoreodonto', quantity: 1, unit_price: taxa, currency_id: 'BRL' }],
external_reference: `cand:${c.id}`,
back_urls: {
success: `${appUrl}/?view=candidatura-tutor&payment=success`,
pending: `${appUrl}/?view=candidatura-tutor&payment=pending`,
failure: `${appUrl}/?view=candidatura-tutor&payment=failure`,
},
auto_return: 'approved',
notification_url: `${appUrl}/api/tutoria/mp-webhook`,
...(c.email ? { payer: { email: c.email } } : {}),
};
const mpRes = await fetch('https://api.mercadopago.com/checkout/preferences', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(pref),
});
const data = await mpRes.json().catch(() => ({}));
if (!mpRes.ok || !data.init_point) {
console.error('[MP] preference error:', data?.message || mpRes.status);
return res.json({ success: false, message: 'ERRO AO GERAR LINK DE PAGAMENTO NO MERCADOPAGO. VERIFIQUE AS CREDENCIAIS.' });
}
res.json({ success: true, checkout_url: data.init_point, taxa });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Webhook do MercadoPago (público) — ativa o tutor quando o pagamento é aprovado.
app.post('/api/tutoria/mp-webhook', async (req, res) => {
try {
const cfg = await getTutorConfig();
const token = cfg.mp_access_token;
if (!token) return res.sendStatus(200);
const topic = req.body?.type || req.query?.topic || req.query?.type;
const paymentId = req.body?.data?.id || req.query?.id || req.query?.['data.id'];
if ((topic && topic !== 'payment') || !paymentId) return res.sendStatus(200);
const pr = await fetch(`https://api.mercadopago.com/v1/payments/${paymentId}`, { headers: { 'Authorization': `Bearer ${token}` } });
const pay = await pr.json().catch(() => ({}));
if (pr.ok && pay.status === 'approved') {
const ref = String(pay.external_reference || '');
const candId = ref.startsWith('cand:') ? ref.slice(5) : null;
if (candId) {
await withTransaction((client) => ativarCandidaturaTutor(client, candId));
console.log('[MP] candidatura ativada via webhook:', candId);
}
}
res.sendStatus(200);
} catch (err) { console.error('[MP] webhook error:', err.message); res.sendStatus(200); }
});
// --- ADMIN DE TUTORES ---
app.get('/api/admin/tutor-config', authGuard, async (req, res) => {
try { res.json({ success: true, config: await getTutorConfig() }); }
catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/admin/tutor-config', authGuard, async (req, res) => {
try {
const b = req.body;
await pool.query(`INSERT INTO ortho_tutor_config (id, taxa_inscricao, chave_pix, instrucoes_pagamento, mp_access_token, mp_public_key, comissao_pct, texto_responsabilidade, updated_at)
VALUES ('default',$1,$2,$3,$4,$5,$6,$7,NOW())
ON CONFLICT (id) DO UPDATE SET taxa_inscricao = EXCLUDED.taxa_inscricao, chave_pix = EXCLUDED.chave_pix, instrucoes_pagamento = EXCLUDED.instrucoes_pagamento,
mp_access_token = EXCLUDED.mp_access_token, mp_public_key = EXCLUDED.mp_public_key, comissao_pct = EXCLUDED.comissao_pct,
texto_responsabilidade = EXCLUDED.texto_responsabilidade, updated_at = NOW()`,
[b.taxa_inscricao || 0, b.chave_pix || null, b.instrucoes_pagamento || null, b.mp_access_token || null, b.mp_public_key || null, b.comissao_pct || 20, b.texto_responsabilidade || null]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.get('/api/admin/candidaturas', authGuard, async (req, res) => {
try {
const { status } = req.query;
const { rows } = status
? await pool.query('SELECT * FROM ortho_tutor_candidatura WHERE status = $1 ORDER BY created_at DESC', [status])
: await pool.query('SELECT * FROM ortho_tutor_candidatura ORDER BY created_at DESC');
res.json({ success: true, candidaturas: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.put('/api/admin/candidaturas/:id/status', authGuard, async (req, res) => {
try {
const { status, observacao } = req.body;
const novo = status === 'APROVADO' ? 'AGUARDANDO_PAGAMENTO' : status;
await pool.query('UPDATE ortho_tutor_candidatura SET status = $1, observacao_admin = $2 WHERE id = $3', [novo, observacao || null, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// confirma pagamento → ativa candidatura e cria/atualiza o perfil de tutor
app.post('/api/admin/candidaturas/:id/confirmar-pagamento', authGuard, async (req, res) => {
try {
await withTransaction((client) => ativarCandidaturaTutor(client, req.params.id));
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
app.get('/api/admin/repasses', authGuard, async (req, res) => {
try {
const { rows } = await pool.query(`SELECT r.id, r.paciente_nome, r.valor_consultoria, r.valor_tutor, r.comissao_pct, COALESCE(r.status_repasse,'PENDENTE') AS status_repasse,
r.created_at, t.nome AS tutor_nome, t.pix_key
FROM ortho_tutoring_request r LEFT JOIN ortho_tutor_profile t ON t.id = r.tutor_id
WHERE r.valor_consultoria IS NOT NULL AND COALESCE(r.status_repasse,'PENDENTE') <> 'PAGO' ORDER BY r.created_at`);
res.json({ success: true, repasses: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.post('/api/admin/repasses/:id/confirmar', authGuard, async (req, res) => {
try {
await pool.query(`UPDATE ortho_tutoring_request SET status_repasse = 'PAGO' WHERE id = $1`, [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
app.listen(PORT, '0.0.0.0', async () => {
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
await loadGoogleCredsFromDb();
await runMigrations();
await loadSheetsConfig();
generateIntelligentNotifications();
setInterval(generateIntelligentNotifications, 5 * 60 * 1000);
});