ca7eba4531
Superadmin / Visualizar como: - adiciona BIOMÉDICO(A) à lista de preview do superadmin - enterPreview ativa os plugins (RX/Salas/Profissionais) p/ teste; exitPreview restaura Locação de Salas — ativação POR USUÁRIO (independe de clínica): - backend: tabela usuario_plugins + GET/POST /api/me/plugins (authGuard, sem tenant) - sala continua dona=owner_usuario_id (pessoa) + salaID, clinica_id opcional - backend.ts: getMyPlugins/setMyPlugin/hydrateMyPlugins; login + App montam o hydrate - isViewAllowed: 'salas' liberado se plugin ativo na conta; catálogo 'plugins' p/ não-paciente - Sidebar: entrada ADD-ONS/PLUGINS p/ papéis operacionais ativarem - PluginsView: toggle de locacao-salas persiste no servidor - escopo: só locacao-salas é server-backed (RX/Profissionais seguem localStorage) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5566 lines
304 KiB
JavaScript
5566 lines
304 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 { WebSocketServer } from 'ws';
|
||
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.
|
||
// ── WebSocket: tempo real por clínica ───────────────────────────────────────
|
||
// Registro de conexões vivas agrupadas por clínica. wsBroadcast() empurra um
|
||
// aviso de mudança para todos os clientes daquela clínica → refetch instantâneo.
|
||
const wsClients = new Map(); // clinicaId -> Set<ws>
|
||
function wsBroadcast(clinicaId, type, payload = {}) {
|
||
if (!clinicaId) return;
|
||
const set = wsClients.get(clinicaId);
|
||
const n = set ? set.size : 0;
|
||
console.log(`[WS] broadcast type=${type} clinica=${String(clinicaId).slice(0, 12)} -> ${n} cliente(s)`);
|
||
if (!n) return;
|
||
const msg = JSON.stringify({ type, ...payload, at: Date.now() });
|
||
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(msg); } catch { /* ignore */ } } }
|
||
}
|
||
// Entidades cujas mudanças interessam à agenda em tempo real.
|
||
const WS_AGENDA_ENTIDADES = new Set(['agendamento', 'avaliacao']);
|
||
|
||
async function registrarAudit(q, { clinicaId, entidade, entidadeId, acao, actorId, actorNome, detalhes }) {
|
||
const run = q || pool;
|
||
await run.query(
|
||
`INSERT INTO audit_log (id, clinica_id, entidade, entidade_id, acao, actor_id, actor_nome, detalhes, at)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())`,
|
||
['au_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), clinicaId || null, entidade, entidadeId || null, acao, actorId || null, actorNome || null, JSON.stringify(detalhes || {})]
|
||
);
|
||
// Avisa os clientes conectados que a agenda mudou (chokepoint único de mutações).
|
||
if (WS_AGENDA_ENTIDADES.has(entidade)) wsBroadcast(clinicaId, 'agenda', { entidade, entidadeId, acao });
|
||
}
|
||
// Abre uma pendência "a reagendar" (motivo: falta | cancelado | remarcar).
|
||
async function abrirPendencia(q, { clinicaId, pacienteId, pacienteNome, origemId, motivo }) {
|
||
const run = q || pool;
|
||
await run.query(
|
||
`INSERT INTO agenda_pendencias (id, clinica_id, paciente_id, paciente_nome, origem_agendamento_id, motivo, status, created_at)
|
||
VALUES ($1,$2,$3,$4,$5,$6,'aberta',NOW())`,
|
||
['pend_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), clinicaId || null, pacienteId || null, pacienteNome || null, origemId || null, motivo]
|
||
);
|
||
}
|
||
// Resolve pendências abertas de um paciente (ex.: ao surgir um novo agendamento futuro).
|
||
async function resolverPendenciasPaciente(q, clinicaId, pacienteId, { by, byNome, resolucao }) {
|
||
if (!pacienteId) return;
|
||
const run = q || pool;
|
||
await run.query(
|
||
`UPDATE agenda_pendencias SET status='resolvida', resolved_at=NOW(), resolved_by=$3, resolved_by_nome=$4, resolucao=$5
|
||
WHERE clinica_id=$1 AND paciente_id=$2 AND status='aberta'`,
|
||
[clinicaId || null, pacienteId, by || null, byNome || null, resolucao || 'reagendado']
|
||
);
|
||
}
|
||
// Slot liberado (cancel/falta/remarcar) → notifica (como SISTEMA) interesses sobrepostos (Fase 4).
|
||
async function notificarInteressesSlotLivre(a) {
|
||
try {
|
||
const { rows: dr } = await pool.query('SELECT usuario_id FROM dentistas WHERE id = $1', [a.dentistaid]);
|
||
const profUid = dr[0]?.usuario_id || null;
|
||
if (!profUid) return;
|
||
const fim = a.end_time || a.start_time;
|
||
const { rows: ints } = await pool.query(
|
||
`SELECT id, solicitante_usuario_id FROM interesses_agenda
|
||
WHERE profissional_usuario_id = $1 AND status = 'aguardando'
|
||
AND start_time < $3 AND COALESCE(end_time, start_time) > $2`,
|
||
[profUid, a.start_time, fim]);
|
||
for (const it of ints) {
|
||
if (it.solicitante_usuario_id) {
|
||
await pool.query(
|
||
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por)
|
||
VALUES (gen_random_uuid(), $1, $2, 'sucesso', 0, NOW(), $3, 'sistema')`,
|
||
['Horário liberado', 'Um horário que você aguardava ficou livre. Você já pode agendar.', it.solicitante_usuario_id]);
|
||
}
|
||
await pool.query(`UPDATE interesses_agenda SET status = 'liberado' WHERE id = $1`, [it.id]);
|
||
}
|
||
} catch (e) { console.error('[interesses]', e.message); }
|
||
}
|
||
|
||
// --- GOOGLE CALENDAR como BACKUP + NOTIFICAÇÃO (sentido único: sistema → Google) ---
|
||
// Best-effort: nunca quebra o agendamento. Eventos levam marcador extendedProperties
|
||
// private.scoreodonto p/ não duplicarem na leitura (getGoogleEvents filtra esses).
|
||
async function getClinicOAuth(clinicaId) {
|
||
const { rows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1 ORDER BY updated_at DESC NULLS LAST LIMIT 1', [clinicaId]);
|
||
if (!rows.length) return null;
|
||
const tk = rows[0];
|
||
const c = createOAuth2Client();
|
||
c.setCredentials({ refresh_token: tk.refresh_token, access_token: tk.access_token, expiry_date: tk.expiry_date });
|
||
return c;
|
||
}
|
||
// action: 'create' | 'update' | 'delete'. `ag` usa colunas do banco (start_time, pacienteid, etc.).
|
||
async function pushGoogleEvent(action, ag) {
|
||
try {
|
||
if (!ag || !ag.clinica_id) return;
|
||
const auth = await getClinicOAuth(ag.clinica_id);
|
||
if (!auth) return; // clínica sem Google conectado → sem backup/notificação
|
||
const calendar = google.calendar({ version: 'v3', auth });
|
||
|
||
if (action === 'delete') {
|
||
if (ag.google_event_id) {
|
||
await calendar.events.delete({ calendarId: 'primary', eventId: ag.google_event_id, sendUpdates: 'all' }).catch(() => { });
|
||
await pool.query('UPDATE agendamentos SET google_event_id = NULL WHERE id = $1', [ag.id]).catch(() => { });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Convidado: paciente (se tiver e-mail) → Google envia convite + lembretes.
|
||
let attendees; let pacEmail; let pacCelular;
|
||
if (ag.pacienteid) {
|
||
const { rows: pr } = await pool.query('SELECT email, celular FROM pacientes WHERE id = $1', [ag.pacienteid]);
|
||
pacEmail = pr[0]?.email; pacCelular = pr[0]?.celular;
|
||
if (pacEmail && /@/.test(pacEmail)) attendees = [{ email: pacEmail }];
|
||
}
|
||
// Nome do profissional para a descrição.
|
||
let dentNome;
|
||
if (ag.dentistaid) {
|
||
const { rows: dn } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [ag.dentistaid]);
|
||
dentNome = dn[0]?.nome;
|
||
}
|
||
// Descrição rica: dados do agendamento (antes ficava só "cópia de backup").
|
||
const linhas = [];
|
||
if (ag.pacientenome) linhas.push(`👤 Paciente: ${ag.pacientenome}`);
|
||
if (ag.procedimento) linhas.push(`🦷 Procedimento: ${ag.procedimento}`);
|
||
if (dentNome) linhas.push(`🧑⚕️ Profissional: ${dentNome}`);
|
||
if (pacCelular) linhas.push(`📞 Telefone: ${pacCelular}`);
|
||
if (ag.observacoes) linhas.push(`📝 Obs.: ${ag.observacoes}`);
|
||
linhas.push('', '— Agendamento ScoreOdonto (cópia de backup).');
|
||
const resource = {
|
||
summary: `${ag.pacientenome || 'Paciente'}${ag.procedimento ? ' — ' + ag.procedimento : ''}`,
|
||
description: linhas.join('\n'),
|
||
start: { dateTime: new Date(ag.start_time).toISOString() },
|
||
end: { dateTime: new Date(ag.end_time || ag.start_time).toISOString() },
|
||
attendees,
|
||
reminders: { useDefault: false, overrides: [{ method: 'email', minutes: 1440 }, { method: 'popup', minutes: 60 }] },
|
||
extendedProperties: { private: { scoreodonto: ag.id } },
|
||
};
|
||
|
||
if (action === 'update' && ag.google_event_id) {
|
||
try {
|
||
await calendar.events.patch({ calendarId: 'primary', eventId: ag.google_event_id, sendUpdates: 'all', requestBody: resource });
|
||
} catch {
|
||
const ev = await calendar.events.insert({ calendarId: 'primary', sendUpdates: 'all', requestBody: resource });
|
||
await pool.query('UPDATE agendamentos SET google_event_id = $1 WHERE id = $2', [ev.data.id, ag.id]);
|
||
}
|
||
} else {
|
||
const ev = await calendar.events.insert({ calendarId: 'primary', sendUpdates: 'all', requestBody: resource });
|
||
await pool.query('UPDATE agendamentos SET google_event_id = $1 WHERE id = $2', [ev.data.id, ag.id]);
|
||
}
|
||
} catch (e) { console.error('[google-push]', e.message); }
|
||
}
|
||
|
||
// --- REGISTRO DE USUÁRIOS ---
|
||
app.post('/api/register', async (req, res) => {
|
||
const { nome, email, senha, role, workspaceNome, especialidade } = req.body;
|
||
if (!nome || !email || !senha) return res.status(400).json({ success: false, message: 'Nome, e-mail e senha são obrigatórios.' });
|
||
const VALID_ROLES = ['donoconsultorio', 'donoclinica', 'dentista', 'biomedico', 'protetico', 'paciente'];
|
||
const userRole = VALID_ROLES.includes(role) ? role : 'donoclinica';
|
||
try {
|
||
const existing = await pool.query('SELECT id FROM usuarios WHERE email = $1', [email.toLowerCase().trim()]);
|
||
if (existing.rows.length > 0) return res.status(409).json({ success: false, message: 'E-mail já cadastrado.' });
|
||
const userId = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||
const nomeUpper = nome.trim().toUpperCase(); // padrão do sistema: tudo em maiúsculas
|
||
const esp = (Array.isArray(especialidade) ? especialidade[0] : especialidade) || null;
|
||
await pool.query(
|
||
'INSERT INTO usuarios (id, nome, email, senha, role, especialidade) VALUES ($1, $2, $3, $4, $5, $6)',
|
||
[userId, nomeUpper, email.toLowerCase().trim(), await hashSenha(senha), userRole, esp]
|
||
);
|
||
|
||
// Fase 1 — workspace pessoal automático para profissionais e paciente.
|
||
// (donoconsultorio/donoclinica criam a clínica-empresa no passo seguinte.)
|
||
let workspaces = [];
|
||
if (['dentista', 'biomedico', 'protetico', 'paciente'].includes(userRole)) {
|
||
const wsId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
// Nome do estabelecimento informado pelo usuário; senão, default por papel
|
||
// (evita a unidade nascer com o NOME DA PESSOA sem querer).
|
||
const defaultNome = userRole === 'dentista' ? `CONSULTÓRIO DE ${nome.trim()}`
|
||
: userRole === 'biomedico' ? `CONSULTÓRIO DE ${nome.trim()}`
|
||
: userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}`
|
||
: nome.trim();
|
||
const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase();
|
||
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, clinicaId) {
|
||
// Multi-tenant: exporta SOMENTE os dados da clínica que está fazendo o backup
|
||
// (todas as SYNC_TABLES têm coluna clinica_id). Sem isso, vazava tudo de todas.
|
||
const q = clinicaId ? tableDef.query.replace(/ORDER BY/i, 'WHERE clinica_id = $1 ORDER BY') : tableDef.query;
|
||
const { rows } = await pool.query(q, clinicaId ? [clinicaId] : []);
|
||
const realTab = await ensureSheetTab(sheets, tableDef.tab);
|
||
await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
|
||
const sheetRows = [tableDef.headers, ...rows.map(row =>
|
||
tableDef.headers.map(h => {
|
||
const v = row[h] ?? row[h.toLowerCase()];
|
||
return v === null || v === undefined ? '' : String(v);
|
||
})
|
||
)];
|
||
await sheets.spreadsheets.values.update({
|
||
spreadsheetId: SHEETS_BACKUP_ID,
|
||
range: `${realTab}!A1`,
|
||
valueInputOption: 'RAW',
|
||
requestBody: { values: sheetRows }
|
||
});
|
||
return rows.length;
|
||
}
|
||
|
||
async function pullTableFromSheet(tableDef, sheets) {
|
||
const realTab = await ensureSheetTab(sheets, tableDef.tab);
|
||
const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
|
||
const sheetRows = resp.data.values || [];
|
||
if (sheetRows.length < 2) return 0;
|
||
const headers = sheetRows[0];
|
||
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c !== ''));
|
||
let upserted = 0;
|
||
for (const row of dataRows) {
|
||
const obj = {};
|
||
headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
|
||
if (!obj.id) continue;
|
||
try {
|
||
await pool.query(tableDef.upsertSql, tableDef.upsertParams(obj));
|
||
upserted++;
|
||
} catch (e) {
|
||
console.error(`[SHEETS PULL] ${tableDef.tab} row ${obj.id}:`, e.message);
|
||
}
|
||
}
|
||
return upserted;
|
||
}
|
||
|
||
async function saveSyncStatus(updates) {
|
||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
|
||
const current = rows[0] ? JSON.parse(rows[0].data) : {};
|
||
const merged = { ...current, ...updates };
|
||
await pool.query(
|
||
`INSERT INTO settings (id,category,data) VALUES ('sheets_sync_status','sync',$1)
|
||
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
|
||
[JSON.stringify(merged)]
|
||
);
|
||
}
|
||
|
||
// Auto-push GLOBAL desativado: vazava dados de todas as clínicas para a planilha/token
|
||
// do primeiro usuário. O backup agora é MANUAL e POR CLÍNICA (ver /api/clinica-sync/*).
|
||
// Mantido como no-op para não quebrar as ~8 chamadas espalhadas nas rotas de escrita.
|
||
function schedulePush(_tabName) { /* desativado — backup por clínica é manual */ }
|
||
|
||
// =============================================================================
|
||
// BACKUP POR CLÍNICA → Google Sheets (planilha própria, na conta Google da unidade)
|
||
// Cada workspace (clínica/consultório/laboratório/dentista) tem sua planilha,
|
||
// criada automaticamente na conta Google conectada, com SÓ os dados daquela clínica.
|
||
// =============================================================================
|
||
const CLINIC_SYNC_TABLES = [
|
||
{
|
||
tab: 'pacientes',
|
||
query: `SELECT id, nome, cpf, telefone, email, ultimotratamento AS "ultimoTratamento", convenio, datanascimento AS "dataNascimento" FROM pacientes WHERE clinica_id = $1 ORDER BY nome`,
|
||
headers: ['id', 'nome', 'cpf', 'telefone', 'email', 'ultimoTratamento', 'convenio', 'dataNascimento'],
|
||
upsert: `INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,ultimotratamento=EXCLUDED.ultimotratamento,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
|
||
params: (r, cid) => [r.id, cid, r.nome, r.cpf, r.telefone, r.email, r.ultimoTratamento, r.convenio, r.dataNascimento],
|
||
},
|
||
{
|
||
tab: 'agendamentos',
|
||
query: `SELECT id, pacientenome AS "pacienteNome", dentistaid AS "dentistaId", start_time::text, end_time::text, status, procedimento, observacoes FROM agendamentos WHERE clinica_id = $1 ORDER BY start_time DESC`,
|
||
headers: ['id', 'pacienteNome', 'dentistaId', 'start_time', 'end_time', 'status', 'procedimento', 'observacoes'],
|
||
upsert: `INSERT INTO agendamentos (id,clinica_id,pacientenome,dentistaid,start_time,end_time,status,procedimento,observacoes)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,pacientenome=EXCLUDED.pacientenome,dentistaid=EXCLUDED.dentistaid,start_time=EXCLUDED.start_time,end_time=EXCLUDED.end_time,status=EXCLUDED.status,procedimento=EXCLUDED.procedimento,observacoes=EXCLUDED.observacoes`,
|
||
params: (r, cid) => [r.id, cid, r.pacienteNome, r.dentistaId, r.start_time || null, r.end_time || null, r.status, r.procedimento, r.observacoes],
|
||
},
|
||
{
|
||
tab: 'financeiro',
|
||
query: `SELECT id, pacientenome AS "pacienteNome", descricao, valor::text, datavencimento::text AS "dataVencimento", status, formapagamento AS "formaPagamento", tipo FROM financeiro WHERE clinica_id = $1 ORDER BY datavencimento DESC`,
|
||
headers: ['id', 'pacienteNome', 'descricao', 'valor', 'dataVencimento', 'status', 'formaPagamento', 'tipo'],
|
||
upsert: `INSERT INTO financeiro (id,clinica_id,pacientenome,descricao,valor,datavencimento,status,formapagamento,tipo)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,pacientenome=EXCLUDED.pacientenome,descricao=EXCLUDED.descricao,valor=EXCLUDED.valor,datavencimento=EXCLUDED.datavencimento,status=EXCLUDED.status,formapagamento=EXCLUDED.formapagamento,tipo=EXCLUDED.tipo`,
|
||
params: (r, cid) => [r.id, cid, r.pacienteNome, r.descricao, r.valor || null, r.dataVencimento || null, r.status, r.formaPagamento, r.tipo],
|
||
},
|
||
{
|
||
tab: 'leads',
|
||
query: `SELECT id, nome, sobrenome, cpf, whatsapp, tipointeresse AS "tipoInteresse", datacadastro::text AS "dataCadastro", status FROM leads WHERE clinica_id = $1 ORDER BY datacadastro DESC`,
|
||
headers: ['id', 'nome', 'sobrenome', 'cpf', 'whatsapp', 'tipoInteresse', 'dataCadastro', 'status'],
|
||
upsert: `INSERT INTO leads (id,clinica_id,nome,sobrenome,cpf,whatsapp,tipointeresse,datacadastro,status)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,sobrenome=EXCLUDED.sobrenome,cpf=EXCLUDED.cpf,whatsapp=EXCLUDED.whatsapp,tipointeresse=EXCLUDED.tipointeresse,status=EXCLUDED.status`,
|
||
params: (r, cid) => [r.id, cid, r.nome, r.sobrenome, r.cpf, r.whatsapp, r.tipoInteresse, r.dataCadastro || null, r.status],
|
||
},
|
||
];
|
||
|
||
const clinicSheetUrl = (id) => `https://docs.google.com/spreadsheets/d/${id}`;
|
||
|
||
async function getClinicSheetsClient(clinicaId) {
|
||
const { rows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1 ORDER BY updated_at DESC NULLS LAST LIMIT 1', [clinicaId]);
|
||
if (!rows.length) throw new Error('NO_GOOGLE_TOKEN');
|
||
const t = rows[0];
|
||
const auth = createOAuth2Client();
|
||
auth.setCredentials({ refresh_token: t.refresh_token, access_token: t.access_token, expiry_date: t.expiry_date });
|
||
return google.sheets({ version: 'v4', auth });
|
||
}
|
||
|
||
async function ensureClinicSpreadsheet(clinicaId, sheets) {
|
||
const { rows } = await pool.query('SELECT sheets_id, nome_fantasia FROM clinicas WHERE id = $1', [clinicaId]);
|
||
if (!rows.length) throw new Error('CLINICA_NOT_FOUND');
|
||
let id = rows[0].sheets_id;
|
||
if (id) {
|
||
try { await sheets.spreadsheets.get({ spreadsheetId: id, fields: 'spreadsheetId' }); return { id, created: false }; }
|
||
catch { id = null; } // sumiu/sem acesso → recria
|
||
}
|
||
const title = `ScoreOdonto — ${rows[0].nome_fantasia || 'Unidade'}`;
|
||
const r = await sheets.spreadsheets.create({ requestBody: { properties: { title } } });
|
||
id = r.data.spreadsheetId;
|
||
await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [id, clinicaId]);
|
||
return { id, created: true };
|
||
}
|
||
|
||
async function ensureClinicTab(sheets, spreadsheetId, tabName) {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId });
|
||
const ex = meta.data.sheets.find(s => s.properties.title.toLowerCase() === tabName.toLowerCase());
|
||
if (ex) return ex.properties.title;
|
||
await sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] } });
|
||
return tabName;
|
||
}
|
||
|
||
async function pushClinicTable(def, sheets, spreadsheetId, clinicaId) {
|
||
const { rows } = await pool.query(def.query, [clinicaId]);
|
||
const tab = await ensureClinicTab(sheets, spreadsheetId, def.tab);
|
||
await sheets.spreadsheets.values.clear({ spreadsheetId, range: `${tab}!A:ZZ` });
|
||
const values = [def.headers, ...rows.map(row => def.headers.map(h => { const v = row[h] ?? row[h.toLowerCase()]; return v == null ? '' : String(v); }))];
|
||
await sheets.spreadsheets.values.update({ spreadsheetId, range: `${tab}!A1`, valueInputOption: 'RAW', requestBody: { values } });
|
||
return rows.length;
|
||
}
|
||
|
||
async function pullClinicTable(def, sheets, spreadsheetId, clinicaId) {
|
||
const tab = await ensureClinicTab(sheets, spreadsheetId, def.tab);
|
||
const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A:ZZ` });
|
||
const sheetRows = resp.data.values || [];
|
||
if (sheetRows.length < 2) return 0;
|
||
const headers = sheetRows[0];
|
||
let n = 0;
|
||
for (const row of sheetRows.slice(1)) {
|
||
if (!row.some(c => c !== '')) continue;
|
||
const obj = {}; headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
|
||
if (!obj.id) continue;
|
||
try { await pool.query(def.upsert, def.params(obj, clinicaId)); n++; }
|
||
catch (e) { console.error(`[CLINIC-SYNC pull] ${def.tab} ${obj.id}:`, e.message); }
|
||
}
|
||
return n;
|
||
}
|
||
|
||
async function saveClinicSyncStatus(clinicaId, data) {
|
||
await pool.query(`INSERT INTO settings (id,category,data) VALUES ($1,'sync',$2) ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`, [`sync:${clinicaId}`, JSON.stringify(data)]);
|
||
}
|
||
|
||
app.get('/api/clinica-sync/status', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.query.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const { rows: cl } = await pool.query('SELECT sheets_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
const { rows: tk } = await pool.query('SELECT owner_id FROM google_tokens WHERE clinica_id = $1 LIMIT 1', [clinicaId]);
|
||
const counts = {};
|
||
for (const d of CLINIC_SYNC_TABLES) { const { rows } = await pool.query(`SELECT COUNT(*) n FROM ${d.tab} WHERE clinica_id = $1`, [clinicaId]); counts[d.tab] = parseInt(rows[0].n); }
|
||
const { rows: st } = await pool.query('SELECT data FROM settings WHERE id = $1', [`sync:${clinicaId}`]);
|
||
const sheetsId = cl[0]?.sheets_id || null;
|
||
res.json({ success: true, connected: tk.length > 0, googleEmail: tk[0]?.owner_id || null, spreadsheetId: sheetsId, spreadsheetUrl: sheetsId ? clinicSheetUrl(sheetsId) : null, counts, lastSync: st[0] ? JSON.parse(st[0].data) : null });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/clinica-sync/push', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
const logs = [];
|
||
try {
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
const { id, created } = await ensureClinicSpreadsheet(clinicaId, sheets);
|
||
if (created) logs.push('[OK] PLANILHA CRIADA NO SEU GOOGLE DRIVE');
|
||
const counts = {};
|
||
for (const d of CLINIC_SYNC_TABLES) {
|
||
try { const c = await pushClinicTable(d, sheets, id, clinicaId); logs.push(`[OK] ${d.tab.toUpperCase()}: ${c} REGISTROS`); counts[d.tab] = c; }
|
||
catch (e) { logs.push(`[ERRO] ${d.tab}: ${e.message}`); }
|
||
}
|
||
await saveClinicSyncStatus(clinicaId, { lastPush: new Date().toISOString(), counts });
|
||
res.json({ success: true, spreadsheetUrl: clinicSheetUrl(id), logs });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE (AGENDA → CONFIGURAÇÕES) ANTES DE FAZER BACKUP.' : err.message;
|
||
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
|
||
}
|
||
});
|
||
|
||
app.post('/api/clinica-sync/pull', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
const logs = [];
|
||
try {
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
const { rows: cl } = await pool.query('SELECT sheets_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
if (!cl[0]?.sheets_id) return res.status(400).json({ success: false, message: 'Nenhuma planilha ainda. Faça um backup (enviar) primeiro.' });
|
||
const id = cl[0].sheets_id;
|
||
for (const d of CLINIC_SYNC_TABLES) {
|
||
try { const c = await pullClinicTable(d, sheets, id, clinicaId); logs.push(`[OK] ${d.tab.toUpperCase()}: ${c} IMPORTADOS`); }
|
||
catch (e) { logs.push(`[ERRO] ${d.tab}: ${e.message}`); }
|
||
}
|
||
await saveClinicSyncStatus(clinicaId, { lastPull: new Date().toISOString() });
|
||
res.json({ success: true, logs });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE.' : err.message;
|
||
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
|
||
}
|
||
});
|
||
|
||
// --- IMPORT/EXPORT DE PACIENTES (CSV) ---
|
||
// Normaliza cabeçalho (minúsculo, sem acento, só letras/números) e dicionário de sinônimos.
|
||
const _normHdr = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]/g, '');
|
||
const PAC_FIELD_SYNS = {
|
||
id: ['id'],
|
||
nome: ['nome', 'name', 'paciente', 'nomecompleto', 'nomepaciente'],
|
||
sobrenome: ['sobrenome', 'surname'],
|
||
cpf: ['cpf', 'documento'],
|
||
telefone: ['telefone', 'tefefoneprincipal', 'telefoneprincipal', 'whatsapp', 'celular', 'fone', 'contato', 'telefone1'],
|
||
email: ['email'],
|
||
convenio: ['convenio', 'credenciada', 'plano', 'planos', 'planosaude'],
|
||
datanascimento: ['datanascimento', 'datanasci', 'nascimento', 'dtnasc', 'dn'],
|
||
};
|
||
function mapPacHeaders(headers) {
|
||
const norm = (headers || []).map(_normHdr);
|
||
const map = {};
|
||
for (const [field, syns] of Object.entries(PAC_FIELD_SYNS)) {
|
||
map[field] = norm.findIndex(h => syns.includes(h));
|
||
}
|
||
return map;
|
||
}
|
||
// Acha a melhor aba "pacientes" (mais colunas casadas) e importa só os campos do sistema.
|
||
async function smartImportPacientes(sheets, spreadsheetId, clinicaId) {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets.properties.title' });
|
||
const tabs = (meta.data.sheets || []).map(s => s.properties.title);
|
||
const cands = tabs.filter(t => _normHdr(t).includes('paciente'));
|
||
if (!cands.length) throw new Error('Nenhuma aba com "pacientes" no nome foi encontrada na planilha.');
|
||
let best = null;
|
||
for (const tab of cands) {
|
||
const r = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A1:ZZ1` });
|
||
const headers = (r.data.values || [])[0] || [];
|
||
const map = mapPacHeaders(headers);
|
||
const score = Object.values(map).filter(i => i >= 0).length;
|
||
if (!best || score > best.score) best = { tab, headers, map, score };
|
||
}
|
||
if (best.map.nome < 0) throw new Error(`Aba "${best.tab}": não encontrei a coluna NOME.`);
|
||
const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${best.tab}!A:ZZ` });
|
||
const rows = resp.data.values || [];
|
||
const get = (row, i) => (i >= 0 && i < row.length ? String(row[i] || '').trim() : '');
|
||
let imported = 0, skipped = 0;
|
||
for (const row of rows.slice(1)) {
|
||
if (!row.some(c => String(c || '').trim() !== '')) continue;
|
||
let nome = get(row, best.map.nome);
|
||
if (best.map.sobrenome >= 0) { const sn = get(row, best.map.sobrenome); if (sn) nome = `${nome} ${sn}`.trim(); }
|
||
nome = nome.toUpperCase();
|
||
if (!nome) { skipped++; continue; }
|
||
const cpf = get(row, best.map.cpf); const cpfDigits = cpf.replace(/\D/g, '');
|
||
let id = get(row, best.map.id) || cpfDigits || ('imp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7));
|
||
try {
|
||
await pool.query(
|
||
`INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,convenio,datanascimento)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
|
||
[id.substring(0, 50), clinicaId, nome.substring(0, 255), cpf.substring(0, 20) || null, get(row, best.map.telefone).substring(0, 30) || null, get(row, best.map.email).substring(0, 150) || null, get(row, best.map.convenio).substring(0, 100) || null, get(row, best.map.datanascimento).substring(0, 20) || null]
|
||
);
|
||
imported++;
|
||
} catch { skipped++; }
|
||
}
|
||
const matched = Object.entries(best.map).filter(([, i]) => i >= 0).map(([f]) => f.toUpperCase());
|
||
return { tab: best.tab, matched, imported, skipped };
|
||
}
|
||
|
||
// Importa pacientes de uma planilha EXISTENTE (ID/URL). NÃO altera o sheets_id nem a planilha fonte.
|
||
app.post('/api/clinica-sync/import-pacientes', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
let sheetsId = String(req.body?.sheetsId || '').trim();
|
||
const logs = [];
|
||
try {
|
||
if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' });
|
||
const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); if (m) sheetsId = m[1];
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
try { await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'spreadsheetId' }); }
|
||
catch { return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade.' }); }
|
||
const r = await smartImportPacientes(sheets, sheetsId, clinicaId);
|
||
logs.push(`[←] ABA "${r.tab}" — colunas casadas: ${r.matched.join(', ')}`);
|
||
logs.push(`[✓] ${r.imported} PACIENTES IMPORTADOS${r.skipped ? ` (${r.skipped} ignorados)` : ''}`);
|
||
res.json({ success: true, imported: r.imported, skipped: r.skipped, tab: r.tab, matched: r.matched, logs });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message;
|
||
res.status(400).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
|
||
}
|
||
});
|
||
|
||
// Export CSV dos pacientes (o bem mais precioso). Sem "ultimo tratamento". Não precisa de Google.
|
||
app.get('/api/pacientes/export', authGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.query.clinicaId;
|
||
if (!clinicaId) return res.status(400).send('clinicaId obrigatório');
|
||
const cols = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'datanascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes'];
|
||
const header = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'dataNascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes'];
|
||
const { rows } = await pool.query(`SELECT ${cols.join(',')} FROM pacientes WHERE clinica_id = $1 ORDER BY nome`, [clinicaId]);
|
||
const esc = v => { v = v == null ? '' : String(v); return /[",\n;]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
|
||
const csv = '' + [header.join(',')].concat(rows.map(r => cols.map(c => esc(r[c])).join(','))).join('\n');
|
||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||
res.setHeader('Content-Disposition', `attachment; filename="pacientes.csv"`);
|
||
res.send(csv);
|
||
} catch (err) { res.status(500).send('erro ao exportar'); }
|
||
});
|
||
|
||
// Define a planilha (ID/URL) a ser usada por esta clínica → habilita IMPORTAR de uma
|
||
// planilha EXISTENTE (ex.: migração de pacientes). Valida o acesso com a conta da unidade.
|
||
app.post('/api/clinica-sync/set-id', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
let sheetsId = String(req.body?.sheetsId || '').trim();
|
||
try {
|
||
if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' });
|
||
const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); // aceita URL completa
|
||
if (m) sheetsId = m[1];
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
let title, abas = [];
|
||
try {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'properties.title,sheets.properties.title' });
|
||
title = meta.data.properties.title;
|
||
abas = (meta.data.sheets || []).map(s => s.properties.title);
|
||
} catch {
|
||
return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade (' + sheetsId + ').' });
|
||
}
|
||
await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [sheetsId, clinicaId]);
|
||
res.json({ success: true, spreadsheetId: sheetsId, spreadsheetUrl: clinicSheetUrl(sheetsId), title, abas });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message;
|
||
res.status(400).json({ success: false, message: msg });
|
||
}
|
||
});
|
||
|
||
// --- GOOGLE AUTH ROUTES ---
|
||
app.get('/api/auth/google/url', (req, res) => {
|
||
if (!googleCreds.clientId || !googleCreds.clientSecret) {
|
||
return res.status(503).json({ error: 'Credenciais OAuth não configuradas. Acesse Configurações → Integrações Google e salve o Client ID e Client Secret.' });
|
||
}
|
||
const { dentistId, clinicaId, ownerId } = req.query;
|
||
const resolvedOwner = ownerId || dentistId || 'admin';
|
||
const resolvedClinica = clinicaId || '';
|
||
const oauth2Client = createOAuth2Client();
|
||
const url = oauth2Client.generateAuthUrl({
|
||
access_type: 'offline',
|
||
scope: [
|
||
'https://www.googleapis.com/auth/userinfo.email',
|
||
// leitura+escrita de EVENTOS (criar/editar/convidar) → backup + notificação
|
||
'https://www.googleapis.com/auth/calendar.events',
|
||
'https://www.googleapis.com/auth/spreadsheets'
|
||
],
|
||
state: `${resolvedOwner}|${resolvedClinica}`,
|
||
prompt: 'consent'
|
||
});
|
||
res.json({ url });
|
||
});
|
||
|
||
app.get('/api/oauth2callback', async (req, res) => {
|
||
const { code, state } = req.query;
|
||
if (!code) return res.status(400).send('Código não fornecido');
|
||
try {
|
||
const oauth2Client = createOAuth2Client();
|
||
const { tokens } = await oauth2Client.getToken(code);
|
||
// Decodifica defensivamente (o '|' pode voltar como %7C / %257C no round-trip do Google),
|
||
// senão o split falha e o owner_id fica colado com a clínica (clinica_id nulo).
|
||
let rawState = String(state || 'admin|');
|
||
for (let i = 0; i < 2 && /%[0-9a-f]{2}/i.test(rawState); i++) {
|
||
try { rawState = decodeURIComponent(rawState); } catch { break; }
|
||
}
|
||
let [rawOwner, clinicaId] = rawState.split('|');
|
||
let ownerId = rawOwner || 'admin';
|
||
// Salvaguarda: nunca grava owner colado com a clínica.
|
||
if (ownerId.includes('|')) { const pp = ownerId.split('|'); ownerId = pp[0]; clinicaId = clinicaId || pp[1] || null; }
|
||
if (ownerId === 'admin') {
|
||
oauth2Client.setCredentials(tokens);
|
||
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
|
||
const { data } = await oauth2.userinfo.get();
|
||
ownerId = data.email;
|
||
}
|
||
await pool.query(`
|
||
INSERT INTO google_tokens (owner_id, clinica_id, refresh_token, access_token, expiry_date)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (owner_id) DO UPDATE SET
|
||
clinica_id = EXCLUDED.clinica_id,
|
||
refresh_token = EXCLUDED.refresh_token,
|
||
access_token = EXCLUDED.access_token,
|
||
expiry_date = EXCLUDED.expiry_date
|
||
`, [ownerId, clinicaId || null, tokens.refresh_token, tokens.access_token, tokens.expiry_date]);
|
||
// Conta-clínica: UMA por clínica. Ao (re)conectar uma conta de e-mail, remove
|
||
// e-mails antigos desta mesma clínica (evita acúmulo/confusão de várias contas).
|
||
if (clinicaId && String(ownerId).includes('@')) {
|
||
await pool.query("DELETE FROM google_tokens WHERE clinica_id = $1 AND owner_id LIKE '%@%' AND owner_id <> $2", [clinicaId, ownerId]);
|
||
}
|
||
res.send(`<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', superadminGuard, 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', superadminGuard, 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 }); }
|
||
});
|
||
|
||
// Paleta padrão de cores de EVENTO do Google Calendar (colorId 1..11).
|
||
const GOOGLE_EVENT_COLORS = {
|
||
'1': '#7986cb', '2': '#33b679', '3': '#8e24aa', '4': '#e67c73', '5': '#f6bf26', '6': '#f4511e',
|
||
'7': '#039be5', '8': '#616161', '9': '#3f51b5', '10': '#0b8043', '11': '#d50000'
|
||
};
|
||
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]);
|
||
// Eventos já IMPORTADOS para o sistema não são re-exibidos como evento do Google (evita duplicar).
|
||
const { rows: impRows } = await pool.query('SELECT google_event_id FROM agendamentos WHERE clinica_id = $1 AND google_event_id IS NOT NULL', [clinicaId]);
|
||
const importedSet = new Set(impRows.map(r => r.google_event_id));
|
||
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 });
|
||
// Cor da AGENDA (definida no Google) — fallback p/ eventos sem cor própria.
|
||
let calColor = null;
|
||
try {
|
||
const cm = await calendar.calendarList.get({ calendarId: 'primary' });
|
||
calColor = cm.data.backgroundColor || null;
|
||
} catch { /* escopo/indisponível → frontend escolhe cor não usada */ }
|
||
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;
|
||
}
|
||
|
||
// Ignora eventos que o sistema empurrou (backup) OU que já foram importados → não duplica.
|
||
const mapped = response.data.items
|
||
.filter(item => !item.extendedProperties?.private?.scoreodonto && !importedSet.has(item.id))
|
||
.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,
|
||
// cor do evento (colorId) > cor da agenda > null (frontend atribui cor não usada)
|
||
backgroundColor: (item.colorId && GOOGLE_EVENT_COLORS[item.colorId]) || calColor || null,
|
||
borderColor: (item.colorId && GOOGLE_EVENT_COLORS[item.colorId]) || calColor || null
|
||
}));
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const cacheKey = `dashboard:stats:${clinicaId}`;
|
||
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 WHERE clinica_id = $1', [clinicaId]);
|
||
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
|
||
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]);
|
||
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
|
||
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
|
||
|
||
// 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' AND clinica_id = $1
|
||
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
|
||
ORDER BY mes ASC
|
||
`, [clinicaId]);
|
||
|
||
// 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
|
||
WHERE a.clinica_id = $1
|
||
ORDER BY a.start_time DESC
|
||
LIMIT 5
|
||
`, [clinicaId]);
|
||
|
||
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 ---
|
||
// Notificações são do USUÁRIO logado e da CLÍNICA ativa.
|
||
// Regra de visibilidade: (minhas OU da clínica toda) E (da clínica ativa OU globais do sistema).
|
||
// usuario_id = me → endereçada a mim
|
||
// usuario_id IS NULL → da clínica toda (qualquer membro vê)
|
||
// clinica_id = ativa → desta unidade
|
||
// clinica_id IS NULL → global do sistema (ex.: aviso de setup)
|
||
const NOTIF_SCOPE = `(usuario_id = $1 OR usuario_id IS NULL) AND ($2::text IS NULL OR clinica_id = $2 OR clinica_id IS NULL)`;
|
||
|
||
app.get('/api/notificacoes', authGuard, async (req, res) => {
|
||
try {
|
||
const userId = req.authUser?.userId || null;
|
||
const clinicaId = req.query.clinicaId || null;
|
||
const { rows } = await pool.query(
|
||
`SELECT * FROM notificacoes WHERE ${NOTIF_SCOPE} ORDER BY data DESC LIMIT 100`,
|
||
[userId, clinicaId]);
|
||
res.json(rows.map(n => ({ ...n, lida: !!n.lida })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/notificacoes/read-all', authGuard, async (req, res) => {
|
||
try {
|
||
const userId = req.authUser?.userId || null;
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId || null;
|
||
await pool.query(
|
||
`UPDATE notificacoes SET lida = 1 WHERE lida = 0 AND ${NOTIF_SCOPE}`,
|
||
[userId, clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/notificacoes/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const userId = req.authUser?.userId || null;
|
||
await pool.query('DELETE FROM notificacoes WHERE id = $1 AND (usuario_id = $2 OR usuario_id IS NULL)', [req.params.id, userId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/notificacoes/:id/read', authGuard, async (req, res) => {
|
||
try {
|
||
const userId = req.authUser?.userId || null;
|
||
await pool.query('UPDATE notificacoes SET lida = 1 WHERE id = $1 AND (usuario_id = $2 OR usuario_id IS NULL)', [req.params.id, userId]);
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
const userId = req.authUser?.userId || null;
|
||
const { clinicaId } = req.query;
|
||
let rows;
|
||
if (clinicaId) {
|
||
// valida vínculo do usuário com a clínica pedida (anti-leak)
|
||
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]);
|
||
if (!v.length) return res.status(403).json({ error: 'Acesso negado a esta clínica.' });
|
||
({ rows } = await pool.query('SELECT * FROM dentistas WHERE clinica_id = $1 ORDER BY ordem ASC, nome ASC', [clinicaId]));
|
||
} else {
|
||
// sem clinicaId: só dentistas das clínicas em que o usuário tem vínculo
|
||
({ rows } = await pool.query(
|
||
`SELECT d.* FROM dentistas d
|
||
JOIN vinculos v ON v.clinica_id = d.clinica_id
|
||
WHERE v.usuario_id = $1 ORDER BY d.ordem ASC, d.nome ASC`, [userId]));
|
||
}
|
||
res.json(rows.map(d => ({ ...d, ativo: !!d.ativo, corAgenda: d.coragenda })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Filtra o payload de dentista para só colunas válidas (descarta celular/cro/cro_uf etc.),
|
||
// converte 'ativo' (boolean) para smallint e serializa as listas (especialidades/dias).
|
||
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id']);
|
||
function sanitizeDentista(d) {
|
||
const out = {};
|
||
for (const [k, v] of Object.entries(d || {})) {
|
||
const lk = k.toLowerCase();
|
||
if (DENTISTA_COLS.has(lk)) out[lk] = v;
|
||
}
|
||
if ('ativo' in out) out.ativo = (out.ativo === false || out.ativo === 0 || out.ativo === '0' || out.ativo === 'false') ? 0 : 1;
|
||
if (out.especialidades !== undefined) out.especialidades = JSON.stringify(Array.isArray(out.especialidades) ? out.especialidades : []);
|
||
if (out.dias_atendimento !== undefined) out.dias_atendimento = JSON.stringify(Array.isArray(out.dias_atendimento) ? out.dias_atendimento : []);
|
||
return out;
|
||
}
|
||
|
||
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', sanitizeDentista({ ...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', sanitizeDentista(req.body), 'id', req.params.id);
|
||
if (upd) 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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.json([]); // sem clínica escopada → nada (isolamento entre unidades)
|
||
const { rows } = await pool.query('SELECT * FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
|
||
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 }); }
|
||
});
|
||
|
||
// Anti-overbooking GLOBAL: resolve a IDENTIDADE do profissional (dentistas.usuario_id)
|
||
// e procura SOBREPOSIÇÃO de horário em QUALQUER clínica/consultório onde ele atua —
|
||
// inclusive a agenda pessoal. Garante "não pode estar em 2 lugares ao mesmo tempo".
|
||
// Ignora status inativos (cancelado/falta/remarcar) que liberam o slot. `ignoreId`
|
||
// exclui o próprio agendamento (no reagendamento). Retorna a linha em conflito ou null.
|
||
async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
|
||
if (!dentistaId || !start) return null;
|
||
let dentistaIds = [dentistaId];
|
||
const { rows: dr } = await db.query('SELECT usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||
const profUid = dr[0]?.usuario_id || null;
|
||
if (profUid) {
|
||
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [profUid]);
|
||
if (ids.length) dentistaIds = ids.map(r => r.id);
|
||
}
|
||
const fim = end || start;
|
||
const params = [dentistaIds, start, fim];
|
||
let extra = '';
|
||
if (ignoreId) { params.push(ignoreId); extra = ` AND id <> $${params.length}`; }
|
||
const { rows } = await db.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${extra}
|
||
LIMIT 1 FOR UPDATE`,
|
||
params);
|
||
return rows[0] || null;
|
||
}
|
||
|
||
// 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.
|
||
const conflito = await findOverbookingConflict(client, dentistaId, start, end);
|
||
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
||
|
||
// 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' });
|
||
// Se houver avaliação solicitada para este paciente, vincula-a a este slot (Fase B).
|
||
await client.query(
|
||
`UPDATE avaliacoes SET agendamento_id=$1, status='agendada', updated_at=NOW()
|
||
WHERE id = (SELECT id FROM avaliacoes WHERE clinica_id=$2 AND paciente_id=$3 AND status='solicitada' AND agendamento_id IS NULL ORDER BY created_at DESC LIMIT 1)`,
|
||
[id, rest.clinica_id, rest.pacienteId]);
|
||
}
|
||
});
|
||
|
||
schedulePush('agendamentos');
|
||
wsBroadcast(rest.clinica_id, 'agenda', { acao: 'criou', id }); // pós-commit (garante refetch correto)
|
||
// Backup + notificação no Google (best-effort, não bloqueia a resposta).
|
||
pushGoogleEvent('create', {
|
||
id, clinica_id: rest.clinica_id, pacientenome: rest.pacienteNome, pacienteid: rest.pacienteId,
|
||
dentistaid: rest.dentistaId, procedimento: rest.procedimento, observacoes: rest.observacoes,
|
||
start_time: start, end_time: end
|
||
});
|
||
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 before = await loadAgendamentoScoped(req, res);
|
||
if (!before) return;
|
||
// 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.' });
|
||
}
|
||
|
||
// Anti-overbooking ao REAGENDAR / trocar de profissional: não pode colidir com
|
||
// outro compromisso do mesmo dentista (em qualquer clínica). Exclui o próprio agendamento.
|
||
const mudouTempoOuDent = (start && String(start) !== String(before.start_time))
|
||
|| (end && String(end) !== String(before.end_time))
|
||
|| (rest.dentistaId && rest.dentistaId !== before.dentistaid);
|
||
if (mudouTempoOuDent) {
|
||
const novoDent = rest.dentistaId || before.dentistaid;
|
||
const conflito = await findOverbookingConflict(pool, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||
if (conflito) {
|
||
return res.status(409).json({ success: false, conflito: true, podeRegistrarInteresse: true, ocupado: conflito, message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.' });
|
||
}
|
||
}
|
||
|
||
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');
|
||
wsBroadcast(before.clinica_id, 'agenda', { acao: 'editou', id: req.params.id }); // pós-commit
|
||
// Sincroniza a alteração no Google (patch se já existe evento, senão cria).
|
||
pushGoogleEvent(before.google_event_id ? 'update' : 'create', {
|
||
id: req.params.id, clinica_id: before.clinica_id, google_event_id: before.google_event_id,
|
||
pacientenome: rest.pacienteNome || before.pacientenome, pacienteid: rest.pacienteId || before.pacienteid,
|
||
dentistaid: rest.dentistaId || before.dentistaid,
|
||
procedimento: rest.procedimento || before.procedimento,
|
||
observacoes: rest.observacoes ?? before.observacoes,
|
||
start_time: start || before.start_time, end_time: end || before.end_time
|
||
});
|
||
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 });
|
||
}
|
||
});
|
||
|
||
// Carrega um agendamento E garante que o usuário logado tem vínculo com a clínica dele.
|
||
// Responde 404/403 e retorna null se não puder prosseguir (anti-IDOR).
|
||
async function loadAgendamentoScoped(req, res) {
|
||
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
|
||
if (!ag.length) { res.status(404).json({ success: false, message: 'Agendamento não encontrado.' }); return null; }
|
||
const a = ag[0];
|
||
const userId = req.authUser?.userId || null;
|
||
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, a.clinica_id]);
|
||
if (!v.length) { res.status(403).json({ success: false, message: 'Acesso negado: agendamento de outra clínica.' }); return null; }
|
||
return a;
|
||
}
|
||
|
||
// 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 a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
|
||
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 }
|
||
});
|
||
if (a.pacienteid) await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'cancelado' });
|
||
await notificarInteressesSlotLivre(a);
|
||
pushGoogleEvent('delete', a); // remove a cópia/notificação do Google
|
||
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 a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
// Falta exige paciente vinculado (importado/provisório não pontua).
|
||
if (!a.pacienteid || String(a.status).toLowerCase() === 'importado') {
|
||
return res.status(400).json({ success: false, message: 'Vincule um paciente (confirme o agendamento) antes de marcar falta.' });
|
||
}
|
||
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 a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
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 } });
|
||
if (a.pacienteid) await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' });
|
||
await notificarInteressesSlotLivre(a);
|
||
pushGoogleEvent('delete', 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 a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
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]);
|
||
pushGoogleEvent('create', { ...a, google_event_id: null }); // recria a cópia no Google
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// CONFIRMAR um agendamento IMPORTADO: vincula o paciente (exige CPF+telefone) e vira 'agendado'.
|
||
// Não escreve no Google (vínculo é interno). O paciente é enriquecido se faltava CPF/telefone.
|
||
app.post('/api/agendamentos/:id/confirmar', authGuard, async (req, res) => {
|
||
try {
|
||
const actor = req.authUser?.userId || null;
|
||
const actorNm = await actorNome(actor);
|
||
const { pacienteId, cpf, telefone, procedimento } = req.body || {};
|
||
if (!pacienteId) return res.status(400).json({ success: false, message: 'Selecione um paciente.' });
|
||
const a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
const { rows: pr } = await pool.query('SELECT id, nome, cpf, telefone FROM pacientes WHERE id = $1 AND clinica_id = $2', [pacienteId, a.clinica_id]);
|
||
if (!pr.length) return res.status(404).json({ success: false, message: 'Paciente não encontrado nesta clínica.' });
|
||
const pac = pr[0];
|
||
const novoCpf = String(cpf ?? pac.cpf ?? '').trim();
|
||
const novoTel = String(telefone ?? pac.telefone ?? '').trim();
|
||
if (!novoCpf || !novoTel) return res.status(400).json({ success: false, message: 'CPF e telefone são obrigatórios para confirmar.' });
|
||
// Enriquece o cadastro do paciente se faltava CPF/telefone.
|
||
if (novoCpf !== String(pac.cpf || '') || novoTel !== String(pac.telefone || '')) {
|
||
await pool.query('UPDATE pacientes SET cpf = $1, telefone = $2 WHERE id = $3', [novoCpf.slice(0, 20), novoTel.slice(0, 30), pacienteId]);
|
||
}
|
||
await pool.query(
|
||
`UPDATE agendamentos SET pacienteid=$1, pacientenome=$2, procedimento=COALESCE($3, procedimento), status='agendado',
|
||
updated_by=$4, updated_by_nome=$5, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$6`,
|
||
[pacienteId, pac.nome, procedimento || null, actor, actorNm, a.id]);
|
||
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'editou', actorId: actor, actorNome: actorNm, detalhes: { confirmou: true, paciente: pac.nome } });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Avisa o dono da clínica (best-effort, não quebra a ação).
|
||
async function notificarDonoClinica(clinicaId, titulo, mensagem) {
|
||
try {
|
||
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
if (cl[0]?.owner_id) {
|
||
await pool.query(
|
||
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por, clinica_id)
|
||
VALUES (gen_random_uuid(), $1, $2, 'sucesso', 0, NOW(), $3, 'sistema', $4)`,
|
||
[titulo, mensagem, cl[0].owner_id, clinicaId]);
|
||
wsBroadcast(clinicaId, 'notificacao', { usuarioId: cl[0].owner_id, titulo });
|
||
}
|
||
} catch (e) { console.error('[notif-dono]', e.message); }
|
||
}
|
||
|
||
// Dentista REENCAMINHA o agendamento de volta à clínica, com um motivo.
|
||
app.post('/api/agendamentos/:id/reencaminhar', authGuard, async (req, res) => {
|
||
try {
|
||
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
|
||
const { motivo } = req.body || {};
|
||
const a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'reencaminhou', actorId: actor, actorNome: actorNm, detalhes: { motivo: motivo || null } });
|
||
// Devolve à recepção: entra na lista "a reagendar" para a secretária redirecionar.
|
||
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.paciente_id || a.pacienteid || null, pacienteNome: a.pacientenome, origemId: a.id, motivo: `Reencaminhado pelo dentista${motivo ? ': ' + motivo : ''}` });
|
||
await notificarDonoClinica(a.clinica_id, 'Agendamento reencaminhado', `${actorNm || 'O dentista'} reencaminhou ${a.pacientenome || 'um paciente'}${motivo ? ': ' + motivo : ''}.`);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Dentista SOLICITA UMA AVALIAÇÃO (Fase A: registra o pedido + avisa; odontograma/fotos = Fase B).
|
||
app.post('/api/agendamentos/:id/solicitar-avaliacao', authGuard, async (req, res) => {
|
||
try {
|
||
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
|
||
const { motivo } = req.body || {};
|
||
const a = await loadAgendamentoScoped(req, res);
|
||
if (!a) return;
|
||
// Cria a avaliação (status=solicitada). A recepção agenda um slot → vincula-se automaticamente.
|
||
const avalId = _oid('aval');
|
||
await pool.query(
|
||
`INSERT INTO avaliacoes (id, clinica_id, paciente_id, paciente_nome, dentista_id, dentista_nome, agendamento_origem_id, status, motivo, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,'solicitada',$8,$9)`,
|
||
[avalId, a.clinica_id, a.pacienteid || null, a.pacientenome || null, a.dentistaid || null, actorNm || null, a.id, motivo || null, actor]);
|
||
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'solicitou_avaliacao', actorId: actor, actorNome: actorNm, detalhes: { motivo: motivo || null, avaliacao_id: avalId } });
|
||
// Entra na lista "a reagendar" da recepção para marcar o slot de avaliação.
|
||
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid || null, pacienteNome: a.pacientenome, origemId: a.id, motivo: `Avaliação solicitada${motivo ? ': ' + motivo : ''}` });
|
||
await notificarDonoClinica(a.clinica_id, 'Avaliação solicitada', `${actorNm || 'O dentista'} solicitou avaliação para ${a.pacientenome || 'um paciente'}${motivo ? ': ' + motivo : ''}.`);
|
||
res.json({ success: true, avaliacao_id: avalId });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// ===================================================================
|
||
// FASE B · AVALIAÇÕES CLÍNICAS (odontograma + fotos ligados à clínica)
|
||
// ===================================================================
|
||
async function fotosDaAvaliacao(avaliacaoId) {
|
||
const { rows } = await pool.query('SELECT id, dente, original_name, created_at FROM avaliacao_foto WHERE avaliacao_id = $1 ORDER BY created_at ASC', [avaliacaoId]);
|
||
return rows.map(f => ({ id: f.id, dente: f.dente, original_name: f.original_name, url: `/api/avaliacoes/fotos/${f.id}/raw` }));
|
||
}
|
||
|
||
// Lista avaliações de uma clínica (opcionalmente por status). Usado pela recepção/dono.
|
||
app.get('/api/avaliacoes', authGuard, tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const { status, paciente_id } = req.query;
|
||
const params = [clinicaId]; let where = 'clinica_id = $1'; let i = 2;
|
||
if (status) { where += ` AND status = $${i++}`; params.push(status); }
|
||
if (paciente_id) { where += ` AND paciente_id = $${i++}`; params.push(paciente_id); }
|
||
const { rows } = await pool.query(`SELECT * FROM avaliacoes WHERE ${where} ORDER BY created_at DESC`, params);
|
||
res.json({ success: true, avaliacoes: rows });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Avaliação ligada a um agendamento (o dentista abre pelo slot de avaliação).
|
||
app.get('/api/agendamentos/:id/avaliacao', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await loadAgendamentoScoped(req, res)) return;
|
||
const { rows } = await pool.query('SELECT * FROM avaliacoes WHERE agendamento_id = $1 ORDER BY created_at DESC LIMIT 1', [req.params.id]);
|
||
if (!rows.length) return res.json({ success: true, avaliacao: null });
|
||
const av = rows[0];
|
||
res.json({ success: true, avaliacao: { ...av, fotos: await fotosDaAvaliacao(av.id) } });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Detalhe de uma avaliação (odontograma + fotos).
|
||
app.get('/api/avaliacoes/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT * FROM avaliacoes WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ success: false, message: 'Avaliação não encontrada.' });
|
||
const av = rows[0];
|
||
res.json({ success: true, avaliacao: { ...av, fotos: await fotosDaAvaliacao(av.id) } });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Recepção VINCULA a avaliação a um slot agendado (status → agendada).
|
||
app.post('/api/avaliacoes/:id/agendar', authGuard, async (req, res) => {
|
||
try {
|
||
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
|
||
const { agendamento_id } = req.body || {};
|
||
if (!agendamento_id) return res.status(400).json({ success: false, message: 'agendamento_id obrigatório.' });
|
||
const { rows } = await pool.query(
|
||
`UPDATE avaliacoes SET agendamento_id=$1, status=CASE WHEN status='solicitada' THEN 'agendada' ELSE status END, updated_at=NOW() WHERE id=$2 RETURNING *`,
|
||
[agendamento_id, req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ success: false, message: 'Avaliação não encontrada.' });
|
||
await registrarAudit(pool, { clinicaId: rows[0].clinica_id, entidade: 'avaliacao', entidadeId: rows[0].id, acao: 'agendou', actorId: actor, actorNome: actorNm, detalhes: { agendamento_id } });
|
||
res.json({ success: true, avaliacao: rows[0] });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Dentista SALVA o odontograma / observações / status.
|
||
app.put('/api/avaliacoes/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
|
||
const { odontograma, observacoes, status } = req.body || {};
|
||
const sets = ['updated_at=NOW()']; const params = []; let i = 1;
|
||
if (odontograma !== undefined) { sets.push(`odontograma=$${i++}`); params.push(JSON.stringify(odontograma)); }
|
||
if (observacoes !== undefined) { sets.push(`observacoes=$${i++}`); params.push(observacoes); }
|
||
if (status !== undefined) { sets.push(`status=$${i++}`); params.push(status); }
|
||
params.push(req.params.id);
|
||
const { rows } = await pool.query(`UPDATE avaliacoes SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, params);
|
||
if (!rows.length) return res.status(404).json({ success: false, message: 'Avaliação não encontrada.' });
|
||
await registrarAudit(pool, { clinicaId: rows[0].clinica_id, entidade: 'avaliacao', entidadeId: rows[0].id, acao: 'editou', actorId: actor, actorNome: actorNm, detalhes: { status: status || null } });
|
||
res.json({ success: true, avaliacao: { ...rows[0], fotos: await fotosDaAvaliacao(rows[0].id) } });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Upload de foto de dente para a avaliação.
|
||
app.post('/api/avaliacoes/:id/fotos', authGuard, ortoUpload.single('foto'), async (req, res) => {
|
||
try {
|
||
if (!req.file) return res.status(400).json({ success: false, message: 'foto obrigatória.' });
|
||
const { rows: av } = await pool.query('SELECT id FROM avaliacoes WHERE id = $1', [req.params.id]);
|
||
if (!av.length) return res.status(404).json({ success: false, message: 'Avaliação não encontrada.' });
|
||
const dente = (req.body?.dente || '').toString().slice(0, 8) || null;
|
||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||
const rel = path.join('avaliacoes', req.params.id, `${Date.now()}_${Math.random().toString(36).slice(2, 6)}.${ext}`);
|
||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'avaliacoes', req.params.id), { recursive: true });
|
||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||
const id = _oid('avf');
|
||
await pool.query('INSERT INTO avaliacao_foto (id, avaliacao_id, dente, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6)', [id, req.params.id, dente, rel, req.file.mimetype, req.file.originalname]);
|
||
res.json({ success: true, foto: { id, dente, url: `/api/avaliacoes/fotos/${id}/raw`, original_name: req.file.originalname } });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Stream público da foto (img tags não enviam Authorization).
|
||
app.get('/api/avaliacoes/fotos/:id/raw', async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT filename, mimetype FROM avaliacao_foto 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/avaliacoes/fotos/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT filename FROM avaliacao_foto 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 avaliacao_foto WHERE id = $1', [req.params.id]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Histórico (auditoria) de um agendamento.
|
||
app.get('/api/agendamentos/:id/historico', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await loadAgendamentoScoped(req, res)) return;
|
||
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 }); }
|
||
});
|
||
|
||
// Score do paciente: faltas (no-show sem aviso = ponto negativo) + remarcações (não pontua,
|
||
// mas em grande número = atenção). Remarcações vêm do HISTÓRICO (audit_log 'remarcou'), durável.
|
||
app.get('/api/pacientes/:id/score', authGuard, async (req, res) => {
|
||
try {
|
||
const pid = req.params.id;
|
||
const { rows: fr } = await pool.query(`SELECT count(*)::int AS faltas FROM agendamentos WHERE pacienteid=$1 AND lower(status)='falta'`, [pid]);
|
||
const { rows: rr } = await pool.query(
|
||
`SELECT count(*)::int AS remarcacoes FROM audit_log a JOIN agendamentos ag ON ag.id = a.entidade_id
|
||
WHERE a.entidade='agendamento' AND a.acao='remarcou' AND ag.pacienteid=$1`, [pid]);
|
||
const faltas = fr[0]?.faltas || 0;
|
||
const remarcacoes = rr[0]?.remarcacoes || 0;
|
||
let nivel = 'BOM';
|
||
if (faltas >= 3) nivel = 'RISCO';
|
||
else if (faltas >= 1 || remarcacoes >= 4) nivel = 'ATENCAO';
|
||
res.json({ success: true, faltas, remarcacoes, nivel });
|
||
} 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 }); }
|
||
});
|
||
|
||
// Feed de ATIVIDADE da equipe (auditoria da clínica) — default: hoje.
|
||
app.get('/api/agenda/atividade', authGuard, async (req, res) => {
|
||
try {
|
||
const { clinicaId, desde } = req.query;
|
||
if (!clinicaId) return res.json({ success: true, atividade: [] });
|
||
const since = desde || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
|
||
const { rows } = await pool.query(
|
||
`SELECT a.acao, a.actor_nome, a.entidade, a.entidade_id, a.detalhes, a.at, ag.pacientenome
|
||
FROM audit_log a
|
||
LEFT JOIN agendamentos ag ON ag.id = a.entidade_id
|
||
WHERE a.clinica_id = $1 AND a.at >= $2
|
||
ORDER BY a.at DESC LIMIT 80`,
|
||
[clinicaId, since]);
|
||
res.json({ success: true, atividade: rows });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Importa agendamentos do Google Calendar p/ DENTRO do sistema (viram agendamentos editáveis).
|
||
// Janela: últimos 7 dias + próximos N dias (default 60). Idempotente (dedup por google_event_id).
|
||
app.post('/api/agenda/importar-google', authGuard, async (req, res) => {
|
||
try {
|
||
const { clinicaId, dias } = req.body || {};
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
// Só dono/admin da clínica importa.
|
||
if (await nivelNaClinica(req.authUser.userId, clinicaId) < 99999) {
|
||
return res.status(403).json({ success: false, message: 'Apenas o dono/admin pode importar.' });
|
||
}
|
||
const auth = await getClinicOAuth(clinicaId);
|
||
if (!auth) return res.status(400).json({ success: false, message: 'Nenhuma conta Google conectada nesta clínica.' });
|
||
const calendar = google.calendar({ version: 'v3', auth });
|
||
// Cor da agenda (fallback p/ eventos sem cor própria).
|
||
let calColor = null;
|
||
try { const cm = await calendar.calendarList.get({ calendarId: 'primary' }); calColor = cm.data.backgroundColor || null; } catch { /* escopo */ }
|
||
const now = Date.now();
|
||
const timeMin = new Date(now - 7 * 86400000).toISOString();
|
||
const timeMax = new Date(now + (Number(dias) || 60) * 86400000).toISOString();
|
||
const resp = await calendar.events.list({ calendarId: 'primary', timeMin, timeMax, singleEvents: true, orderBy: 'startTime', maxResults: 250 });
|
||
const items = (resp.data.items || []).filter(it => !it.extendedProperties?.private?.scoreodonto);
|
||
const actor = req.authUser?.userId || null;
|
||
const actorNm = await actorNome(actor);
|
||
let criados = 0, pulados = 0;
|
||
for (const it of items) {
|
||
const start = it.start?.dateTime || it.start?.date;
|
||
if (!start) { pulados++; continue; }
|
||
const { rows: ex } = await pool.query('SELECT 1 FROM agendamentos WHERE google_event_id = $1 AND clinica_id = $2 LIMIT 1', [it.id, clinicaId]);
|
||
if (ex.length) { pulados++; continue; }
|
||
const end = it.end?.dateTime || it.end?.date || start;
|
||
const cor = (it.colorId && GOOGLE_EVENT_COLORS[it.colorId]) || calColor || null;
|
||
// Separa "NOME - PROCEDIMENTO"; sem separador, o título inteiro vira o nome.
|
||
const titulo = String(it.summary || 'Importado do Google').trim();
|
||
const sep = titulo.indexOf(' - ');
|
||
const pacNome = sep > 0 ? titulo.slice(0, sep).trim() : titulo;
|
||
const proc = sep > 0 ? titulo.slice(sep + 3).trim() : null;
|
||
const obs = it.description ? String(it.description).slice(0, 2000) : null;
|
||
const id = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
// status 'importado' = PROVISÓRIO (não pontua, não conta falta até confirmar/vincular paciente).
|
||
await pool.query(
|
||
`INSERT INTO agendamentos (id, clinica_id, pacientenome, start_time, end_time, status, procedimento, observacoes, google_event_id, cor, created_by, created_by_nome, version)
|
||
VALUES ($1,$2,$3,$4,$5,'importado',$6,$7,$8,$9,$10,$11,1)`,
|
||
[id, clinicaId, pacNome, start, end, proc, obs, it.id, cor, actor, actorNm]);
|
||
await registrarAudit(pool, { clinicaId, entidade: 'agendamento', entidadeId: id, acao: 'criou', actorId: actor, actorNome: actorNm, detalhes: { origem: 'google', summary: it.summary } });
|
||
criados++;
|
||
}
|
||
res.json({ success: true, criados, pulados, total: items.length });
|
||
} 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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rowCount } = await pool.query('DELETE FROM especialidades WHERE id = $1 AND clinica_id = $2', [req.params.id, clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Não encontrado nesta clínica (itens globais não são removíveis).' });
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rowCount } = await pool.query('DELETE FROM planos WHERE id = $1 AND clinica_id = $2', [req.params.id, clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Não encontrado nesta clínica (itens globais não são removíveis).' });
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rowCount } = await pool.query('DELETE FROM procedimentos WHERE id = $1 AND clinica_id = $2', [req.params.id, clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Não encontrado nesta clínica (itens globais não são removíveis).' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// --- FINANCEIRO ---
|
||
app.get('/api/financeiro', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query('SELECT * FROM financeiro WHERE clinica_id = $1 ORDER BY datavencimento DESC', [clinicaId]);
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rowCount } = await pool.query('DELETE FROM financeiro WHERE id = $1 AND clinica_id = $2', [req.params.id, clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Lançamento não encontrado nesta clínica.' });
|
||
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 }); }
|
||
});
|
||
|
||
// Converte um LEAD em PACIENTE (e opcionalmente cria o 1º agendamento). Escopo por clínica.
|
||
app.post('/api/leads/:id/converter', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
|
||
const { data, hora, dentistaId } = req.body || {};
|
||
const { rows: lr } = await pool.query('SELECT * FROM leads WHERE id = $1 AND clinica_id = $2', [req.params.id, clinicaId]);
|
||
if (!lr.length) return res.status(404).json({ success: false, message: 'Lead não encontrado nesta clínica.' });
|
||
const lead = lr[0];
|
||
if (String(lead.status || '').toLowerCase() === 'convertido') {
|
||
return res.status(409).json({ success: false, message: 'Este lead já foi convertido.' });
|
||
}
|
||
const nomeCompleto = `${lead.nome || ''} ${lead.sobrenome || ''}`.trim().toUpperCase();
|
||
// Resolve o slot (se houver) e checa anti-overbooking ANTES de criar qualquer coisa
|
||
// (evita paciente órfão se o horário estiver ocupado).
|
||
let slotStart = null, slotEnd = null;
|
||
if (data && hora) {
|
||
const s = new Date(`${data}T${hora}`);
|
||
if (!isNaN(s.getTime())) {
|
||
slotStart = s.toISOString();
|
||
slotEnd = new Date(s.getTime() + 30 * 60000).toISOString();
|
||
const conflito = await findOverbookingConflict(pool, dentistaId, slotStart, slotEnd);
|
||
if (conflito) {
|
||
return res.status(409).json({ success: false, conflito: true, ocupado: conflito, message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO. ESCOLHA OUTRO HORÁRIO (o paciente ainda não foi criado).' });
|
||
}
|
||
}
|
||
}
|
||
// 1) cria o paciente
|
||
const pacId = 'pac_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||
await pool.query(
|
||
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, convenio) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||
[pacId, clinicaId, nomeCompleto, lead.cpf || null, lead.whatsapp || null, lead.tipointeresse || null]);
|
||
// 2) cria o agendamento inicial (se slot válido e livre)
|
||
let agId = null;
|
||
if (slotStart) {
|
||
agId = 'ag_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||
await pool.query(
|
||
`INSERT INTO agendamentos (id, clinica_id, pacienteid, pacientenome, dentistaid, start_time, end_time, status, procedimento, created_by, created_by_nome, version)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,'agendado','CONSULTA',$8,$9,1)`,
|
||
[agId, clinicaId, pacId, nomeCompleto, dentistaId || null, slotStart, slotEnd, actor, actorNm]);
|
||
await registrarAudit(pool, { clinicaId, entidade: 'agendamento', entidadeId: agId, acao: 'criou', actorId: actor, actorNome: actorNm, detalhes: { origem: 'lead', lead_id: lead.id } });
|
||
}
|
||
// 3) marca o lead como convertido
|
||
await pool.query("UPDATE leads SET status = 'Convertido' WHERE id = $1 AND clinica_id = $2", [req.params.id, clinicaId]);
|
||
schedulePush('leads');
|
||
res.json({ success: true, pacienteId: pacId, agendamentoId: agId });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// --- GUIAS ODONTOLÓGICAS (TRATAMENTOS) ---
|
||
app.get('/api/guias', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
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 clinica_id = $1';
|
||
const params = [clinicaId];
|
||
let paramIdx = 2;
|
||
|
||
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', superadminGuard, 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', superadminGuard, async (req, res) => {
|
||
const logs = [];
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ success: false, logs: ['[ERRO] clinicaId obrigatório para o backup.'] });
|
||
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, clinicaId);
|
||
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', superadminGuard, 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', superadminGuard, 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, clinica_id) VALUES ($1, $2, $3, $4, $5)', [
|
||
id,
|
||
'PRÓXIMO ATENDIMENTO',
|
||
`Paciente ${appt.pacientenome} às ${new Date(appt.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
|
||
'agenda',
|
||
appt.clinica_id || null
|
||
]);
|
||
wsBroadcast(appt.clinica_id, 'notificacao', { titulo: 'PRÓXIMO ATENDIMENTO' });
|
||
}
|
||
}
|
||
|
||
// 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, clinica_id) VALUES ($1, $2, $3, $4, $5)', [
|
||
id,
|
||
'FATURA ATRASADA',
|
||
`A conta "${bill.descricao}" de R$ ${bill.valor} venceu em ${new Date(bill.datavencimento).toLocaleDateString('pt-BR')}`,
|
||
'financeiro',
|
||
bill.clinica_id || null
|
||
]);
|
||
wsBroadcast(bill.clinica_id, 'notificacao', { titulo: 'FATURA ATRASADA' });
|
||
}
|
||
}
|
||
|
||
// 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, clinica_id) VALUES ($1, $2, $3, $4, $5)', [
|
||
id,
|
||
'VENCIMENTO HOJE',
|
||
`A conta "${bill.descricao}" de R$ ${bill.valor} vence hoje!`,
|
||
'financeiro',
|
||
bill.clinica_id || null
|
||
]);
|
||
wsBroadcast(bill.clinica_id, 'notificacao', { titulo: 'VENCIMENTO HOJE' });
|
||
}
|
||
}
|
||
|
||
// 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)`,
|
||
// Especialidades que o dentista atende (várias) + dias de atendimento na clínica.
|
||
`ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS especialidades JSONB DEFAULT '[]'::jsonb`,
|
||
`ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS dias_atendimento JSONB DEFAULT '[]'::jsonb`,
|
||
// 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','biomedico','protetico','funcionario','donoclinica','donoconsultorio','admin']))`,
|
||
// role na tabela usuarios também precisa aceitar 'biomedico' (constraint pré-existente do banco)
|
||
`ALTER TABLE usuarios DROP CONSTRAINT IF EXISTS usuarios_role_check`,
|
||
`ALTER TABLE usuarios ADD CONSTRAINT usuarios_role_check CHECK (role = ANY(ARRAY['paciente','dentista','biomedico','protetico','funcionario','donoclinica','donoconsultorio','admin','superadmin']))`,
|
||
`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`,
|
||
`ALTER TABLE notificacoes ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
|
||
`CREATE INDEX IF NOT EXISTS idx_notif_user_clinica ON notificacoes (usuario_id, clinica_id, data DESC)`,
|
||
// Backfill: notificações inteligentes antigas (sem clinica_id) herdam a clínica da origem.
|
||
`UPDATE notificacoes n SET clinica_id = f.clinica_id FROM financeiro f WHERE n.clinica_id IS NULL AND n.id = 'notif_fin_overdue_' || f.id`,
|
||
`UPDATE notificacoes n SET clinica_id = f.clinica_id FROM financeiro f WHERE n.clinica_id IS NULL AND n.id = 'notif_fin_today_' || f.id`,
|
||
`UPDATE notificacoes n SET clinica_id = a.clinica_id FROM agendamentos a WHERE n.clinica_id IS NULL AND n.id = 'notif_appt_' || a.id`,
|
||
// ===================================================================
|
||
// 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 ('importado','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`,
|
||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS google_event_id TEXT`,
|
||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS cor TEXT`,
|
||
`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)
|
||
)`,
|
||
// ===================================================================
|
||
// FASE B · AVALIAÇÃO CLÍNICA (dentista) → odontograma + fotos, ligada à clínica.
|
||
// Fluxo: dentista "solicita avaliação" (status=solicitada) → recepção agenda
|
||
// um slot (vincula agendamento_id, status=agendada) → dentista preenche
|
||
// odontograma/fotos no atendimento (em_andamento → concluida).
|
||
// ===================================================================
|
||
`CREATE TABLE IF NOT EXISTS avaliacoes (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT,
|
||
paciente_id TEXT,
|
||
paciente_nome TEXT,
|
||
dentista_id TEXT,
|
||
dentista_nome TEXT,
|
||
agendamento_origem_id TEXT,
|
||
agendamento_id TEXT,
|
||
status TEXT DEFAULT 'solicitada',
|
||
motivo TEXT,
|
||
odontograma JSONB DEFAULT '{}'::jsonb,
|
||
observacoes TEXT,
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_aval_clinica ON avaliacoes (clinica_id, status, created_at DESC)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_aval_agendamento ON avaliacoes (agendamento_id)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_aval_paciente ON avaliacoes (paciente_id)`,
|
||
`CREATE TABLE IF NOT EXISTS avaliacao_foto (
|
||
id TEXT PRIMARY KEY,
|
||
avaliacao_id TEXT NOT NULL,
|
||
dente TEXT,
|
||
filename TEXT NOT NULL,
|
||
mimetype TEXT,
|
||
original_name TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_aval_foto_aval ON avaliacao_foto (avaliacao_id)`,
|
||
// ── PLUGIN: Locação de Salas ──────────────────────────────────────────
|
||
`CREATE TABLE IF NOT EXISTS salas (
|
||
id TEXT PRIMARY KEY,
|
||
owner_usuario_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
nome TEXT NOT NULL,
|
||
tipo TEXT DEFAULT 'independente',
|
||
descricao TEXT,
|
||
equipamentos TEXT,
|
||
endereco TEXT,
|
||
bairro TEXT,
|
||
cidade TEXT,
|
||
estado TEXT,
|
||
cep TEXT,
|
||
valor_hora NUMERIC,
|
||
valor_turno NUMERIC,
|
||
valor_diaria NUMERIC,
|
||
valor_semanal NUMERIC,
|
||
valor_mensal NUMERIC,
|
||
disponivel BOOLEAN DEFAULT true,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_salas_owner ON salas (owner_usuario_id)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_salas_local ON salas (estado, cidade)`,
|
||
`CREATE TABLE IF NOT EXISTS sala_reservas (
|
||
id TEXT PRIMARY KEY,
|
||
sala_id TEXT NOT NULL,
|
||
solicitante_id TEXT NOT NULL,
|
||
solicitante_nome TEXT,
|
||
inicio TIMESTAMPTZ NOT NULL,
|
||
fim TIMESTAMPTZ NOT NULL,
|
||
modelo TEXT DEFAULT 'hora',
|
||
valor NUMERIC,
|
||
status TEXT DEFAULT 'pendente',
|
||
observacoes TEXT,
|
||
respondida_em TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_sala_reservas_sala ON sala_reservas (sala_id, inicio)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_sala_reservas_solic ON sala_reservas (solicitante_id, inicio)`,
|
||
// Reserva institucional: clínica/consultório reserva em nome da unidade,
|
||
// opcionalmente designando o profissional que vai ocupar a sala.
|
||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS clinica_id TEXT`,
|
||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS clinica_nome TEXT`,
|
||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_usuario_id TEXT`,
|
||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_nome TEXT`,
|
||
`CREATE INDEX IF NOT EXISTS idx_sala_reservas_clinica ON sala_reservas (clinica_id, inicio)`,
|
||
// Ativação de plugin POR USUÁRIO (add-on por conta) — independe de clínica.
|
||
// É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas).
|
||
`CREATE TABLE IF NOT EXISTS usuario_plugins (
|
||
usuario_id TEXT NOT NULL,
|
||
plugin_id TEXT NOT NULL,
|
||
ativo BOOLEAN DEFAULT true,
|
||
ativado_em TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (usuario_id, plugin_id)
|
||
)`,
|
||
// ── PLUGIN: Marketplace de Profissionais ─────────────────────────────
|
||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`,
|
||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS raio_atuacao_km INTEGER`,
|
||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS bio_profissional TEXT`,
|
||
`CREATE TABLE IF NOT EXISTS propostas_profissional (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
clinica_nome TEXT,
|
||
profissional_id TEXT NOT NULL,
|
||
enviada_por TEXT,
|
||
enviada_por_nome TEXT,
|
||
mensagem TEXT,
|
||
status TEXT DEFAULT 'pendente',
|
||
respondida_em TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_propostas_prof ON propostas_profissional (profissional_id, status)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_propostas_clinica ON propostas_profissional (clinica_id, status)`,
|
||
];
|
||
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 }); }
|
||
});
|
||
|
||
// =============================================================================
|
||
// PLUGIN: LOCAÇÃO DE SALAS (marketplace de salas + reservas anti-conflito)
|
||
// =============================================================================
|
||
const SALA_TIPOS = ['clinica', 'consultorio', 'independente'];
|
||
const RESERVA_MODELOS = ['hora', 'turno', 'diaria', 'semanal', 'mensal'];
|
||
// Status que OCUPAM o slot da sala (recusada/cancelada liberam).
|
||
const RESERVA_ATIVAS = ['pendente', 'confirmada'];
|
||
|
||
async function notificarUsuario(usuarioId, titulo, mensagem, tipo = 'info') {
|
||
if (!usuarioId) return;
|
||
try {
|
||
await pool.query(
|
||
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por)
|
||
VALUES (gen_random_uuid(), $1, $2, $3, 0, NOW(), $4, 'sistema')`,
|
||
[titulo, mensagem, tipo, usuarioId]);
|
||
} catch (e) { console.error('[notificarUsuario]', e.message); }
|
||
}
|
||
|
||
// ── Plugins POR USUÁRIO (add-on por conta) ──────────────────────────────────
|
||
// Fonte da verdade da ativação de plugins do usuário logado (ex.: locacao-salas).
|
||
// Sem tenantGuard de propósito: independe de o usuário ter clínica.
|
||
app.get('/api/me/plugins', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT plugin_id FROM usuario_plugins WHERE usuario_id = $1 AND ativo = true',
|
||
[req.authUser.userId]);
|
||
res.json({ plugins: rows.map(r => r.plugin_id) });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/me/plugins', authGuard, async (req, res) => {
|
||
try {
|
||
const pluginId = String(req.body?.pluginId || '').trim();
|
||
const ativo = req.body?.ativo !== false;
|
||
if (!pluginId) return res.status(400).json({ error: 'pluginId é obrigatório.' });
|
||
await pool.query(
|
||
`INSERT INTO usuario_plugins (usuario_id, plugin_id, ativo, ativado_em)
|
||
VALUES ($1, $2, $3, NOW())
|
||
ON CONFLICT (usuario_id, plugin_id) DO UPDATE SET ativo = EXCLUDED.ativo, ativado_em = NOW()`,
|
||
[req.authUser.userId, pluginId, ativo]);
|
||
res.json({ success: true, pluginId, ativo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Minhas salas (como locador)
|
||
app.get('/api/salas/minhas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT s.*, c.nome AS clinica_nome,
|
||
(SELECT COUNT(*)::int FROM sala_reservas r WHERE r.sala_id = s.id AND r.status = 'pendente') AS reservas_pendentes
|
||
FROM salas s LEFT JOIN clinicas c ON c.id = s.clinica_id
|
||
WHERE s.owner_usuario_id = $1 ORDER BY s.created_at DESC`,
|
||
[req.authUser.userId]);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/salas', authGuard, async (req, res) => {
|
||
try {
|
||
const b = req.body || {};
|
||
if (!b.nome || !b.cidade || !b.estado) return res.status(400).json({ error: 'Nome, cidade e estado são obrigatórios.' });
|
||
const id = `sala_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO salas (id, owner_usuario_id, clinica_id, nome, tipo, descricao, equipamentos, endereco, bairro, cidade, estado, cep,
|
||
valor_hora, valor_turno, valor_diaria, valor_semanal, valor_mensal, disponivel)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
|
||
[id, req.authUser.userId, b.clinicaId || null, b.nome, SALA_TIPOS.includes(b.tipo) ? b.tipo : 'independente',
|
||
b.descricao || null, b.equipamentos || null, b.endereco || null,
|
||
b.bairro?.toUpperCase() || null, String(b.cidade).toUpperCase(), String(b.estado).toUpperCase(), b.cep || null,
|
||
b.valorHora ?? null, b.valorTurno ?? null, b.valorDiaria ?? null, b.valorSemanal ?? null, b.valorMensal ?? null,
|
||
b.disponivel !== false]);
|
||
await registrarAudit(pool, { clinicaId: b.clinicaId || null, entidade: 'sala', entidadeId: id, acao: 'criou', actorId: req.authUser.userId, actorNome: await actorNome(req.authUser.userId), detalhes: { nome: b.nome } });
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/salas/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT owner_usuario_id, clinica_id FROM salas WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Sala não encontrada.' });
|
||
if (rows[0].owner_usuario_id !== req.authUser.userId) return res.status(403).json({ error: 'Apenas o proprietário pode editar a sala.' });
|
||
const b = req.body || {};
|
||
const COLS = { nome: b.nome, tipo: SALA_TIPOS.includes(b.tipo) ? b.tipo : undefined, clinica_id: b.clinicaId, descricao: b.descricao, equipamentos: b.equipamentos,
|
||
endereco: b.endereco, bairro: b.bairro?.toUpperCase(), cidade: b.cidade ? String(b.cidade).toUpperCase() : undefined,
|
||
estado: b.estado ? String(b.estado).toUpperCase() : undefined, cep: b.cep,
|
||
valor_hora: b.valorHora, valor_turno: b.valorTurno, valor_diaria: b.valorDiaria, valor_semanal: b.valorSemanal, valor_mensal: b.valorMensal,
|
||
disponivel: b.disponivel };
|
||
const keys = Object.keys(COLS).filter(k => COLS[k] !== undefined);
|
||
if (!keys.length) return res.json({ success: true });
|
||
const set = keys.map((k, i) => `${k} = $${i + 1}`).join(', ');
|
||
await pool.query(`UPDATE salas SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(k => COLS[k]), req.params.id]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/salas/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Sala não encontrada.' });
|
||
if (rows[0].owner_usuario_id !== req.authUser.userId) return res.status(403).json({ error: 'Apenas o proprietário pode excluir a sala.' });
|
||
const { rows: ativas } = await pool.query(
|
||
`SELECT id FROM sala_reservas WHERE sala_id = $1 AND status = ANY($2) AND fim > NOW() LIMIT 1`,
|
||
[req.params.id, RESERVA_ATIVAS]);
|
||
if (ativas.length) return res.status(409).json({ error: 'Sala possui reservas futuras ativas. Recuse/cancele as reservas antes de excluir.' });
|
||
await pool.query('DELETE FROM sala_reservas WHERE sala_id = $1', [req.params.id]);
|
||
await pool.query('DELETE FROM salas WHERE id = $1', [req.params.id]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Marketplace de salas (qualquer usuário autenticado)
|
||
app.get('/api/salas/marketplace', authGuard, async (req, res) => {
|
||
try {
|
||
const { tipo, estado, cidade } = req.query;
|
||
let where = `WHERE s.disponivel = true`;
|
||
const params = [];
|
||
if (tipo && SALA_TIPOS.includes(tipo)) { params.push(tipo); where += ` AND s.tipo = $${params.length}`; }
|
||
if (estado) { params.push(String(estado).toUpperCase()); where += ` AND s.estado = $${params.length}`; }
|
||
if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND s.cidade ILIKE $${params.length}`; }
|
||
const { rows } = await pool.query(
|
||
`SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep,
|
||
s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal,
|
||
s.owner_usuario_id, u.nome AS owner_nome, c.nome AS clinica_nome
|
||
FROM salas s LEFT JOIN usuarios u ON u.id = s.owner_usuario_id LEFT JOIN clinicas c ON c.id = s.clinica_id
|
||
${where} ORDER BY s.estado, s.cidade, s.nome LIMIT 200`, params);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Agenda pública da sala (slots ocupados — sem expor quem reservou a terceiros)
|
||
app.get('/api/salas/:id/reservas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows: sr } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [req.params.id]);
|
||
if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' });
|
||
const isOwner = sr[0].owner_usuario_id === req.authUser.userId;
|
||
const { rows } = await pool.query(
|
||
`SELECT id, inicio::text, fim::text, modelo, status, valor,
|
||
CASE WHEN $2 OR solicitante_id = $3 THEN solicitante_nome ELSE NULL END AS solicitante_nome
|
||
FROM sala_reservas WHERE sala_id = $1 AND status = ANY($4) AND fim > NOW() - INTERVAL '1 day'
|
||
ORDER BY inicio LIMIT 300`,
|
||
[req.params.id, isOwner, req.authUser.userId, RESERVA_ATIVAS]);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Reservar sala — pessoal OU em nome de uma unidade (clinicaId + profissional
|
||
// designado opcional). Anti-conflito sobre QUEM OCUPA a sala (profissional
|
||
// designado ou o próprio solicitante): (1) sala ocupada, (2) reserva ativa do
|
||
// ocupante em outra sala, (3) agenda global (dentistas.usuario_id) → 409.
|
||
app.post('/api/salas/:id/reservas', authGuard, async (req, res) => {
|
||
try {
|
||
const { inicio, fim, modelo, observacoes, clinicaId, profissionalUsuarioId } = req.body || {};
|
||
if (!inicio || !fim) return res.status(400).json({ error: 'Início e fim são obrigatórios.' });
|
||
if (new Date(fim) <= new Date(inicio)) return res.status(400).json({ error: 'Fim deve ser depois do início.' });
|
||
const userId = req.authUser.userId;
|
||
const userNome = await actorNome(userId);
|
||
// Reserva institucional exige vínculo do solicitante com a unidade.
|
||
let clinicaNome = null;
|
||
if (clinicaId) {
|
||
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]);
|
||
if (!v.length) return res.status(403).json({ error: 'Sem vínculo com a unidade informada.' });
|
||
const { rows: cl } = await pool.query('SELECT nome FROM clinicas WHERE id = $1', [clinicaId]);
|
||
clinicaNome = cl[0]?.nome || null;
|
||
}
|
||
// Quem ocupa a sala: profissional designado (reserva de clínica) ou o solicitante.
|
||
const ocupanteId = (clinicaId && profissionalUsuarioId) ? profissionalUsuarioId : userId;
|
||
const ocupanteNome = ocupanteId === userId ? userNome : await actorNome(ocupanteId);
|
||
if (ocupanteId !== userId && !ocupanteNome) return res.status(404).json({ error: 'Profissional designado não encontrado.' });
|
||
const id = `res_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
const result = await withTransaction(async (client) => {
|
||
const { rows: sr } = await client.query('SELECT id, nome, owner_usuario_id, disponivel, valor_hora FROM salas WHERE id = $1 FOR UPDATE', [req.params.id]);
|
||
if (!sr.length) throw { code: 'NOT_FOUND' };
|
||
const sala = sr[0];
|
||
if (!sala.disponivel) throw { code: 'INDISPONIVEL' };
|
||
if (sala.owner_usuario_id === userId) throw { code: 'PROPRIA_SALA' };
|
||
// (1) sala já ocupada no intervalo
|
||
const { rows: confSala } = await client.query(
|
||
`SELECT id, inicio::text, fim::text FROM sala_reservas
|
||
WHERE sala_id = $1 AND status = ANY($4) AND inicio < $3 AND fim > $2 LIMIT 1 FOR UPDATE`,
|
||
[req.params.id, inicio, fim, RESERVA_ATIVAS]);
|
||
if (confSala.length) throw { code: 'CONF_SALA', conflito: confSala[0] };
|
||
// (2) ocupante já tem reserva ativa sobreposta (qualquer sala)
|
||
const { rows: confSolic } = await client.query(
|
||
`SELECT id, sala_id, inicio::text, fim::text FROM sala_reservas
|
||
WHERE COALESCE(profissional_usuario_id, solicitante_id) = $1 AND status = ANY($4) AND inicio < $3 AND fim > $2 LIMIT 1`,
|
||
[ocupanteId, inicio, fim, RESERVA_ATIVAS]);
|
||
if (confSolic.length) throw { code: 'CONF_PROFISSIONAL', conflito: confSolic[0] };
|
||
// (3) agenda global: ocupante com atendimento marcado em qualquer clínica
|
||
const { rows: dents } = await client.query('SELECT id FROM dentistas WHERE usuario_id = $1', [ocupanteId]);
|
||
if (dents.length) {
|
||
const conflito = await findOverbookingConflict(client, dents[0].id, inicio, fim);
|
||
if (conflito) throw { code: 'CONF_AGENDA', conflito };
|
||
}
|
||
await client.query(
|
||
`INSERT INTO sala_reservas (id, sala_id, solicitante_id, solicitante_nome, inicio, fim, modelo, valor, status, observacoes,
|
||
clinica_id, clinica_nome, profissional_usuario_id, profissional_nome)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pendente',$9,$10,$11,$12,$13)`,
|
||
[id, req.params.id, userId, userNome, inicio, fim,
|
||
RESERVA_MODELOS.includes(modelo) ? modelo : 'hora', req.body?.valor ?? null, observacoes || null,
|
||
clinicaId || null, clinicaNome,
|
||
ocupanteId !== userId ? ocupanteId : null, ocupanteId !== userId ? ocupanteNome : null]);
|
||
await registrarAudit(client, { clinicaId: clinicaId || null, entidade: 'sala_reserva', entidadeId: id, acao: 'solicitou', actorId: userId, actorNome: userNome, detalhes: { salaId: req.params.id, inicio, fim, profissionalUsuarioId: ocupanteId !== userId ? ocupanteId : null } });
|
||
return sala;
|
||
});
|
||
const quem = clinicaNome
|
||
? `${clinicaNome}${ocupanteId !== userId ? ` (p/ ${ocupanteNome})` : ''}`
|
||
: (userNome || 'Um profissional');
|
||
await notificarUsuario(result.owner_usuario_id, 'Nova solicitação de reserva',
|
||
`${quem} solicitou reserva da sala "${result.nome}". Confirme ou recuse na área de Locação de Salas.`);
|
||
// Profissional designado fica sabendo que a unidade reservou em nome dele.
|
||
if (ocupanteId !== userId) {
|
||
await notificarUsuario(ocupanteId, 'Sala reservada para você',
|
||
`${clinicaNome || 'Sua unidade'} solicitou a sala "${result.nome}" em seu nome (${new Date(inicio).toLocaleString('pt-BR')}).`);
|
||
}
|
||
res.json({ success: true, id, status: 'pendente' });
|
||
} catch (err) {
|
||
if (err.code === 'NOT_FOUND') return res.status(404).json({ error: 'Sala não encontrada.' });
|
||
if (err.code === 'INDISPONIVEL') return res.status(409).json({ error: 'Sala indisponível para locação.' });
|
||
if (err.code === 'PROPRIA_SALA') return res.status(400).json({ error: 'Você não pode reservar a própria sala.' });
|
||
if (err.code === 'CONF_SALA') return res.status(409).json({ error: 'CONFLITO: a sala já está reservada neste horário.', conflito: err.conflito });
|
||
if (err.code === 'CONF_PROFISSIONAL') return res.status(409).json({ error: 'CONFLITO: o profissional já possui uma reserva neste horário.', conflito: err.conflito });
|
||
if (err.code === 'CONF_AGENDA') return res.status(409).json({ error: 'CONFLITO: o profissional possui atendimento marcado neste horário em outra unidade.', conflito: err.conflito });
|
||
res.status(500).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// Minhas reservas: feitas (pessoais + da unidade ativa via ?clinicaId= + onde sou
|
||
// o profissional designado) + recebidas (como locador)
|
||
app.get('/api/sala-reservas/minhas', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
let clinicaId = req.query.clinicaId || null;
|
||
if (clinicaId) {
|
||
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [uid, clinicaId]);
|
||
if (!v.length) clinicaId = null; // sem vínculo → ignora o filtro institucional
|
||
}
|
||
const { rows: feitas } = await pool.query(
|
||
`SELECT r.*, r.inicio::text AS inicio, r.fim::text AS fim, s.nome AS sala_nome, s.cidade, s.estado, u.nome AS owner_nome
|
||
FROM sala_reservas r JOIN salas s ON s.id = r.sala_id LEFT JOIN usuarios u ON u.id = s.owner_usuario_id
|
||
WHERE r.solicitante_id = $1 OR r.profissional_usuario_id = $1 OR ($2::text IS NOT NULL AND r.clinica_id = $2)
|
||
ORDER BY r.inicio DESC LIMIT 200`, [uid, clinicaId]);
|
||
const { rows: recebidas } = await pool.query(
|
||
`SELECT r.*, r.inicio::text AS inicio, r.fim::text AS fim, s.nome AS sala_nome, s.cidade, s.estado
|
||
FROM sala_reservas r JOIN salas s ON s.id = r.sala_id
|
||
WHERE s.owner_usuario_id = $1 ORDER BY r.inicio DESC LIMIT 200`, [uid]);
|
||
res.json({ feitas, recebidas });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Responder reserva: locador confirma/recusa; solicitante (ou locador) cancela.
|
||
app.put('/api/sala-reservas/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { acao } = req.body || {};
|
||
if (!['confirmar', 'recusar', 'cancelar'].includes(acao)) return res.status(400).json({ error: 'Ação inválida (confirmar | recusar | cancelar).' });
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query(
|
||
`SELECT r.*, s.owner_usuario_id, s.nome AS sala_nome FROM sala_reservas r JOIN salas s ON s.id = r.sala_id WHERE r.id = $1`,
|
||
[req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Reserva não encontrada.' });
|
||
const r = rows[0];
|
||
const isOwner = r.owner_usuario_id === uid;
|
||
const isSolicitante = r.solicitante_id === uid;
|
||
// Reserva institucional: qualquer membro da unidade pode cancelar.
|
||
let isDaUnidade = false;
|
||
if (!isOwner && !isSolicitante && r.clinica_id) {
|
||
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [uid, r.clinica_id]);
|
||
isDaUnidade = v.length > 0;
|
||
}
|
||
if ((acao === 'confirmar' || acao === 'recusar') && !isOwner) return res.status(403).json({ error: 'Apenas o proprietário da sala pode responder à reserva.' });
|
||
if (acao === 'cancelar' && !isOwner && !isSolicitante && !isDaUnidade) return res.status(403).json({ error: 'Sem permissão para cancelar esta reserva.' });
|
||
if (!RESERVA_ATIVAS.includes(r.status)) return res.status(409).json({ error: `Reserva já está "${r.status}".` });
|
||
const novo = acao === 'confirmar' ? 'confirmada' : acao === 'recusar' ? 'recusada' : 'cancelada';
|
||
await pool.query(`UPDATE sala_reservas SET status = $1, respondida_em = NOW() WHERE id = $2`, [novo, req.params.id]);
|
||
await registrarAudit(pool, { clinicaId: null, entidade: 'sala_reserva', entidadeId: r.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { salaId: r.sala_id } });
|
||
const destino = isOwner ? r.solicitante_id : r.owner_usuario_id;
|
||
await notificarUsuario(destino, `Reserva ${novo}`,
|
||
`A reserva da sala "${r.sala_nome}" (${new Date(r.inicio).toLocaleString('pt-BR')}) foi ${novo}.`,
|
||
novo === 'confirmada' ? 'sucesso' : 'info');
|
||
res.json({ success: true, status: novo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// =============================================================================
|
||
// PLUGIN: MARKETPLACE DE PROFISSIONAIS (perfil + busca + propostas)
|
||
// =============================================================================
|
||
const MARKETPLACE_ROLES = ['dentista', 'biomedico', 'protetico'];
|
||
|
||
// Meu perfil profissional (campos do marketplace)
|
||
app.get('/api/profissionais/perfil', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, nome, email, celular, role, estado, cidade, bairro, cep, especialidade_dir, raio_atuacao_km, bio_profissional, disponivel_diretorio
|
||
FROM usuarios WHERE id = $1`, [req.authUser.userId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Usuário não encontrado.' });
|
||
res.json(rows[0]);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/profissionais/perfil', authGuard, async (req, res) => {
|
||
const b = req.body || {};
|
||
if (b.disponivel_diretorio && (!b.estado || !b.cidade)) return res.status(400).json({ error: 'Estado e cidade são obrigatórios para aparecer no marketplace.' });
|
||
try {
|
||
await pool.query(
|
||
`UPDATE usuarios SET estado = $1, cidade = $2, bairro = $3, cep = $4, especialidade_dir = $5,
|
||
raio_atuacao_km = $6, bio_profissional = $7, disponivel_diretorio = $8
|
||
WHERE id = $9`,
|
||
[b.estado?.toUpperCase() || null, b.cidade?.toUpperCase() || null, b.bairro?.toUpperCase() || null,
|
||
b.cep || null, b.especialidade?.toUpperCase() || b.especialidade_dir?.toUpperCase() || null,
|
||
Number.isFinite(+b.raio_atuacao_km) && +b.raio_atuacao_km > 0 ? Math.min(+b.raio_atuacao_km, 2000) : null,
|
||
b.bio_profissional || null, b.disponivel_diretorio === true, req.authUser.userId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Busca de profissionais (inclui biomédicos; supera o diretório antigo)
|
||
app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
|
||
const { tipo, estado, cidade, especialidade, cep } = req.query;
|
||
let where = `WHERE disponivel_diretorio = true AND COALESCE(ativo, true) = true AND role = ANY($1::text[])`;
|
||
const params = [tipo && MARKETPLACE_ROLES.includes(tipo) ? [tipo] : MARKETPLACE_ROLES];
|
||
if (estado) { params.push(String(estado).toUpperCase()); where += ` AND estado = $${params.length}`; }
|
||
if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND cidade ILIKE $${params.length}`; }
|
||
if (especialidade) { params.push(`%${String(especialidade).toUpperCase()}%`); where += ` AND especialidade_dir ILIKE $${params.length}`; }
|
||
if (cep) { params.push(`${String(cep).replace(/\D/g, '').slice(0, 5)}%`); where += ` AND regexp_replace(COALESCE(cep,''), '\\D', '', 'g') LIKE $${params.length}`; }
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, nome, email, celular, role, estado, cidade, bairro, cep, especialidade_dir AS especialidade, raio_atuacao_km, bio_profissional
|
||
FROM usuarios ${where} ORDER BY estado, cidade, nome LIMIT 200`, params);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Proposta de contratação: clínica → profissional (exige vínculo com a clínica)
|
||
app.post('/api/propostas', tenantGuard, async (req, res) => {
|
||
try {
|
||
const { clinicaId, profissionalId, mensagem } = req.body || {};
|
||
if (!clinicaId || !profissionalId) return res.status(400).json({ error: 'clinicaId e profissionalId são obrigatórios.' });
|
||
const { rows: prof } = await pool.query(
|
||
`SELECT id, nome FROM usuarios WHERE id = $1 AND disponivel_diretorio = true AND role = ANY($2::text[])`,
|
||
[profissionalId, MARKETPLACE_ROLES]);
|
||
if (!prof.length) return res.status(404).json({ error: 'Profissional não encontrado no marketplace.' });
|
||
const { rows: dup } = await pool.query(
|
||
`SELECT id FROM propostas_profissional WHERE clinica_id = $1 AND profissional_id = $2 AND status = 'pendente' LIMIT 1`,
|
||
[clinicaId, profissionalId]);
|
||
if (dup.length) return res.status(409).json({ error: 'Já existe uma proposta pendente desta unidade para este profissional.' });
|
||
const { rows: cl } = await pool.query('SELECT nome FROM clinicas WHERE id = $1', [clinicaId]);
|
||
const uid = req.authUser.userId;
|
||
const uNome = await actorNome(uid);
|
||
const id = `prop_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO propostas_profissional (id, clinica_id, clinica_nome, profissional_id, enviada_por, enviada_por_nome, mensagem, status)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,'pendente')`,
|
||
[id, clinicaId, cl[0]?.nome || null, profissionalId, uid, uNome, mensagem || null]);
|
||
await registrarAudit(pool, { clinicaId, entidade: 'proposta', entidadeId: id, acao: 'enviou', actorId: uid, actorNome: uNome, detalhes: { profissionalId } });
|
||
await notificarUsuario(profissionalId, 'Nova proposta de trabalho',
|
||
`${cl[0]?.nome || 'Uma unidade'} enviou uma proposta para você. Responda na área de Profissionais.`);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Minhas propostas: recebidas (como profissional) e enviadas (?clinicaId= → tenantGuard valida o vínculo)
|
||
app.get('/api/propostas', tenantGuard, async (req, res) => {
|
||
try {
|
||
if (req.query.clinicaId) {
|
||
const { rows } = await pool.query(
|
||
`SELECT p.*, u.nome AS profissional_nome, u.email AS profissional_email, u.role AS profissional_role
|
||
FROM propostas_profissional p LEFT JOIN usuarios u ON u.id = p.profissional_id
|
||
WHERE p.clinica_id = $1 ORDER BY p.created_at DESC LIMIT 200`, [req.query.clinicaId]);
|
||
return res.json({ enviadas: rows, recebidas: [] });
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT * FROM propostas_profissional WHERE profissional_id = $1 ORDER BY created_at DESC LIMIT 200`,
|
||
[req.authUser.userId]);
|
||
res.json({ enviadas: [], recebidas: rows });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Responder proposta: profissional aceita/recusa; quem enviou (com vínculo) cancela.
|
||
app.put('/api/propostas/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { acao } = req.body || {};
|
||
if (!['aceitar', 'recusar', 'cancelar'].includes(acao)) return res.status(400).json({ error: 'Ação inválida (aceitar | recusar | cancelar).' });
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM propostas_profissional WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Proposta não encontrada.' });
|
||
const p = rows[0];
|
||
if (p.status !== 'pendente') return res.status(409).json({ error: `Proposta já está "${p.status}".` });
|
||
if (acao === 'cancelar') {
|
||
const { rows: v } = await pool.query('SELECT id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [uid, p.clinica_id]);
|
||
if (!v.length) return res.status(403).json({ error: 'Sem vínculo com a unidade desta proposta.' });
|
||
} else if (p.profissional_id !== uid) {
|
||
return res.status(403).json({ error: 'Apenas o profissional destinatário pode responder.' });
|
||
}
|
||
const novo = acao === 'aceitar' ? 'aceita' : acao === 'recusar' ? 'recusada' : 'cancelada';
|
||
await pool.query(`UPDATE propostas_profissional SET status = $1, respondida_em = NOW() WHERE id = $2`, [novo, req.params.id]);
|
||
await registrarAudit(pool, { clinicaId: p.clinica_id, entidade: 'proposta', entidadeId: p.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: {} });
|
||
const destino = acao === 'cancelar' ? p.profissional_id : p.enviada_por;
|
||
await notificarUsuario(destino, `Proposta ${novo}`,
|
||
`A proposta ${p.clinica_nome ? `de "${p.clinica_nome}" ` : ''}foi ${novo}.`,
|
||
novo === 'aceita' ? 'sucesso' : 'info');
|
||
res.json({ success: true, status: novo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// --- LIMPEZA DE DADOS LEGADOS (sem clinica_id) ---
|
||
app.delete('/api/sync/limpar-legado', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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', superadminGuard, 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 }); }
|
||
});
|
||
|
||
// Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin)
|
||
// aparecem na tabela mesmo p/ configs gravadas antes delas existirem.
|
||
const PRICING_DEFAULTS = {
|
||
donoconsultorio: 49.90, donoclinica: 499.00, dentista: 49.90, biomedico: 49.90, protetico: 49.90, paciente: 0,
|
||
'plugin:locacao-salas': 0, 'plugin:profissionais-marketplace': 0,
|
||
};
|
||
app.get('/api/superadmin/pricing', superadminGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'pricing'");
|
||
res.json({ ...PRICING_DEFAULTS, ...(rows[0] ? JSON.parse(rows[0].data) : {}) });
|
||
} 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, 0, 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, 0, 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 ---
|
||
// =============================================================================
|
||
// RX (radiografias) · integração com o servidor dental (rx.scoreodonto.com)
|
||
// Config GLOBAL (só superadmin) em settings.rx_config_global: { rx_url, api_token }.
|
||
// O api_token é o token estático da conta-mestre (consulttclinic@gmail.com) e dá
|
||
// acesso admin no RX. Cada clínica é identificada por clinic_name = clinicaId.
|
||
// Hierarquia: scoreodonto / conta-mestre / clinicaId (clinic_name) / paciente.
|
||
// =============================================================================
|
||
const _rxLoadCfg = async () => {
|
||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'rx_config_global'");
|
||
return rows[0] ? JSON.parse(rows[0].data) : {};
|
||
};
|
||
const _rxConfigured = (cfg) => !!(cfg && cfg.rx_url && cfg.api_token);
|
||
|
||
// Chama a API do RX usando o token estático global como Bearer.
|
||
async function _rxAdminFetch(cfg, path, opts = {}) {
|
||
return fetch(`${cfg.rx_url}${path}`, {
|
||
...opts,
|
||
headers: { ...(opts.headers || {}), 'Authorization': `Bearer ${cfg.api_token}` }
|
||
});
|
||
}
|
||
|
||
// Leitura da config (sem expor o token) — qualquer usuário autenticado.
|
||
app.get('/api/rx/config', authGuard, async (req, res) => {
|
||
try {
|
||
const cfg = await _rxLoadCfg();
|
||
res.json({
|
||
rx_url: cfg.rx_url || process.env.RX_URL || 'https://rx.scoreodonto.com',
|
||
has_token: !!cfg.api_token,
|
||
configured: _rxConfigured(cfg)
|
||
});
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Escrita da config GERAL — só superadmin. Token preservado se vier vazio.
|
||
app.put('/api/rx/config', superadminGuard, async (req, res) => {
|
||
const { rx_url, api_token } = req.body;
|
||
try {
|
||
const prev = await _rxLoadCfg();
|
||
const data = {
|
||
rx_url: (rx_url ?? prev.rx_url ?? '').trim(),
|
||
api_token: (api_token && api_token.length) ? api_token.trim() : (prev.api_token || '')
|
||
};
|
||
await pool.query(
|
||
`INSERT INTO settings (id,category,data) VALUES ('rx_config_global','rx',$1)
|
||
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
|
||
[JSON.stringify(data)]
|
||
);
|
||
res.json({ success: true, configured: _rxConfigured(data) });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Lista dispositivos/clínicas do RX; filtra por clinica_id (tenant) se vier.
|
||
app.get('/api/rx/devices', authGuard, async (req, res) => {
|
||
const { clinicaId } = req.query;
|
||
try {
|
||
const cfg = await _rxLoadCfg();
|
||
if (!_rxConfigured(cfg)) return res.json([]);
|
||
const qs = clinicaId ? `?clinica_id=${encodeURIComponent(clinicaId)}` : '';
|
||
const r = await _rxAdminFetch(cfg, `/api/admin/clinics${qs}`);
|
||
const data = await r.json().catch(() => ({}));
|
||
res.json(data.clinics || []);
|
||
} catch (err) { res.json([]); }
|
||
});
|
||
|
||
// Cria um dispositivo/clínica no RX vinculado ao tenant (clinica_id). O clinic_name
|
||
// é só o rótulo legível (nome_fantasia). e-mail único por PC → default por tenant+PC.
|
||
app.post('/api/rx/devices', tenantGuard, async (req, res) => {
|
||
const { clinicaId, pcName, email, password } = req.body;
|
||
try {
|
||
const cfg = await _rxLoadCfg();
|
||
if (!_rxConfigured(cfg)) return res.status(400).json({ success: false, message: 'RX não configurado: o superadmin precisa definir URL e token do servidor RX.' });
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const { rows: cl } = await pool.query('SELECT nome_fantasia FROM clinicas WHERE id = $1', [clinicaId]);
|
||
const clinicLabel = cl[0]?.nome_fantasia || clinicaId;
|
||
const tenantId = await _rxResolveTenant(clinicaId); // conta/dono da clínica
|
||
const pcSlug = (pcName || 'pc').toString().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||
const r = await _rxAdminFetch(cfg, '/api/admin/clinics', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
clinica_id: clinicaId,
|
||
tenant_id: tenantId,
|
||
clinic_name: clinicLabel,
|
||
email: email || `${clinicaId}.${pcSlug}@rx.scoreodonto.com`,
|
||
password: password || clinicaId,
|
||
pc_name: pcName
|
||
})
|
||
});
|
||
const data = await r.json().catch(() => ({ success: false, message: 'Resposta inválida do servidor RX.' }));
|
||
// Propaga o motivo real do RX (e-mail já cadastrado, PC duplicado, etc.).
|
||
if (!r.ok) return res.status(r.status).json({ success: false, message: data.message || data.error || 'Erro no servidor RX.' });
|
||
res.json(data);
|
||
} catch (err) {
|
||
res.status(502).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
// Resolve a conta (dono) de uma clínica — via vínculo dono* ou owner_id.
|
||
async function _rxResolveTenant(clinicaId) {
|
||
if (!clinicaId) return null;
|
||
const { rows } = await pool.query(
|
||
`SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY role LIMIT 1`,
|
||
[clinicaId]
|
||
);
|
||
if (rows[0]?.usuario_id) return rows[0].usuario_id;
|
||
const { rows: o } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
return o[0]?.owner_id || null;
|
||
}
|
||
|
||
// Escopo de leitura do RX → { ok, filters:{ tenant_id?, clinica_id? } }.
|
||
// - superadmin: qualquer escopo. - clinica_id: liberado com vínculo (drill-down).
|
||
// - tenant_id: liberado só se for a PRÓPRIA conta do usuário (ele é o dono) → visão da conta.
|
||
async function _rxReadScope(userId, { tenantId, clinicaId }) {
|
||
const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [userId]);
|
||
const isSuper = u[0]?.role === 'superadmin';
|
||
if (clinicaId) {
|
||
if (isSuper) return { ok: true, filters: { clinica_id: clinicaId } };
|
||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]);
|
||
return rows.length ? { ok: true, filters: { clinica_id: clinicaId } } : { ok: false };
|
||
}
|
||
if (tenantId) {
|
||
if (isSuper || tenantId === userId) return { ok: true, filters: { tenant_id: tenantId } };
|
||
return { ok: false };
|
||
}
|
||
return isSuper ? { ok: true, filters: {} } : { ok: false };
|
||
}
|
||
|
||
// Pacientes do RX (proxy). Escopo: tenant_id (conta) ou clinica_id (clínica). Token só no servidor.
|
||
app.get('/api/rx/patients', authGuard, async (req, res) => {
|
||
const { tenantId = '', clinicaId = '', search = '', page = '1', limit = '50' } = req.query;
|
||
try {
|
||
const scope = await _rxReadScope(req.authUser?.userId, { tenantId, clinicaId });
|
||
if (!scope.ok) return res.status(403).json([]);
|
||
const cfg = await _rxLoadCfg();
|
||
if (!_rxConfigured(cfg)) return res.json([]);
|
||
const qs = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||
if (scope.filters.tenant_id) qs.set('tenant_id', scope.filters.tenant_id);
|
||
if (scope.filters.clinica_id) qs.set('clinica_id', scope.filters.clinica_id);
|
||
if (search) qs.set('search', search);
|
||
const r = await _rxAdminFetch(cfg, `/api/images/patients?${qs.toString()}`);
|
||
const data = await r.json().catch(() => []);
|
||
res.json(Array.isArray(data) ? data : []);
|
||
} catch (err) { res.json([]); }
|
||
});
|
||
|
||
// Imagens de um paciente (proxy). Escopo: tenant_id (conta) ou clinica_id (clínica).
|
||
app.get('/api/rx/images', authGuard, async (req, res) => {
|
||
const { tenantId = '', clinicaId = '', paciente = '' } = req.query;
|
||
if (!paciente) return res.status(400).json({ error: 'paciente obrigatório.' });
|
||
try {
|
||
const scope = await _rxReadScope(req.authUser?.userId, { tenantId, clinicaId });
|
||
if (!scope.ok) return res.status(403).json([]);
|
||
const cfg = await _rxLoadCfg();
|
||
if (!_rxConfigured(cfg)) return res.json([]);
|
||
const qs = new URLSearchParams({ name: String(paciente) });
|
||
if (scope.filters.tenant_id) qs.set('tenant_id', scope.filters.tenant_id);
|
||
if (scope.filters.clinica_id) qs.set('clinica_id', scope.filters.clinica_id);
|
||
const r = await _rxAdminFetch(cfg, `/api/images/by-patient?${qs.toString()}`);
|
||
const data = await r.json().catch(() => []);
|
||
res.json(Array.isArray(data) ? data : []);
|
||
} catch (err) { res.json([]); }
|
||
});
|
||
|
||
// Saúde/reconciliação do RX (proxy) — só superadmin. Repassa clinica_id/checkLimit.
|
||
app.get('/api/rx/health', superadminGuard, async (req, res) => {
|
||
try {
|
||
const cfg = await _rxLoadCfg();
|
||
if (!_rxConfigured(cfg)) return res.json({ configured: false });
|
||
const qs = new URLSearchParams();
|
||
if (req.query.clinicaId) qs.set('clinica_id', String(req.query.clinicaId));
|
||
if (req.query.checkLimit) qs.set('checkLimit', String(req.query.checkLimit));
|
||
const r = await _rxAdminFetch(cfg, `/api/admin/rx-health${qs.toString() ? '?' + qs.toString() : ''}`);
|
||
const data = await r.json().catch(() => ({}));
|
||
res.status(r.ok ? 200 : r.status).json(data);
|
||
} catch (err) { res.status(502).json({ error: 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 }); }
|
||
});
|
||
|
||
const httpServer = 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);
|
||
});
|
||
|
||
// ── WebSocket server (tempo real) em /api/ws ────────────────────────────────
|
||
// Auth via query string (?token=&clinicaId=), pois EventSource/WebSocket do
|
||
// navegador não enviam header Authorization. Valida JWT + vínculo com a clínica.
|
||
const wss = new WebSocketServer({ server: httpServer, path: '/api/ws' });
|
||
wss.on('connection', async (ws, req) => {
|
||
try {
|
||
const u = new URL(req.url, 'http://localhost');
|
||
const token = u.searchParams.get('token');
|
||
const clinicaId = u.searchParams.get('clinicaId');
|
||
let payload;
|
||
try { payload = jwt.verify(token, JWT_SECRET); } catch { console.log('[WS] reject auth (token inválido/ausente)'); ws.close(4001, 'auth'); return; }
|
||
if (clinicaId) {
|
||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [payload.userId, clinicaId]);
|
||
if (!rows.length) { console.log(`[WS] reject forbidden user=${String(payload.userId).slice(0, 12)} clinica=${String(clinicaId).slice(0, 12)}`); ws.close(4003, 'forbidden'); return; }
|
||
}
|
||
ws.clinicaId = clinicaId || null;
|
||
ws.userId = payload.userId;
|
||
ws.isAlive = true;
|
||
if (clinicaId) {
|
||
if (!wsClients.has(clinicaId)) wsClients.set(clinicaId, new Set());
|
||
wsClients.get(clinicaId).add(ws);
|
||
}
|
||
ws.on('pong', () => { ws.isAlive = true; });
|
||
ws.on('close', () => {
|
||
const set = wsClients.get(ws.clinicaId);
|
||
if (set) { set.delete(ws); if (!set.size) wsClients.delete(ws.clinicaId); }
|
||
});
|
||
ws.on('error', () => { });
|
||
console.log(`[WS] connect user=${String(payload.userId).slice(0, 12)} clinica=${String(clinicaId).slice(0, 12)} (clínica agora com ${wsClients.get(clinicaId)?.size || 0} conexão(ões))`);
|
||
ws.send(JSON.stringify({ type: 'connected', at: Date.now() }));
|
||
} catch { try { ws.close(); } catch { /* ignore */ } }
|
||
});
|
||
// Heartbeat: derruba conexões mortas e mantém o proxy de pé (read timeout).
|
||
setInterval(() => {
|
||
wss.clients.forEach((ws) => {
|
||
if (ws.isAlive === false) { try { ws.terminate(); } catch { /* ignore */ } return; }
|
||
ws.isAlive = false;
|
||
try { ws.ping(); } catch { /* ignore */ }
|
||
});
|
||
}, 30000);
|