abbd9374b4
Backend:
- config.js: config lida da tabela settings (id=newwhats_config) com fallback ENV;
loadConfigFromDb/saveConfigToDb + cache.
- index.js: endpoints superadmin GET/PUT /api/nw/config (secrets nunca em claro;
branco mantém valor), GET /api/nw/clinicas (dropdown), GET /api/nw/status (testar
conexão). server.js passa { superadminGuard }.
Frontend:
- PluginsView: NewwhatsConfigModal dedicado (carrega/salva no backend, dropdown de
clínicas em vez de ID cru, botão Testar conexão). Substitui o form genérico p/ newwhats.
Resolve UX ruim: nada de digitar ID de clínica; config passa a funcionar de fato.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9993 lines
614 KiB
JavaScript
9993 lines
614 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';
|
||
import crypto from 'crypto';
|
||
import { registerNewwhats } from './newwhats/index.js';
|
||
|
||
const { Pool } = pg;
|
||
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
||
|
||
// URL assinada para imagens clínicas servidas em <img> (sem header de auth).
|
||
// Token = "<exp>.<hmac>"; expira em 12h. Reemitido a cada listagem de fotos.
|
||
const MEDIA_TTL_MS = 12 * 60 * 60 * 1000;
|
||
function signMedia(id) {
|
||
const exp = Date.now() + MEDIA_TTL_MS;
|
||
const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${id}.${exp}`).digest('hex').slice(0, 32);
|
||
return `${exp}.${sig}`;
|
||
}
|
||
function verifyMedia(id, token) {
|
||
if (!token || typeof token !== 'string' || !token.includes('.')) return false;
|
||
const i = token.indexOf('.');
|
||
const exp = Number(token.slice(0, i));
|
||
if (!exp || Date.now() > exp) return false;
|
||
const good = crypto.createHmac('sha256', JWT_SECRET).update(`${id}.${exp}`).digest('hex').slice(0, 32);
|
||
try { return crypto.timingSafeEqual(Buffer.from(token.slice(i + 1)), Buffer.from(good)); } catch { return false; }
|
||
}
|
||
// Documentação fotográfica de ortodontia: uploads vão para o volume persistente
|
||
// /app/media (montado de /home/deploy/scoreodonto-media), servidos via /api/orto/fotos/:id/raw.
|
||
const MEDIA_ROOT = process.env.MEDIA_PATH || '/app/media';
|
||
const ortoUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
|
||
|
||
// =============================================================================
|
||
// DATABASE — PostgreSQL nativo (pg Pool)
|
||
// =============================================================================
|
||
const pool = new Pool({
|
||
connectionString: process.env.DATABASE_URL,
|
||
ssl: process.env.DATABASE_URL && !/localhost|@pgdev/.test(process.env.DATABASE_URL)
|
||
? { rejectUnauthorized: false } : false,
|
||
max: 10,
|
||
idleTimeoutMillis: 30000
|
||
});
|
||
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
|
||
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
|
||
|
||
// Helper: transaction wrapper
|
||
async function withTransaction(fn) {
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
const result = await fn(client);
|
||
await client.query('COMMIT');
|
||
return result;
|
||
} catch (err) {
|
||
await client.query('ROLLBACK');
|
||
throw err;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
|
||
// Helper: build INSERT query from plain object (PG native)
|
||
// Keys are lowercased to match PG's case-folded column storage.
|
||
function buildInsert(table, obj) {
|
||
const allowed = Object.entries(obj).filter(([, v]) => v !== undefined && v !== null || typeof v === 'boolean' || v === 0);
|
||
const keys = allowed.map(([k]) => k.toLowerCase());
|
||
const vals = allowed.map(([, v]) => v);
|
||
const cols = keys.join(', ');
|
||
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
|
||
return { text: `INSERT INTO ${table} (${cols}) VALUES (${placeholders})`, values: vals };
|
||
}
|
||
|
||
// Helper: build UPDATE SET clause from plain object (PG native)
|
||
// Keys are lowercased to match PG's case-folded column storage.
|
||
function buildUpdate(table, obj, whereCol, whereVal) {
|
||
const skip = new Set(['id', 'createdat', 'created_at']);
|
||
const entries = Object.entries(obj).filter(([k, v]) => !skip.has(k.toLowerCase()) && v !== undefined);
|
||
const set = entries.map(([k], i) => `${k.toLowerCase()} = $${i + 1}`).join(', ');
|
||
const values = [...entries.map(([, v]) => v), whereVal];
|
||
return { text: `UPDATE ${table} SET ${set} WHERE ${whereCol.toLowerCase()} = $${entries.length + 1}`, values };
|
||
}
|
||
|
||
// Helper: convert PostgreSQL error codes to friendly messages
|
||
function pgErrCode(err) { return err.code; } // '23505' = unique violation
|
||
|
||
// =============================================================================
|
||
// SENHAS (bcrypt) + provisionamento de contas
|
||
// =============================================================================
|
||
const SENHA_ROUNDS = 10;
|
||
async function hashSenha(plain) { return bcrypt.hash(String(plain), SENHA_ROUNDS); }
|
||
function isHashed(s) { return typeof s === 'string' && /^\$2[aby]\$/.test(s); }
|
||
// Verifica senha; migra senhas legadas em texto puro para hash de forma preguiçosa.
|
||
async function verificarSenha(userId, plainInput, stored) {
|
||
if (isHashed(stored)) return bcrypt.compare(String(plainInput), stored);
|
||
if (stored != null && String(plainInput) === String(stored)) {
|
||
try { await pool.query('UPDATE usuarios SET senha = $1 WHERE id = $2', [await hashSenha(plainInput), userId]); } catch { /* ignore */ }
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
// Senha temporária legível (sem caracteres ambíguos).
|
||
function gerarSenhaTemp() {
|
||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||
let s = '';
|
||
for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
||
return s;
|
||
}
|
||
// Encontra (ou provisiona) a conta de um membro pelo e-mail. Se não existe,
|
||
// cria conta FREE com o papel dado, senha temporária (hash) e troca obrigatória.
|
||
async function ensureUsuarioPorEmail(email, nome, role) {
|
||
const e = String(email).toLowerCase().trim();
|
||
const { rows } = await pool.query('SELECT id, nome FROM usuarios WHERE lower(email) = $1', [e]);
|
||
if (rows.length) return { id: rows[0].id, nome: rows[0].nome, created: false, tempPassword: null };
|
||
const id = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||
const temp = gerarSenhaTemp();
|
||
await pool.query(
|
||
'INSERT INTO usuarios (id, nome, email, senha, role, must_change_password) VALUES ($1,$2,$3,$4,$5,true)',
|
||
[id, (nome || e).toUpperCase(), e, await hashSenha(temp), role]
|
||
);
|
||
return { id, nome: (nome || e).toUpperCase(), created: true, tempPassword: temp };
|
||
}
|
||
|
||
// Nível hierárquico do usuário NA clínica (dono/admin = topo). Usado para "só gerencia abaixo".
|
||
async function nivelNaClinica(userId, clinicaId) {
|
||
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
if (cl[0]?.owner_id === userId) return 100000;
|
||
const { rows } = await pool.query(
|
||
'SELECT v.role, f.nivel FROM vinculos v LEFT JOIN funcoes f ON f.id = v.funcao_id WHERE v.usuario_id = $1 AND v.clinica_id = $2',
|
||
[userId, clinicaId]);
|
||
if (!rows.length) return -1; // não é membro
|
||
if (['donoclinica', 'donoconsultorio', 'admin'].includes(rows[0].role)) return 99999;
|
||
return rows[0].nivel ?? 0;
|
||
}
|
||
async function nivelDaFuncao(funcaoId) {
|
||
if (!funcaoId) return 0;
|
||
const { rows } = await pool.query('SELECT nivel FROM funcoes WHERE id = $1', [funcaoId]);
|
||
return rows[0]?.nivel ?? 0;
|
||
}
|
||
|
||
// =============================================================================
|
||
// DRAGONFLY / REDIS CACHE
|
||
// =============================================================================
|
||
let redis = null;
|
||
try {
|
||
const DRAGONFLY_URL = process.env.DRAGONFLY_URL || 'redis://localhost:6379';
|
||
redis = new Redis(DRAGONFLY_URL, { lazyConnect: true });
|
||
redis.on('connect', () => console.log('[DRAGONFLY] Connected'));
|
||
redis.on('error', (err) => console.error('[DRAGONFLY] Error:', err.message));
|
||
await redis.connect().catch(() => { redis = null; });
|
||
} catch (e) {
|
||
console.error('[DRAGONFLY] Failed to initialize:', e.message);
|
||
redis = null;
|
||
}
|
||
|
||
// =============================================================================
|
||
// EXPRESS
|
||
// =============================================================================
|
||
const app = express();
|
||
app.use(express.json({ limit: '2mb' }));
|
||
app.use(cors({
|
||
origin: process.env.CORS_ORIGIN || '*',
|
||
credentials: true
|
||
}));
|
||
|
||
// --- HEALTH CHECK ---
|
||
app.get('/api/ping', (req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));
|
||
|
||
// --- VERSION / BUILD INFO (carimbado no build via ARGs do Dockerfile) ---
|
||
const BUILD_INFO = {
|
||
name: 'ScoreOdonto API',
|
||
version: process.env.APP_VERSION || 'dev',
|
||
commit: process.env.GIT_COMMIT || 'dev',
|
||
build: process.env.BUILD_TIME || '',
|
||
environment: (process.env.APP_ENV || 'DEV').toUpperCase(),
|
||
};
|
||
app.get('/api/version', (req, res) => res.json(BUILD_INFO));
|
||
|
||
// Health check (público, sem auth) — status do backend + dependências.
|
||
// Banco é crítico (down => HTTP 503); cache é opcional (down => "degraded", mas 200).
|
||
app.get('/api/health', async (req, res) => {
|
||
const checks = {};
|
||
try {
|
||
const t = Date.now();
|
||
await pool.query('SELECT 1');
|
||
checks.database = { status: 'ok', latency_ms: Date.now() - t };
|
||
} catch {
|
||
checks.database = { status: 'down' };
|
||
}
|
||
try {
|
||
if (redis) {
|
||
const t = Date.now();
|
||
await redis.ping();
|
||
checks.cache = { status: 'ok', latency_ms: Date.now() - t };
|
||
} else {
|
||
checks.cache = { status: 'disabled' };
|
||
}
|
||
} catch {
|
||
checks.cache = { status: 'down' };
|
||
}
|
||
const dbOk = checks.database.status === 'ok';
|
||
const cacheDegraded = checks.cache.status === 'down';
|
||
const status = !dbOk ? 'down' : (cacheDegraded ? 'degraded' : 'ok');
|
||
res.status(dbOk ? 200 : 503).json({
|
||
status,
|
||
version: BUILD_INFO.version,
|
||
commit: BUILD_INFO.commit,
|
||
environment: BUILD_INFO.environment,
|
||
uptime_s: Math.round(process.uptime()),
|
||
checks,
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// SECURITY MIDDLEWARE: Tenant Guard
|
||
// =============================================================================
|
||
async function tenantGuard(req, res, next) {
|
||
const authHeader = req.headers['authorization'];
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
return res.status(401).json({ success: false, message: 'TOKEN DE AUTENTICAÇÃO NÃO ENVIADO.' });
|
||
}
|
||
let payload;
|
||
try {
|
||
payload = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
|
||
} catch {
|
||
return res.status(401).json({ success: false, message: 'TOKEN INVÁLIDO OU EXPIRADO. FAÇA LOGIN NOVAMENTE.' });
|
||
}
|
||
const clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId;
|
||
req.isSala = false;
|
||
if (clinicaId) {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2',
|
||
[payload.userId, clinicaId]
|
||
);
|
||
if (rows.length === 0) {
|
||
// Workspace pode ser uma SALA própria (locação): valida pela posse, não por vínculo.
|
||
const { rows: sl } = await pool.query('SELECT id FROM salas WHERE id = $1 AND owner_usuario_id = $2', [clinicaId, payload.userId]);
|
||
if (sl.length === 0) {
|
||
console.warn(`[TENANT VIOLATION] User ${payload.userId} -> clinica ${clinicaId}`);
|
||
return res.status(403).json({ success: false, message: 'ACESSO NEGADO: VOCÊ NÃO TEM VÍNCULO COM ESTA UNIDADE.' });
|
||
}
|
||
req.isSala = true;
|
||
}
|
||
} catch (err) {
|
||
return res.status(500).json({ success: false, message: 'ERRO INTERNO DE AUTENTICAÇÃO.' });
|
||
}
|
||
}
|
||
req.authUser = payload;
|
||
req.clinicaId = clinicaId || null;
|
||
next();
|
||
}
|
||
|
||
function authGuard(req, res, next) {
|
||
const authHeader = req.headers['authorization'];
|
||
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Não autenticado.' });
|
||
try {
|
||
req.authUser = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
|
||
next();
|
||
} catch {
|
||
res.status(401).json({ error: 'Token inválido.' });
|
||
}
|
||
}
|
||
|
||
// ── RBAC intra-clínica ───────────────────────────────────────────────────────
|
||
// Exige que o usuário tenha a "capability" (chave de view do frontend) no workspace
|
||
// ATIVO. Espelha o isViewAllowed do frontend: superadmin global → ok; dono
|
||
// (donoclinica/donoconsultorio/admin) → ok; cargo com permissões explícitas → ok se
|
||
// inclui a capability; senão, mapa padrão por papel. DEVE rodar APÓS o tenantGuard.
|
||
const CAP_OWNER_ROLES = ['donoclinica', 'donoconsultorio', 'admin'];
|
||
const CAP_DEFAULTS = {
|
||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||
};
|
||
// Núcleo da decisão de capability (req.clinicaId já resolvido).
|
||
async function capabilityCore(cap, req, res, next) {
|
||
const userId = req.authUser && req.authUser.userId;
|
||
if (!userId) return res.status(401).json({ error: 'Não autenticado.' });
|
||
const { rows: ur } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [userId]);
|
||
if (ur[0] && ur[0].role === 'superadmin') return next();
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query(
|
||
`SELECT v.role, f.permissoes FROM vinculos v LEFT JOIN funcoes f ON f.id = v.funcao_id
|
||
WHERE v.usuario_id = $1 AND v.clinica_id = $2`, [userId, clinicaId]);
|
||
if (!rows.length) return res.status(403).json({ error: 'ACESSO NEGADO: sem vínculo com esta unidade.' });
|
||
const role = rows[0].role;
|
||
if (CAP_OWNER_ROLES.includes(role)) return next();
|
||
let perms = rows[0].permissoes;
|
||
if (typeof perms === 'string') { try { perms = JSON.parse(perms); } catch { perms = null; } }
|
||
if (Array.isArray(perms) && perms.length > 0) {
|
||
if (perms.includes(cap)) return next();
|
||
return res.status(403).json({ error: `ACESSO NEGADO: seu cargo não inclui '${cap}'.` });
|
||
}
|
||
if ((CAP_DEFAULTS[role] || []).includes(cap)) return next();
|
||
return res.status(403).json({ error: `ACESSO NEGADO: seu cargo não permite '${cap}'.` });
|
||
}
|
||
|
||
// Capability pela clínica da REQUISIÇÃO (param/body/query). Valida vínculo + cargo.
|
||
function requireCapability(cap) {
|
||
return async function (req, res, next) {
|
||
try {
|
||
if (!req.clinicaId) req.clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId || null;
|
||
return await capabilityCore(cap, req, res, next);
|
||
} catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); }
|
||
};
|
||
}
|
||
|
||
// Capability pela clínica da LINHA do recurso (tabela + req.params.id) — para PUT/DELETE /:id
|
||
// quando o clinicaId não vem na requisição. Itens globais (clinica_id NULL) → só superadmin.
|
||
function requireCapabilityRow(cap, table) {
|
||
return async function (req, res, next) {
|
||
try {
|
||
const { rows } = await pool.query(`SELECT clinica_id AS cid FROM ${table} WHERE id = $1`, [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Recurso não encontrado.' });
|
||
req.clinicaId = rows[0].cid || null;
|
||
return await capabilityCore(cap, req, res, next);
|
||
} catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); }
|
||
};
|
||
}
|
||
|
||
// Capability para reordenação: clinicaId vem do 1º item de req.body.orders ([{id,ordem}]).
|
||
function requireCapabilityFromOrders(cap, table) {
|
||
return async function (req, res, next) {
|
||
try {
|
||
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
|
||
const firstId = orders.length ? orders[0].id : null;
|
||
if (!firstId) return res.status(400).json({ error: 'Lista de ordenação vazia.' });
|
||
const { rows } = await pool.query(`SELECT clinica_id AS cid FROM ${table} WHERE id = $1`, [firstId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Item não encontrado.' });
|
||
req.clinicaId = rows[0].cid || null;
|
||
return await capabilityCore(cap, req, res, next);
|
||
} catch (e) { return res.status(500).json({ error: 'Erro interno de autorização.' }); }
|
||
};
|
||
}
|
||
|
||
|
||
// =============================================================================
|
||
// AUDITORIA GENÉRICA + PENDÊNCIAS DE AGENDA (Fase 1)
|
||
// =============================================================================
|
||
async function actorNome(userId) {
|
||
if (!userId) return null;
|
||
try { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]); return rows[0]?.nome || null; }
|
||
catch { return null; }
|
||
}
|
||
// Registra um evento IMUTÁVEL de auditoria. Passe o client da transação quando houver.
|
||
// ── WebSocket: tempo real por clínica ───────────────────────────────────────
|
||
// Registro de conexões vivas agrupadas por clínica. wsBroadcast() empurra um
|
||
// aviso de mudança para todos os clientes daquela clínica → refetch instantâneo.
|
||
const wsClients = new Map(); // clinicaId -> Set<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); }
|
||
}
|
||
|
||
// Área padrão a partir do papel do usuário (default ao criar especialidade/procedimento).
|
||
function roleToArea(role) {
|
||
if (role === 'biomedico') return 'biomedicina';
|
||
if (role === 'protetico') return 'protese';
|
||
return 'odontologia';
|
||
}
|
||
|
||
// Workspaces do usuário = clínicas (via vínculos) + salas que ele é dono (tipo='sala').
|
||
// Cada sala é um workspace próprio, isolado da clínica (tem endereço próprio).
|
||
async function listarWorkspaces(userId, userRole) {
|
||
const { rows: clinicas } = await pool.query(`
|
||
SELECT v.clinica_id AS id, c.nome_fantasia AS nome, c.cor, v.role,
|
||
COALESCE(c.tipo, 'clinica') AS tipo,
|
||
v.funcao_id, f.nome AS funcao_nome, f.permissoes, f.nivel
|
||
FROM vinculos v
|
||
JOIN clinicas c ON v.clinica_id = c.id
|
||
LEFT JOIN funcoes f ON f.id = v.funcao_id
|
||
WHERE v.usuario_id = $1
|
||
`, [userId]);
|
||
const { rows: salas } = await pool.query(
|
||
`SELECT id, nome, cidade, estado FROM salas WHERE owner_usuario_id = $1 ORDER BY nome`, [userId]);
|
||
const salaWs = salas.map(s => ({
|
||
id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'donosala', tipo: 'sala',
|
||
cidade: s.cidade, estado: s.estado, funcao_id: null, funcao_nome: null, permissoes: null, nivel: null,
|
||
}));
|
||
// Salas das clínicas em que o usuário tem vínculo (mas NÃO é dono): acesso OPERACIONAL —
|
||
// ver a agenda da sala e bloquear horário para manutenção, sem locação/financeiro.
|
||
const { rows: salasOp } = await pool.query(`
|
||
SELECT DISTINCT s.id, s.nome, s.cidade, s.estado
|
||
FROM salas s
|
||
JOIN vinculos v ON v.clinica_id = s.clinica_id AND v.usuario_id = $1
|
||
WHERE s.clinica_id IS NOT NULL AND s.owner_usuario_id <> $1
|
||
ORDER BY s.nome`, [userId]);
|
||
const salaOpWs = salasOp.map(s => ({
|
||
id: s.id, nome: s.nome, cor: '#0d9488', role: userRole || 'funcionario', tipo: 'sala',
|
||
cidade: s.cidade, estado: s.estado, papel_sala: 'operador',
|
||
funcao_id: null, funcao_nome: null, permissoes: null, nivel: null,
|
||
}));
|
||
return [...clinicas, ...salaWs, ...salaOpWs];
|
||
}
|
||
|
||
// Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido).
|
||
const BIOMED_ESPECIALIDADES = [
|
||
'BIOMEDICINA ESTÉTICA', 'HARMONIZAÇÃO OROFACIAL', 'ESTÉTICA FACIAL', 'ESTÉTICA CORPORAL', 'PROCEDIMENTOS INJETÁVEIS',
|
||
];
|
||
const BIOMED_PROCEDIMENTOS = [
|
||
{ nome: 'TOXINA BOTULÍNICA (BOTOX)', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 800 },
|
||
{ nome: 'PREENCHIMENTO COM ÁCIDO HIALURÔNICO', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 1200 },
|
||
{ nome: 'BIOESTIMULADOR DE COLÁGENO', esp: 'ESTÉTICA FACIAL', valor: 1500 },
|
||
{ nome: 'SKINBOOSTER', esp: 'ESTÉTICA FACIAL', valor: 900 },
|
||
{ nome: 'PEELING QUÍMICO', esp: 'ESTÉTICA FACIAL', valor: 300 },
|
||
{ nome: 'MICROAGULHAMENTO', esp: 'ESTÉTICA FACIAL', valor: 400 },
|
||
{ nome: 'LIMPEZA DE PELE', esp: 'ESTÉTICA FACIAL', valor: 150 },
|
||
{ nome: 'FIOS DE SUSTENTAÇÃO (PDO)', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2000 },
|
||
{ nome: 'HARMONIZAÇÃO FACIAL', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2500 },
|
||
{ nome: 'ESCLEROTERAPIA (VASINHOS)', esp: 'ESTÉTICA CORPORAL', valor: 350 },
|
||
{ nome: 'INTRADERMOTERAPIA / MESOTERAPIA', esp: 'ESTÉTICA CORPORAL', valor: 400 },
|
||
{ nome: 'LIPÓLISE ENZIMÁTICA', esp: 'ESTÉTICA CORPORAL', valor: 500 },
|
||
];
|
||
// Popula o catálogo de biomedicina de uma clínica/consultório (idempotente: só se vazio).
|
||
async function seedBiomedicina(clinicaId) {
|
||
if (!clinicaId) return false;
|
||
const { rows } = await pool.query('SELECT 1 FROM especialidades WHERE clinica_id = $1 LIMIT 1', [clinicaId]);
|
||
if (rows.length) return false;
|
||
const espId = {};
|
||
for (let i = 0; i < BIOMED_ESPECIALIDADES.length; i++) {
|
||
const id = `esp_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query("INSERT INTO especialidades (id, nome, clinica_id, ordem, ativo, area) VALUES ($1,$2,$3,$4,true,'biomedicina')", [id, BIOMED_ESPECIALIDADES[i], clinicaId, i]);
|
||
espId[BIOMED_ESPECIALIDADES[i]] = id;
|
||
}
|
||
for (let i = 0; i < BIOMED_PROCEDIMENTOS.length; i++) {
|
||
const pr = BIOMED_PROCEDIMENTOS[i];
|
||
const id = `proc_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query(
|
||
"INSERT INTO procedimentos (id, nome, especialidadeid, especialidadenome, valorparticular, clinica_id, ordem, ativo, area) VALUES ($1,$2,$3,$4,$5,$6,$7,true,'biomedicina')",
|
||
[id, pr.nome, espId[pr.esp] || null, pr.esp, pr.valor, clinicaId, i]);
|
||
}
|
||
// marca a clínica como área de biomedicina
|
||
await pool.query("INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,'biomedicina') ON CONFLICT DO NOTHING", [clinicaId]);
|
||
return true;
|
||
}
|
||
|
||
// Catálogo geral de produtos de prótese (preço fixo, SEM especialidade — o protético é geral).
|
||
// Garantia padrão de 12 meses por produto.
|
||
const PROTESE_PRODUTOS_PADRAO = [
|
||
{ nome: 'Coroa de Zircônia', preco: 350 },
|
||
{ nome: 'Coroa Metalocerâmica', preco: 250 },
|
||
{ nome: 'Coroa Provisória', preco: 80 },
|
||
{ nome: 'Faceta / Laminado de Porcelana', preco: 400 },
|
||
{ nome: 'Onlay / Inlay Cerâmico', preco: 300 },
|
||
{ nome: 'Núcleo Metálico Fundido', preco: 90 },
|
||
{ nome: 'Prótese Total (Dentadura)', preco: 600 },
|
||
{ nome: 'Prótese Parcial Removível (PPR)', preco: 500 },
|
||
{ nome: 'Coroa sobre Implante', preco: 600 },
|
||
{ nome: 'Protocolo sobre Implante (arco)', preco: 1800 },
|
||
{ nome: 'Placa de Bruxismo / Miorrelaxante',preco: 150 },
|
||
{ nome: 'Provisório em Resina (unidade)', preco: 60 },
|
||
];
|
||
// Idempotente: só popula se o protético ainda não tem nenhum produto.
|
||
async function seedProteseProdutos(proteticoId) {
|
||
if (!proteticoId) return false;
|
||
const { rows } = await pool.query('SELECT 1 FROM protese_produtos WHERE protetico_id = $1 LIMIT 1', [proteticoId]);
|
||
if (rows.length) return false;
|
||
for (let i = 0; i < PROTESE_PRODUTOS_PADRAO.length; i++) {
|
||
const p = PROTESE_PRODUTOS_PADRAO[i];
|
||
await pool.query(
|
||
"INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,12,true,$5)",
|
||
[`ppr_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, proteticoId, p.nome, p.preco, i]);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// --- REGISTRO DE USUÁRIOS ---
|
||
app.post('/api/register', async (req, res) => {
|
||
const { nome, email, senha, role, workspaceNome, especialidade } = req.body;
|
||
if (!nome || !email || !senha) return res.status(400).json({ success: false, message: 'Nome, e-mail e senha são obrigatórios.' });
|
||
const VALID_ROLES = ['donoconsultorio', 'donoclinica', 'donosala', 'dentista', 'biomedico', 'protetico', 'paciente'];
|
||
const userRole = VALID_ROLES.includes(role) ? role : 'donoclinica';
|
||
try {
|
||
const existing = await pool.query('SELECT id FROM usuarios WHERE email = $1', [email.toLowerCase().trim()]);
|
||
if (existing.rows.length > 0) return res.status(409).json({ success: false, message: 'E-mail já cadastrado.' });
|
||
const userId = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||
const nomeUpper = nome.trim().toUpperCase(); // padrão do sistema: tudo em maiúsculas
|
||
const esp = (Array.isArray(especialidade) ? especialidade[0] : especialidade) || null;
|
||
await pool.query(
|
||
'INSERT INTO usuarios (id, nome, email, senha, role, especialidade) VALUES ($1, $2, $3, $4, $5, $6)',
|
||
[userId, nomeUpper, email.toLowerCase().trim(), await hashSenha(senha), userRole, esp]
|
||
);
|
||
|
||
// Fase 1 — workspace pessoal automático para profissionais e paciente.
|
||
// (donoconsultorio/donoclinica criam a clínica-empresa no passo seguinte.)
|
||
let workspaces = [];
|
||
if (['dentista', 'biomedico', 'protetico', 'paciente'].includes(userRole)) {
|
||
const wsId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
// Nome do estabelecimento informado pelo usuário; senão, default por papel
|
||
// (evita a unidade nascer com o NOME DA PESSOA sem querer).
|
||
const defaultNome = userRole === 'dentista' ? `CONSULTÓRIO DE ${nome.trim()}`
|
||
: userRole === 'biomedico' ? `CONSULTÓRIO DE ${nome.trim()}`
|
||
: userRole === 'protetico' ? `LABORATÓRIO DE ${nome.trim()}`
|
||
: nome.trim();
|
||
const wsNome = (workspaceNome && workspaceNome.trim() ? workspaceNome.trim() : defaultNome).toUpperCase();
|
||
// Fase 2: o workspace do protético é um LABORATÓRIO (área de gestão de 1ª classe).
|
||
const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
|
||
await pool.query(
|
||
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1, $2, $3, $4)`,
|
||
[wsId, wsNome, wsTipo, userId]
|
||
);
|
||
await pool.query(
|
||
'INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)',
|
||
[`v_${userId}_${wsId}`, userId, wsId, userRole]
|
||
);
|
||
workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }];
|
||
// Biomédico já nasce com o catálogo de biomedicina estética no consultório dele.
|
||
if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } }
|
||
// Protético já nasce com o catálogo geral de produtos de prótese (preço fixo + garantia).
|
||
if (userRole === 'protetico') { try { await seedProteseProdutos(userId); } catch (e) { console.error('[seedProteseProdutos]', e.message); } }
|
||
}
|
||
|
||
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
|
||
console.log(`[REGISTER] New user: ${email}, role: ${userRole}, workspaces: ${workspaces.length}`);
|
||
res.json({ success: true, token, user: { id: userId, nome: nomeUpper, email: email.toLowerCase().trim(), role: userRole }, workspaces, noWorkspace: workspaces.length === 0, userRole });
|
||
} catch (err) {
|
||
console.error('[REGISTER]', err.message);
|
||
res.status(500).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
// --- CRIAÇÃO E GERENCIAMENTO DE CLÍNICAS ---
|
||
app.post('/api/clinicas', async (req, res) => {
|
||
const authHeader = req.headers['authorization'];
|
||
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ success: false, message: 'Não autenticado.' });
|
||
let payload;
|
||
try { payload = jwt.verify(authHeader.split(' ')[1], JWT_SECRET); } catch { return res.status(401).json({ success: false, message: 'Token inválido.' }); }
|
||
|
||
const { nome_fantasia, documento } = req.body;
|
||
if (!nome_fantasia) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' });
|
||
|
||
try {
|
||
const { rows: userRows } = await pool.query('SELECT email, nome, role FROM usuarios WHERE id = $1', [payload.userId]);
|
||
if (!userRows.length) return res.status(404).json({ success: false, message: 'Usuário não encontrado.' });
|
||
const { role: ownerRole } = userRows[0];
|
||
const vinculoRole = ['donoclinica', 'donoconsultorio'].includes(ownerRole) ? ownerRole : 'donoclinica';
|
||
|
||
const clinicaId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
const tipoWs = vinculoRole === 'donoconsultorio' ? 'consultorio' : 'clinica';
|
||
await pool.query('INSERT INTO clinicas (id, nome_fantasia, documento, owner_id, tipo) VALUES ($1, $2, $3, $4, $5)',
|
||
[clinicaId, nome_fantasia.toUpperCase(), documento || null, payload.userId, tipoWs]);
|
||
const vinculoId = `v_${payload.userId}_${clinicaId}`;
|
||
await pool.query('INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1, $2, $3, $4)',
|
||
[vinculoId, payload.userId, clinicaId, vinculoRole]);
|
||
|
||
console.log(`[CLINICA] Created: ${nome_fantasia} by ${userRows[0].email}`);
|
||
res.json({ success: true, clinica: { id: clinicaId, nome_fantasia: nome_fantasia.toUpperCase() } });
|
||
} catch (err) {
|
||
console.error('[POST /api/clinicas]', err.message);
|
||
res.status(500).json({ success: false, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(`
|
||
SELECT u.id, u.nome, u.email, v.role, v.setor_id, v.funcao_id,
|
||
s.nome AS setor_nome, f.nome AS funcao_nome, f.nivel AS funcao_nivel
|
||
FROM vinculos v
|
||
JOIN usuarios u ON u.id = v.usuario_id
|
||
LEFT JOIN setores s ON s.id = v.setor_id
|
||
LEFT JOIN funcoes f ON f.id = v.funcao_id
|
||
WHERE v.clinica_id = $1 ORDER BY u.nome
|
||
`, [req.params.clinicaId]);
|
||
res.json({ success: true, membros: rows });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const { email, role, setor_id, funcao_id, nome } = req.body;
|
||
if (!email || !role) return res.status(400).json({ success: false, message: 'E-mail e função são obrigatórios.' });
|
||
try {
|
||
// Hierarquia: só pode adicionar/atribuir cargo ABAIXO do seu próprio nível.
|
||
const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId);
|
||
if (reqNivel <= await nivelDaFuncao(funcao_id)) {
|
||
return res.status(403).json({ success: false, message: 'Você só pode gerenciar cargos de nível inferior ao seu.' });
|
||
}
|
||
// Provisiona a conta se o e-mail ainda não tem login (dentista/secretária/funcionário).
|
||
const u = await ensureUsuarioPorEmail(email, nome, role);
|
||
const vinculoId = `v_${u.id}_${req.params.clinicaId}`;
|
||
await pool.query(`
|
||
INSERT INTO vinculos (id, usuario_id, clinica_id, role, setor_id, funcao_id) VALUES ($1, $2, $3, $4, $5, $6)
|
||
ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = $4, setor_id = $5, funcao_id = $6
|
||
`, [vinculoId, u.id, req.params.clinicaId, role, setor_id || null, funcao_id || null]);
|
||
res.json({ success: true, membro: { id: u.id, nome: u.nome, email: String(email).toLowerCase().trim(), role }, created: u.created, tempPassword: u.tempPassword });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const { role, setor_id, funcao_id } = req.body;
|
||
try {
|
||
const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId);
|
||
const alvoNivel = await nivelNaClinica(req.params.userId, req.params.clinicaId);
|
||
if (reqNivel <= alvoNivel || reqNivel <= await nivelDaFuncao(funcao_id)) {
|
||
return res.status(403).json({ success: false, message: 'Você só pode gerenciar membros/cargos de nível inferior ao seu.' });
|
||
}
|
||
await pool.query('UPDATE vinculos SET role = COALESCE($1, role), setor_id = $2, funcao_id = $3 WHERE usuario_id = $4 AND clinica_id = $5',
|
||
[role || null, setor_id || null, funcao_id || null, req.params.userId, req.params.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// Reset de senha pelo dono/admin da clínica → gera nova senha temporária (resolve
|
||
// contas criadas sem senha ou esquecidas). Alvo precisa ser membro desta unidade.
|
||
app.post('/api/clinicas/:clinicaId/membros/:userId/reset-senha', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const clinicaId = req.params.clinicaId, alvo = req.params.userId, requester = req.authUser.userId;
|
||
try {
|
||
const { rows: tv } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [alvo, clinicaId]);
|
||
if (!tv.length) return res.status(404).json({ success: false, message: 'Membro não encontrado nesta unidade.' });
|
||
// Hierarquia: só reseta a senha de quem está ABAIXO do seu nível.
|
||
if (await nivelNaClinica(requester, clinicaId) <= await nivelNaClinica(alvo, clinicaId)) {
|
||
return res.status(403).json({ success: false, message: 'Você só pode resetar a senha de membros de nível inferior ao seu.' });
|
||
}
|
||
const { rows: u } = await pool.query('SELECT email FROM usuarios WHERE id = $1', [alvo]);
|
||
if (!u.length) return res.status(404).json({ success: false, message: 'Usuário não encontrado.' });
|
||
const temp = gerarSenhaTemp();
|
||
await pool.query('UPDATE usuarios SET senha = $1, must_change_password = true WHERE id = $2', [await hashSenha(temp), alvo]);
|
||
res.json({ success: true, email: u[0].email, tempPassword: temp });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
try {
|
||
if (await nivelNaClinica(req.authUser.userId, req.params.clinicaId) <= await nivelNaClinica(req.params.userId, req.params.clinicaId)) {
|
||
return res.status(403).json({ success: false, message: 'Você só pode remover membros de nível inferior ao seu.' });
|
||
}
|
||
await pool.query('DELETE FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [req.params.userId, req.params.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// --- SETORES (por clínica) ---
|
||
app.get('/api/clinicas/:clinicaId/setores', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT id, nome, descricao, cor FROM setores WHERE clinica_id = $1 ORDER BY nome', [req.params.clinicaId]);
|
||
res.json({ success: true, setores: rows });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/clinicas/:clinicaId/setores', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const { nome, descricao, cor } = req.body;
|
||
if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' });
|
||
try {
|
||
const id = _oid('set');
|
||
await pool.query('INSERT INTO setores (id, clinica_id, nome, descricao, cor) VALUES ($1,$2,$3,$4,$5)', [id, req.params.clinicaId, nome, descricao || null, cor || '#2563eb']);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/clinicas/:clinicaId/setores/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const { nome, descricao, cor } = req.body;
|
||
try {
|
||
await pool.query('UPDATE setores SET nome = COALESCE($1, nome), descricao = $2, cor = COALESCE($3, cor) WHERE id = $4 AND clinica_id = $5',
|
||
[nome || null, descricao || null, cor || null, req.params.id, req.params.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/clinicas/:clinicaId/setores/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
try {
|
||
await pool.query('UPDATE vinculos SET setor_id = NULL WHERE setor_id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
|
||
await pool.query('DELETE FROM setores WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// --- FUNÇÕES (por clínica) ---
|
||
app.get('/api/clinicas/:clinicaId/funcoes', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT id, nome, descricao, permissoes, nivel FROM funcoes WHERE clinica_id = $1 ORDER BY nivel DESC, nome', [req.params.clinicaId]);
|
||
res.json({ success: true, funcoes: rows });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/clinicas/:clinicaId/funcoes', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const { nome, descricao, permissoes, nivel } = req.body;
|
||
if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' });
|
||
try {
|
||
const id = _oid('fnc');
|
||
await pool.query('INSERT INTO funcoes (id, clinica_id, nome, descricao, permissoes, nivel) VALUES ($1,$2,$3,$4,$5,$6)',
|
||
[id, req.params.clinicaId, nome, descricao || null, JSON.stringify(Array.isArray(permissoes) ? permissoes : []), Number.isFinite(nivel) ? nivel : 1]);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
const { nome, descricao, permissoes, nivel } = req.body;
|
||
try {
|
||
await pool.query(
|
||
`UPDATE funcoes SET nome = COALESCE($1, nome), descricao = $2,
|
||
permissoes = COALESCE($3, permissoes), nivel = COALESCE($4, nivel)
|
||
WHERE id = $5 AND clinica_id = $6`,
|
||
[nome || null, descricao || null, permissoes !== undefined ? JSON.stringify(permissoes) : null, Number.isFinite(nivel) ? nivel : null, req.params.id, req.params.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/clinicas/:clinicaId/funcoes/:id', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||
try {
|
||
await pool.query('UPDATE vinculos SET funcao_id = NULL WHERE funcao_id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
|
||
await pool.query('DELETE FROM funcoes WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
// --- CLINICAS ---
|
||
app.put('/api/clinicas/:id/color', authGuard, async (req, res) => {
|
||
const { cor } = req.body;
|
||
if (!cor || !/^#[0-9a-fA-F]{6}$/.test(cor)) return res.status(400).json({ error: 'Cor inválida (use #RRGGBB).' });
|
||
// Só dono/admin da unidade altera a identidade visual.
|
||
const { rows: _own } = await pool.query(
|
||
`SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2
|
||
UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`,
|
||
[req.params.id, req.authUser?.userId]);
|
||
if (!_own.length) return res.status(403).json({ error: 'Apenas o dono/admin pode alterar a cor da unidade.' });
|
||
try {
|
||
await pool.query('UPDATE clinicas SET cor = $1 WHERE id = $2', [cor, req.params.id]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Renomear a unidade (clínica/consultório/laboratório). Só o dono (ou vínculo dono/admin).
|
||
app.put('/api/clinicas/:id/nome', authGuard, async (req, res) => {
|
||
const nome = (req.body?.nome_fantasia || '').trim();
|
||
if (!nome) return res.status(400).json({ success: false, message: 'Nome é obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ success: false, message: 'Unidade não encontrada.' });
|
||
let allowed = rows[0].owner_id === req.authUser.userId;
|
||
if (!allowed) {
|
||
const { rows: v } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, req.params.id]);
|
||
allowed = v.length > 0 && ['donoclinica', 'donoconsultorio', 'admin'].includes(v[0].role);
|
||
}
|
||
if (!allowed) return res.status(403).json({ success: false, message: 'Apenas o dono pode renomear a unidade.' });
|
||
const finalNome = nome.toUpperCase();
|
||
await pool.query('UPDATE clinicas SET nome_fantasia = $1 WHERE id = $2', [finalNome, req.params.id]);
|
||
res.json({ success: true, nome: finalNome });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// Perfil completo da unidade (leitura: qualquer membro; edição: só dono/admin).
|
||
app.get('/api/clinicas/:clinicaId', tenantGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, nome_fantasia, documento, telefone, tipo, cor, logradouro, numero, bairro, cidade, estado, cep, owner_id
|
||
FROM clinicas WHERE id = $1`, [req.params.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ success: false, message: 'Unidade não encontrada.' });
|
||
res.json({ success: true, clinica: rows[0] });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/clinicas/:clinicaId', authGuard, async (req, res) => {
|
||
const COLS = ['nome_fantasia', 'documento', 'telefone', 'cor', 'logradouro', 'numero', 'bairro', 'cidade', 'estado', 'cep'];
|
||
const UPPER = new Set(['nome_fantasia', 'documento', 'logradouro', 'bairro', 'cidade', 'estado']);
|
||
try {
|
||
const { rows } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [req.params.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ success: false, message: 'Unidade não encontrada.' });
|
||
let allowed = rows[0].owner_id === req.authUser.userId;
|
||
if (!allowed) {
|
||
const { rows: v } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, req.params.clinicaId]);
|
||
allowed = v.length > 0 && ['donoclinica', 'donoconsultorio', 'admin'].includes(v[0].role);
|
||
}
|
||
if (!allowed) return res.status(403).json({ success: false, message: 'Apenas o dono pode editar a unidade.' });
|
||
const b = req.body || {};
|
||
const keys = COLS.filter(c => b[c] !== undefined);
|
||
if (!keys.length) return res.json({ success: true });
|
||
const val = (c) => (UPPER.has(c) && typeof b[c] === 'string') ? b[c].trim().toUpperCase() : b[c];
|
||
const set = keys.map((c, i) => `${c} = $${i + 1}`).join(', ');
|
||
await pool.query(`UPDATE clinicas SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(val), req.params.clinicaId]);
|
||
if (b.cep !== undefined) await setGeoByCep('clinicas', req.params.clinicaId, b.cep);
|
||
res.json({ success: true, nome: b.nome_fantasia ? String(b.nome_fantasia).trim().toUpperCase() : undefined });
|
||
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
|
||
});
|
||
|
||
// --- LOGIN ROUTE ---
|
||
app.post('/api/login', async (req, res) => {
|
||
const { email, password } = req.body;
|
||
if (!email || !password) return res.status(400).json({ success: false, message: 'EMAIL E SENHA SÃO OBRIGATÓRIOS.' });
|
||
console.log(`[LOGIN ATTEMPT] Email: ${email}`);
|
||
try {
|
||
const { rows: users } = await pool.query(
|
||
'SELECT id, nome, email, role, senha, must_change_password FROM usuarios WHERE email = $1 AND (ativo IS NULL OR ativo = true)',
|
||
[email.toLowerCase().trim()]
|
||
);
|
||
const user = users[0];
|
||
if (!user || !(await verificarSenha(user.id, password, user.senha))) {
|
||
console.log(`[LOGIN FAILED] Invalid credentials for: ${email}`);
|
||
return res.status(401).json({ success: false, message: 'CREDENCIAIS INVÁLIDAS' });
|
||
}
|
||
const userRole = user.role || 'dentista';
|
||
const mustChangePassword = !!user.must_change_password;
|
||
const workspaces = await listarWorkspaces(user.id, userRole);
|
||
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '12h' });
|
||
if (workspaces.length === 0) {
|
||
console.log(`[LOGIN NO-WORKSPACE] User: ${user.email}, role: ${userRole}`);
|
||
res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email, role: userRole }, workspaces: [], noWorkspace: true, userRole, mustChangePassword });
|
||
return;
|
||
}
|
||
console.log(`[LOGIN SUCCESS] User: ${user.email}, Workspaces: ${workspaces.length}`);
|
||
res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email, role: userRole }, workspaces, mustChangePassword });
|
||
} catch (err) {
|
||
console.error(`[LOGIN ERROR]: ${err.message}`);
|
||
res.status(500).json({ success: false, error: 'ERRO INTERNO DO SERVIDOR.' });
|
||
}
|
||
});
|
||
|
||
const PORT = process.env.PORT || 3005;
|
||
|
||
// --- GOOGLE OAUTH2 SETUP ---
|
||
// Credentials: env vars take priority; DB-stored values used as fallback (loaded at startup).
|
||
const googleCreds = {
|
||
clientId: process.env.GOOGLE_CLIENT_ID || null,
|
||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || null,
|
||
};
|
||
|
||
async function loadGoogleCredsFromDb() {
|
||
try {
|
||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'google_credentials'");
|
||
if (rows[0]) {
|
||
const saved = JSON.parse(rows[0].data);
|
||
if (!googleCreds.clientId && saved.clientId) googleCreds.clientId = saved.clientId;
|
||
if (!googleCreds.clientSecret && saved.clientSecret) googleCreds.clientSecret = saved.clientSecret;
|
||
if (googleCreds.clientId) console.log('[GOOGLE] Credentials loaded from DB');
|
||
}
|
||
} catch (e) { console.error('[GOOGLE] Could not load credentials from DB:', e.message); }
|
||
}
|
||
|
||
const createOAuth2Client = () => new google.auth.OAuth2(
|
||
googleCreds.clientId,
|
||
googleCreds.clientSecret,
|
||
process.env.GOOGLE_REDIRECT_URI || `http://localhost:${PORT}/api/oauth2callback`
|
||
);
|
||
|
||
// =============================================================================
|
||
// GOOGLE SHEETS BACKUP SYNC
|
||
// =============================================================================
|
||
let SHEETS_BACKUP_ID = process.env.SHEETS_BACKUP_ID || '1q1sf1GOji2u3qIJEToWfbLCb4rzb_pKLOX_AylMDT4g';
|
||
|
||
async function loadSheetsConfig() {
|
||
try {
|
||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_config'");
|
||
if (rows[0]) {
|
||
const cfg = JSON.parse(rows[0].data);
|
||
if (cfg.spreadsheetId) SHEETS_BACKUP_ID = cfg.spreadsheetId;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Which tables sync, how to export from PG and how to import back
|
||
const SYNC_TABLES = [
|
||
{
|
||
tab: 'pacientes',
|
||
pgTable: 'pacientes',
|
||
query: `SELECT id, nome, cpf, telefone, email,
|
||
ultimotratamento as "ultimoTratamento",
|
||
convenio, datanascimento as "dataNascimento"
|
||
FROM pacientes ORDER BY nome`,
|
||
headers: ['id', 'nome', 'cpf', 'telefone', 'email', 'ultimoTratamento', 'convenio', 'dataNascimento'],
|
||
upsertSql: `INSERT INTO pacientes (id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,
|
||
email=EXCLUDED.email,ultimotratamento=EXCLUDED.ultimotratamento,
|
||
convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
|
||
upsertParams: r => [r.id,r.nome,r.cpf,r.telefone,r.email,r.ultimoTratamento,r.convenio,r.dataNascimento]
|
||
},
|
||
{
|
||
tab: 'agendamentos',
|
||
pgTable: 'agendamentos',
|
||
query: `SELECT id,clinica_id,pacientenome as "pacienteNome",dentistaid as "dentistaId",
|
||
start_time::text,end_time::text,status,procedimento,observacoes
|
||
FROM agendamentos ORDER BY start_time DESC`,
|
||
headers: ['id','clinica_id','pacienteNome','dentistaId','start_time','end_time','status','procedimento','observacoes'],
|
||
upsertSql: `INSERT INTO agendamentos (id,clinica_id,pacientenome,dentistaid,start_time,end_time,status,procedimento,observacoes)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
pacientenome=EXCLUDED.pacientenome,status=EXCLUDED.status,
|
||
procedimento=EXCLUDED.procedimento,observacoes=EXCLUDED.observacoes`,
|
||
upsertParams: r => [r.id,r.clinica_id,r.pacienteNome,r.dentistaId,r.start_time,r.end_time,r.status,r.procedimento,r.observacoes]
|
||
},
|
||
{
|
||
tab: 'financeiro',
|
||
pgTable: 'financeiro',
|
||
query: `SELECT id,clinica_id,pacientenome as "pacienteNome",descricao,
|
||
valor::text,datavencimento::text as "dataVencimento",status,
|
||
formapagamento as "formaPagamento",tipo
|
||
FROM financeiro ORDER BY datavencimento DESC`,
|
||
headers: ['id','clinica_id','pacienteNome','descricao','valor','dataVencimento','status','formaPagamento','tipo'],
|
||
upsertSql: `INSERT INTO financeiro (id,clinica_id,pacientenome,descricao,valor,datavencimento,status,formapagamento,tipo)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
pacientenome=EXCLUDED.pacientenome,descricao=EXCLUDED.descricao,
|
||
valor=EXCLUDED.valor,datavencimento=EXCLUDED.datavencimento,
|
||
status=EXCLUDED.status,formapagamento=EXCLUDED.formapagamento`,
|
||
upsertParams: r => [r.id,r.clinica_id,r.pacienteNome,r.descricao,r.valor,r.dataVencimento,r.status,r.formaPagamento,r.tipo]
|
||
},
|
||
{
|
||
tab: 'leads',
|
||
pgTable: 'leads',
|
||
query: `SELECT id,nome,sobrenome,cpf,whatsapp,
|
||
tipointeresse as "tipoInteresse",
|
||
datacadastro::text as "dataCadastro",status
|
||
FROM leads ORDER BY datacadastro DESC`,
|
||
headers: ['id','nome','sobrenome','cpf','whatsapp','tipoInteresse','dataCadastro','status'],
|
||
upsertSql: `INSERT INTO leads (id,nome,sobrenome,cpf,whatsapp,tipointeresse,datacadastro,status)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
nome=EXCLUDED.nome,whatsapp=EXCLUDED.whatsapp,status=EXCLUDED.status`,
|
||
upsertParams: r => [r.id,r.nome,r.sobrenome,r.cpf,r.whatsapp,r.tipoInteresse,r.dataCadastro,r.status]
|
||
},
|
||
{
|
||
tab: 'guias',
|
||
pgTable: 'guias_odontologicas',
|
||
query: `SELECT id,numeroguiaprestador as "numeroGuiaPrestador",
|
||
numeroguiaoperadora as "numeroGuiaOperadora",
|
||
datasolicitacao::text as "dataSolicitacao",
|
||
tipotratamento as "tipoTratamento",status,
|
||
beneficiarioid as "beneficiarioId",
|
||
beneficiarionome as "beneficiarioNome",
|
||
beneficiarioidentificacao as "beneficiarioIdentificacao"
|
||
FROM guias_odontologicas ORDER BY datasolicitacao DESC`,
|
||
headers: ['id','numeroGuiaPrestador','numeroGuiaOperadora','dataSolicitacao','tipoTratamento','status','beneficiarioId','beneficiarioNome','beneficiarioIdentificacao'],
|
||
upsertSql: `INSERT INTO guias_odontologicas (id,numeroguiaprestador,numeroguiaoperadora,datasolicitacao,tipotratamento,status,beneficiarioid,beneficiarionome,beneficiarioidentificacao)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
status=EXCLUDED.status,numeroguiaoperadora=EXCLUDED.numeroguiaoperadora`,
|
||
upsertParams: r => [r.id,r.numeroGuiaPrestador,r.numeroGuiaOperadora,r.dataSolicitacao,r.tipoTratamento,r.status,r.beneficiarioId,r.beneficiarioNome,r.beneficiarioIdentificacao]
|
||
}
|
||
];
|
||
|
||
async function getSheetsClient() {
|
||
const { rows } = await pool.query('SELECT * FROM google_tokens LIMIT 1');
|
||
if (rows.length === 0) throw new Error('NO_GOOGLE_TOKEN');
|
||
const t = rows[0];
|
||
const auth = createOAuth2Client();
|
||
auth.setCredentials({ refresh_token: t.refresh_token, access_token: t.access_token, expiry_date: t.expiry_date });
|
||
return google.sheets({ version: 'v4', auth });
|
||
}
|
||
|
||
// Returns the actual tab title as it exists in the sheet (case-preserving).
|
||
async function ensureSheetTab(sheets, tabName) {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
|
||
const existing = meta.data.sheets.find(s =>
|
||
s.properties.title.toLowerCase() === tabName.toLowerCase()
|
||
);
|
||
if (existing) return existing.properties.title; // already exists, return real name
|
||
try {
|
||
await sheets.spreadsheets.batchUpdate({
|
||
spreadsheetId: SHEETS_BACKUP_ID,
|
||
requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] }
|
||
});
|
||
} catch (e) {
|
||
if (!e.message?.includes('already exists') && !e.message?.includes('Já existe')) throw e;
|
||
// Re-fetch to get the real name after race-condition create
|
||
const meta2 = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
|
||
const found = meta2.data.sheets.find(s => s.properties.title.toLowerCase() === tabName.toLowerCase());
|
||
if (found) return found.properties.title;
|
||
}
|
||
return tabName;
|
||
}
|
||
|
||
async function pushTableToSheet(tableDef, sheets, clinicaId) {
|
||
// Multi-tenant: exporta SOMENTE os dados da clínica que está fazendo o backup
|
||
// (todas as SYNC_TABLES têm coluna clinica_id). Sem isso, vazava tudo de todas.
|
||
const q = clinicaId ? tableDef.query.replace(/ORDER BY/i, 'WHERE clinica_id = $1 ORDER BY') : tableDef.query;
|
||
const { rows } = await pool.query(q, clinicaId ? [clinicaId] : []);
|
||
const realTab = await ensureSheetTab(sheets, tableDef.tab);
|
||
await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
|
||
const sheetRows = [tableDef.headers, ...rows.map(row =>
|
||
tableDef.headers.map(h => {
|
||
const v = row[h] ?? row[h.toLowerCase()];
|
||
return v === null || v === undefined ? '' : String(v);
|
||
})
|
||
)];
|
||
await sheets.spreadsheets.values.update({
|
||
spreadsheetId: SHEETS_BACKUP_ID,
|
||
range: `${realTab}!A1`,
|
||
valueInputOption: 'RAW',
|
||
requestBody: { values: sheetRows }
|
||
});
|
||
return rows.length;
|
||
}
|
||
|
||
async function pullTableFromSheet(tableDef, sheets) {
|
||
const realTab = await ensureSheetTab(sheets, tableDef.tab);
|
||
const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
|
||
const sheetRows = resp.data.values || [];
|
||
if (sheetRows.length < 2) return 0;
|
||
const headers = sheetRows[0];
|
||
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c !== ''));
|
||
let upserted = 0;
|
||
for (const row of dataRows) {
|
||
const obj = {};
|
||
headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
|
||
if (!obj.id) continue;
|
||
try {
|
||
await pool.query(tableDef.upsertSql, tableDef.upsertParams(obj));
|
||
upserted++;
|
||
} catch (e) {
|
||
console.error(`[SHEETS PULL] ${tableDef.tab} row ${obj.id}:`, e.message);
|
||
}
|
||
}
|
||
return upserted;
|
||
}
|
||
|
||
async function saveSyncStatus(updates) {
|
||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
|
||
const current = rows[0] ? JSON.parse(rows[0].data) : {};
|
||
const merged = { ...current, ...updates };
|
||
await pool.query(
|
||
`INSERT INTO settings (id,category,data) VALUES ('sheets_sync_status','sync',$1)
|
||
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
|
||
[JSON.stringify(merged)]
|
||
);
|
||
}
|
||
|
||
// Auto-push GLOBAL desativado: vazava dados de todas as clínicas para a planilha/token
|
||
// do primeiro usuário. O backup agora é MANUAL e POR CLÍNICA (ver /api/clinica-sync/*).
|
||
// Mantido como no-op para não quebrar as ~8 chamadas espalhadas nas rotas de escrita.
|
||
function schedulePush(_tabName) { /* desativado — backup por clínica é manual */ }
|
||
|
||
// =============================================================================
|
||
// BACKUP POR CLÍNICA → Google Sheets (planilha própria, na conta Google da unidade)
|
||
// Cada workspace (clínica/consultório/laboratório/dentista) tem sua planilha,
|
||
// criada automaticamente na conta Google conectada, com SÓ os dados daquela clínica.
|
||
// =============================================================================
|
||
const CLINIC_SYNC_TABLES = [
|
||
{
|
||
tab: 'pacientes',
|
||
query: `SELECT id, nome, cpf, telefone, email, ultimotratamento AS "ultimoTratamento", convenio, datanascimento AS "dataNascimento" FROM pacientes WHERE clinica_id = $1 ORDER BY nome`,
|
||
headers: ['id', 'nome', 'cpf', 'telefone', 'email', 'ultimoTratamento', 'convenio', 'dataNascimento'],
|
||
upsert: `INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,ultimotratamento=EXCLUDED.ultimotratamento,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
|
||
params: (r, cid) => [r.id, cid, r.nome, r.cpf, r.telefone, r.email, r.ultimoTratamento, r.convenio, r.dataNascimento],
|
||
},
|
||
{
|
||
tab: 'agendamentos',
|
||
query: `SELECT id, pacientenome AS "pacienteNome", dentistaid AS "dentistaId", start_time::text, end_time::text, status, procedimento, observacoes FROM agendamentos WHERE clinica_id = $1 ORDER BY start_time DESC`,
|
||
headers: ['id', 'pacienteNome', 'dentistaId', 'start_time', 'end_time', 'status', 'procedimento', 'observacoes'],
|
||
upsert: `INSERT INTO agendamentos (id,clinica_id,pacientenome,dentistaid,start_time,end_time,status,procedimento,observacoes)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,pacientenome=EXCLUDED.pacientenome,dentistaid=EXCLUDED.dentistaid,start_time=EXCLUDED.start_time,end_time=EXCLUDED.end_time,status=EXCLUDED.status,procedimento=EXCLUDED.procedimento,observacoes=EXCLUDED.observacoes`,
|
||
params: (r, cid) => [r.id, cid, r.pacienteNome, r.dentistaId, r.start_time || null, r.end_time || null, r.status, r.procedimento, r.observacoes],
|
||
},
|
||
{
|
||
tab: 'financeiro',
|
||
query: `SELECT id, pacientenome AS "pacienteNome", descricao, valor::text, datavencimento::text AS "dataVencimento", status, formapagamento AS "formaPagamento", tipo FROM financeiro WHERE clinica_id = $1 ORDER BY datavencimento DESC`,
|
||
headers: ['id', 'pacienteNome', 'descricao', 'valor', 'dataVencimento', 'status', 'formaPagamento', 'tipo'],
|
||
upsert: `INSERT INTO financeiro (id,clinica_id,pacientenome,descricao,valor,datavencimento,status,formapagamento,tipo)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,pacientenome=EXCLUDED.pacientenome,descricao=EXCLUDED.descricao,valor=EXCLUDED.valor,datavencimento=EXCLUDED.datavencimento,status=EXCLUDED.status,formapagamento=EXCLUDED.formapagamento,tipo=EXCLUDED.tipo`,
|
||
params: (r, cid) => [r.id, cid, r.pacienteNome, r.descricao, r.valor || null, r.dataVencimento || null, r.status, r.formaPagamento, r.tipo],
|
||
},
|
||
{
|
||
tab: 'leads',
|
||
query: `SELECT id, nome, sobrenome, cpf, whatsapp, tipointeresse AS "tipoInteresse", datacadastro::text AS "dataCadastro", status FROM leads WHERE clinica_id = $1 ORDER BY datacadastro DESC`,
|
||
headers: ['id', 'nome', 'sobrenome', 'cpf', 'whatsapp', 'tipoInteresse', 'dataCadastro', 'status'],
|
||
upsert: `INSERT INTO leads (id,clinica_id,nome,sobrenome,cpf,whatsapp,tipointeresse,datacadastro,status)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,sobrenome=EXCLUDED.sobrenome,cpf=EXCLUDED.cpf,whatsapp=EXCLUDED.whatsapp,tipointeresse=EXCLUDED.tipointeresse,status=EXCLUDED.status`,
|
||
params: (r, cid) => [r.id, cid, r.nome, r.sobrenome, r.cpf, r.whatsapp, r.tipoInteresse, r.dataCadastro || null, r.status],
|
||
},
|
||
];
|
||
|
||
const clinicSheetUrl = (id) => `https://docs.google.com/spreadsheets/d/${id}`;
|
||
|
||
async function getClinicSheetsClient(clinicaId) {
|
||
const { rows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1 ORDER BY updated_at DESC NULLS LAST LIMIT 1', [clinicaId]);
|
||
if (!rows.length) throw new Error('NO_GOOGLE_TOKEN');
|
||
const t = rows[0];
|
||
const auth = createOAuth2Client();
|
||
auth.setCredentials({ refresh_token: t.refresh_token, access_token: t.access_token, expiry_date: t.expiry_date });
|
||
return google.sheets({ version: 'v4', auth });
|
||
}
|
||
|
||
async function ensureClinicSpreadsheet(clinicaId, sheets) {
|
||
const { rows } = await pool.query('SELECT sheets_id, nome_fantasia FROM clinicas WHERE id = $1', [clinicaId]);
|
||
if (!rows.length) throw new Error('CLINICA_NOT_FOUND');
|
||
let id = rows[0].sheets_id;
|
||
if (id) {
|
||
try { await sheets.spreadsheets.get({ spreadsheetId: id, fields: 'spreadsheetId' }); return { id, created: false }; }
|
||
catch { id = null; } // sumiu/sem acesso → recria
|
||
}
|
||
const title = `ScoreOdonto — ${rows[0].nome_fantasia || 'Unidade'}`;
|
||
const r = await sheets.spreadsheets.create({ requestBody: { properties: { title } } });
|
||
id = r.data.spreadsheetId;
|
||
await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [id, clinicaId]);
|
||
return { id, created: true };
|
||
}
|
||
|
||
async function ensureClinicTab(sheets, spreadsheetId, tabName) {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId });
|
||
const ex = meta.data.sheets.find(s => s.properties.title.toLowerCase() === tabName.toLowerCase());
|
||
if (ex) return ex.properties.title;
|
||
await sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] } });
|
||
return tabName;
|
||
}
|
||
|
||
async function pushClinicTable(def, sheets, spreadsheetId, clinicaId) {
|
||
const { rows } = await pool.query(def.query, [clinicaId]);
|
||
const tab = await ensureClinicTab(sheets, spreadsheetId, def.tab);
|
||
await sheets.spreadsheets.values.clear({ spreadsheetId, range: `${tab}!A:ZZ` });
|
||
const values = [def.headers, ...rows.map(row => def.headers.map(h => { const v = row[h] ?? row[h.toLowerCase()]; return v == null ? '' : String(v); }))];
|
||
await sheets.spreadsheets.values.update({ spreadsheetId, range: `${tab}!A1`, valueInputOption: 'RAW', requestBody: { values } });
|
||
return rows.length;
|
||
}
|
||
|
||
async function pullClinicTable(def, sheets, spreadsheetId, clinicaId) {
|
||
const tab = await ensureClinicTab(sheets, spreadsheetId, def.tab);
|
||
const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A:ZZ` });
|
||
const sheetRows = resp.data.values || [];
|
||
if (sheetRows.length < 2) return 0;
|
||
const headers = sheetRows[0];
|
||
let n = 0;
|
||
for (const row of sheetRows.slice(1)) {
|
||
if (!row.some(c => c !== '')) continue;
|
||
const obj = {}; headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
|
||
if (!obj.id) continue;
|
||
try { await pool.query(def.upsert, def.params(obj, clinicaId)); n++; }
|
||
catch (e) { console.error(`[CLINIC-SYNC pull] ${def.tab} ${obj.id}:`, e.message); }
|
||
}
|
||
return n;
|
||
}
|
||
|
||
async function saveClinicSyncStatus(clinicaId, data) {
|
||
await pool.query(`INSERT INTO settings (id,category,data) VALUES ($1,'sync',$2) ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`, [`sync:${clinicaId}`, JSON.stringify(data)]);
|
||
}
|
||
|
||
app.get('/api/clinica-sync/status', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.query.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const { rows: cl } = await pool.query('SELECT sheets_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
const { rows: tk } = await pool.query('SELECT owner_id FROM google_tokens WHERE clinica_id = $1 LIMIT 1', [clinicaId]);
|
||
const counts = {};
|
||
for (const d of CLINIC_SYNC_TABLES) { const { rows } = await pool.query(`SELECT COUNT(*) n FROM ${d.tab} WHERE clinica_id = $1`, [clinicaId]); counts[d.tab] = parseInt(rows[0].n); }
|
||
const { rows: st } = await pool.query('SELECT data FROM settings WHERE id = $1', [`sync:${clinicaId}`]);
|
||
const sheetsId = cl[0]?.sheets_id || null;
|
||
res.json({ success: true, connected: tk.length > 0, googleEmail: tk[0]?.owner_id || null, spreadsheetId: sheetsId, spreadsheetUrl: sheetsId ? clinicSheetUrl(sheetsId) : null, counts, lastSync: st[0] ? JSON.parse(st[0].data) : null });
|
||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/clinica-sync/push', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
const logs = [];
|
||
try {
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
const { id, created } = await ensureClinicSpreadsheet(clinicaId, sheets);
|
||
if (created) logs.push('[OK] PLANILHA CRIADA NO SEU GOOGLE DRIVE');
|
||
const counts = {};
|
||
for (const d of CLINIC_SYNC_TABLES) {
|
||
try { const c = await pushClinicTable(d, sheets, id, clinicaId); logs.push(`[OK] ${d.tab.toUpperCase()}: ${c} REGISTROS`); counts[d.tab] = c; }
|
||
catch (e) { logs.push(`[ERRO] ${d.tab}: ${e.message}`); }
|
||
}
|
||
await saveClinicSyncStatus(clinicaId, { lastPush: new Date().toISOString(), counts });
|
||
res.json({ success: true, spreadsheetUrl: clinicSheetUrl(id), logs });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE (AGENDA → CONFIGURAÇÕES) ANTES DE FAZER BACKUP.' : err.message;
|
||
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
|
||
}
|
||
});
|
||
|
||
app.post('/api/clinica-sync/pull', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
const logs = [];
|
||
try {
|
||
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
const { rows: cl } = await pool.query('SELECT sheets_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||
if (!cl[0]?.sheets_id) return res.status(400).json({ success: false, message: 'Nenhuma planilha ainda. Faça um backup (enviar) primeiro.' });
|
||
const id = cl[0].sheets_id;
|
||
for (const d of CLINIC_SYNC_TABLES) {
|
||
try { const c = await pullClinicTable(d, sheets, id, clinicaId); logs.push(`[OK] ${d.tab.toUpperCase()}: ${c} IMPORTADOS`); }
|
||
catch (e) { logs.push(`[ERRO] ${d.tab}: ${e.message}`); }
|
||
}
|
||
await saveClinicSyncStatus(clinicaId, { lastPull: new Date().toISOString() });
|
||
res.json({ success: true, logs });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE.' : err.message;
|
||
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
|
||
}
|
||
});
|
||
|
||
// --- IMPORT/EXPORT DE PACIENTES (CSV) ---
|
||
// Normaliza cabeçalho (minúsculo, sem acento, só letras/números) e dicionário de sinônimos.
|
||
const _normHdr = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]/g, '');
|
||
const PAC_FIELD_SYNS = {
|
||
id: ['id'],
|
||
nome: ['nome', 'name', 'paciente', 'nomecompleto', 'nomepaciente'],
|
||
sobrenome: ['sobrenome', 'surname'],
|
||
cpf: ['cpf', 'documento'],
|
||
telefone: ['telefone', 'tefefoneprincipal', 'telefoneprincipal', 'whatsapp', 'celular', 'fone', 'contato', 'telefone1'],
|
||
email: ['email'],
|
||
convenio: ['convenio', 'credenciada', 'plano', 'planos', 'planosaude'],
|
||
datanascimento: ['datanascimento', 'datanasci', 'nascimento', 'dtnasc', 'dn'],
|
||
};
|
||
function mapPacHeaders(headers) {
|
||
const norm = (headers || []).map(_normHdr);
|
||
const map = {};
|
||
for (const [field, syns] of Object.entries(PAC_FIELD_SYNS)) {
|
||
map[field] = norm.findIndex(h => syns.includes(h));
|
||
}
|
||
return map;
|
||
}
|
||
// Acha a melhor aba "pacientes" (mais colunas casadas) e importa só os campos do sistema.
|
||
async function smartImportPacientes(sheets, spreadsheetId, clinicaId) {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets.properties.title' });
|
||
const tabs = (meta.data.sheets || []).map(s => s.properties.title);
|
||
const cands = tabs.filter(t => _normHdr(t).includes('paciente'));
|
||
if (!cands.length) throw new Error('Nenhuma aba com "pacientes" no nome foi encontrada na planilha.');
|
||
let best = null;
|
||
for (const tab of cands) {
|
||
const r = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A1:ZZ1` });
|
||
const headers = (r.data.values || [])[0] || [];
|
||
const map = mapPacHeaders(headers);
|
||
const score = Object.values(map).filter(i => i >= 0).length;
|
||
if (!best || score > best.score) best = { tab, headers, map, score };
|
||
}
|
||
if (best.map.nome < 0) throw new Error(`Aba "${best.tab}": não encontrei a coluna NOME.`);
|
||
const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${best.tab}!A:ZZ` });
|
||
const rows = resp.data.values || [];
|
||
const get = (row, i) => (i >= 0 && i < row.length ? String(row[i] || '').trim() : '');
|
||
let imported = 0, skipped = 0;
|
||
for (const row of rows.slice(1)) {
|
||
if (!row.some(c => String(c || '').trim() !== '')) continue;
|
||
let nome = get(row, best.map.nome);
|
||
if (best.map.sobrenome >= 0) { const sn = get(row, best.map.sobrenome); if (sn) nome = `${nome} ${sn}`.trim(); }
|
||
nome = nome.toUpperCase();
|
||
if (!nome) { skipped++; continue; }
|
||
const cpf = get(row, best.map.cpf); const cpfDigits = cpf.replace(/\D/g, '');
|
||
let id = get(row, best.map.id) || cpfDigits || ('imp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7));
|
||
try {
|
||
await pool.query(
|
||
`INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,convenio,datanascimento)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
|
||
[id.substring(0, 50), clinicaId, nome.substring(0, 255), cpf.substring(0, 20) || null, get(row, best.map.telefone).substring(0, 30) || null, get(row, best.map.email).substring(0, 150) || null, get(row, best.map.convenio).substring(0, 100) || null, get(row, best.map.datanascimento).substring(0, 20) || null]
|
||
);
|
||
imported++;
|
||
} catch { skipped++; }
|
||
}
|
||
const matched = Object.entries(best.map).filter(([, i]) => i >= 0).map(([f]) => f.toUpperCase());
|
||
return { tab: best.tab, matched, imported, skipped };
|
||
}
|
||
|
||
// Importa pacientes de uma planilha EXISTENTE (ID/URL). NÃO altera o sheets_id nem a planilha fonte.
|
||
app.post('/api/clinica-sync/import-pacientes', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
let sheetsId = String(req.body?.sheetsId || '').trim();
|
||
const logs = [];
|
||
try {
|
||
if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' });
|
||
const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); if (m) sheetsId = m[1];
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
try { await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'spreadsheetId' }); }
|
||
catch { return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade.' }); }
|
||
const r = await smartImportPacientes(sheets, sheetsId, clinicaId);
|
||
logs.push(`[←] ABA "${r.tab}" — colunas casadas: ${r.matched.join(', ')}`);
|
||
logs.push(`[✓] ${r.imported} PACIENTES IMPORTADOS${r.skipped ? ` (${r.skipped} ignorados)` : ''}`);
|
||
res.json({ success: true, imported: r.imported, skipped: r.skipped, tab: r.tab, matched: r.matched, logs });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message;
|
||
res.status(400).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
|
||
}
|
||
});
|
||
|
||
// Export CSV dos pacientes (o bem mais precioso). Sem "ultimo tratamento". Não precisa de Google.
|
||
app.get('/api/pacientes/export', authGuard, requireCapability('agenda'), async (req, res) => {
|
||
try {
|
||
const clinicaId = req.query.clinicaId;
|
||
if (!clinicaId) return res.status(400).send('clinicaId obrigatório');
|
||
const cols = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'datanascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes'];
|
||
const header = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'dataNascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes'];
|
||
const { rows } = await pool.query(`SELECT ${cols.join(',')} FROM pacientes WHERE clinica_id = $1 ORDER BY nome`, [clinicaId]);
|
||
const esc = v => { v = v == null ? '' : String(v); return /[",\n;]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
|
||
const csv = '' + [header.join(',')].concat(rows.map(r => cols.map(c => esc(r[c])).join(','))).join('\n');
|
||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||
res.setHeader('Content-Disposition', `attachment; filename="pacientes.csv"`);
|
||
res.send(csv);
|
||
} catch (err) { res.status(500).send('erro ao exportar'); }
|
||
});
|
||
|
||
// Define a planilha (ID/URL) a ser usada por esta clínica → habilita IMPORTAR de uma
|
||
// planilha EXISTENTE (ex.: migração de pacientes). Valida o acesso com a conta da unidade.
|
||
app.post('/api/clinica-sync/set-id', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
|
||
let sheetsId = String(req.body?.sheetsId || '').trim();
|
||
try {
|
||
if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' });
|
||
const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); // aceita URL completa
|
||
if (m) sheetsId = m[1];
|
||
const sheets = await getClinicSheetsClient(clinicaId);
|
||
let title, abas = [];
|
||
try {
|
||
const meta = await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'properties.title,sheets.properties.title' });
|
||
title = meta.data.properties.title;
|
||
abas = (meta.data.sheets || []).map(s => s.properties.title);
|
||
} catch {
|
||
return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade (' + sheetsId + ').' });
|
||
}
|
||
await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [sheetsId, clinicaId]);
|
||
res.json({ success: true, spreadsheetId: sheetsId, spreadsheetUrl: clinicSheetUrl(sheetsId), title, abas });
|
||
} catch (err) {
|
||
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message;
|
||
res.status(400).json({ success: false, message: msg });
|
||
}
|
||
});
|
||
|
||
// --- GOOGLE AUTH ROUTES ---
|
||
app.get('/api/auth/google/url', (req, res) => {
|
||
if (!googleCreds.clientId || !googleCreds.clientSecret) {
|
||
return res.status(503).json({ error: 'Credenciais OAuth não configuradas. Acesse Configurações → Integrações Google e salve o Client ID e Client Secret.' });
|
||
}
|
||
const { dentistId, clinicaId, ownerId } = req.query;
|
||
const resolvedOwner = ownerId || dentistId || 'admin';
|
||
const resolvedClinica = clinicaId || '';
|
||
const oauth2Client = createOAuth2Client();
|
||
const url = oauth2Client.generateAuthUrl({
|
||
access_type: 'offline',
|
||
scope: [
|
||
'https://www.googleapis.com/auth/userinfo.email',
|
||
// leitura+escrita de EVENTOS (criar/editar/convidar) → backup + notificação
|
||
'https://www.googleapis.com/auth/calendar.events',
|
||
'https://www.googleapis.com/auth/spreadsheets'
|
||
],
|
||
state: `${resolvedOwner}|${resolvedClinica}`,
|
||
prompt: 'consent'
|
||
});
|
||
res.json({ url });
|
||
});
|
||
|
||
app.get('/api/oauth2callback', async (req, res) => {
|
||
const { code, state } = req.query;
|
||
if (!code) return res.status(400).send('Código não fornecido');
|
||
try {
|
||
const oauth2Client = createOAuth2Client();
|
||
const { tokens } = await oauth2Client.getToken(code);
|
||
// Decodifica defensivamente (o '|' pode voltar como %7C / %257C no round-trip do Google),
|
||
// senão o split falha e o owner_id fica colado com a clínica (clinica_id nulo).
|
||
let rawState = String(state || 'admin|');
|
||
for (let i = 0; i < 2 && /%[0-9a-f]{2}/i.test(rawState); i++) {
|
||
try { rawState = decodeURIComponent(rawState); } catch { break; }
|
||
}
|
||
let [rawOwner, clinicaId] = rawState.split('|');
|
||
let ownerId = rawOwner || 'admin';
|
||
// Salvaguarda: nunca grava owner colado com a clínica.
|
||
if (ownerId.includes('|')) { const pp = ownerId.split('|'); ownerId = pp[0]; clinicaId = clinicaId || pp[1] || null; }
|
||
if (ownerId === 'admin') {
|
||
oauth2Client.setCredentials(tokens);
|
||
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
|
||
const { data } = await oauth2.userinfo.get();
|
||
ownerId = data.email;
|
||
}
|
||
await pool.query(`
|
||
INSERT INTO google_tokens (owner_id, clinica_id, refresh_token, access_token, expiry_date)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (owner_id) DO UPDATE SET
|
||
clinica_id = EXCLUDED.clinica_id,
|
||
refresh_token = EXCLUDED.refresh_token,
|
||
access_token = EXCLUDED.access_token,
|
||
expiry_date = EXCLUDED.expiry_date
|
||
`, [ownerId, clinicaId || null, tokens.refresh_token, tokens.access_token, tokens.expiry_date]);
|
||
// Conta-clínica: UMA por clínica. Ao (re)conectar uma conta de e-mail, remove
|
||
// e-mails antigos desta mesma clínica (evita acúmulo/confusão de várias contas).
|
||
if (clinicaId && String(ownerId).includes('@')) {
|
||
await pool.query("DELETE FROM google_tokens WHERE clinica_id = $1 AND owner_id LIKE '%@%' AND owner_id <> $2", [clinicaId, ownerId]);
|
||
}
|
||
res.send(`<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', authGuard, async (req, res) => {
|
||
try {
|
||
const owner = req.params.ownerId;
|
||
const uid = req.authUser?.userId;
|
||
// só o próprio usuário ou um membro da clínica dona pode desconectar a conta Google
|
||
if (owner !== uid && !await userHasVinculo(uid, owner)) return res.status(403).json({ error: 'Acesso negado.' });
|
||
await pool.query('DELETE FROM google_tokens WHERE owner_id = $1', [owner]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// --- SETTINGS ---
|
||
app.get('/api/settings', tenantGuard, async (req, res) => {
|
||
try {
|
||
// Escopo por workspace/clínica (tenantGuard valida o vínculo). 'main' = legado/global.
|
||
const id = req.clinicaId || 'main';
|
||
if (id === 'main') {
|
||
const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]);
|
||
if (u[0]?.role !== 'superadmin') return res.json({}); // não superadmin não lê o global legado
|
||
}
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const id = req.clinicaId || 'main';
|
||
if (id === 'main') {
|
||
const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]);
|
||
if (u[0]?.role !== 'superadmin') return res.status(403).json({ error: 'Acesso negado.' });
|
||
}
|
||
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', authGuard, 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;
|
||
let clinicaId = req.query.clinicaId || null;
|
||
// Só aplica o escopo de clínica com vínculo; senão '' = apenas globais + pessoais (não vaza outras unidades).
|
||
if (!await userHasVinculo(userId, clinicaId)) clinicaId = '';
|
||
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;
|
||
let clinicaId = req.body?.clinicaId || req.query.clinicaId || null;
|
||
if (!await userHasVinculo(userId, clinicaId)) clinicaId = ''; // sem vínculo → não marca de outras unidades
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const { q, sort } = req.query;
|
||
const clinicaId = req.clinicaId;
|
||
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, requireCapability('agenda'), 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.' });
|
||
if (!await userHasVinculo(req.authUser?.userId, clinicaId)) return res.status(403).json({ error: 'Acesso negado: sem vínculo com esta unidade.' });
|
||
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, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
|
||
try {
|
||
const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [req.params.id]);
|
||
if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
|
||
if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado: paciente de outra clínica.' });
|
||
const body = { ...req.body }; delete body.clinica_id; // não permite mover de clínica
|
||
const upd = buildUpdate('pacientes', 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', 'setor_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', tenantGuard, requireCapability('dentistas'), async (req, res) => {
|
||
try {
|
||
const d = req.body;
|
||
let tempPassword = null, contaCriada = false;
|
||
// E-mail OBRIGATÓRIO: todo dentista precisa de conta para ter agenda e identidade
|
||
// global — sem ela o anti-overbooking entre clínicas não reconhece a mesma pessoa.
|
||
// (O protético interno "sem conta" usa outro fluxo e NÃO passa por aqui.)
|
||
const emailLimpo = (d.email || '').trim();
|
||
if (!emailLimpo || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailLimpo)) {
|
||
return res.status(400).json({ success: false, message: 'E-MAIL VÁLIDO É OBRIGATÓRIO PARA CADASTRAR UM DENTISTA (ele precisa de conta para ter agenda).' });
|
||
}
|
||
d.email = emailLimpo;
|
||
// 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', authGuard, requireCapabilityFromOrders('dentistas', 'dentistas'), async (req, res) => {
|
||
const { orders } = req.body; // Array of {id, ordem}
|
||
try {
|
||
const ids = (orders || []).map(o => o.id);
|
||
if (ids.length) {
|
||
const { rows } = await pool.query('SELECT DISTINCT clinica_id FROM dentistas WHERE id = ANY($1)', [ids]);
|
||
for (const r of rows) if (!await userHasVinculo(req.authUser?.userId, r.clinica_id)) return res.status(403).json({ error: 'Acesso negado: dentista de outra clínica.' });
|
||
}
|
||
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', authGuard, requireCapabilityRow('dentistas', 'dentistas'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.id])) return;
|
||
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', authGuard, requireCapabilityRow('dentistas', 'dentistas'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.id])) return;
|
||
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', authGuard, requireCapability('agenda'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas WHERE id = $1', [req.params.dentistaId])) return;
|
||
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', authGuard, requireCapability('agenda'), async (req, res) => {
|
||
try {
|
||
if (!await assertCidScoped(req, res, req.body?.clinica_id)) return;
|
||
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', authGuard, requireCapabilityRow('agenda', 'dentistas_horarios'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM dentistas_horarios WHERE id = $1', [req.params.id])) return;
|
||
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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
const { 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 }); }
|
||
});
|
||
|
||
// --- RELATÓRIO FINANCEIRO (H3.1 período / H3.2 por paciente e profissional) ---
|
||
app.get('/api/financeiro/relatorio', tenantGuard, requireCapability('financeiro'), async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { inicio, fim, pacienteId, profissionalId } = req.query;
|
||
|
||
// WHERE dinâmico: sempre escopado por clínica + soft delete; período por vencimento
|
||
const where = ['clinica_id = $1', 'deleted_at IS NULL'];
|
||
const params = [clinicaId];
|
||
where.push(`datavencimento BETWEEN $${params.length + 1} AND $${params.length + 2}`);
|
||
params.push(inicio || '2000-01-01', fim || '2100-01-01');
|
||
if (pacienteId) { params.push(pacienteId); where.push(`paciente_id = $${params.length}`); }
|
||
if (profissionalId) { params.push(profissionalId); where.push(`profissional_id = $${params.length}`); }
|
||
const W = where.join(' AND ');
|
||
|
||
// Resumo por status/tipo (recebido = RECEITA Pago; a receber = RECEITA Pendente+Atrasado)
|
||
const { rows: resumo } = await pool.query(`
|
||
SELECT
|
||
COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Pago' THEN valor ELSE 0 END),0) AS recebido,
|
||
COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Pendente' THEN valor ELSE 0 END),0) AS a_receber,
|
||
COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Atrasado' THEN valor ELSE 0 END),0) AS atrasado,
|
||
COALESCE(SUM(CASE WHEN tipo='DESPESA' AND status='Pago' THEN valor ELSE 0 END),0) AS despesa_paga,
|
||
COALESCE(SUM(CASE WHEN tipo='DESPESA' AND status<>'Pago' THEN valor ELSE 0 END),0) AS despesa_aberta,
|
||
COALESCE(SUM(CASE WHEN tipo='RECEITA' THEN COALESCE(valor_glosa,0) ELSE 0 END),0) AS glosa,
|
||
COUNT(*) AS total_lancamentos
|
||
FROM financeiro WHERE ${W}
|
||
`, params);
|
||
|
||
// Série por mês (competência = vencimento)
|
||
const { rows: porMes } = await pool.query(`
|
||
SELECT to_char(date_trunc('month', datavencimento), 'YYYY-MM') AS mes,
|
||
COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status='Pago' THEN valor ELSE 0 END),0) AS recebido,
|
||
COALESCE(SUM(CASE WHEN tipo='RECEITA' AND status<>'Pago' THEN valor ELSE 0 END),0) AS a_receber
|
||
FROM financeiro WHERE ${W}
|
||
GROUP BY 1 ORDER BY 1
|
||
`, params);
|
||
|
||
// Por forma de pagamento (somente recebido)
|
||
const { rows: porForma } = await pool.query(`
|
||
SELECT COALESCE(NULLIF(formapagamento,''),'—') AS forma, COALESCE(SUM(valor),0) AS total, COUNT(*) AS qtd
|
||
FROM financeiro WHERE ${W} AND tipo='RECEITA' AND status='Pago'
|
||
GROUP BY 1 ORDER BY total DESC
|
||
`, params);
|
||
|
||
// Despesas por centro de custo / fornecedor (H3.3)
|
||
const { rows: porCategoria } = await pool.query(`
|
||
SELECT COALESCE(NULLIF(centro_custo,''),'Sem categoria') AS centro_custo,
|
||
COALESCE(SUM(valor),0) AS total, COUNT(*) AS qtd
|
||
FROM financeiro WHERE ${W} AND tipo='DESPESA'
|
||
GROUP BY 1 ORDER BY total DESC
|
||
`, params);
|
||
|
||
// Receitas por convênio (GTO / planos) — cada condição do WHERE começa pelo nome da coluna, então prefixar com f. é seguro
|
||
const Wf = where.map(c => 'f.' + c).join(' AND ');
|
||
const { rows: porConvenio } = await pool.query(`
|
||
SELECT COALESCE(c.nome, f.convenio_id, 'Particular') AS convenio,
|
||
COALESCE(SUM(f.valor),0) AS total,
|
||
COALESCE(SUM(CASE WHEN f.status='Pago' THEN f.valor ELSE 0 END),0) AS recebido,
|
||
COALESCE(SUM(CASE WHEN f.status<>'Pago' THEN f.valor ELSE 0 END),0) AS a_receber,
|
||
COALESCE(SUM(f.valor_glosa),0) AS glosa,
|
||
COUNT(*) AS qtd
|
||
FROM financeiro f
|
||
LEFT JOIN planos c ON c.id = f.convenio_id
|
||
WHERE ${Wf} AND f.tipo='RECEITA'
|
||
GROUP BY 1 ORDER BY total DESC
|
||
`, params);
|
||
|
||
// Detalhe (lista) — útil para H3.2 paciente/profissional
|
||
const { rows: lancamentos } = await pool.query(`
|
||
SELECT id, descricao, pacientenome, paciente_id, profissional_id, valor, tipo, status,
|
||
formapagamento, datavencimento, pago_em, origem, sem_comissao, fornecedor, centro_custo, recorrente
|
||
FROM financeiro WHERE ${W}
|
||
ORDER BY datavencimento DESC LIMIT 500
|
||
`, params);
|
||
|
||
const num = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, isNaN(Number(v)) ? v : Number(v)]));
|
||
const resumoNum = num(resumo[0]);
|
||
// Líquido a receber = pendentes + atrasados − glosa (convênio negou)
|
||
resumoNum.a_receber_liquido = Number(((resumoNum.a_receber + resumoNum.atrasado) - resumoNum.glosa).toFixed(2));
|
||
res.json({
|
||
resumo: resumoNum,
|
||
porMes: porMes.map(num),
|
||
porForma: porForma.map(num),
|
||
porCategoria: porCategoria.map(num),
|
||
porConvenio: porConvenio.map(num),
|
||
lancamentos: lancamentos.map(l => ({ ...l, valor: Number(l.valor || 0) })),
|
||
});
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ═══ GLOSAS DE CONVÊNIO ══════════════════════════════════════════════════════
|
||
// Glosa = valor negado pelo convênio sobre um recebível. Acumula em financeiro.valor_glosa
|
||
// e mantém histórico em `glosas` (com recurso/recuperação).
|
||
app.get('/api/glosas', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { status } = req.query;
|
||
const params = [req.clinicaId];
|
||
let cond = 'g.clinica_id = $1';
|
||
if (status) { params.push(status); cond += ` AND g.status = $${params.length}`; }
|
||
const { rows } = await pool.query(`
|
||
SELECT g.*, f.descricao AS financeiro_descricao, f.pacientenome, f.valor AS financeiro_valor,
|
||
c.nome AS convenio_nome
|
||
FROM glosas g
|
||
JOIN financeiro f ON f.id = g.financeiro_id
|
||
LEFT JOIN planos c ON c.id = f.convenio_id
|
||
WHERE ${cond} ORDER BY g.criado_em DESC LIMIT 300
|
||
`, params);
|
||
res.json(rows.map(r => ({ ...r, valor: Number(r.valor), financeiro_valor: Number(r.financeiro_valor || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Recebíveis de convênio elegíveis a glosa (GTO/convênio, ainda não totalmente glosados/pagos)
|
||
app.get('/api/glosas/recebiveis', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query(`
|
||
SELECT f.id, f.descricao, f.pacientenome, f.valor, f.valor_glosa, f.status, f.datavencimento, c.nome AS convenio_nome
|
||
FROM financeiro f LEFT JOIN planos c ON c.id = f.convenio_id
|
||
WHERE f.clinica_id = $1 AND f.deleted_at IS NULL AND f.tipo = 'RECEITA'
|
||
AND (f.origem = 'gto' OR f.convenio_id IS NOT NULL)
|
||
AND COALESCE(f.valor_glosa,0) < f.valor
|
||
ORDER BY f.datavencimento DESC LIMIT 200
|
||
`, [req.clinicaId]);
|
||
res.json(rows.map(r => ({ ...r, valor: Number(r.valor), valor_glosa: Number(r.valor_glosa || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Itens da GTO por trás de um recebível (para glosa por item) — com flag de já glosado
|
||
app.get('/api/glosas/itens/:financeiroId', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
// confirma que o recebível é desta clínica
|
||
const fr = await pool.query('SELECT 1 FROM financeiro WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.financeiroId, req.clinicaId]);
|
||
if (!fr.rowCount) return res.status(404).json({ error: 'Recebível não encontrado.' });
|
||
const { rows } = await pool.query(`
|
||
SELECT i.id, i.descricao, i.valortotal,
|
||
EXISTS (SELECT 1 FROM glosas g WHERE g.gto_item_id = i.id AND g.status <> 'recuperado') AS ja_glosado
|
||
FROM gto_builders b JOIN gto_items i ON i.builderid = b.id
|
||
WHERE b.financeiro_id = $1 ORDER BY i.descricao`, [req.params.financeiroId]);
|
||
res.json(rows.map(r => ({ ...r, valortotal: Number(r.valortotal || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Registra glosa sobre um recebível
|
||
app.post('/api/glosas', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { financeiro_id, motivo, gto_item_id, item_descricao, protocolo, prazo_recurso } = req.body;
|
||
const valor = Number(req.body.valor);
|
||
if (!financeiro_id || !(valor > 0)) return res.status(400).json({ error: 'Informe o recebível e um valor de glosa positivo.' });
|
||
const { rows: fr } = await pool.query("SELECT valor, valor_glosa FROM financeiro WHERE id = $1 AND clinica_id = $2 AND deleted_at IS NULL", [financeiro_id, req.clinicaId]);
|
||
if (!fr.length) return res.status(404).json({ error: 'Recebível não encontrado nesta clínica.' });
|
||
const restante = Number(fr[0].valor) - Number(fr[0].valor_glosa || 0);
|
||
if (valor > restante + 0.001) return res.status(400).json({ error: `Glosa (${valor.toFixed(2)}) excede o valor restante (${restante.toFixed(2)}).` });
|
||
if (gto_item_id) {
|
||
const dup = await pool.query("SELECT 1 FROM glosas WHERE clinica_id=$1 AND gto_item_id=$2 AND status<>'recuperado'", [req.clinicaId, gto_item_id]);
|
||
if (dup.rowCount) return res.status(409).json({ error: 'Este item já possui glosa ativa.' });
|
||
}
|
||
const id = `gl_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const nome = await actorNome(req.authUser?.userId);
|
||
await pool.query(`INSERT INTO glosas (id, clinica_id, financeiro_id, valor, motivo, status, gto_item_id, item_descricao, protocolo, prazo_recurso, criado_por, criado_por_nome) VALUES ($1,$2,$3,$4,$5,'glosado',$6,$7,$8,$9,$10,$11)`,
|
||
[id, req.clinicaId, financeiro_id, valor, motivo || null, gto_item_id || null, item_descricao || null, protocolo || null, prazo_recurso || null, req.authUser?.userId, nome]);
|
||
await pool.query('UPDATE financeiro SET valor_glosa = COALESCE(valor_glosa,0) + $1 WHERE id = $2', [valor, financeiro_id]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: id, acao: 'registrou_glosa', actorId: req.authUser?.userId, actorNome: nome, detalhes: { financeiro_id, valor, motivo, gto_item_id } });
|
||
schedulePush('financeiro');
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Abre recurso na glosa (em andamento) — registra protocolo + prazo, mantém valor_glosa
|
||
app.post('/api/glosas/:id/recorrer', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { protocolo, prazo_recurso } = req.body;
|
||
const { rows } = await pool.query('SELECT status FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Glosa não encontrada.' });
|
||
if (rows[0].status !== 'glosado') return res.status(400).json({ error: 'Só é possível abrir recurso de glosa em aberto.' });
|
||
await pool.query("UPDATE glosas SET status='recurso', protocolo=COALESCE($1,protocolo), prazo_recurso=COALESCE($2,prazo_recurso) WHERE id=$3", [protocolo || null, prazo_recurso || null, req.params.id]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: req.params.id, acao: 'abriu_recurso', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { protocolo, prazo_recurso } });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Recurso ganho / glosa recuperada → devolve o valor ao recebível
|
||
app.post('/api/glosas/:id/recuperar', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query('SELECT * FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Glosa não encontrada.' });
|
||
if (rows[0].status === 'recuperado') return res.status(400).json({ error: 'Glosa já recuperada.' });
|
||
await pool.query("UPDATE glosas SET status = 'recuperado' WHERE id = $1", [req.params.id]);
|
||
await pool.query('UPDATE financeiro SET valor_glosa = GREATEST(0, COALESCE(valor_glosa,0) - $1) WHERE id = $2', [Number(rows[0].valor), rows[0].financeiro_id]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: req.params.id, acao: 'recuperou_glosa', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { valor: Number(rows[0].valor) } });
|
||
schedulePush('financeiro');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Exclui glosa (correção) — devolve o valor se estava ativa
|
||
app.delete('/api/glosas/:id', tenantGuard, requireCapability('glosas'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query('SELECT * FROM glosas WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Glosa não encontrada.' });
|
||
if (rows[0].status !== 'recuperado') { // glosado/recurso ainda impactam o recebível
|
||
await pool.query('UPDATE financeiro SET valor_glosa = GREATEST(0, COALESCE(valor_glosa,0) - $1) WHERE id = $2', [Number(rows[0].valor), rows[0].financeiro_id]);
|
||
}
|
||
await pool.query('DELETE FROM glosas WHERE id = $1', [req.params.id]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'glosa', entidadeId: req.params.id, acao: 'excluiu_glosa', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { valor: Number(rows[0].valor) } });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ═══ V2 COMISSÃO / REPASSE ═══════════════════════════════════════════════════
|
||
// Percentual por procedimento (regra específica) com fallback p/ regra padrão (procedimento_id NULL).
|
||
// Base selecionável: 'recebido' (caixa, status=Pago via pago_em/vencimento) ou 'producao' (competência, vencimento).
|
||
async function computeComissoes(q, clinicaId, { inicio, fim, base = 'recebido', profissionalId } = {}) {
|
||
const params = [clinicaId, inicio || '2000-01-01', fim || '2100-01-01'];
|
||
let baseCond;
|
||
if (base === 'producao') {
|
||
// produção = competência pela data de conclusão do procedimento (fallback: vencimento)
|
||
baseCond = `COALESCE(f.data_competencia, f.datavencimento) BETWEEN $2 AND $3`;
|
||
} else {
|
||
baseCond = `f.status = 'Pago' AND COALESCE(f.pago_em::date, f.datavencimento) BETWEEN $2 AND $3`;
|
||
}
|
||
let profCond = '';
|
||
if (profissionalId) { params.push(profissionalId); profCond = ` AND f.profissional_id = $${params.length}`; }
|
||
// Resolução do %: override do profissional > regra do procedimento > padrão > 0.
|
||
// Custo de lab (Gap 2): deduzido do valor antes da comissão; vem da regra do procedimento.
|
||
const { rows } = await q.query(`
|
||
SELECT f.id, f.descricao, f.profissional_id, d.nome AS profissional_nome, f.procedimento_id,
|
||
f.valor, f.status, f.datavencimento, f.pago_em,
|
||
COALESCE(rprof.percentual, rproc.percentual, rdef.percentual, 0) AS percentual,
|
||
COALESCE(rproc.custo_lab, 0) AS custo_lab
|
||
FROM financeiro f
|
||
LEFT JOIN comissao_regras rprof ON rprof.clinica_id = f.clinica_id AND rprof.profissional_id = f.profissional_id AND rprof.procedimento_id IS NULL
|
||
LEFT JOIN comissao_regras rproc ON rproc.clinica_id = f.clinica_id AND rproc.procedimento_id = f.procedimento_id AND rproc.profissional_id IS NULL
|
||
LEFT JOIN comissao_regras rdef ON rdef.clinica_id = f.clinica_id AND rdef.procedimento_id IS NULL AND rdef.profissional_id IS NULL
|
||
LEFT JOIN dentistas d ON d.id = f.profissional_id AND d.clinica_id = f.clinica_id
|
||
WHERE f.clinica_id = $1 AND f.deleted_at IS NULL AND f.tipo = 'RECEITA'
|
||
AND f.sem_comissao = false AND f.profissional_id IS NOT NULL
|
||
AND ${baseCond}${profCond}
|
||
ORDER BY d.nome NULLS LAST
|
||
`, params);
|
||
// Agrupa por profissional (mantém detalhe p/ snapshot/auditoria)
|
||
const mapa = new Map();
|
||
for (const l of rows) {
|
||
const pct = Number(l.percentual) || 0;
|
||
const custoLab = Math.min(Number(l.custo_lab) || 0, Number(l.valor)); // lab não excede o valor
|
||
const baseLiquida = Number(l.valor) - custoLab;
|
||
const comissao = baseLiquida * pct / 100;
|
||
if (!mapa.has(l.profissional_id)) {
|
||
mapa.set(l.profissional_id, { profissional_id: l.profissional_id, profissional_nome: l.profissional_nome || '—', base_valor: 0, custo_lab_total: 0, comissao_valor: 0, qtd: 0, detalhe: [] });
|
||
}
|
||
const g = mapa.get(l.profissional_id);
|
||
g.base_valor += Number(l.valor);
|
||
g.custo_lab_total += custoLab;
|
||
g.comissao_valor += comissao;
|
||
g.qtd += 1;
|
||
g.detalhe.push({ id: l.id, descricao: l.descricao, valor: Number(l.valor), custo_lab: Number(custoLab.toFixed(2)), percentual: pct, comissao: Number(comissao.toFixed(2)) });
|
||
}
|
||
const grupos = [...mapa.values()].map(g => ({
|
||
...g,
|
||
base_valor: Number(g.base_valor.toFixed(2)),
|
||
custo_lab_total: Number(g.custo_lab_total.toFixed(2)),
|
||
comissao_valor: Number(g.comissao_valor.toFixed(2)),
|
||
percentual_medio: (g.base_valor - g.custo_lab_total) > 0 ? Number((g.comissao_valor / (g.base_valor - g.custo_lab_total) * 100).toFixed(2)) : 0,
|
||
})).sort((a, b) => b.comissao_valor - a.comissao_valor);
|
||
return {
|
||
base, inicio: inicio || null, fim: fim || null,
|
||
total_base: Number(grupos.reduce((s, g) => s + g.base_valor, 0).toFixed(2)),
|
||
total_custo_lab: Number(grupos.reduce((s, g) => s + g.custo_lab_total, 0).toFixed(2)),
|
||
total_comissao: Number(grupos.reduce((s, g) => s + g.comissao_valor, 0).toFixed(2)),
|
||
grupos,
|
||
};
|
||
}
|
||
|
||
// Regras de comissão (% por procedimento + padrão)
|
||
app.get('/api/comissoes/regras', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query(`
|
||
SELECT r.id, r.procedimento_id, r.profissional_id, r.percentual, r.custo_lab,
|
||
p.nome AS procedimento_nome, d.nome AS profissional_nome
|
||
FROM comissao_regras r
|
||
LEFT JOIN procedimentos p ON p.id = r.procedimento_id
|
||
LEFT JOIN dentistas d ON d.id = r.profissional_id AND d.clinica_id = r.clinica_id
|
||
WHERE r.clinica_id = $1
|
||
ORDER BY (r.procedimento_id IS NULL AND r.profissional_id IS NULL) DESC, d.nome NULLS LAST, p.nome NULLS FIRST
|
||
`, [req.clinicaId]);
|
||
res.json(rows.map(r => ({ ...r, percentual: Number(r.percentual), custo_lab: Number(r.custo_lab || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Upsert de regra (procedimento_id null = padrão da clínica)
|
||
app.put('/api/comissoes/regras', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const procId = req.body.procedimento_id || null;
|
||
const profId = req.body.profissional_id || null;
|
||
if (procId && profId) return res.status(400).json({ error: 'Regra é por procedimento OU por profissional, não ambos.' });
|
||
const pct = Number(req.body.percentual);
|
||
if (!(pct >= 0 && pct <= 100)) return res.status(400).json({ error: 'Percentual deve estar entre 0 e 100.' });
|
||
const custoLab = Math.max(0, Number(req.body.custo_lab) || 0);
|
||
const id = `cr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(`
|
||
INSERT INTO comissao_regras (id, clinica_id, procedimento_id, profissional_id, percentual, custo_lab) VALUES ($1, $2, $3, $4, $5, $6)
|
||
ON CONFLICT (clinica_id, COALESCE(procedimento_id, ''), COALESCE(profissional_id, '')) DO UPDATE SET percentual = EXCLUDED.percentual, custo_lab = EXCLUDED.custo_lab
|
||
`, [id, req.clinicaId, procId, profId, pct, custoLab]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'comissao_regra', entidadeId: procId || profId || 'padrao', acao: 'definiu_percentual', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { procedimento_id: procId, profissional_id: profId, percentual: pct, custo_lab: custoLab } });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/comissoes/regras/:id', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rowCount } = await pool.query('DELETE FROM comissao_regras WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Regra não encontrada nesta clínica.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Relatório de comissão ao vivo (não persiste)
|
||
app.get('/api/comissoes/relatorio', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { inicio, fim, base, profissionalId } = req.query;
|
||
const out = await computeComissoes(pool, req.clinicaId, { inicio, fim, base, profissionalId });
|
||
res.json(out);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Fecha repasse do período (snapshot imutável por profissional + auditoria)
|
||
app.post('/api/repasses/fechar', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { inicio, fim, base, profissionalId } = req.body;
|
||
if (!inicio || !fim) return res.status(400).json({ error: 'Informe início e fim do período.' });
|
||
const out = await computeComissoes(pool, req.clinicaId, { inicio, fim, base, profissionalId });
|
||
if (out.grupos.length === 0) return res.status(400).json({ error: 'Nenhuma comissão a fechar no período/filtro.' });
|
||
const nome = await actorNome(req.authUser?.userId);
|
||
const criados = [];
|
||
for (const g of out.grupos) {
|
||
const id = `rp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(`
|
||
INSERT INTO repasses (id, clinica_id, profissional_id, profissional_nome, inicio, fim, base, base_valor, custo_lab_total, comissao_valor, qtd_lancamentos, detalhes, status, criado_por, criado_por_nome)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'fechado',$13,$14)
|
||
`, [id, req.clinicaId, g.profissional_id, g.profissional_nome, inicio, fim, out.base, g.base_valor, g.custo_lab_total, g.comissao_valor, g.qtd, JSON.stringify(g.detalhe), req.authUser?.userId, nome]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: id, acao: 'fechou_repasse', actorId: req.authUser?.userId, actorNome: nome, detalhes: { profissional: g.profissional_nome, base: out.base, periodo: `${inicio}..${fim}`, comissao: g.comissao_valor } });
|
||
criados.push(id);
|
||
}
|
||
res.json({ success: true, criados: criados.length, total_comissao: out.total_comissao });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Lista fechamentos de repasse
|
||
app.get('/api/repasses', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query(`
|
||
SELECT id, profissional_id, profissional_nome, inicio, fim, base, base_valor, custo_lab_total, comissao_valor, qtd_lancamentos, status, pago_em, financeiro_id, comprovante_filename, comprovante_nome, criado_por_nome, criado_em
|
||
FROM repasses WHERE clinica_id = $1 ORDER BY criado_em DESC LIMIT 300
|
||
`, [req.clinicaId]);
|
||
res.json(rows.map(r => ({
|
||
...r, base_valor: Number(r.base_valor), custo_lab_total: Number(r.custo_lab_total || 0), comissao_valor: Number(r.comissao_valor),
|
||
comprovante_url: r.comprovante_filename ? `/api/repasses/comprovante/${r.id}/raw?t=${signMedia(r.id)}` : null,
|
||
})));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Detalhe de um fechamento (lançamentos snapshot)
|
||
app.get('/api/repasses/:id', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query('SELECT * FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' });
|
||
const r = rows[0];
|
||
res.json({ ...r, base_valor: Number(r.base_valor), comissao_valor: Number(r.comissao_valor) });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Cancela um fechamento (não apaga — auditoria)
|
||
app.post('/api/repasses/:id/cancelar', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query('SELECT profissional_nome, status, financeiro_id FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' });
|
||
if (rows[0].status === 'cancelado') return res.status(400).json({ error: 'Repasse já cancelado.' });
|
||
// Se estava pago, estorna (soft-delete) a despesa gerada no financeiro
|
||
let estornou = null;
|
||
if (rows[0].status === 'pago' && rows[0].financeiro_id) {
|
||
await pool.query('UPDATE financeiro SET deleted_at = NOW() WHERE id = $1 AND clinica_id = $2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]);
|
||
estornou = rows[0].financeiro_id;
|
||
}
|
||
await pool.query("UPDATE repasses SET status = 'cancelado', financeiro_id = NULL WHERE id = $1 AND clinica_id = $2", [req.params.id, req.clinicaId]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: req.params.id, acao: 'cancelou_repasse', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { profissional: rows[0].profissional_nome, despesa_estornada: estornou } });
|
||
if (estornou) schedulePush('financeiro');
|
||
res.json({ success: true, despesa_estornada: estornou });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Marca o repasse como PAGO e lança a DESPESA correspondente no financeiro (Gap 3)
|
||
app.post('/api/repasses/:id/pagar', tenantGuard, requireCapability('comissoes'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rows } = await pool.query('SELECT * FROM repasses WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' });
|
||
const rp = rows[0];
|
||
if (rp.status !== 'fechado') return res.status(400).json({ error: `Só é possível pagar repasse fechado (atual: ${rp.status}).` });
|
||
const pagoEm = req.body?.pago_em || new Date().toISOString();
|
||
const forma = FIN_FORMA[(req.body?.formapagamento || '').toUpperCase()] || 'Pix';
|
||
const finId = `fin_rp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const ymd = d => d instanceof Date ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` : String(d).slice(0, 10);
|
||
// Lançamento de DESPESA já pago (origem='repasse'); sem_comissao p/ não recomissionar
|
||
await pool.query(`
|
||
INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, formapagamento, datavencimento, pago_em, profissional_id, origem, centro_custo, fornecedor, sem_comissao)
|
||
VALUES ($1,$2,$3,$4,'DESPESA','Pago',$5,$6,$7,$8,'repasse','COMISSÕES',$9,true)
|
||
`, [finId, req.clinicaId, `REPASSE ${rp.profissional_nome} (${ymd(rp.inicio)} a ${ymd(rp.fim)})`, rp.comissao_valor, forma, ymd(pagoEm), pagoEm, rp.profissional_id, rp.profissional_nome]);
|
||
await pool.query("UPDATE repasses SET status = 'pago', pago_em = $1, financeiro_id = $2 WHERE id = $3 AND clinica_id = $4", [pagoEm, finId, req.params.id, req.clinicaId]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: req.params.id, acao: 'pagou_repasse', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { profissional: rp.profissional_nome, valor: rp.comissao_valor, financeiro_id: finId } });
|
||
schedulePush('financeiro');
|
||
res.json({ success: true, financeiro_id: finId });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Anexa comprovante (imagem/PDF) ao repasse pago
|
||
app.post('/api/repasses/:id/comprovante', tenantGuard, requireCapability('comissoes'), ortoUpload.single('arquivo'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
if (!req.file) return res.status(400).json({ error: 'Arquivo obrigatório.' });
|
||
const mt = req.file.mimetype || '';
|
||
if (!(mt.startsWith('image/') || mt === 'application/pdf')) return res.status(400).json({ error: 'Envie imagem ou PDF.' });
|
||
const { rows } = await pool.query('SELECT status FROM repasses WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Repasse não encontrado.' });
|
||
const ext = mt === 'application/pdf' ? 'pdf' : ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg');
|
||
const rel = path.join('repasses', req.params.id, `comprovante_${Date.now()}.${ext}`);
|
||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'repasses', req.params.id), { recursive: true });
|
||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||
await pool.query('UPDATE repasses SET comprovante_filename=$1, comprovante_mimetype=$2, comprovante_nome=$3 WHERE id=$4 AND clinica_id=$5',
|
||
[rel, mt, req.file.originalname, req.params.id, req.clinicaId]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'repasse', entidadeId: req.params.id, acao: 'anexou_comprovante', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { nome: req.file.originalname } });
|
||
res.json({ success: true, url: `/api/repasses/comprovante/${req.params.id}/raw?t=${signMedia(req.params.id)}` });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// stream do comprovante (assinado, sem Authorization)
|
||
app.get('/api/repasses/comprovante/:id/raw', async (req, res) => {
|
||
try {
|
||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||
const { rows } = await pool.query('SELECT comprovante_filename, comprovante_mimetype FROM repasses WHERE id=$1', [req.params.id]);
|
||
if (!rows.length || !rows[0].comprovante_filename) return res.status(404).end();
|
||
res.type(rows[0].comprovante_mimetype || 'application/octet-stream');
|
||
res.sendFile(path.join(MEDIA_ROOT, rows[0].comprovante_filename), (err) => { if (err && !res.headersSent) res.status(404).end(); });
|
||
} catch { res.status(404).end(); }
|
||
});
|
||
|
||
// --- 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)
|
||
// Janela de datas OPCIONAL (?from&to): quando enviada, limita a varredura à janela
|
||
// visível do calendário em vez de carregar a agenda histórica inteira. Compatível
|
||
// com clientes antigos (sem from/to → comportamento anterior).
|
||
const { from, to } = req.query;
|
||
const params = [clinicaId];
|
||
let janela = '';
|
||
if (from) { params.push(from); janela += ` AND COALESCE(end_time, start_time) >= $${params.length}`; }
|
||
if (to) { params.push(to); janela += ` AND start_time <= $${params.length}`; }
|
||
// Isolamento por setor (opt-in). Dono/admin e membro SEM setor → vê tudo
|
||
// (compat). Membro COM setor → só o próprio setor + os "gerais" (setor_id
|
||
// NULL). Não se aplica a salas (req.isSala não tem vínculo/setor → vê tudo).
|
||
if (!req.isSala) {
|
||
const { ehDono, setorId } = await resolverSetorUsuario(pool, req.authUser?.userId, clinicaId);
|
||
if (!ehDono && setorId) {
|
||
params.push(setorId);
|
||
janela += ` AND (setor_id = $${params.length} OR setor_id IS NULL)`;
|
||
}
|
||
}
|
||
const { rows } = await pool.query(
|
||
`SELECT * FROM agendamentos WHERE clinica_id = $1${janela} ORDER BY start_time`, params);
|
||
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, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||
const profUid = dr[0]?.usuario_id || null;
|
||
const profEmail = (dr[0]?.email || '').trim().toLowerCase() || 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);
|
||
} else if (profEmail) {
|
||
// Profissional SEM conta (usuario_id NULL): agrupa os registros da MESMA pessoa
|
||
// pelo email — a mesma chave usada no backfill de identidade (Fase 2). Garante o
|
||
// anti-overbooking para o dentista que atende fixo numa clínica e avulso em outras,
|
||
// mesmo sem login. Sem email não há como vincular → cai no próprio dentistaId.
|
||
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [profEmail]);
|
||
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;
|
||
}
|
||
|
||
// Privacidade entre tenants: NUNCA devolver id/clinica_id do compromisso em conflito
|
||
// (revelaria a clínica concorrente e o agendamento alheio a quem está agendando aqui).
|
||
// Só o intervalo de tempo, que é o necessário para "escolha outro horário".
|
||
function slotOcupadoPublico(conflito) {
|
||
if (!conflito) return null;
|
||
return { start: conflito.start_time, end: conflito.end_time };
|
||
}
|
||
|
||
// POST with concurrency lock: prevents double-booking the same dentist+time slot
|
||
app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => {
|
||
try {
|
||
const actor = req.authUser?.userId || null;
|
||
const actorNm = await actorNome(actor);
|
||
const { start, end, ...rest } = req.body;
|
||
const { dentistaId } = rest;
|
||
if (!rest.clinica_id) return res.status(400).json({ error: 'clinica_id obrigatório.' });
|
||
if (!await userHasVinculo(actor, rest.clinica_id)) return res.status(403).json({ error: 'Acesso negado: sem vínculo com esta unidade.' });
|
||
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 };
|
||
|
||
// Setor do agendamento (isolamento opt-in): ancora no setor do DENTISTA —
|
||
// primeiro um override explícito (dentistas.setor_id), senão o setor do
|
||
// vínculo do próprio profissional na clínica (definido em Gestão de Equipe,
|
||
// UI já existente). Sem isso, herda o setor de quem está agendando (recepção
|
||
// do setor). NULL = geral, visível a todos.
|
||
let setorAg = null;
|
||
const { rows: ds } = await client.query(
|
||
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
|
||
FROM dentistas d
|
||
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
|
||
WHERE d.id = $1`, [dentistaId, rest.clinica_id]);
|
||
setorAg = ds[0]?.d_setor || ds[0]?.v_setor || null;
|
||
if (!setorAg) {
|
||
const { rows: vs } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, rest.clinica_id]);
|
||
setorAg = vs[0]?.setor_id || null;
|
||
}
|
||
|
||
// 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,
|
||
setor_id: setorAg,
|
||
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: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
|
||
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, requireCapabilityRow('agenda', 'agendamentos'), 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);
|
||
|
||
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;
|
||
|
||
// remarcou (mudou horário) vs editou (outros campos)
|
||
const movido = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time));
|
||
|
||
// Tudo numa única transação: a checagem de overbooking (FOR UPDATE) e o UPDATE
|
||
// ficam atômicos — sem isso, dois reagendamentos simultâneos para o mesmo slot
|
||
// passariam os dois pela verificação (o lock só vale dentro da transação).
|
||
await withTransaction(async (client) => {
|
||
if (mudouTempoOuDent) {
|
||
const novoDent = rest.dentistaId || before.dentistaid;
|
||
const conflito = await findOverbookingConflict(client, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
||
}
|
||
// Trocou o dentista → o setor segue o novo profissional (mesma derivação do
|
||
// POST: override do dentista > setor do vínculo dele > setor de quem edita).
|
||
// Sem isso, o agendamento ficaria no setor do dentista ANTERIOR.
|
||
if (rest.dentistaId && rest.dentistaId !== before.dentistaid) {
|
||
const { rows: dd } = await client.query(
|
||
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
|
||
FROM dentistas d
|
||
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
|
||
WHERE d.id = $1`, [rest.dentistaId, before.clinica_id]);
|
||
let novoSetor = dd[0]?.d_setor || dd[0]?.v_setor || null;
|
||
if (!novoSetor) {
|
||
const { rows: av } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, before.clinica_id]);
|
||
novoSetor = av[0]?.setor_id || null;
|
||
}
|
||
updateData.setor_id = novoSetor;
|
||
}
|
||
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
||
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
|
||
await registrarAudit(client, {
|
||
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 === 'CONF_OVERLAP') {
|
||
return res.status(409).json({
|
||
success: false, conflito: true, podeRegistrarInteresse: true,
|
||
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
|
||
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.'
|
||
});
|
||
}
|
||
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;
|
||
}
|
||
|
||
// Anti-IDOR de escrita: o usuário tem vínculo com a clínica? (boolean, sem resposta HTTP)
|
||
async function userHasVinculo(userId, clinicaId) {
|
||
if (!userId || !clinicaId) return false;
|
||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [userId, clinicaId]);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
// Contexto de setor do usuário NA clínica (para isolamento opt-in da agenda):
|
||
// { ehDono, setorId }. ehDono (dono/admin/superadmin) → vê tudo. setorId NULL
|
||
// (membro sem setor) → vê tudo (compat: clínica que não usa setores). setorId
|
||
// preenchido → vê só o próprio setor + os "gerais" (setor_id NULL).
|
||
const DONO_ROLES = ['donoclinica', 'donoconsultorio', 'admin'];
|
||
async function resolverSetorUsuario(db, userId, clinicaId) {
|
||
if (!userId || !clinicaId) return { ehDono: false, setorId: null };
|
||
const { rows: u } = await db.query('SELECT role FROM usuarios WHERE id = $1', [userId]);
|
||
if (u[0]?.role === 'superadmin') return { ehDono: true, setorId: null };
|
||
const { rows } = await db.query('SELECT role, setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]);
|
||
if (!rows.length) return { ehDono: false, setorId: null };
|
||
return { ehDono: DONO_ROLES.includes(rows[0].role), setorId: rows[0].setor_id || null };
|
||
}
|
||
|
||
// Carrega um tratamento orto e valida o vínculo do usuário com a clínica dele (403 se não).
|
||
async function assertOrtoScoped(req, res) {
|
||
const { rows } = await pool.query('SELECT clinica_id FROM ortho_tratamento WHERE id = $1', [req.params.id]);
|
||
if (!rows.length) { res.status(404).json({ success: false, error: 'Tratamento não encontrado.' }); return null; }
|
||
if (!await userHasVinculo(req.authUser?.userId, rows[0].clinica_id)) {
|
||
res.status(403).json({ success: false, error: 'Acesso negado: tratamento de outra clínica.' }); return null;
|
||
}
|
||
return rows[0];
|
||
}
|
||
|
||
// Valida posse de um recurso por sua clínica. superadmin passa; recurso global (cid null) só superadmin.
|
||
async function assertCidScoped(req, res, cid) {
|
||
const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]);
|
||
if (u[0]?.role === 'superadmin') return true;
|
||
if (!cid || !await userHasVinculo(req.authUser?.userId, cid)) {
|
||
res.status(403).json({ error: 'Acesso negado: recurso de outra clínica.' }); return false;
|
||
}
|
||
return true;
|
||
}
|
||
// Carrega o clinica_id de um recurso (sql deve retornar coluna "cid") e valida a posse.
|
||
async function assertRowScoped(req, res, sql, params) {
|
||
const { rows } = await pool.query(sql, params);
|
||
if (!rows.length) { res.status(404).json({ error: 'Recurso não encontrado.' }); return false; }
|
||
return await assertCidScoped(req, res, rows[0].cid);
|
||
}
|
||
|
||
// 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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapabilityRow('agenda', 'agendamentos'), 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?t=${signMedia(f.id)}` }));
|
||
}
|
||
|
||
// 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, requireCapabilityRow('agenda', 'agendamentos'), 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?t=${signMedia(id)}`, 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 {
|
||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||
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, requireCapabilityRow('agenda', 'agendamentos'), 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, requireCapability('agenda'), 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, requireCapabilityRow('agenda', 'agenda_pendencias'), 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, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return;
|
||
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, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return;
|
||
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, requireCapability('agenda'), 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, requireCapability('agenda'), 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, requireCapability('agenda'), 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, requireCapability('agenda'), 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, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [req.params.id])) return;
|
||
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', authGuard, requireCapability('agenda'), 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.' });
|
||
if (!await assertCidScoped(req, res, clinicaInteressadaId)) return;
|
||
|
||
// 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', authGuard, requireCapability('agenda'), async (req, res) => {
|
||
try {
|
||
const { profissionalId, clinicaId } = req.query;
|
||
const uid = req.authUser?.userId;
|
||
// Escopo: a clínica (com vínculo) vê os seus; o profissional vê só os dele.
|
||
if (clinicaId) { if (!await userHasVinculo(uid, clinicaId)) return res.status(403).json({ error: 'Acesso negado a esta unidade.' }); }
|
||
else if (profissionalId) { if (profissionalId !== uid) return res.status(403).json({ error: 'Acesso negado.' }); }
|
||
else return res.status(400).json({ error: 'profissionalId ou clinicaId obrigatório.' });
|
||
// 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, requireCapability('especialidades'), 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 area = req.body.area || roleToArea(userRows[0]?.role);
|
||
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, area });
|
||
await pool.query(ins.text, ins.values);
|
||
if (clinicaId && area) await pool.query('INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [clinicaId, area]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/especialidades/reorder', authGuard, requireCapabilityFromOrders('especialidades', 'especialidades'), 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', authGuard, requireCapabilityRow('especialidades', 'especialidades'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM especialidades WHERE id = $1', [req.params.id])) return;
|
||
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, requireCapability('especialidades'), 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, requireCapability('planos'), 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', authGuard, requireCapabilityRow('planos', 'planos'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM planos WHERE id = $1', [req.params.id])) return;
|
||
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, requireCapability('planos'), 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, requireCapability('procedimentos'), 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,
|
||
area: proc.area || roleToArea(userRows[0]?.role),
|
||
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();
|
||
if (clinicaId && payload.area) await pool.query('INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [clinicaId, payload.area]);
|
||
|
||
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', authGuard, requireCapabilityFromOrders('procedimentos', 'procedimentos'), 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', authGuard, requireCapabilityRow('procedimentos', 'procedimentos'), async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM procedimentos WHERE id = $1', [req.params.id])) return;
|
||
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, requireCapability('procedimentos'), 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 ---
|
||
// Normaliza enums p/ as CHECK constraints (UI envia em MAIÚSCULO via enforceUppercase)
|
||
const FIN_STATUS = { PAGO: 'Pago', PENDENTE: 'Pendente', ATRASADO: 'Atrasado' };
|
||
const FIN_FORMA = { PIX: 'Pix', 'CARTÃO': 'Cartão', CARTAO: 'Cartão', BOLETO: 'Boleto', DINHEIRO: 'Dinheiro' };
|
||
function normalizeFinanceiro(body) {
|
||
const b = { ...body };
|
||
if (typeof b.status === 'string') b.status = FIN_STATUS[b.status.toUpperCase()] || b.status;
|
||
if (typeof b.tipo === 'string') b.tipo = b.tipo.toUpperCase();
|
||
if (typeof b.formapagamento === 'string') {
|
||
b.formapagamento = b.formapagamento.trim() === '' ? undefined : (FIN_FORMA[b.formapagamento.toUpperCase()] || b.formapagamento);
|
||
}
|
||
return b;
|
||
}
|
||
|
||
app.get('/api/financeiro', tenantGuard, requireCapability('financeiro'), 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 AND deleted_at IS NULL 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', tenantGuard, requireCapability('financeiro'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const ins = buildInsert('financeiro', { ...normalizeFinanceiro(req.body), clinica_id: req.clinicaId });
|
||
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', tenantGuard, requireCapability('financeiro'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const own = await pool.query('SELECT * FROM financeiro WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!own.rowCount) return res.status(404).json({ error: 'Lançamento não encontrado nesta unidade.' });
|
||
const body = normalizeFinanceiro(req.body); delete body.clinica_id;
|
||
// H2.1 — baixa de pagamento: ao marcar Pago, carimba pago_em (se não informado)
|
||
const virouPago = body.status === 'Pago' && own.rows[0].status !== 'Pago';
|
||
if (body.status === 'Pago' && !body.pago_em) body.pago_em = new Date().toISOString();
|
||
const upd = buildUpdate('financeiro', body, 'id', req.params.id);
|
||
await pool.query(upd.text, upd.values);
|
||
if (virouPago) {
|
||
await registrarAudit(pool, {
|
||
clinicaId: req.clinicaId, entidade: 'financeiro', entidadeId: req.params.id, acao: 'baixa_pagamento',
|
||
actorId: req.authUser?.userId, actorNome: req.authUser?.email,
|
||
detalhes: { valor: own.rows[0].valor, forma: body.formapagamento || own.rows[0].formapagamento, pago_em: body.pago_em },
|
||
});
|
||
}
|
||
schedulePush('financeiro');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/financeiro/:id', tenantGuard, requireCapability('financeiro'), async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { rowCount } = await pool.query('UPDATE financeiro SET deleted_at = NOW() WHERE id = $1 AND clinica_id = $2 AND deleted_at IS NULL', [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 }); }
|
||
});
|
||
|
||
// ═══ PRONTUÁRIO · Procedimentos realizados (reabertura/garantia sem comissão + fotos antes/depois) ═══
|
||
// Regra de comissão: garantia => sem comissão; reabertura => sem comissão SE for o MESMO profissional do original.
|
||
app.post('/api/procedimentos-realizados', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const b = req.body || {};
|
||
const tipo = ['novo', 'reabertura', 'garantia'].includes(b.tipo) ? b.tipo : 'novo';
|
||
if (!b.paciente_id) return res.status(400).json({ error: 'paciente_id obrigatório.' });
|
||
try {
|
||
let semComissao = false, origemId = b.origem_id || null;
|
||
if (tipo === 'reabertura' || tipo === 'garantia') {
|
||
if (!origemId) return res.status(400).json({ error: 'Reabertura/garantia exige o procedimento original (origem_id).' });
|
||
const { rows: o } = await pool.query('SELECT profissional_id FROM procedimentos_realizados WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [origemId, req.clinicaId]);
|
||
if (!o.length) return res.status(404).json({ error: 'Procedimento original não encontrado.' });
|
||
// garantia: sempre sem comissão. reabertura: sem comissão só se o MESMO profissional.
|
||
semComissao = (tipo === 'garantia') || (!!b.profissional_id && b.profissional_id === o[0].profissional_id);
|
||
}
|
||
const id = `pr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO procedimentos_realizados (id, clinica_id, paciente_id, paciente_nome, profissional_id, profissional_nome, procedimento_id, procedimento_nome, agendamento_id, tipo, origem_id, sem_comissao, valor, observacoes, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
||
[id, req.clinicaId, b.paciente_id, b.paciente_nome || null, b.profissional_id || null, b.profissional_nome || null,
|
||
b.procedimento_id || null, b.procedimento_nome || null, b.agendamento_id || null, tipo, origemId, semComissao,
|
||
Number(b.valor) || 0, b.observacoes || null, req.authUser?.userId]);
|
||
// Lançamento financeiro opcional (só se houver valor) — herda sem_comissao para o futuro repasse (V2).
|
||
let lancamentoId = null;
|
||
if (Number(b.valor) > 0 && b.gerarLancamento !== false) {
|
||
lancamentoId = `fin_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const dataConclusao = b.data_conclusao || null; // competência = data em que o procedimento foi concluído
|
||
await pool.query(
|
||
`INSERT INTO financeiro (id, clinica_id, pacientenome, descricao, valor, datavencimento, data_competencia, status, tipo, paciente_id, profissional_id, procedimento_id, agendamento_id, realizado_id, origem, sem_comissao)
|
||
VALUES ($1,$2,$3,$4,$5,CURRENT_DATE, COALESCE($12::date, CURRENT_DATE),'Pendente','RECEITA',$6,$7,$8,$9,$10,'procedimento',$11)`,
|
||
[lancamentoId, req.clinicaId, b.paciente_nome || null, b.procedimento_nome || 'Procedimento', Number(b.valor),
|
||
b.paciente_id, b.profissional_id || null, b.procedimento_id || null, b.agendamento_id || null, id, semComissao, dataConclusao]);
|
||
}
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'procedimento_realizado', entidadeId: id, acao: tipo === 'novo' ? 'registrou' : tipo, actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { semComissao, origemId } });
|
||
res.json({ success: true, id, sem_comissao: semComissao, lancamento_id: lancamentoId });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.get('/api/procedimentos-realizados', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const pacienteId = req.query.pacienteId;
|
||
if (!pacienteId) return res.status(400).json({ error: 'pacienteId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT r.*,
|
||
(SELECT COUNT(*) FROM procedimento_realizado_foto f WHERE f.realizado_id=r.id AND f.momento='antes') AS fotos_antes,
|
||
(SELECT COUNT(*) FROM procedimento_realizado_foto f WHERE f.realizado_id=r.id AND f.momento='depois') AS fotos_depois
|
||
FROM procedimentos_realizados r
|
||
WHERE r.clinica_id=$1 AND r.paciente_id=$2 AND r.deleted_at IS NULL
|
||
ORDER BY r.created_at DESC LIMIT 200`, [req.clinicaId, pacienteId]);
|
||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.get('/api/procedimentos-realizados/:id', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT * FROM procedimentos_realizados WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Procedimento não encontrado.' });
|
||
const { rows: fotos } = await pool.query('SELECT id, momento, original_name FROM procedimento_realizado_foto WHERE realizado_id=$1 ORDER BY momento, created_at', [req.params.id]);
|
||
res.json({ ...rows[0], valor: parseFloat(rows[0].valor || 0), fotos: fotos.map(f => ({ ...f, url: `/api/procedimentos-realizados/fotos/${f.id}/raw?t=${signMedia(f.id)}` })) });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/procedimentos-realizados/:id/foto', tenantGuard, ortoUpload.single('foto'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const momento = req.body?.momento === 'depois' ? 'depois' : 'antes';
|
||
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT id FROM procedimentos_realizados WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Procedimento não encontrado.' });
|
||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||
const rel = path.join('procedimentos', req.params.id, `${momento}_${Date.now()}.${ext}`);
|
||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'procedimentos', req.params.id), { recursive: true });
|
||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||
const fid = `prf_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query('INSERT INTO procedimento_realizado_foto (id, realizado_id, clinica_id, momento, filename, mimetype, original_name) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||
[fid, req.params.id, req.clinicaId, momento, rel, req.file.mimetype, req.file.originalname]);
|
||
res.json({ success: true, id: fid, momento, url: `/api/procedimentos-realizados/fotos/${fid}/raw?t=${signMedia(fid)}` });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// stream da imagem (img tag não envia Authorization → URL assinada)
|
||
app.get('/api/procedimentos-realizados/fotos/:id/raw', async (req, res) => {
|
||
try {
|
||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||
const { rows } = await pool.query('SELECT filename, mimetype FROM procedimento_realizado_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/procedimentos-realizados/:id', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rowCount } = await pool.query('UPDATE procedimentos_realizados SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Procedimento não encontrado.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ═══ PRÓTESES · Ordem de Serviço (OS): clínica/dentista → laboratório/protético ═══
|
||
// Cross-workspace (padrão das propostas): a clínica cria por tenantGuard (clinica_id origem);
|
||
// o protético vê na "Bancada" por authGuard (protetico_id = destinatário).
|
||
const PROTESE_STATUS = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue', 'cancelado'];
|
||
|
||
// Verifica o acesso de um usuário a uma OS: laboratório (destinatário) ou clínica de origem (com vínculo).
|
||
// O workspace 'laboratorio' do protético (1 por protético nesta fase). Usado para escopar a
|
||
// bancada e as OS por laboratório (empresa), além do protetico_id (pessoa) que segue como fallback.
|
||
async function labDoProtetico(proteticoId) {
|
||
if (!proteticoId) return null;
|
||
const { rows } = await pool.query(
|
||
"SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' LIMIT 1", [proteticoId]);
|
||
return rows[0]?.id || null;
|
||
}
|
||
|
||
// Fase 4: o laboratório do usuário — como DONO (owner) ou como TÉCNICO (vínculo). Usado na bancada.
|
||
async function labDoUsuario(uid) {
|
||
if (!uid) return null;
|
||
const dono = await labDoProtetico(uid);
|
||
if (dono) return dono;
|
||
const { rows } = await pool.query(
|
||
"SELECT c.id FROM vinculos v JOIN clinicas c ON c.id = v.clinica_id WHERE v.usuario_id=$1 AND c.tipo='laboratorio' AND v.role='tecnico_protese' LIMIT 1", [uid]);
|
||
return rows[0]?.id || null;
|
||
}
|
||
|
||
async function proteseOsAccesso(os, uid) {
|
||
if (!os) return { isLab: false, isClinic: false };
|
||
let isLab = !!uid && os.protetico_id === uid;
|
||
// Técnico vinculado ao laboratório de destino também é "lab" (acessa a produção).
|
||
if (!isLab && uid && os.laboratorio_id) {
|
||
const { rows } = await pool.query("SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2 AND role='tecnico_protese'", [uid, os.laboratorio_id]);
|
||
isLab = rows.length > 0;
|
||
}
|
||
let isClinic = false;
|
||
if (uid) {
|
||
const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [uid, os.clinica_id]);
|
||
isClinic = rows.length > 0;
|
||
}
|
||
return { isLab, isClinic };
|
||
}
|
||
|
||
// Um protético é "acessível" por uma clínica se for interno (vínculo) OU estiver no diretório.
|
||
// Mesma regra do dropdown de Nova OS — usada para travar criação de OS e leitura de catálogo.
|
||
async function proteticoAcessivelPelaClinica(proteticoId, clinicaId) {
|
||
if (!proteticoId || !clinicaId) return false;
|
||
const { rows } = await pool.query(
|
||
`SELECT 1 FROM usuarios u
|
||
LEFT JOIN vinculos v ON v.usuario_id = u.id AND v.clinica_id = $2
|
||
WHERE u.id = $1 AND u.role = 'protetico'
|
||
AND (v.clinica_id = $2 OR u.disponivel_diretorio = true)
|
||
LIMIT 1`, [proteticoId, clinicaId]);
|
||
return rows.length > 0;
|
||
}
|
||
|
||
// Laboratórios/protéticos disponíveis para o dropdown da Nova OS:
|
||
// protéticos com vínculo na clínica (internos) + protéticos no diretório (externos).
|
||
app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
const { rows } = await pool.query(
|
||
`SELECT u.id, u.nome, u.email, u.celular,
|
||
BOOL_OR(v.clinica_id = $1) AS interno,
|
||
BOOL_OR(pf.favorito) AS favorito,
|
||
BOOL_OR(pf.padrao) AS padrao
|
||
FROM usuarios u
|
||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
|
||
WHERE u.role = 'protetico'
|
||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
|
||
GROUP BY u.id, u.nome, u.email, u.celular
|
||
ORDER BY u.nome`, [clinicaId]);
|
||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||
const notas = await notasScorePorProtetico(rows.map(r => r.id));
|
||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||
// Ranking: padrão → favorito → interno → maior nota → nome.
|
||
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||
res.json(lista);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
|
||
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const protId = req.params.proteticoId;
|
||
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
|
||
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||
const favorito = req.body?.favorito;
|
||
const padrao = req.body?.padrao;
|
||
// Só pode haver UM padrão por clínica.
|
||
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
|
||
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
|
||
await pool.query(
|
||
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
|
||
VALUES ($1,$2,$3,$4)
|
||
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
|
||
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
|
||
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
|
||
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
|
||
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
|
||
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const email = (req.body?.email || '').toLowerCase().trim();
|
||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
|
||
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
|
||
const p = rows[0];
|
||
await pool.query(
|
||
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
|
||
[`v_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
|
||
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma.
|
||
// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno,
|
||
// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN.
|
||
app.post('/api/protese/proteticos-internos', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const nome = (req.body?.nome || '').trim();
|
||
if (!nome) return res.status(400).json({ error: 'Nome obrigatório.' });
|
||
try {
|
||
const id = 'u_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
|
||
const email = `protetico_${id}@interno.local`;
|
||
await pool.query(
|
||
"INSERT INTO usuarios (id, nome, email, senha, role, celular, especialidade_dir) VALUES ($1,$2,$3,$4,'protetico',$5,$6)",
|
||
[id, nome.toUpperCase(), email, await hashSenha(gerarSenhaTemp()), req.body?.telefone || null, req.body?.especialidade || null]);
|
||
await pool.query("INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
|
||
[`v_${id}_${req.clinicaId}`, id, req.clinicaId]);
|
||
try { await seedProteseProdutos(id); } catch (e) { console.error('[seed interno]', e.message); }
|
||
res.json({ success: true, id, nome: nome.toUpperCase() });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ── Catálogo de produtos do protético (preço fixo, geral) ───────────────────
|
||
// O protético gerencia o próprio catálogo (escopo por usuário). Lazy-seed na 1ª leitura.
|
||
app.get('/api/protese/produtos', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
await seedProteseProdutos(uid); // idempotente — protéticos antigos ganham o catálogo padrão
|
||
const { rows } = await pool.query('SELECT * FROM protese_produtos WHERE protetico_id=$1 AND deleted_at IS NULL ORDER BY ordem, nome', [uid]);
|
||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Catálogo ATIVO de um laboratório (lido pela clínica ao criar a OS).
|
||
app.get('/api/protese/laboratorios/:id/produtos', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
if (!await proteticoAcessivelPelaClinica(req.params.id, req.clinicaId)) {
|
||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||
}
|
||
await seedProteseProdutos(req.params.id);
|
||
const { rows } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE protetico_id=$1 AND ativo=true AND deleted_at IS NULL ORDER BY ordem, nome', [req.params.id]);
|
||
res.json(rows.map(r => ({ ...r, preco: parseFloat(r.preco || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/protese/produtos', authGuard, async (req, res) => {
|
||
const b = req.body || {};
|
||
if (!b.nome) return res.status(400).json({ error: 'nome obrigatório.' });
|
||
try {
|
||
const id = `ppr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query('INSERT INTO protese_produtos (id, protetico_id, nome, preco, garantia_meses, ativo, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
||
[id, req.authUser.userId, b.nome, Number(b.preco) || 0, Number(b.garantia_meses) || 12, b.ativo !== false, Number(b.ordem) || 0]);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||
const b = req.body || {};
|
||
try {
|
||
const { rowCount } = await pool.query(
|
||
'UPDATE protese_produtos SET nome=COALESCE($1,nome), preco=COALESCE($2,preco), garantia_meses=COALESCE($3,garantia_meses), ativo=COALESCE($4,ativo) WHERE id=$5 AND protetico_id=$6 AND deleted_at IS NULL',
|
||
[b.nome ?? null, b.preco != null ? Number(b.preco) : null, b.garantia_meses != null ? Number(b.garantia_meses) : null, b.ativo ?? null, req.params.id, req.authUser.userId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/protese/produtos/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rowCount } = await pool.query('UPDATE protese_produtos SET deleted_at=NOW() WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [req.params.id, req.authUser.userId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Produto não encontrado.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Criar OS (clínica origem).
|
||
app.post('/api/protese/os', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const b = req.body || {};
|
||
if (!b.protetico_id) return res.status(400).json({ error: 'protetico_id (laboratório) obrigatório.' });
|
||
try {
|
||
const { rows: prot } = await pool.query("SELECT id, nome FROM usuarios WHERE id=$1 AND role='protetico'", [b.protetico_id]);
|
||
if (!prot.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||
// Só pode pedir OS a protético interno (vínculo) ou no diretório — mesma regra do dropdown.
|
||
if (!await proteticoAcessivelPelaClinica(b.protetico_id, req.clinicaId)) {
|
||
return res.status(403).json({ error: 'Este laboratório não está disponível para a sua clínica.' });
|
||
}
|
||
// Produto do catálogo do laboratório = preço fixo (custo de lab) + garantia.
|
||
let tipo = b.tipo || null, custo = Number(b.custo) || 0, garantiaMeses = 12, produtoId = null;
|
||
if (b.produto_id) {
|
||
const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, b.protetico_id]);
|
||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' });
|
||
produtoId = pr[0].id; tipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantiaMeses = Number(pr[0].garantia_meses) || 12;
|
||
}
|
||
if (!tipo) return res.status(400).json({ error: 'Selecione um produto/tipo de prótese.' });
|
||
// OS de garantia: refaz uma OS anterior sem novo custo (dentro da garantia).
|
||
const ehGarantia = b.tipo_os === 'garantia' && !!b.origem_os_id;
|
||
if (ehGarantia) custo = 0;
|
||
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
const uid = req.authUser?.userId;
|
||
const labId = await labDoProtetico(b.protetico_id); // Fase 2: escopo por laboratório (empresa)
|
||
await pool.query(
|
||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, procedimento_id, agendamento_id, tipo, produto_id, tipo_os, origem_os_id, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,'solicitado',$24)`,
|
||
[id, req.clinicaId, b.protetico_id, labId, prot[0].nome || null, b.paciente_id || null, b.paciente_nome || null,
|
||
b.dentista_id || null, b.dentista_nome || null, b.procedimento_id || null, b.agendamento_id || null,
|
||
tipo, produtoId, ehGarantia ? 'garantia' : 'nova', ehGarantia ? b.origem_os_id : null, garantiaMeses,
|
||
b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
|
||
b.prazo_entrega || null, Number(b.valor) || 0, custo, uid]);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, req.clinicaId, 'OS criada', uid, await actorNome(uid)]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: id, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { protetico_id: b.protetico_id, tipo } });
|
||
await notificarUsuario(b.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${tipo}). Veja na Bancada.`);
|
||
wsBroadcast(req.clinicaId, 'protese', { entidadeId: id, acao: 'criou' });
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Lista da clínica origem (dentista/recepção).
|
||
app.get('/api/protese/os', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const vals = [req.clinicaId];
|
||
let where = 'clinica_id=$1 AND deleted_at IS NULL';
|
||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
|
||
if (req.query.paciente_id) { vals.push(req.query.paciente_id); where += ` AND paciente_id=$${vals.length}`; }
|
||
const { rows } = await pool.query(`SELECT * FROM protese_os WHERE ${where} ORDER BY created_at DESC LIMIT 300`, vals);
|
||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0), custo: parseFloat(r.custo || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Bancada do laboratório (protético): OS destinadas a mim, com flag de atraso.
|
||
app.get('/api/protese/bancada', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoUsuario(uid); // Fase 2/4: bancada do LABORATÓRIO (dono ou técnico)
|
||
const vals = [uid, labId];
|
||
// Roteia por laboratorio_id; fallback por pessoa cobre OS legadas sem laboratorio_id.
|
||
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2) OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`;
|
||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) { vals.push(req.query.status); where += ` AND status=$${vals.length}`; }
|
||
const { rows } = await pool.query(
|
||
`SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id = protese_os.clinica_id) AS clinica_nome,
|
||
(prazo_entrega IS NOT NULL AND prazo_entrega < CURRENT_DATE AND status NOT IN ('entregue','cancelado')) AS atrasada
|
||
FROM protese_os WHERE ${where}
|
||
ORDER BY status='entregue', status='cancelado', prazo_entrega ASC NULLS LAST, created_at DESC LIMIT 300`, vals);
|
||
// Bancada é a fila do laboratório: vê a clínica de origem e o próprio custo, mas NÃO o
|
||
// valor cobrado ao paciente nem o CPF (paciente_id é o CPF).
|
||
res.json(rows.map(r => ({ ...r, valor: 0, paciente_id: null, custo: parseFloat(r.custo || 0), atrasada: !!r.atrasada })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 3 — Clientes do laboratório: clínicas que enviam OS, com métricas operacionais e financeiras.
|
||
app.get('/api/protese/lab/clientes', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoProtetico(uid);
|
||
const escopo = `(laboratorio_id = $2 OR (laboratorio_id IS NULL AND protetico_id = $1)) AND deleted_at IS NULL`;
|
||
const { rows } = await pool.query(
|
||
`SELECT o.clinica_id, c.nome_fantasia AS clinica_nome, c.tipo AS clinica_tipo, u.role AS owner_role,
|
||
u.celular AS contato, u.email AS contato_email,
|
||
COUNT(*)::int AS total,
|
||
COUNT(*) FILTER (WHERE o.status NOT IN ('entregue','cancelado'))::int AS ativas,
|
||
COUNT(*) FILTER (WHERE o.prazo_entrega IS NOT NULL AND o.prazo_entrega < CURRENT_DATE AND o.status NOT IN ('entregue','cancelado'))::int AS atrasadas,
|
||
COUNT(*) FILTER (WHERE o.status = 'entregue')::int AS entregues,
|
||
COALESCE(SUM(o.custo) FILTER (WHERE o.status = 'entregue' AND o.tipo_os <> 'garantia'), 0) AS faturado,
|
||
MAX(o.created_at) AS ultimo_pedido
|
||
FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id LEFT JOIN usuarios u ON u.id = c.owner_id
|
||
WHERE ${escopo}
|
||
GROUP BY o.clinica_id, c.nome_fantasia, c.tipo, u.role, u.celular, u.email ORDER BY total DESC`, [uid, labId]);
|
||
const { rows: pagos } = await pool.query(
|
||
`SELECT o.clinica_id, COALESCE(SUM(p.valor), 0) AS recebido
|
||
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
|
||
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
|
||
GROUP BY o.clinica_id`, [uid, labId]);
|
||
const recPorClinica = Object.fromEntries(pagos.map(p => [p.clinica_id, parseFloat(p.recebido || 0)]));
|
||
const origemDe = (tipo, role) => tipo === 'consultorio' ? 'Consultório'
|
||
: tipo === 'dentista' ? 'Dentista'
|
||
: tipo === 'pessoal' ? (role === 'dentista' ? 'Dentista' : role === 'biomedico' ? 'Biomédico' : 'Profissional')
|
||
: 'Clínica';
|
||
res.json(rows.map(r => {
|
||
const faturado = parseFloat(r.faturado || 0), recebido = recPorClinica[r.clinica_id] || 0;
|
||
return {
|
||
...r, faturado, recebido, a_receber: Math.max(0, faturado - recebido),
|
||
origem: origemDe(r.clinica_tipo, r.owner_role),
|
||
ticket_medio: r.entregues > 0 ? Math.round(faturado / r.entregues * 100) / 100 : 0,
|
||
};
|
||
}));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
// CRM financeiro do lab — fechamento mensal (faturado x recebido por mês).
|
||
app.get('/api/protese/lab/fechamento', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoProtetico(uid);
|
||
const esc = `(o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))`;
|
||
const { rows: fat } = await pool.query(
|
||
`SELECT to_char(o.updated_at,'YYYY-MM') AS mes, COALESCE(SUM(o.custo),0) AS faturado, COUNT(*)::int AS entregues
|
||
FROM protese_os o WHERE o.status='entregue' AND o.tipo_os<>'garantia' AND o.updated_at IS NOT NULL AND ${esc} AND o.deleted_at IS NULL GROUP BY mes`, [uid, labId]);
|
||
const { rows: rec } = await pool.query(
|
||
`SELECT to_char(p.data,'YYYY-MM') AS mes, COALESCE(SUM(p.valor),0) AS recebido
|
||
FROM protese_os_pagamento p JOIN protese_os o ON o.id=p.os_id WHERE p.lado='lab' AND ${esc} GROUP BY mes`, [uid, labId]);
|
||
const meses = {};
|
||
for (const f of fat) meses[f.mes] = { mes: f.mes, faturado: parseFloat(f.faturado || 0), entregues: f.entregues, recebido: 0 };
|
||
for (const r of rec) { (meses[r.mes] = meses[r.mes] || { mes: r.mes, faturado: 0, entregues: 0, recebido: 0 }).recebido = parseFloat(r.recebido || 0); }
|
||
res.json(Object.values(meses).sort((a, b) => b.mes.localeCompare(a.mes)));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 6 — OS indicadas a mim ainda SEM laboratório vinculado. Só o dono as vê (fallback);
|
||
// ao importar, entram no lab e a equipe/técnicos passam a vê-las.
|
||
app.get('/api/protese/lab/os-indicadas', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query(
|
||
`SELECT o.id, o.tipo, o.status, o.custo, o.prazo_entrega, o.created_at, o.dentista_nome, o.paciente_nome,
|
||
c.nome_fantasia AS cliente_nome, c.tipo AS cliente_tipo
|
||
FROM protese_os o LEFT JOIN clinicas c ON c.id = o.clinica_id
|
||
WHERE o.protetico_id=$1 AND o.laboratorio_id IS NULL AND o.deleted_at IS NULL
|
||
AND o.status NOT IN ('entregue','cancelado')
|
||
ORDER BY o.created_at DESC LIMIT 100`, [uid]);
|
||
res.json(rows.map(r => ({ ...r, custo: parseFloat(r.custo || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 6 — Importar/vincular a OS ao MEU laboratório (formaliza a entrada).
|
||
app.post('/api/protese/os/:id/vincular-lab', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (!acc.isLab) return res.status(403).json({ error: 'Apenas o protético destinatário importa a OS.' });
|
||
if (acc.os.laboratorio_id) return res.status(409).json({ error: 'Esta OS já está vinculada a um laboratório.' });
|
||
const labId = await labDoUsuario(uid);
|
||
if (!labId) return res.status(400).json({ error: 'Você não possui laboratório.' });
|
||
const novoStatus = acc.os.status === 'solicitado' ? 'recebido' : acc.os.status;
|
||
await pool.query('UPDATE protese_os SET laboratorio_id=$1, status=$2 WHERE id=$3', [labId, novoStatus, acc.os.id]);
|
||
await logEventoOS(acc.os, uid, 'Trabalho importado para o laboratório', 'vinculo');
|
||
if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Trabalho aceito pelo laboratório', `A OS (${acc.os.tipo}) foi importada pelo laboratório.`);
|
||
wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'vinculou' });
|
||
res.json({ success: true, laboratorio_id: labId, status: novoStatus });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 6 — Solicitar pagamento (cobrar saldo): notifica o cliente e registra no histórico da OS.
|
||
app.post('/api/protese/os/:id/cobrar', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (!acc.isLab) return res.status(403).json({ error: 'Apenas o laboratório solicita pagamento.' });
|
||
const { rows: pg } = await pool.query("SELECT COALESCE(SUM(valor),0) AS pago FROM protese_os_pagamento WHERE os_id=$1 AND lado='lab'", [acc.os.id]);
|
||
const pago = parseFloat(pg[0].pago || 0);
|
||
const saldo = Math.max(0, parseFloat(acc.os.custo || 0) - pago);
|
||
const valor = req.body?.valor != null ? Number(req.body.valor) : saldo;
|
||
if (!(valor > 0)) return res.status(400).json({ error: 'Nada a cobrar: o saldo já está quitado.' });
|
||
const brl = valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||
const obs = (req.body?.observacao || '').trim();
|
||
await logEventoOS(acc.os, uid, `Pagamento solicitado ao cliente: ${brl}${obs ? ` — ${obs}` : ''}`, 'cobranca');
|
||
if (acc.os.created_by) await notificarUsuario(acc.os.created_by, 'Solicitação de pagamento', `O laboratório solicitou ${brl} referente à OS (${acc.os.tipo}).${obs ? ` ${obs}` : ''}`);
|
||
wsBroadcast(acc.os.clinica_id, 'protese', { entidadeId: acc.os.id, acao: 'cobranca' });
|
||
res.json({ success: true, valor, saldo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 6 — Dar entrada em OS pelo lado do LABORATÓRIO (trabalho recebido fora do app).
|
||
// Cliente: clínica existente (cliente_id) OU cliente avulso (cliente_nome + cliente_tipo).
|
||
app.post('/api/protese/lab/os', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoProtetico(uid);
|
||
if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório dá entrada em OS.' });
|
||
const b = req.body || {};
|
||
// Produto do catálogo (preço fixo) ou tipo livre + custo.
|
||
let tipo = (b.tipo || '').trim() || null, custo = Number(b.custo) || 0, garantiaMeses = 12, produtoId = null;
|
||
if (b.produto_id) {
|
||
const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, uid]);
|
||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence ao seu laboratório.' });
|
||
produtoId = pr[0].id; tipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantiaMeses = Number(pr[0].garantia_meses) || 12;
|
||
}
|
||
if (!tipo) return res.status(400).json({ error: 'Informe o produto ou o tipo de prótese.' });
|
||
// Cliente: existente (valida acesso ao lab) ou avulso (materializa registro mínimo, owner nulo).
|
||
let clienteId = b.cliente_id || null;
|
||
if (clienteId) {
|
||
const { rows: cl } = await pool.query('SELECT id FROM clinicas WHERE id=$1', [clienteId]);
|
||
if (!cl.length) return res.status(404).json({ error: 'Cliente não encontrado.' });
|
||
} else {
|
||
const nome = (b.cliente_nome || '').trim();
|
||
if (!nome) return res.status(400).json({ error: 'Informe o cliente (existente ou avulso).' });
|
||
const tipoCli = ['clinica', 'consultorio', 'dentista'].includes(b.cliente_tipo) ? b.cliente_tipo : 'clinica';
|
||
clienteId = `cliext_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query('INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,NULL)', [clienteId, nome.toUpperCase(), tipoCli]);
|
||
}
|
||
const id = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
const status = b.local_atual === 'laboratorio' ? 'recebido' : 'recebido'; // já está com o lab
|
||
await pool.query(
|
||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_nome, dentista_nome, tipo, produto_id, tipo_os, garantia_meses, material, cor, dentes, observacoes, prazo_entrega, valor, custo, status, local_atual, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'nova',$10,$11,$12,$13,$14,$15,0,$16,$17,'laboratorio',$18)`,
|
||
[id, clienteId, uid, labId, (await actorNome(uid)) || null, b.paciente_nome || null, b.dentista_nome || null,
|
||
tipo, produtoId, garantiaMeses, b.material || null, b.cor || null, b.dentes || null, b.observacoes || null,
|
||
b.prazo_entrega || null, custo, status, uid]);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'recebido',$4,$5,$6)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, id, clienteId, 'Entrada registrada pelo laboratório', uid, await actorNome(uid)]);
|
||
res.json({ success: true, id, cliente_id: clienteId });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 3 — Extrato de recebimentos do laboratório (pagamentos lado 'lab').
|
||
app.get('/api/protese/lab/pagamentos', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoProtetico(uid);
|
||
const { rows } = await pool.query(
|
||
`SELECT p.id, p.valor, p.forma, p.data, p.recebido_nome, o.tipo, o.paciente_nome,
|
||
(SELECT nome_fantasia FROM clinicas WHERE id = o.clinica_id) AS clinica_nome
|
||
FROM protese_os_pagamento p JOIN protese_os o ON o.id = p.os_id
|
||
WHERE p.lado = 'lab' AND (o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1))
|
||
ORDER BY p.data DESC, p.created_at DESC LIMIT 200`, [uid, labId]);
|
||
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 4 — Dados do laboratório (PF/PJ, CPF/CNPJ). GET: dono ou técnico; PATCH: só dono.
|
||
app.get('/api/protese/lab', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoUsuario(uid);
|
||
if (!labId) return res.status(404).json({ error: 'Sem laboratório.' });
|
||
const { rows } = await pool.query("SELECT id, nome_fantasia, documento, COALESCE(pessoa_tipo,'PF') AS pessoa_tipo, owner_id FROM clinicas WHERE id=$1", [labId]);
|
||
res.json({ ...rows[0], ehDono: rows[0].owner_id === uid });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.patch('/api/protese/lab', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoProtetico(uid);
|
||
if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório edita os dados.' });
|
||
const b = req.body || {};
|
||
if (!b.nome_fantasia || !String(b.nome_fantasia).trim()) return res.status(400).json({ error: 'Nome é obrigatório.' });
|
||
const pessoa = ['PF', 'PJ'].includes(b.pessoa_tipo) ? b.pessoa_tipo : 'PF';
|
||
await pool.query('UPDATE clinicas SET nome_fantasia=$1, documento=$2, pessoa_tipo=$3 WHERE id=$4',
|
||
[String(b.nome_fantasia).toUpperCase().trim(), (b.documento || '').trim() || null, pessoa, labId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 4 — Equipe (técnicos) do laboratório. Apenas o dono gerencia.
|
||
app.get('/api/protese/lab/equipe', authGuard, async (req, res) => {
|
||
try {
|
||
const labId = await labDoProtetico(req.authUser.userId);
|
||
if (!labId) return res.json([]);
|
||
const { rows } = await pool.query(
|
||
"SELECT u.id, u.nome, u.email FROM vinculos v JOIN usuarios u ON u.id=v.usuario_id WHERE v.clinica_id=$1 AND v.role='tecnico_protese' ORDER BY u.nome", [labId]);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/protese/lab/equipe', authGuard, async (req, res) => {
|
||
try {
|
||
const labId = await labDoProtetico(req.authUser.userId);
|
||
if (!labId) return res.status(403).json({ error: 'Apenas o dono do laboratório adiciona técnicos.' });
|
||
const email = (req.body?.email || '').toLowerCase().trim();
|
||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
|
||
const u = await ensureUsuarioPorEmail(email, req.body?.nome, 'protetico');
|
||
await pool.query(
|
||
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'tecnico_protese') ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role='tecnico_protese'",
|
||
[`v_${u.id}_${labId}`, u.id, labId]);
|
||
res.json({ success: true, tecnico: { id: u.id, nome: u.nome, email }, created: u.created, tempPassword: u.tempPassword });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.delete('/api/protese/lab/equipe/:userId', authGuard, async (req, res) => {
|
||
try {
|
||
const labId = await labDoProtetico(req.authUser.userId);
|
||
if (!labId) return res.status(403).json({ error: 'Apenas o dono remove técnicos.' });
|
||
await pool.query("DELETE FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2 AND role='tecnico_protese'", [req.params.userId, labId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// FASE 5 — Indicadores derivados da captura (Fases 2-4) + Nota ScoreOdonto.
|
||
// Só dados que JÁ existem: entregas/prazo, garantia (retrabalho), ocorrências RESOLVIDAS
|
||
// (contraditório respeitado), componentes ausentes, tempo médio. Nota só com volume mínimo.
|
||
const SCORE_MIN_OS = 3;
|
||
async function calcularIndicadoresLab(uid, labId) {
|
||
const esc = `(o.laboratorio_id = $2 OR (o.laboratorio_id IS NULL AND o.protetico_id = $1)) AND o.deleted_at IS NULL`;
|
||
const { rows: a } = await pool.query(
|
||
`SELECT COUNT(*)::int AS total,
|
||
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
|
||
COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia,
|
||
COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados,
|
||
COALESCE(AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/86400) FILTER (WHERE status='entregue' AND updated_at IS NOT NULL), 0) AS dias_medio
|
||
FROM protese_os o WHERE ${esc}`, [uid, labId]);
|
||
const { rows: oc } = await pool.query(
|
||
`SELECT COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND ${esc}`, [uid, labId]);
|
||
const { rows: cp } = await pool.query(
|
||
`SELECT COUNT(*)::int AS n FROM protese_os_componente c JOIN protese_os o ON o.id = c.os_id WHERE c.status_conferencia='ausente' AND ${esc}`, [uid, labId]);
|
||
const { rows: av } = await pool.query(
|
||
`SELECT AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = (SELECT owner_id FROM clinicas WHERE id=$1) OR protetico_id = $2`, [labId, uid]);
|
||
const r = a[0], entregues = r.entregues, pct = n => entregues > 0 ? (n / entregues * 100) : 0;
|
||
const atraso_pct = pct(r.atrasados), retrab_pct = pct(r.garantia), ocor_pct = pct(oc[0].n);
|
||
const satisf = av[0].n > 0 ? parseFloat(av[0].media) : null;
|
||
let nota = null;
|
||
if (entregues >= SCORE_MIN_OS) {
|
||
const operacional = Math.max(0, Math.min(10, 10 - (atraso_pct / 100 * 3 + retrab_pct / 100 * 4 + ocor_pct / 100 * 3)));
|
||
nota = satisf != null ? 0.6 * operacional + 0.4 * (satisf / 5 * 10) : operacional;
|
||
nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
|
||
}
|
||
const r1 = n => Math.round(Number(n) * 10) / 10;
|
||
return {
|
||
total: r.total, entregues, garantia: r.garantia, atrasados: r.atrasados,
|
||
ocorrencias_resolvidas: oc[0].n, componentes_ausentes: cp[0].n, dias_medio: r1(r.dias_medio),
|
||
atraso_pct: r1(atraso_pct), retrabalho_pct: r1(retrab_pct), ocorrencia_pct: r1(ocor_pct),
|
||
satisfacao: satisf != null ? r1(satisf) : null, avaliacoes: av[0].n, verificado: entregues >= SCORE_VERIF_MIN,
|
||
nota, min_os: SCORE_MIN_OS,
|
||
};
|
||
}
|
||
app.get('/api/protese/lab/indicadores', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoProtetico(uid);
|
||
if (!labId) return res.status(403).json({ error: 'Apenas o dono vê os indicadores.' });
|
||
res.json(await calcularIndicadoresLab(uid, labId));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Nota ScoreOdonto por protético (mapa id→{nota, avaliacoes, satisfacao, verificado}).
|
||
// A nota combina o operacional (atraso/retrabalho/ocorrências) com a satisfação da clínica (estrelas).
|
||
const SCORE_VERIF_MIN = 5; // entregas p/ selo "verificado"
|
||
async function notasScorePorProtetico(ids) {
|
||
const out = {};
|
||
if (!ids || !ids.length) return out;
|
||
const { rows: ag } = await pool.query(
|
||
`SELECT protetico_id,
|
||
COUNT(*) FILTER (WHERE status='entregue')::int AS entregues,
|
||
COUNT(*) FILTER (WHERE status='entregue' AND tipo_os='garantia')::int AS garantia,
|
||
COUNT(*) FILTER (WHERE status='entregue' AND prazo_entrega IS NOT NULL AND updated_at::date > prazo_entrega)::int AS atrasados
|
||
FROM protese_os WHERE protetico_id = ANY($1) AND deleted_at IS NULL GROUP BY protetico_id`, [ids]);
|
||
const { rows: ocs } = await pool.query(
|
||
`SELECT o.protetico_id, COUNT(DISTINCT oc.os_id)::int AS n FROM protese_os_ocorrencia oc JOIN protese_os o ON o.id = oc.os_id WHERE oc.status='resolvida' AND o.protetico_id = ANY($1) GROUP BY o.protetico_id`, [ids]);
|
||
const { rows: avs } = await pool.query(
|
||
`SELECT protetico_id, AVG(estrelas)::numeric AS media, COUNT(*)::int AS n FROM protese_avaliacao WHERE protetico_id = ANY($1) GROUP BY protetico_id`, [ids]);
|
||
const ocMap = Object.fromEntries(ocs.map(o => [o.protetico_id, o.n]));
|
||
const avMap = Object.fromEntries(avs.map(a => [a.protetico_id, { media: parseFloat(a.media), n: a.n }]));
|
||
for (const g of ag) {
|
||
const av = avMap[g.protetico_id];
|
||
const info = { nota: null, avaliacoes: av?.n || 0, satisfacao: av ? Math.round(av.media * 10) / 10 : null, verificado: g.entregues >= SCORE_VERIF_MIN };
|
||
if (g.entregues >= SCORE_MIN_OS) {
|
||
const pct = n => g.entregues > 0 ? n / g.entregues * 100 : 0;
|
||
const operacional = Math.max(0, Math.min(10, 10 - (pct(g.atrasados) / 100 * 3 + pct(g.garantia) / 100 * 4 + pct(ocMap[g.protetico_id] || 0) / 100 * 3)));
|
||
// Com avaliações: 60% operacional + 40% satisfação (estrelas→0-10). Sem: só operacional.
|
||
const nota = av && av.n > 0 ? 0.6 * operacional + 0.4 * (av.media / 5 * 10) : operacional;
|
||
info.nota = Math.round(Math.max(0, Math.min(10, nota)) * 10) / 10;
|
||
}
|
||
out[g.protetico_id] = info;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// MODO 1 — gerar/recuperar o link seguro de acompanhamento da OS (clínica ou lab).
|
||
app.post('/api/protese/os/:id/link', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const { rows: ex } = await pool.query(
|
||
"SELECT token FROM protese_os_link WHERE os_id=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1", [acc.os.id]);
|
||
let token = ex[0]?.token;
|
||
if (!token) {
|
||
token = crypto.randomBytes(24).toString('base64url');
|
||
await pool.query("INSERT INTO protese_os_link (token, os_id, protetico_id, created_by, expires_at) VALUES ($1,$2,$3,$4, NOW() + INTERVAL '60 days')",
|
||
[token, acc.os.id, acc.os.protetico_id, uid]);
|
||
await logEventoOS(acc.os, uid, 'Link de acompanhamento gerado para o protético', 'link');
|
||
}
|
||
const base = process.env.APP_PUBLIC_URL || 'https://scoreodonto.com';
|
||
res.json({ success: true, token, url: `${base}/p/${token}` });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/protese/os/:id/link/revogar', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
await pool.query("UPDATE protese_os_link SET revoked_at=NOW() WHERE os_id=$1 AND revoked_at IS NULL", [acc.os.id]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
// Status do link ativo (para a UI da clínica gerir: copiar/revogar, ver acessos/expiração).
|
||
app.get('/api/protese/os/:id/link/status', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const { rows } = await pool.query(
|
||
"SELECT token, expires_at, last_access_at, acessos FROM protese_os_link WHERE os_id=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1", [acc.os.id]);
|
||
if (!rows.length) return res.json({ ativo: false });
|
||
const base = process.env.APP_PUBLIC_URL || 'https://scoreodonto.com';
|
||
res.json({ ativo: true, url: `${base}/p/${rows[0].token}`, expires_at: rows[0].expires_at, last_access_at: rows[0].last_access_at, acessos: rows[0].acessos });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ── Fatia 3: Cotação (RFQ) — clínica pede, N labs cotam, clínica escolhe → vira OS ──
|
||
app.post('/api/protese/rfq', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const b = req.body || {};
|
||
const proteticos = Array.isArray(b.proteticos) ? b.proteticos.filter(Boolean) : [];
|
||
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva o trabalho.' });
|
||
if (!proteticos.length) return res.status(400).json({ error: 'Convide ao menos um laboratório.' });
|
||
try {
|
||
const uid = req.authUser?.userId;
|
||
const rfqId = `rfq_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query(
|
||
`INSERT INTO protese_rfq (id, clinica_id, descricao, dentes, prazo_desejado, paciente_id, paciente_nome, dentista_id, dentista_nome, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||
[rfqId, req.clinicaId, String(b.descricao).trim(), b.dentes || null, b.prazo_desejado || null, b.paciente_id || null, b.paciente_nome || null, b.dentista_id || null, b.dentista_nome || null, uid]);
|
||
let convidados = 0;
|
||
for (const pid of proteticos) {
|
||
if (!await proteticoAcessivelPelaClinica(pid, req.clinicaId)) continue;
|
||
const { rows: pr } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [pid]);
|
||
if (!pr.length) continue;
|
||
await pool.query(`INSERT INTO protese_rfq_cotacao (id, rfq_id, protetico_id, protetico_nome) VALUES ($1,$2,$3,$4)`,
|
||
[`cot_${Date.now()}_${Math.random().toString(36).slice(2, 6)}_${convidados}`, rfqId, pid, pr[0].nome]);
|
||
await notificarUsuario(pid, 'Novo pedido de cotação', 'Você recebeu um pedido de cotação de prótese. Responda em Cotações.');
|
||
convidados++;
|
||
}
|
||
res.json({ success: true, id: rfqId, convidados });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.get('/api/protese/rfq', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows: rfqs } = await pool.query("SELECT * FROM protese_rfq WHERE clinica_id=$1 ORDER BY created_at DESC LIMIT 100", [req.clinicaId]);
|
||
const ids = rfqs.map(r => r.id);
|
||
let cots = [];
|
||
if (ids.length) { const { rows } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE rfq_id = ANY($1) ORDER BY (valor IS NULL), valor ASC", [ids]); cots = rows; }
|
||
res.json(rfqs.map(r => ({ ...r, cotacoes: cots.filter(c => c.rfq_id === r.id).map(c => ({ ...c, valor: c.valor != null ? parseFloat(c.valor) : null })) })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.get('/api/protese/rfq/recebidas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT c.id AS cotacao_id, c.valor, c.prazo_dias, c.observacao, c.status AS cotacao_status,
|
||
r.id AS rfq_id, r.descricao, r.dentes, r.prazo_desejado, r.paciente_nome, r.dentista_nome, r.status AS rfq_status, r.created_at,
|
||
(SELECT nome_fantasia FROM clinicas WHERE id=r.clinica_id) AS clinica_nome
|
||
FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id = c.rfq_id
|
||
WHERE c.protetico_id=$1 ORDER BY r.created_at DESC LIMIT 100`, [req.authUser.userId]);
|
||
res.json(rows.map(r => ({ ...r, valor: r.valor != null ? parseFloat(r.valor) : null })));
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/protese/rfq/cotacao/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query("SELECT c.*, r.status AS rfq_status FROM protese_rfq_cotacao c JOIN protese_rfq r ON r.id=c.rfq_id WHERE c.id=$1", [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
|
||
if (rows[0].protetico_id !== req.authUser.userId) return res.status(403).json({ error: 'Não é sua cotação.' });
|
||
if (rows[0].rfq_status !== 'aberta') return res.status(409).json({ error: 'Pedido de cotação encerrado.' });
|
||
const valor = Number(req.body?.valor);
|
||
if (!(valor > 0)) return res.status(400).json({ error: 'Informe o valor da cotação.' });
|
||
await pool.query("UPDATE protese_rfq_cotacao SET valor=$1, prazo_dias=$2, observacao=$3, status='enviada', respondida_em=NOW() WHERE id=$4",
|
||
[valor, parseInt(req.body?.prazo_dias, 10) || null, req.body?.observacao || null, req.params.id]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/protese/rfq/:id/escolher', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows: rr } = await pool.query("SELECT * FROM protese_rfq WHERE id=$1 AND clinica_id=$2", [req.params.id, req.clinicaId]);
|
||
if (!rr.length) return res.status(404).json({ error: 'Cotação não encontrada.' });
|
||
const rfq = rr[0];
|
||
if (rfq.status !== 'aberta') return res.status(409).json({ error: 'Pedido já encerrado.' });
|
||
const { rows: cc } = await pool.query("SELECT * FROM protese_rfq_cotacao WHERE id=$1 AND rfq_id=$2 AND status='enviada'", [req.body?.cotacao_id, rfq.id]);
|
||
if (!cc.length) return res.status(400).json({ error: 'Cotação inválida.' });
|
||
const cot = cc[0];
|
||
const uid = req.authUser?.userId;
|
||
const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
const labId = await labDoProtetico(cot.protetico_id);
|
||
await pool.query(
|
||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, dentista_nome, tipo, dentes, observacoes, prazo_entrega, valor, custo, status, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,'solicitado',$15)`,
|
||
[osId, req.clinicaId, cot.protetico_id, labId, cot.protetico_nome, rfq.paciente_id, rfq.paciente_nome, rfq.dentista_id, rfq.dentista_nome, rfq.descricao, rfq.dentes, rfq.descricao, rfq.prazo_desejado, parseFloat(cot.valor || 0), uid]);
|
||
await pool.query(`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, osId, req.clinicaId, 'OS criada a partir de cotação', uid, await actorNome(uid)]);
|
||
await pool.query("UPDATE protese_rfq SET status='fechada', os_id=$1 WHERE id=$2", [osId, rfq.id]);
|
||
await pool.query("UPDATE protese_rfq_cotacao SET status=CASE WHEN id=$1 THEN 'escolhida' ELSE 'recusada' END WHERE rfq_id=$2", [cot.id, rfq.id]);
|
||
await notificarUsuario(cot.protetico_id, 'Cotação aceita!', 'Sua cotação foi escolhida — uma OS entrou na sua Bancada.');
|
||
res.json({ success: true, os_id: osId });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Fatia 4 — a clínica avalia o laboratório ao final da OS (1-5 estrelas + comentário).
|
||
app.post('/api/protese/os/:id/avaliar', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica avalia.' });
|
||
if (acc.os.status !== 'entregue') return res.status(409).json({ error: 'Avalie após a entrega.' });
|
||
const estrelas = parseInt(req.body?.estrelas, 10);
|
||
if (!(estrelas >= 1 && estrelas <= 5)) return res.status(400).json({ error: 'Estrelas de 1 a 5.' });
|
||
const nome = await actorNome(uid);
|
||
await pool.query(
|
||
`INSERT INTO protese_avaliacao (id, os_id, clinica_id, protetico_id, estrelas, comentario, autor_id, autor_nome)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||
ON CONFLICT (os_id) DO UPDATE SET estrelas=$5, comentario=$6, autor_id=$7, autor_nome=$8, created_at=NOW()`,
|
||
[`pav_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, acc.os.id, acc.os.clinica_id, acc.os.protetico_id, estrelas, req.body?.comentario || null, uid, nome]);
|
||
await logEventoOS(acc.os, uid, `Laboratório avaliado: ${estrelas}★`, 'avaliacao');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ── Agenda & Coleta do laboratório ──────────────────────────────────────────
|
||
app.post('/api/protese/os/:id/coleta', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const tipo = ['coleta', 'entrega'].includes(req.body?.tipo) ? req.body.tipo : null;
|
||
if (!tipo) return res.status(400).json({ error: "tipo deve ser 'coleta' ou 'entrega'." });
|
||
const labId = acc.os.laboratorio_id || await labDoProtetico(acc.os.protetico_id);
|
||
const id = `pcol_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const b = req.body || {};
|
||
await pool.query(
|
||
`INSERT INTO protese_coleta (id, os_id, clinica_id, laboratorio_id, protetico_id, tipo, data_hora, endereco, responsavel, observacao, created_by)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||
[id, acc.os.id, acc.os.clinica_id, labId, acc.os.protetico_id, tipo, b.data_hora || null, b.endereco || null, b.responsavel || null, b.observacao || null, uid]);
|
||
const quando = b.data_hora ? new Date(b.data_hora).toLocaleString('pt-BR') : 'sem data';
|
||
await logEventoOS(acc.os, uid, `${tipo === 'coleta' ? 'Coleta' : 'Entrega'} agendada (${quando})`, 'coleta');
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.get('/api/protese/lab/agenda', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const labId = await labDoUsuario(uid);
|
||
const { rows } = await pool.query(
|
||
`SELECT c.*, o.paciente_nome, o.tipo AS os_tipo, o.status AS os_status,
|
||
(SELECT nome_fantasia FROM clinicas WHERE id=c.clinica_id) AS clinica_nome
|
||
FROM protese_coleta c JOIN protese_os o ON o.id = c.os_id
|
||
WHERE (c.laboratorio_id=$2 OR c.protetico_id=$1) AND c.status <> 'cancelada'
|
||
ORDER BY c.status='realizada', c.data_hora ASC NULLS LAST LIMIT 200`, [uid, labId]);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/protese/coleta/:id/status', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM protese_coleta WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Agendamento não encontrado.' });
|
||
const col = rows[0];
|
||
const acc = await carregaOSComAcesso(col.os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const novo = ['realizada', 'cancelada'].includes(req.body?.status) ? req.body.status : null;
|
||
if (!novo) return res.status(400).json({ error: 'Status inválido.' });
|
||
if (col.status !== 'agendada') return res.status(409).json({ error: `Já ${col.status}.` });
|
||
await pool.query('UPDATE protese_coleta SET status=$1 WHERE id=$2', [novo, col.id]);
|
||
if (novo === 'realizada') {
|
||
// Coleta → trabalho no laboratório; entrega → trabalho na clínica. Move a custódia.
|
||
const para = col.tipo === 'coleta' ? 'laboratorio' : 'clinica';
|
||
const de = acc.os.local_atual || 'clinica';
|
||
if (de !== para) {
|
||
const nome = await actorNome(uid);
|
||
await pool.query("INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
|
||
[`pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, uid, nome]);
|
||
await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]);
|
||
}
|
||
await logEventoOS(acc.os, uid, `${col.tipo === 'coleta' ? 'Coleta' : 'Entrega'} realizada`, 'coleta');
|
||
} else {
|
||
await logEventoOS(acc.os, uid, `${col.tipo === 'coleta' ? 'Coleta' : 'Entrega'} cancelada`, 'coleta');
|
||
}
|
||
res.json({ success: true, status: novo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// MODO 1 — leitura PÚBLICA da OS pelo token (sem login). Blindagem: sem CPF, sem valor do paciente.
|
||
app.get('/api/p/:token', async (req, res) => {
|
||
try {
|
||
const { rows: lk } = await pool.query(
|
||
"SELECT * FROM protese_os_link WHERE token=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())", [req.params.token]);
|
||
if (!lk.length) return res.status(404).json({ error: 'Link inválido ou expirado.' });
|
||
const { rows } = await pool.query("SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id=protese_os.clinica_id) AS clinica_nome FROM protese_os WHERE id=$1 AND deleted_at IS NULL", [lk[0].os_id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||
const os = rows[0];
|
||
await pool.query("UPDATE protese_os_link SET last_access_at=NOW(), acessos=acessos+1 WHERE token=$1", [req.params.token]);
|
||
const [ev, ft, cm, oc, cu] = await Promise.all([
|
||
pool.query("SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||
pool.query("SELECT id, legenda, uploaded_nome, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL ORDER BY created_at", [os.id]),
|
||
pool.query("SELECT id, nome, quantidade, COALESCE(direcao,'enviado') AS direcao, COALESCE(status_conferencia,'pendente') AS status_conferencia, enviado_nome, created_at FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||
pool.query("SELECT id, categoria, descricao, status, autor_nome, resposta, resposta_nome, created_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||
pool.query("SELECT de_local, para_local, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at", [os.id]),
|
||
]);
|
||
// Payload curado: o protético vê o trabalho e o próprio custo, NUNCA CPF nem valor ao paciente.
|
||
res.json({
|
||
id: os.id, tipo: os.tipo, tipo_os: os.tipo_os, status: os.status,
|
||
clinica_nome: os.clinica_nome, dentista_nome: os.dentista_nome, paciente_nome: os.paciente_nome,
|
||
dentes: os.dentes, material: os.material, cor: os.cor, observacoes: os.observacoes,
|
||
prazo_entrega: os.prazo_entrega, garantia_ate: os.garantia_ate, garantia_meses: os.garantia_meses,
|
||
custo: parseFloat(os.custo || 0), local_atual: os.local_atual || 'clinica',
|
||
eventos: ev.rows,
|
||
fotos: ft.rows.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||
componentes: cm.rows, ocorrencias: oc.rows, custodia: cu.rows,
|
||
finalizada: os.status === 'entregue' || os.status === 'cancelado',
|
||
});
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Modo 1 — carteira do protético pelo link: trabalhos (andamento/finalizados) + mini financeiro
|
||
// (fechamento mensal). Escopo = a RELAÇÃO clínica↔protético da OS do token (não vaza outras clínicas).
|
||
app.get('/api/p/:token/carteira', async (req, res) => {
|
||
try {
|
||
const ctx = await resolveLinkToken(req.params.token);
|
||
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
|
||
const clinicaId = ctx.os.clinica_id, proteticoId = ctx.os.protetico_id;
|
||
const { rows: trab } = await pool.query(
|
||
"SELECT id, tipo, status, prazo_entrega, custo, paciente_nome, updated_at FROM protese_os WHERE clinica_id=$1 AND protetico_id=$2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 200",
|
||
[clinicaId, proteticoId]);
|
||
const { rows: pag } = await pool.query(
|
||
"SELECT to_char(p.data,'YYYY-MM') AS mes, SUM(p.valor)::numeric AS total, COUNT(*)::int AS n FROM protese_os_pagamento p JOIN protese_os o ON o.id=p.os_id WHERE p.lado='lab' AND o.clinica_id=$1 AND o.protetico_id=$2 GROUP BY mes ORDER BY mes DESC",
|
||
[clinicaId, proteticoId]);
|
||
const { rows: cl } = await pool.query("SELECT nome_fantasia FROM clinicas WHERE id=$1", [clinicaId]);
|
||
const map = t => ({ id: t.id, tipo: t.tipo, status: t.status, prazo_entrega: t.prazo_entrega, custo: parseFloat(t.custo || 0), paciente_nome: t.paciente_nome, updated_at: t.updated_at });
|
||
const andamento = trab.filter(t => t.status !== 'entregue' && t.status !== 'cancelado').map(map);
|
||
const finalizados = trab.filter(t => t.status === 'entregue').map(map);
|
||
const por_mes = pag.map(m => ({ mes: m.mes, total: parseFloat(m.total || 0), n: m.n }));
|
||
res.json({
|
||
clinica_nome: cl[0]?.nome_fantasia || null,
|
||
andamento, finalizados,
|
||
financeiro: { por_mes, total_pago: Math.round(por_mes.reduce((s, m) => s + m.total, 0) * 100) / 100 },
|
||
});
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Modo 1 (1b) — ações do protético PELO LINK (sem conta). Auditadas como "<nome> (link)".
|
||
async function resolveLinkToken(token) {
|
||
const { rows } = await pool.query("SELECT * FROM protese_os_link WHERE token=$1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())", [token]);
|
||
if (!rows.length) return null;
|
||
const { rows: os } = await pool.query("SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL", [rows[0].os_id]);
|
||
if (!os.length) return null;
|
||
return { link: rows[0], os: os[0] };
|
||
}
|
||
async function logEventoLink(os, link, status, observacao) {
|
||
const nome = `${await actorNome(link.protetico_id)} (link)`;
|
||
await pool.query("INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)",
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, status, observacao, link.protetico_id, nome]);
|
||
return nome;
|
||
}
|
||
|
||
app.post('/api/p/:token/custodia', async (req, res) => {
|
||
try {
|
||
const ctx = await resolveLinkToken(req.params.token);
|
||
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
|
||
const { os, link } = ctx;
|
||
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
|
||
if (!para) return res.status(400).json({ error: 'para_local inválido.' });
|
||
const de = os.local_atual || 'clinica';
|
||
if (de === para) return res.status(409).json({ error: `Já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
|
||
const nome = `${await actorNome(link.protetico_id)} (link)`;
|
||
await pool.query("INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
|
||
[`pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, os.id, os.clinica_id, de, para, os.status, link.protetico_id, nome]);
|
||
await pool.query("UPDATE protese_os SET local_atual=$1 WHERE id=$2", [para, os.id]);
|
||
await logEventoLink(os, link, 'custodia', para === 'laboratorio' ? 'Protético recebeu o trabalho (via link)' : 'Trabalho devolvido à clínica (via link)');
|
||
res.json({ success: true, local_atual: para });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/p/:token/status', async (req, res) => {
|
||
try {
|
||
const ctx = await resolveLinkToken(req.params.token);
|
||
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
|
||
const { os, link } = ctx;
|
||
const PROD = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||
if (!PROD.includes(req.body?.status)) return res.status(400).json({ error: 'Etapa inválida (somente produção pelo link).' });
|
||
if (os.status === 'entregue' || os.status === 'cancelado') return res.status(409).json({ error: `OS já está "${os.status}".` });
|
||
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW() WHERE id=$2', [req.body.status, os.id]);
|
||
await logEventoLink(os, link, req.body.status, null);
|
||
res.json({ success: true, status: req.body.status });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.post('/api/p/:token/foto', ortoUpload.single('foto'), async (req, res) => {
|
||
try {
|
||
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
|
||
const ctx = await resolveLinkToken(req.params.token);
|
||
if (!ctx) return res.status(404).json({ error: 'Link inválido ou expirado.' });
|
||
const { os, link } = ctx;
|
||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
|
||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
|
||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const nome = `${await actorNome(link.protetico_id)} (link)`;
|
||
await pool.query("INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)",
|
||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, link.protetico_id, nome]);
|
||
await logEventoLink(os, link, 'foto', 'Foto anexada (via link)');
|
||
res.json({ success: true, id: fid });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Detalhe + histórico de eventos + fotos.
|
||
app.get('/api/protese/os/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT *, (SELECT nome_fantasia FROM clinicas WHERE id = protese_os.clinica_id) AS clinica_nome FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||
const os = rows[0];
|
||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||
const { rows: eventos } = await pool.query('SELECT status, observacao, actor_nome, created_at FROM protese_os_evento WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||
const { rows: fotos } = await pool.query('SELECT id, original_name, uploaded_nome, legenda, created_at FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NULL ORDER BY created_at', [req.params.id]);
|
||
const { rows: componentes } = await pool.query('SELECT id, nome, quantidade, observacao, enviado_nome, created_at, COALESCE(direcao, $2) AS direcao, COALESCE(status_conferencia, $3) AS status_conferencia, conferido_nome, conferido_em FROM protese_os_componente WHERE os_id=$1 ORDER BY created_at', [req.params.id, 'enviado', 'pendente']);
|
||
const { rows: ocorrencias } = await pool.query('SELECT id, componente_id, categoria, descricao, responsavel, resposta, resposta_nome, status, autor_nome, created_at, resolved_at FROM protese_os_ocorrencia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||
const { rows: ocorFotos } = await pool.query("SELECT id, ocorrencia_id, original_name FROM protese_os_foto WHERE os_id=$1 AND ocorrencia_id IS NOT NULL", [req.params.id]);
|
||
const { rows: pagamentos } = await pool.query('SELECT id, lado, valor, forma, observacao, data, recebido_nome, created_at FROM protese_os_pagamento WHERE os_id=$1 ORDER BY data, created_at', [req.params.id]);
|
||
const { rows: custodia } = await pool.query('SELECT id, de_local, para_local, etapa, observacao, actor_nome, created_at FROM protese_os_custodia WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||
const { rows: aval } = await pool.query('SELECT estrelas, comentario, autor_nome FROM protese_avaliacao WHERE os_id=$1', [req.params.id]);
|
||
const { rows: coletas } = await pool.query('SELECT id, tipo, data_hora, endereco, responsavel, observacao, status FROM protese_coleta WHERE os_id=$1 ORDER BY created_at', [req.params.id]);
|
||
// Privacidade: o protético (lab que NÃO é da clínica) não vê o que o paciente paga/deve.
|
||
const soLab = isLab && !isClinic;
|
||
const pagsVisiveis = soLab ? pagamentos.filter(p => p.lado !== 'clinica') : pagamentos;
|
||
res.json({
|
||
...os, valor: soLab ? 0 : parseFloat(os.valor || 0), custo: parseFloat(os.custo || 0),
|
||
// Blindagem LGPD: o id do paciente É o CPF — não expor ao provedor de prótese.
|
||
paciente_id: soLab ? null : os.paciente_id,
|
||
local_atual: os.local_atual || 'clinica',
|
||
eventos,
|
||
fotos: fotos.map(f => ({ ...f, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||
componentes,
|
||
pagamentos: pagsVisiveis.map(p => ({ ...p, valor: parseFloat(p.valor || 0) })),
|
||
custodia,
|
||
avaliacao: aval[0] || null,
|
||
coletas,
|
||
ocorrencias: ocorrencias.map(o => ({
|
||
...o,
|
||
fotos: ocorFotos.filter(f => f.ocorrencia_id === o.id).map(f => ({ id: f.id, original_name: f.original_name, url: `/api/protese/os/fotos/${f.id}/raw?t=${signMedia(f.id)}` })),
|
||
})),
|
||
});
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Carrega a OS e valida acesso (lab ou clínica de origem). Reuso nos sub-recursos da OS.
|
||
async function carregaOSComAcesso(osId, uid) {
|
||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [osId]);
|
||
if (!rows.length) return { status: 404, error: 'OS não encontrada.' };
|
||
const os = rows[0];
|
||
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
|
||
if (!isLab && !isClinic) return { status: 403, error: 'Sem acesso a esta OS.' };
|
||
return { os, isLab, isClinic };
|
||
}
|
||
async function logEventoOS(os, uid, observacao, status = 'nota') {
|
||
await pool.query(
|
||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, status, observacao, uid, await actorNome(uid)]);
|
||
}
|
||
|
||
// Remover foto (rastreado no histórico).
|
||
app.delete('/api/protese/os/fotos/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Foto não encontrada.' });
|
||
const foto = rows[0];
|
||
const acc = await carregaOSComAcesso(foto.os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
await pool.query('DELETE FROM protese_os_foto WHERE id=$1', [req.params.id]);
|
||
await logEventoOS(acc.os, uid, 'Foto removida', 'foto');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Componentes/insumos enviados ao laboratório.
|
||
app.post('/api/protese/os/:id/componentes', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const b = req.body || {};
|
||
if (!b.nome || !String(b.nome).trim()) return res.status(400).json({ error: 'Nome do componente é obrigatório.' });
|
||
const id = `pcomp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const nome = await actorNome(uid);
|
||
const qtd = Math.max(1, parseInt(b.quantidade, 10) || 1);
|
||
const direcao = b.direcao === 'devolvido' ? 'devolvido' : 'enviado';
|
||
await pool.query(
|
||
`INSERT INTO protese_os_componente (id, os_id, clinica_id, nome, quantidade, observacao, enviado_por, enviado_nome, direcao) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||
[id, acc.os.id, acc.os.clinica_id, String(b.nome).trim(), qtd, b.observacao || null, uid, nome, direcao]);
|
||
await logEventoOS(acc.os, uid, `Componente ${direcao}: ${qtd}× ${String(b.nome).trim()}`, 'componente');
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.delete('/api/protese/os/componentes/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' });
|
||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
await pool.query('DELETE FROM protese_os_componente WHERE id=$1', [req.params.id]);
|
||
await logEventoOS(acc.os, uid, `Componente removido: ${rows[0].nome}`, 'componente');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Pagamentos/recebimentos da OS (lado='lab' clínica→lab | lado='clinica' paciente→clínica).
|
||
app.post('/api/protese/os/:id/pagamentos', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const b = req.body || {};
|
||
const lado = ['lab', 'clinica'].includes(b.lado) ? b.lado : null;
|
||
if (!lado) return res.status(400).json({ error: "lado deve ser 'lab' ou 'clinica'." });
|
||
const valor = Number(b.valor);
|
||
if (!(valor > 0)) return res.status(400).json({ error: 'Valor deve ser maior que zero.' });
|
||
const id = `ppag_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const nome = await actorNome(uid);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_pagamento (id, os_id, clinica_id, lado, valor, forma, observacao, data, recebido_por, recebido_nome)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8::date,CURRENT_DATE),$9,$10)`,
|
||
[id, acc.os.id, acc.os.clinica_id, lado, valor, b.forma || null, b.observacao || null, b.data || null, uid, nome]);
|
||
const rotulo = lado === 'lab' ? 'pago ao laboratório' : 'recebido do paciente';
|
||
await logEventoOS(acc.os, uid, `Pagamento ${rotulo}: ${valor.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`, 'pagamento');
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.delete('/api/protese/os/pagamentos/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Pagamento não encontrado.' });
|
||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
await pool.query('DELETE FROM protese_os_pagamento WHERE id=$1', [req.params.id]);
|
||
await logEventoOS(acc.os, uid, 'Pagamento removido', 'pagamento');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Custódia: registrar que o trabalho mudou de mãos (clínica ↔ laboratório).
|
||
app.post('/api/protese/os/:id/custodia', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const para = ['clinica', 'laboratorio'].includes(req.body?.para_local) ? req.body.para_local : null;
|
||
if (!para) return res.status(400).json({ error: "para_local deve ser 'clinica' ou 'laboratorio'." });
|
||
const de = acc.os.local_atual || 'clinica';
|
||
if (de === para) return res.status(409).json({ error: `O trabalho já está ${para === 'laboratorio' ? 'com o laboratório' : 'na clínica'}.` });
|
||
const id = `pcus_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const nome = await actorNome(uid);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_custodia (id, os_id, clinica_id, de_local, para_local, etapa, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||
[id, acc.os.id, acc.os.clinica_id, de, para, acc.os.status, req.body?.observacao || null, uid, nome]);
|
||
await pool.query('UPDATE protese_os SET local_atual=$1 WHERE id=$2', [para, acc.os.id]);
|
||
const rotulo = para === 'laboratorio' ? 'Protético retirou o trabalho (foi p/ o laboratório)' : 'Trabalho devolvido à clínica';
|
||
await logEventoOS(acc.os, uid, rotulo, 'custodia');
|
||
res.json({ success: true, id, local_atual: para });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Editar campos do trabalho (cor, material, dentes, observações) — lab ou clínica. Auditado.
|
||
app.patch('/api/protese/os/:id', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (acc.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser editada.' });
|
||
const b = req.body || {};
|
||
const muda = [];
|
||
for (const campo of ['cor', 'material', 'dentes', 'observacoes']) {
|
||
if (b[campo] !== undefined) {
|
||
const novo = b[campo] === null || b[campo] === '' ? null : String(b[campo]);
|
||
if ((acc.os[campo] || null) !== novo) { await pool.query(`UPDATE protese_os SET ${campo}=$1 WHERE id=$2`, [novo, acc.os.id]); muda.push(`${campo}: ${acc.os[campo] || '—'} → ${novo || '—'}`); }
|
||
}
|
||
}
|
||
if (muda.length) await logEventoOS(acc.os, uid, `Trabalho editado (${muda.join('; ')})`, 'edicao');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Ajuste do valor cobrado do paciente (acréscimo/redução) — SOMENTE clínica. Auditado.
|
||
app.post('/api/protese/os/:id/ajuste-valor', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (!acc.isClinic) return res.status(403).json({ error: 'Apenas a clínica ajusta o valor do paciente.' });
|
||
const delta = Number(req.body?.delta);
|
||
if (!delta || isNaN(delta)) return res.status(400).json({ error: 'Informe um valor de ajuste (+ ou −).' });
|
||
const atual = parseFloat(acc.os.valor || 0);
|
||
const novo = Math.max(0, atual + delta);
|
||
await pool.query('UPDATE protese_os SET valor=$1 WHERE id=$2', [novo, acc.os.id]);
|
||
const sinal = delta >= 0 ? '+' : '−';
|
||
const obs = req.body?.observacao ? ` · ${String(req.body.observacao).slice(0, 200)}` : '';
|
||
await logEventoOS(acc.os, uid, `Valor ao paciente ${sinal}${Math.abs(delta).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}${obs} (novo total ${novo.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })})`, 'valor');
|
||
res.json({ success: true, valor: novo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Trocar o trabalho por outro (produto do catálogo do lab OU tipo livre) + motivo. Auditado.
|
||
app.post('/api/protese/os/:id/trocar', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (acc.os.status === 'entregue' || acc.os.status === 'cancelado') return res.status(409).json({ error: 'OS finalizada não pode ser trocada.' });
|
||
const b = req.body || {};
|
||
if (!b.motivo || !String(b.motivo).trim()) return res.status(400).json({ error: 'Informe o motivo da troca.' });
|
||
const de = acc.os.tipo;
|
||
let novoTipo = b.tipo ? String(b.tipo).trim() : null, custo = parseFloat(acc.os.custo || 0), garantia = acc.os.garantia_meses, produtoId = acc.os.produto_id;
|
||
if (b.produto_id) {
|
||
const { rows: pr } = await pool.query('SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL', [b.produto_id, acc.os.protetico_id]);
|
||
if (!pr.length) return res.status(404).json({ error: 'Produto não pertence a este laboratório.' });
|
||
produtoId = pr[0].id; novoTipo = pr[0].nome; custo = parseFloat(pr[0].preco || 0); garantia = Number(pr[0].garantia_meses) || 12;
|
||
}
|
||
if (!novoTipo) return res.status(400).json({ error: 'Selecione o novo trabalho.' });
|
||
await pool.query('UPDATE protese_os SET tipo=$1, produto_id=$2, custo=$3, garantia_meses=$4 WHERE id=$5', [novoTipo, produtoId, custo, garantia, acc.os.id]);
|
||
await logEventoOS(acc.os, uid, `Trabalho trocado: ${de} → ${novoTipo} · Motivo: ${String(b.motivo).trim()}`, 'troca');
|
||
res.json({ success: true, tipo: novoTipo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ── FASE 2: rastreabilidade (conferência) + ocorrências (contraditório) ──────
|
||
const OCORRENCIA_CATS = ['nao_devolvido', 'danificado', 'incompativel', 'parafuso_substituido', 'parafuso_desgastado', 'peca_incorreta', 'ajuste_inadequado', 'falha_fabricacao', 'retrabalho', 'outro'];
|
||
|
||
// Conferir um componente (ok | divergente | ausente | pendente).
|
||
app.post('/api/protese/os/componentes/:id/conferir', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM protese_os_componente WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Componente não encontrado.' });
|
||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const st = ['ok', 'divergente', 'ausente', 'pendente'].includes(req.body?.status_conferencia) ? req.body.status_conferencia : null;
|
||
if (!st) return res.status(400).json({ error: 'status_conferencia inválido.' });
|
||
const nome = await actorNome(uid);
|
||
await pool.query('UPDATE protese_os_componente SET status_conferencia=$1, conferido_nome=$2, conferido_em=NOW() WHERE id=$3', [st, nome, req.params.id]);
|
||
await logEventoOS(acc.os, uid, `Componente conferido (${st}): ${rows[0].nome}`, 'componente');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Abrir ocorrência.
|
||
app.post('/api/protese/os/:id/ocorrencias', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const acc = await carregaOSComAcesso(req.params.id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
const b = req.body || {};
|
||
if (!OCORRENCIA_CATS.includes(b.categoria)) return res.status(400).json({ error: 'Categoria inválida.' });
|
||
if (!b.descricao || !String(b.descricao).trim()) return res.status(400).json({ error: 'Descreva a ocorrência.' });
|
||
const id = `poco_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const nome = await actorNome(uid);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_ocorrencia (id, os_id, clinica_id, componente_id, categoria, descricao, responsavel, status, autor_id, autor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7,'aberta',$8,$9)`,
|
||
[id, acc.os.id, acc.os.clinica_id, b.componente_id || null, b.categoria, String(b.descricao).trim(), b.responsavel || null, uid, nome]);
|
||
await logEventoOS(acc.os, uid, `Ocorrência aberta: ${b.categoria}`, 'ocorrencia');
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Responder ocorrência (contraditório) → 'respondida'.
|
||
app.post('/api/protese/os/ocorrencias/:id/resposta', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' });
|
||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida.' });
|
||
if (!req.body?.resposta || !String(req.body.resposta).trim()) return res.status(400).json({ error: 'Escreva a resposta.' });
|
||
const nome = await actorNome(uid);
|
||
await pool.query("UPDATE protese_os_ocorrencia SET resposta=$1, resposta_nome=$2, status=CASE WHEN status='aberta' THEN 'respondida' ELSE status END WHERE id=$3", [String(req.body.resposta).trim(), nome, req.params.id]);
|
||
await logEventoOS(acc.os, uid, `Ocorrência respondida: ${rows[0].categoria}`, 'ocorrencia');
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Mudar status da ocorrência (em_analise | resolvida | contestada). 'resolvida' é final.
|
||
app.post('/api/protese/os/ocorrencias/:id/status', authGuard, async (req, res) => {
|
||
try {
|
||
const uid = req.authUser.userId;
|
||
const novo = req.body?.status;
|
||
if (!['em_analise', 'resolvida', 'contestada'].includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
|
||
const { rows } = await pool.query('SELECT * FROM protese_os_ocorrencia WHERE id=$1', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Ocorrência não encontrada.' });
|
||
const acc = await carregaOSComAcesso(rows[0].os_id, uid);
|
||
if (acc.error) return res.status(acc.status).json({ error: acc.error });
|
||
if (rows[0].status === 'resolvida') return res.status(409).json({ error: 'Ocorrência já resolvida (final).' });
|
||
await pool.query("UPDATE protese_os_ocorrencia SET status=$1, resolved_at=CASE WHEN $1='resolvida' THEN NOW() ELSE resolved_at END WHERE id=$2", [novo, req.params.id]);
|
||
await logEventoOS(acc.os, uid, `Ocorrência ${novo}: ${rows[0].categoria}`, 'ocorrencia');
|
||
res.json({ success: true, status: novo });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Mudar status (laboratório avança a produção; clínica entrega/cancela).
|
||
app.post('/api/protese/os/:id/status', authGuard, async (req, res) => {
|
||
const novo = req.body?.status;
|
||
if (!PROTESE_STATUS.includes(novo)) return res.status(400).json({ error: 'Status inválido.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||
const os = rows[0];
|
||
const uid = req.authUser.userId;
|
||
const { isLab, isClinic } = await proteseOsAccesso(os, uid);
|
||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||
if (os.status === 'cancelado' || os.status === 'entregue') return res.status(409).json({ error: `OS já está "${os.status}".` });
|
||
// Entregue/cancelado são da clínica; estados de produção são do laboratório.
|
||
const estadosClinica = ['entregue', 'cancelado'];
|
||
if (estadosClinica.includes(novo) && !isClinic) return res.status(403).json({ error: 'Só a clínica de origem marca entregue/cancelado.' });
|
||
// Etapas de produção (recebido→…→finalizado): lab OU clínica de origem podem avançar
|
||
// (o coordenador da clínica acompanha o trabalho). Auditado por actor no histórico.
|
||
if (!estadosClinica.includes(novo) && !isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso para avançar a produção.' });
|
||
// Garantia com fotos: para ENTREGAR é obrigatório ter ao menos 1 foto do trabalho (evidência da garantia).
|
||
let garantiaAte = null, finId = os.financeiro_id || null, finLabId = os.financeiro_lab_id || null;
|
||
if (novo === 'entregue') {
|
||
const { rows: fc } = await pool.query('SELECT COUNT(*)::int AS n FROM protese_os_foto WHERE os_id=$1', [os.id]);
|
||
if (!fc[0].n) return res.status(422).json({ error: 'Anexe ao menos 1 foto do trabalho antes de entregar (evidência da garantia).' });
|
||
const meses = Number(os.garantia_meses) || 12;
|
||
const { rows: gd } = await pool.query(`SELECT (CURRENT_DATE + ($1 || ' months')::interval)::date AS d`, [meses]);
|
||
garantiaAte = gd[0].d;
|
||
// Custo de laboratório → DESPESA (contas a pagar ao laboratório), idempotente. OS de garantia não cobra.
|
||
if (!finId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
|
||
finId = `fin_pos_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query(
|
||
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, profissional_id, paciente_id, pacientenome, origem, centro_custo, fornecedor, sem_comissao)
|
||
VALUES ($1,$2,$3,$4,'DESPESA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,$7,'protese','LABORATÓRIO',$8,true)`,
|
||
[finId, os.clinica_id, `PRÓTESE: ${os.tipo} (lab ${os.protetico_nome || ''})`.trim(), Number(os.custo),
|
||
os.protetico_id, os.paciente_id || null, os.paciente_nome || null, os.protetico_nome || null]);
|
||
}
|
||
// Fase 2.1: espelho — RECEITA no workspace do laboratório (clinica_id = laboratorio_id).
|
||
// Mesmo valor/condição da DESPESA; isolado para nunca derrubar a transição de status.
|
||
try {
|
||
const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
|
||
if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
|
||
const { rows: cl } = await pool.query('SELECT nome_fantasia FROM clinicas WHERE id=$1', [os.clinica_id]);
|
||
finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query(
|
||
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, pacientenome, origem, centro_custo, sem_comissao)
|
||
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
|
||
[finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${cl[0]?.nome_fantasia || ''})`.trim(), Number(os.custo),
|
||
os.paciente_id || null, os.paciente_nome || null]);
|
||
}
|
||
} catch (e) { console.error('[protese entrega→receita lab]', e.message); }
|
||
}
|
||
await pool.query('UPDATE protese_os SET status=$1, updated_at=NOW(), garantia_ate=COALESCE($2,garantia_ate), financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($4,financeiro_lab_id) WHERE id=$5', [novo, garantiaAte, finId, finLabId, os.id]);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, novo, req.body?.observacao || null, uid, await actorNome(uid)]);
|
||
await registrarAudit(pool, { clinicaId: os.clinica_id, entidade: 'protese_os', entidadeId: os.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { financeiro_id: finId, garantia_ate: garantiaAte } });
|
||
// Notifica a outra parte.
|
||
const destino = isLab ? os.created_by : os.protetico_id;
|
||
if (destino) await notificarUsuario(destino, `Prótese: ${novo}`, `A OS de ${os.paciente_nome || 'paciente'} (${os.tipo}) mudou para "${novo}".`, novo === 'entregue' ? 'sucesso' : 'info');
|
||
wsBroadcast(os.clinica_id, 'protese', { entidadeId: os.id, acao: novo });
|
||
res.json({ success: true, status: novo, financeiro_id: finId, garantia_ate: garantiaAte });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Anexar foto do trabalho (laboratório ou clínica).
|
||
app.post('/api/protese/os/:id/foto', authGuard, ortoUpload.single('foto'), async (req, res) => {
|
||
if (!req.file) return res.status(400).json({ error: 'foto obrigatória.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT * FROM protese_os WHERE id=$1 AND deleted_at IS NULL', [req.params.id]);
|
||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||
const os = rows[0];
|
||
const { isLab, isClinic } = await proteseOsAccesso(os, req.authUser.userId);
|
||
if (!isLab && !isClinic) return res.status(403).json({ error: 'Sem acesso a esta OS.' });
|
||
const ext = ((req.file.originalname.split('.').pop() || 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '')) || 'jpg';
|
||
const rel = path.join('proteses', os.id, `${Date.now()}.${ext}`);
|
||
await fs.promises.mkdir(path.join(MEDIA_ROOT, 'proteses', os.id), { recursive: true });
|
||
await fs.promises.writeFile(path.join(MEDIA_ROOT, rel), req.file.buffer);
|
||
const fid = `pof_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const uid = req.authUser.userId;
|
||
const nome = await actorNome(uid);
|
||
const legenda = (req.body?.legenda || '').toString().slice(0, 200) || null;
|
||
const ocorrenciaId = req.body?.ocorrencia_id || null; // foto de evidência de uma ocorrência
|
||
await pool.query('INSERT INTO protese_os_foto (id, os_id, clinica_id, filename, mimetype, original_name, uploaded_by, uploaded_nome, legenda, ocorrencia_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
||
[fid, os.id, os.clinica_id, rel, req.file.mimetype, req.file.originalname, uid, nome, legenda, ocorrenciaId]);
|
||
// Anexar foto entra no histórico (rastreabilidade: quem anexou e quando).
|
||
await pool.query(
|
||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, os.id, os.clinica_id, ocorrenciaId ? 'ocorrencia' : 'foto', ocorrenciaId ? 'Evidência anexada à ocorrência' : (legenda ? `Foto anexada: ${legenda}` : 'Foto anexada'), uid, nome]);
|
||
res.json({ success: true, id: fid, url: `/api/protese/os/fotos/${fid}/raw?t=${signMedia(fid)}` });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Stream da imagem (URL assinada — img tag não envia Authorization).
|
||
app.get('/api/protese/os/fotos/:id/raw', async (req, res) => {
|
||
try {
|
||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||
const { rows } = await pool.query('SELECT filename, mimetype FROM protese_os_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(); }
|
||
});
|
||
|
||
// Excluir OS (só a clínica de origem; soft delete).
|
||
app.delete('/api/protese/os/:id', tenantGuard, async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT financeiro_id FROM protese_os WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'OS não encontrada.' });
|
||
await pool.query('UPDATE protese_os SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2', [req.params.id, req.clinicaId]);
|
||
// Estorna a DESPESA de laboratório vinculada (se houver).
|
||
if (rows[0].financeiro_id) await pool.query('UPDATE financeiro SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL', [rows[0].financeiro_id, req.clinicaId]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'protese_os', entidadeId: req.params.id, acao: 'excluiu', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { estornou: rows[0].financeiro_id || null } });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// --- CONTRATOS ---
|
||
app.get('/api/orcamentos', tenantGuard, requireCapability('orcamentos'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { paciente_id, status } = req.query;
|
||
const vals = [req.clinicaId];
|
||
let where = 'WHERE c.clinica_id = $1';
|
||
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: orcamentos } = await pool.query(
|
||
`SELECT c.*, json_agg(ci.* ORDER BY ci.ordem) FILTER (WHERE ci.id IS NOT NULL) AS items
|
||
FROM orcamentos c
|
||
LEFT JOIN orcamento_itens ci ON ci.orcamento_id = c.id
|
||
${where} GROUP BY c.id ORDER BY c.created_at DESC`, vals);
|
||
res.json(orcamentos.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,
|
||
orcamentoId: i.orcamento_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/orcamentos', tenantGuard, requireCapability('orcamentos'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { items, ...contrato } = req.body;
|
||
contrato.clinica_id = req.clinicaId; // escopo forçado pela unidade do vínculo
|
||
if (!contrato.id) contrato.id = Math.random().toString(36).substring(2, 12).toUpperCase();
|
||
await withTransaction(async (client) => {
|
||
const ins = buildInsert('orcamentos', 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('orcamento_itens', { ...item, orcamento_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/orcamentos/:id', tenantGuard, requireCapability('orcamentos'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const own = await pool.query('SELECT financeiro_gerado FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!own.rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' });
|
||
const { items, ...contrato } = req.body;
|
||
delete contrato.clinica_id; // não permite mover de unidade
|
||
await withTransaction(async (client) => {
|
||
const upd = buildUpdate('orcamentos', contrato, 'id', req.params.id);
|
||
await client.query(upd.text, upd.values);
|
||
await client.query('DELETE FROM orcamento_itens WHERE orcamento_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('orcamento_itens', { ...item, orcamento_id: req.params.id });
|
||
await client.query(iIns.text, iIns.values);
|
||
}
|
||
}
|
||
});
|
||
|
||
// H4.1 — Orçamento Assinado gera contas a receber (entrada + parcelas), idempotente
|
||
let lancamentos = 0;
|
||
const { rows: fr } = await pool.query('SELECT * FROM orcamentos WHERE id = $1', [req.params.id]);
|
||
const o = fr[0];
|
||
if (o && o.status === 'Assinado' && !o.financeiro_gerado && Number(o.valor_total) > 0) {
|
||
lancamentos = await gerarFinanceiroOrcamento(o, req);
|
||
}
|
||
res.json({ success: true, lancamentos_gerados: lancamentos });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Gera os lançamentos RECEITA (entrada + N parcelas) de um orçamento assinado
|
||
async function gerarFinanceiroOrcamento(o, req) {
|
||
const total = Number(o.valor_total) || 0;
|
||
const entrada = Math.min(Number(o.entrada) || 0, total);
|
||
const parcelas = Math.max(1, parseInt(o.parcelas) || 1);
|
||
const restante = total - entrada;
|
||
const valorParcela = Math.round((restante / parcelas) * 100) / 100;
|
||
const base = o.data_inicio ? new Date(o.data_inicio) : new Date();
|
||
const baseYmd = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`;
|
||
const lanc = [];
|
||
if (entrada > 0) lanc.push({ desc: `ENTRADA — ${o.titulo || 'Orçamento'}`, valor: entrada, offset: 0 });
|
||
for (let i = 0; i < parcelas; i++) {
|
||
// último ajusta centavos p/ fechar o total
|
||
const v = i === parcelas - 1 ? Number((restante - valorParcela * (parcelas - 1)).toFixed(2)) : valorParcela;
|
||
lanc.push({ desc: `${o.titulo || 'Orçamento'} — ${i + 1}/${parcelas}`, valor: v, offset: entrada > 0 ? i + 1 : i });
|
||
}
|
||
await withTransaction(async (client) => {
|
||
for (const l of lanc) {
|
||
if (l.valor <= 0) continue;
|
||
const id = `fin_orc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await client.query(`
|
||
INSERT INTO financeiro (id, clinica_id, descricao, pacientenome, valor, tipo, status, datavencimento, paciente_id, profissional_id, contrato_id, origem)
|
||
VALUES ($1,$2,$3,$4,$5,'RECEITA','Pendente', ($6::date + ($7 || ' months')::interval), $8, $9, $10, 'orcamento')
|
||
`, [id, o.clinica_id, l.desc, o.paciente_nome, l.valor, baseYmd, String(l.offset), o.paciente_id, o.dentista_id, o.id]);
|
||
}
|
||
await client.query('UPDATE orcamentos SET financeiro_gerado = true WHERE id = $1', [o.id]);
|
||
});
|
||
await registrarAudit(pool, { clinicaId: o.clinica_id, entidade: 'orcamento', entidadeId: o.id, acao: 'gerou_financeiro', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, entrada, parcelas, lancamentos: lanc.filter(l => l.valor > 0).length } });
|
||
return lanc.filter(l => l.valor > 0).length;
|
||
}
|
||
|
||
app.delete('/api/orcamentos/:id', tenantGuard, requireCapability('orcamentos'), async (req, res) => {
|
||
try {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const own = await pool.query('SELECT 1 FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
if (!own.rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' });
|
||
await pool.query('DELETE FROM orcamento_itens WHERE orcamento_id = $1', [req.params.id]);
|
||
await pool.query('DELETE FROM orcamentos WHERE id = $1 AND clinica_id = $2', [req.params.id, req.clinicaId]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// --- LEADS ---
|
||
app.get('/api/leads', tenantGuard, requireCapability('leads'), 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 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', tenantGuard, requireCapability('leads'), async (req, res) => {
|
||
try {
|
||
const clinicaId = req.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, requireCapability('leads'), 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', authGuard, async (req, res) => {
|
||
try {
|
||
const { pacienteId, dentistaId, credenciadaId } = req.body;
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM pacientes WHERE id = $1', [pacienteId])) return;
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return;
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return;
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return;
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT p.clinica_id AS cid FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid WHERE g.id = $1', [req.params.id])) return;
|
||
const { rows: br } = await pool.query(
|
||
`SELECT g.*, p.clinica_id, p.nome AS paciente_nome, c.nome AS convenio_nome
|
||
FROM gto_builders g JOIN pacientes p ON p.id = g.pacienteid
|
||
LEFT JOIN planos c ON c.id = g.credenciadaid WHERE g.id = $1`,
|
||
[req.params.id]);
|
||
const b = br[0];
|
||
if (!b) return res.status(404).json({ error: 'GTO não encontrada.' });
|
||
const jaFinalizada = b.status === 'FINALIZADA';
|
||
await pool.query("UPDATE gto_builders SET status = 'FINALIZADA' WHERE id = $1", [req.params.id]);
|
||
|
||
// GTO finalizada gera RECEITA no financeiro (convênio a receber em 30d) — idempotente
|
||
let financeiroId = b.financeiro_id || null;
|
||
const total = Number(b.total) || 0;
|
||
if (!jaFinalizada && total > 0 && !b.financeiro_id) {
|
||
financeiroId = `fin_gto_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const convLabel = b.convenio_nome || b.credenciadaid || 'convênio';
|
||
await pool.query(`
|
||
INSERT INTO financeiro (id, clinica_id, descricao, pacientenome, valor, tipo, status, datavencimento, data_competencia, paciente_id, profissional_id, convenio_id, origem)
|
||
VALUES ($1,$2,$3,$4,$5,'RECEITA','Pendente', (CURRENT_DATE + INTERVAL '30 days'), CURRENT_DATE, $6, $7, $8, 'gto')
|
||
`, [financeiroId, b.clinica_id, `GTO ${b.id} — ${convLabel}`, b.paciente_nome, total, b.pacienteid, b.dentistaid, b.credenciadaid]);
|
||
await pool.query('UPDATE gto_builders SET financeiro_id = $1 WHERE id = $2', [financeiroId, b.id]);
|
||
await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'gto', entidadeId: b.id, acao: 'finalizou_gto', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { total, financeiro_id: financeiroId } });
|
||
schedulePush('financeiro');
|
||
}
|
||
|
||
// Prótese (Fase 1): cada item de GTO ligado a um produto do protético abre uma OS
|
||
// de prótese (cai na Bancada do laboratório, com etapas/fotos/garantia já existentes).
|
||
// Idempotente por gto_item_id; isolado em try/catch para nunca derrubar o finalize.
|
||
try {
|
||
const { rows: protItems } = await pool.query(
|
||
'SELECT * FROM gto_items WHERE builderid = $1 AND produto_id IS NOT NULL AND protetico_id IS NOT NULL',
|
||
[req.params.id]);
|
||
const uid = req.authUser?.userId;
|
||
for (const it of protItems) {
|
||
const { rows: jaTem } = await pool.query('SELECT id FROM protese_os WHERE gto_item_id = $1 LIMIT 1', [it.id]);
|
||
if (jaTem.length) continue;
|
||
const { rows: pr } = await pool.query(
|
||
'SELECT id, nome, preco, garantia_meses FROM protese_produtos WHERE id=$1 AND protetico_id=$2 AND deleted_at IS NULL',
|
||
[it.produto_id, it.protetico_id]);
|
||
if (!pr.length) continue;
|
||
const { rows: prot } = await pool.query("SELECT nome FROM usuarios WHERE id=$1 AND role='protetico'", [it.protetico_id]);
|
||
const osId = `pos_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
const labId = await labDoProtetico(it.protetico_id); // Fase 2: escopo por laboratório
|
||
await pool.query(
|
||
`INSERT INTO protese_os (id, clinica_id, protetico_id, laboratorio_id, protetico_nome, paciente_id, paciente_nome, dentista_id, procedimento_id, tipo, produto_id, tipo_os, garantia_meses, dentes, observacoes, valor, custo, status, created_by, gto_item_id)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'nova',$12,$13,$14,$15,$16,'solicitado',$17,$18)`,
|
||
[osId, b.clinica_id, it.protetico_id, labId, prot[0]?.nome || null, b.pacienteid, b.paciente_nome,
|
||
b.dentistaid, it.procedimentoid || null, pr[0].nome, pr[0].id, Number(pr[0].garantia_meses) || 12,
|
||
it.dente || null, it.observacao || null, Number(it.valortotal) || 0, parseFloat(pr[0].preco || 0), uid, it.id]);
|
||
await pool.query(
|
||
`INSERT INTO protese_os_evento (id, os_id, clinica_id, status, observacao, actor_id, actor_nome) VALUES ($1,$2,$3,'solicitado',$4,$5,$6)`,
|
||
[`pev_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`, osId, b.clinica_id, 'OS criada a partir da GTO', uid, await actorNome(uid)]);
|
||
await registrarAudit(pool, { clinicaId: b.clinica_id, entidade: 'protese_os', entidadeId: osId, acao: 'criou', actorId: uid, actorNome: await actorNome(uid), detalhes: { origem: 'gto', gto_item_id: it.id, tipo: pr[0].nome } });
|
||
await notificarUsuario(it.protetico_id, 'Nova ordem de prótese', `Você recebeu uma nova OS (${pr[0].nome}) gerada por GTO. Veja na Bancada.`);
|
||
wsBroadcast(b.clinica_id, 'protese', { entidadeId: osId, acao: 'criou' });
|
||
}
|
||
} catch (e) { console.error('[GTO→protese_os]', e.message); }
|
||
|
||
res.json({ success: true, financeiro_id: financeiroId });
|
||
} 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', authGuard, 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', authGuard, 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', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM modelos_receita WHERE id = $1', [req.params.id])) return;
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM modelos_receita WHERE id = $1', [req.params.id])) return;
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
const { pacienteId } = req.query;
|
||
if (!pacienteId) return res.status(400).json({ error: 'pacienteId obrigatório.' });
|
||
const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [pacienteId]);
|
||
if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
|
||
if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado: paciente de outra clínica.' });
|
||
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', authGuard, async (req, res) => {
|
||
try {
|
||
const { pacienteId, pacienteNome, profissionalId, profissionalNome, data, observacoes, items = [] } = req.body;
|
||
const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [pacienteId]);
|
||
if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
|
||
if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado: paciente de outra clínica.' });
|
||
const id = `pe_${Date.now()}`;
|
||
await pool.query(
|
||
`INSERT INTO pedidos_exame (id, paciente_id, paciente_nome, profissional_id, profissional_nome, data, observacoes, clinica_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||
[id, pacienteId || null, pacienteNome, profissionalId || null, profissionalNome || null, data, observacoes || null, pc[0].clinica_id]
|
||
);
|
||
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
|
||
)`,
|
||
// Rename Contratos → Orçamentos (libera 'contratos' para o motor contratual futuro).
|
||
// Ordem importa: coluna antes da tabela; IF EXISTS torna idempotente em banco novo.
|
||
`ALTER TABLE IF EXISTS contrato_itens RENAME COLUMN contrato_id TO orcamento_id`,
|
||
`ALTER TABLE IF EXISTS contratos RENAME TO orcamentos`,
|
||
`ALTER TABLE IF EXISTS contrato_itens RENAME TO orcamento_itens`,
|
||
`CREATE TABLE IF NOT EXISTS orcamentos (
|
||
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 orcamento_itens (
|
||
id TEXT PRIMARY KEY,
|
||
orcamento_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','tecnico_protese','funcionario','donoclinica','donoconsultorio','donosala','admin']))`,
|
||
// Fase 4 — Provedor de Prótese como empresa: PF (individual) | PJ (laboratório). documento = CPF/CNPJ.
|
||
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS pessoa_tipo TEXT DEFAULT 'PF'`,
|
||
// 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','donosala','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')`,
|
||
// Leitura da agenda por clínica + janela de datas (GET /api/agendamentos?from&to).
|
||
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_start ON agendamentos (clinica_id, start_time)`,
|
||
// Anti-overbooking entre clínicas p/ dentista sem conta: busca por dentistaid + janela.
|
||
`CREATE INDEX IF NOT EXISTS idx_agendamentos_dentista_start ON agendamentos (dentistaid, start_time)`,
|
||
`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`,
|
||
// ── Isolamento por setor (opt-in) ──────────────────────────────────────
|
||
// O agendamento carimba o setor a que pertence (do dentista, ou de quem o
|
||
// criou). NULL = "geral" (visível a todos → retrocompat). dentistas.setor_id
|
||
// é o reforço opcional para ancorar a agenda do profissional num setor.
|
||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS setor_id TEXT`,
|
||
`ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS setor_id TEXT`,
|
||
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_setor_start ON agendamentos (clinica_id, setor_id, start_time)`,
|
||
`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)`,
|
||
// Fatia 1b: reserva confirmada vira recebível de locação no financeiro
|
||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS financeiro_id TEXT`,
|
||
// Bloqueio de sala p/ manutenção: reserva tipo='manutencao' (valor 0, nasce confirmada,
|
||
// sem cobrança) que o pessoal da unidade pode criar para tirar a sala da agenda.
|
||
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS tipo TEXT DEFAULT 'locacao'`,
|
||
// 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)
|
||
)`,
|
||
// ── Geolocalização (PostGIS já instalado na VPS3) ────────────────────
|
||
// Cache de CEP → coordenadas (geocoda cada CEP uma vez; economia + multi-provedor).
|
||
`CREATE TABLE IF NOT EXISTS cep_geo (
|
||
cep TEXT PRIMARY KEY,
|
||
lat DOUBLE PRECISION,
|
||
lng DOUBLE PRECISION,
|
||
fonte TEXT,
|
||
precisao TEXT,
|
||
atualizado_em TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
// Coluna geográfica (ponto WGS84) p/ busca por raio em salas e profissionais.
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`,
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS area TEXT`,
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS hora_consulta BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS turno_consulta BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS diaria_consulta BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS semanal_consulta BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS mensal_consulta BOOLEAN DEFAULT false`,
|
||
`UPDATE salas s SET area = CASE u.role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END
|
||
FROM usuarios u WHERE u.id = s.owner_usuario_id AND s.area IS NULL`,
|
||
`UPDATE salas SET area = 'odontologia' WHERE area IS NULL`,
|
||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`,
|
||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS logradouro TEXT`,
|
||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS numero TEXT`,
|
||
`ALTER TABLE especialidades ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`,
|
||
`ALTER TABLE procedimentos ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`,
|
||
// ── Áreas de atuação (Odontologia / Biomedicina / Prótese) ──────────
|
||
`CREATE TABLE IF NOT EXISTS areas (
|
||
slug TEXT PRIMARY KEY, nome TEXT NOT NULL, cor TEXT, icone TEXT, ordem INT DEFAULT 0
|
||
)`,
|
||
`INSERT INTO areas (slug, nome, cor, icone, ordem) VALUES
|
||
('odontologia','Odontologia','#2563eb','Stethoscope',1),
|
||
('biomedicina','Biomedicina Estética','#db2777','Sparkles',2),
|
||
('protese','Prótese','#7c3aed','FlaskConical',3)
|
||
ON CONFLICT (slug) DO NOTHING`,
|
||
`ALTER TABLE especialidades ADD COLUMN IF NOT EXISTS area TEXT`,
|
||
`ALTER TABLE procedimentos ADD COLUMN IF NOT EXISTS area TEXT`,
|
||
// Multi-área por clínica e por usuário (uma unidade pode ter odonto + biomedicina).
|
||
`CREATE TABLE IF NOT EXISTS clinica_areas (clinica_id TEXT NOT NULL, area TEXT NOT NULL, PRIMARY KEY (clinica_id, area))`,
|
||
`CREATE TABLE IF NOT EXISTS usuario_areas (usuario_id TEXT NOT NULL, area TEXT NOT NULL, PRIMARY KEY (usuario_id, area))`,
|
||
// Backfill idempotente do catálogo (só onde area ainda é NULL).
|
||
`UPDATE especialidades SET area = 'biomedicina' WHERE area IS NULL AND nome IN ('BIOMEDICINA ESTÉTICA','ESTÉTICA FACIAL','ESTÉTICA CORPORAL','PROCEDIMENTOS INJETÁVEIS')`,
|
||
`UPDATE especialidades SET area = 'odontologia' WHERE area IS NULL`,
|
||
`UPDATE procedimentos SET area = 'biomedicina' WHERE area IS NULL AND nome IN ('TOXINA BOTULÍNICA (BOTOX)','PREENCHIMENTO COM ÁCIDO HIALURÔNICO','BIOESTIMULADOR DE COLÁGENO','SKINBOOSTER','PEELING QUÍMICO','MICROAGULHAMENTO','LIMPEZA DE PELE','FIOS DE SUSTENTAÇÃO (PDO)','HARMONIZAÇÃO FACIAL','ESCLEROTERAPIA (VASINHOS)','INTRADERMOTERAPIA / MESOTERAPIA','LIPÓLISE ENZIMÁTICA')`,
|
||
`UPDATE procedimentos SET area = 'odontologia' WHERE area IS NULL`,
|
||
// Backfill das junções: usuário pela role; clínica pelas áreas do catálogo.
|
||
`INSERT INTO usuario_areas (usuario_id, area)
|
||
SELECT id, CASE role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END
|
||
FROM usuarios WHERE role IN ('dentista','biomedico','protetico','donoclinica','donoconsultorio','funcionario','admin')
|
||
ON CONFLICT DO NOTHING`,
|
||
`INSERT INTO clinica_areas (clinica_id, area)
|
||
SELECT DISTINCT clinica_id, area FROM especialidades WHERE clinica_id IS NOT NULL AND area IS NOT NULL
|
||
ON CONFLICT DO NOTHING`,
|
||
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_salas_geo ON salas USING GIST (geo)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_usuarios_geo ON usuarios USING GIST (geo)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_clinicas_geo ON clinicas USING GIST (geo)`,
|
||
// ── 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)`,
|
||
// Modelos de proposta editáveis, por clínica e categoria.
|
||
`CREATE TABLE IF NOT EXISTS propostas_modelos (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
categoria TEXT NOT NULL DEFAULT 'dentista',
|
||
titulo TEXT NOT NULL,
|
||
texto TEXT NOT NULL,
|
||
ordem INTEGER DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_propostas_modelos_clinica ON propostas_modelos (clinica_id, categoria, ordem)`,
|
||
// ── MOTOR CONTRATUAL UNIVERSAL ───────────────────────────────────────
|
||
`CREATE TABLE IF NOT EXISTS contrato_modelos (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT, -- NULL = catálogo global padrão
|
||
nome TEXT NOT NULL,
|
||
categoria TEXT,
|
||
tipo_relacao TEXT,
|
||
corpo TEXT NOT NULL, -- texto com {{VARIÁVEIS}}
|
||
variaveis JSONB DEFAULT '[]',
|
||
vigencia_dias INTEGER,
|
||
editavel BOOLEAN DEFAULT true,
|
||
compartilhavel BOOLEAN DEFAULT false,
|
||
ordem INTEGER DEFAULT 0,
|
||
deleted_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_contrato_modelos_clinica ON contrato_modelos (clinica_id, ordem)`,
|
||
`CREATE TABLE IF NOT EXISTS contratos (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_origem_id TEXT,
|
||
modelo_id TEXT,
|
||
titulo TEXT NOT NULL,
|
||
tipo_relacao TEXT,
|
||
status TEXT DEFAULT 'rascunho', -- rascunho|aguardando_assinatura|assinado|vigente|encerrado|cancelado
|
||
corpo_renderizado TEXT,
|
||
valores JSONB DEFAULT '{}',
|
||
data_inicio DATE,
|
||
data_fim DATE,
|
||
renovacao TEXT DEFAULT 'manual', -- manual|automatica
|
||
aviso_dias INTEGER DEFAULT 30,
|
||
plugin_origem TEXT,
|
||
deleted_at TIMESTAMPTZ,
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_contratos_clinica ON contratos (clinica_origem_id, status)`,
|
||
`ALTER TABLE contratos ADD COLUMN IF NOT EXISTS vencimento_avisado BOOLEAN DEFAULT false`,
|
||
`CREATE TABLE IF NOT EXISTS contrato_partes (
|
||
id TEXT PRIMARY KEY,
|
||
contrato_id TEXT NOT NULL,
|
||
papel TEXT NOT NULL, -- CONTRATANTE|CONTRATADO|LOCADOR|LOCATARIO|TESTEMUNHA
|
||
entidade_tipo TEXT, -- usuario|clinica|sala
|
||
entidade_id TEXT,
|
||
nome_snapshot TEXT,
|
||
doc_snapshot TEXT,
|
||
ordem INTEGER DEFAULT 0
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_contrato_partes_contrato ON contrato_partes (contrato_id)`,
|
||
`CREATE TABLE IF NOT EXISTS contrato_assinaturas (
|
||
id TEXT PRIMARY KEY,
|
||
contrato_id TEXT NOT NULL,
|
||
parte_id TEXT,
|
||
assinante_usuario_id TEXT,
|
||
tipo TEXT DEFAULT 'eletronica_simples',
|
||
status TEXT DEFAULT 'pendente', -- pendente|assinado|recusado
|
||
assinado_em TIMESTAMPTZ,
|
||
ip TEXT,
|
||
user_agent TEXT,
|
||
hash TEXT,
|
||
evidencias JSONB DEFAULT '{}'
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_contrato_assinaturas_contrato ON contrato_assinaturas (contrato_id)`,
|
||
`CREATE TABLE IF NOT EXISTS contrato_documentos (
|
||
id TEXT PRIMARY KEY,
|
||
contrato_id TEXT NOT NULL,
|
||
tipo TEXT, -- original|assinado
|
||
filename TEXT,
|
||
versao INTEGER DEFAULT 1,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
// ── FINANCEIRO V1: chaves de ligação + flags (Épico 1) ───────────────
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS paciente_id TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS profissional_id TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS procedimento_id TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS agendamento_id TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS contrato_id TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS realizado_id TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS origem TEXT DEFAULT 'manual'`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS sem_comissao BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS pago_em TIMESTAMPTZ`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS fornecedor TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS centro_custo TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS recorrente BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS convenio_id TEXT`,
|
||
// Competência por data de conclusão (produção) — fallback p/ vencimento quando nula
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS data_competencia DATE`,
|
||
// Índices p/ as queries novas do módulo financeiro
|
||
`CREATE INDEX IF NOT EXISTS idx_financeiro_contrato_comp ON financeiro (contrato_id, competencia) WHERE contrato_id IS NOT NULL`,
|
||
`CREATE INDEX IF NOT EXISTS idx_financeiro_convenio ON financeiro (clinica_id, convenio_id) WHERE convenio_id IS NOT NULL`,
|
||
`CREATE INDEX IF NOT EXISTS idx_glosas_financeiro ON glosas (financeiro_id)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_glosas_item ON glosas (gto_item_id) WHERE gto_item_id IS NOT NULL`,
|
||
`CREATE INDEX IF NOT EXISTS idx_gto_builders_financeiro ON gto_builders (financeiro_id) WHERE financeiro_id IS NOT NULL`,
|
||
// Épico 4: orçamento Assinado gera contas a receber (idempotência)
|
||
`ALTER TABLE IF EXISTS orcamentos ADD COLUMN IF NOT EXISTS financeiro_gerado BOOLEAN DEFAULT false`,
|
||
// Glosa de convênio: acumulado no lançamento + histórico
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS valor_glosa NUMERIC DEFAULT 0`,
|
||
`CREATE TABLE IF NOT EXISTS glosas (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
financeiro_id TEXT NOT NULL,
|
||
valor NUMERIC NOT NULL DEFAULT 0,
|
||
motivo TEXT,
|
||
status TEXT DEFAULT 'glosado',
|
||
criado_por TEXT,
|
||
criado_por_nome TEXT,
|
||
criado_em TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_glosas_clinica ON glosas (clinica_id, status)`,
|
||
// Glosa por item de GTO + recurso/protocolo com prazo
|
||
`ALTER TABLE glosas ADD COLUMN IF NOT EXISTS gto_item_id TEXT`,
|
||
`ALTER TABLE glosas ADD COLUMN IF NOT EXISTS item_descricao TEXT`,
|
||
`ALTER TABLE glosas ADD COLUMN IF NOT EXISTS protocolo TEXT`,
|
||
`ALTER TABLE glosas ADD COLUMN IF NOT EXISTS prazo_recurso DATE`,
|
||
// H4.2: cobrança recorrente a partir do contrato vigente
|
||
`ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS cobranca_automatica BOOLEAN DEFAULT false`,
|
||
`ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS valor_recorrente NUMERIC`,
|
||
`ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS periodicidade TEXT DEFAULT 'mensal'`,
|
||
`ALTER TABLE IF EXISTS contratos ADD COLUMN IF NOT EXISTS dia_cobranca INTEGER`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS competencia TEXT`,
|
||
// V2 Comissão/Repasse — regra de % por procedimento (procedimento_id NULL = padrão da clínica)
|
||
`CREATE TABLE IF NOT EXISTS comissao_regras (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
procedimento_id TEXT,
|
||
percentual NUMERIC NOT NULL DEFAULT 0,
|
||
criado_em TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
// Gap 1: override de % por profissional · Gap 2: custo de laboratório por procedimento
|
||
`ALTER TABLE comissao_regras ADD COLUMN IF NOT EXISTS profissional_id TEXT`,
|
||
`ALTER TABLE comissao_regras ADD COLUMN IF NOT EXISTS custo_lab NUMERIC DEFAULT 0`,
|
||
`DROP INDEX IF EXISTS uq_comissao_regra`,
|
||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_comissao_regra2 ON comissao_regras (clinica_id, COALESCE(procedimento_id, ''), COALESCE(profissional_id, ''))`,
|
||
// Fechamentos de repasse (snapshot imutável + auditoria)
|
||
`CREATE TABLE IF NOT EXISTS repasses (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
profissional_id TEXT,
|
||
profissional_nome TEXT,
|
||
inicio DATE,
|
||
fim DATE,
|
||
base TEXT,
|
||
base_valor NUMERIC DEFAULT 0,
|
||
comissao_valor NUMERIC DEFAULT 0,
|
||
qtd_lancamentos INTEGER DEFAULT 0,
|
||
detalhes JSONB DEFAULT '[]'::jsonb,
|
||
status TEXT DEFAULT 'fechado',
|
||
criado_por TEXT,
|
||
criado_por_nome TEXT,
|
||
criado_em TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_repasses_clinica ON repasses (clinica_id, status)`,
|
||
// Gap 3: pagamento do repasse (gera DESPESA no financeiro)
|
||
`ALTER TABLE repasses ADD COLUMN IF NOT EXISTS pago_em TIMESTAMPTZ`,
|
||
`ALTER TABLE repasses ADD COLUMN IF NOT EXISTS financeiro_id TEXT`,
|
||
`ALTER TABLE repasses ADD COLUMN IF NOT EXISTS custo_lab_total NUMERIC DEFAULT 0`,
|
||
// GTO finalizada → lançamento no financeiro (vínculo p/ idempotência)
|
||
`ALTER TABLE IF EXISTS gto_builders ADD COLUMN IF NOT EXISTS financeiro_id TEXT`,
|
||
// Comprovante do pagamento do repasse (imagem ou PDF)
|
||
`ALTER TABLE repasses ADD COLUMN IF NOT EXISTS comprovante_filename TEXT`,
|
||
`ALTER TABLE repasses ADD COLUMN IF NOT EXISTS comprovante_mimetype TEXT`,
|
||
`ALTER TABLE repasses ADD COLUMN IF NOT EXISTS comprovante_nome TEXT`,
|
||
`ALTER TABLE financeiro ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ`,
|
||
`CREATE INDEX IF NOT EXISTS idx_financeiro_clinica ON financeiro (clinica_id, status, datavencimento)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_financeiro_prof ON financeiro (clinica_id, profissional_id)`,
|
||
// ── PRONTUÁRIO: procedimentos realizados + reabertura/garantia ───────
|
||
`CREATE TABLE IF NOT EXISTS procedimentos_realizados (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
paciente_id TEXT,
|
||
paciente_nome TEXT,
|
||
profissional_id TEXT,
|
||
profissional_nome TEXT,
|
||
procedimento_id TEXT,
|
||
procedimento_nome TEXT,
|
||
agendamento_id TEXT,
|
||
tipo TEXT DEFAULT 'novo', -- novo | reabertura | garantia
|
||
origem_id TEXT, -- procedimento original (em reabertura/garantia)
|
||
sem_comissao BOOLEAN DEFAULT false,
|
||
valor NUMERIC DEFAULT 0,
|
||
observacoes TEXT,
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
deleted_at TIMESTAMPTZ
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_proc_realizados_pac ON procedimentos_realizados (clinica_id, paciente_id, created_at)`,
|
||
`CREATE TABLE IF NOT EXISTS procedimento_realizado_foto (
|
||
id TEXT PRIMARY KEY,
|
||
realizado_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
momento TEXT, -- antes | depois
|
||
filename TEXT,
|
||
mimetype TEXT,
|
||
original_name TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_proc_foto_realizado ON procedimento_realizado_foto (realizado_id, momento)`,
|
||
// ── PRÓTESES: Ordem de Serviço (clínica → laboratório/protético) ─────
|
||
`CREATE TABLE IF NOT EXISTS protese_os (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL, -- clínica origem (quem pediu) — escopo tenantGuard
|
||
laboratorio_id TEXT, -- workspace destino (futuro; null = roteia por protetico_id)
|
||
protetico_id TEXT, -- usuário destinatário (a "bancada")
|
||
protetico_nome TEXT,
|
||
paciente_id TEXT,
|
||
paciente_nome TEXT,
|
||
dentista_id TEXT,
|
||
dentista_nome TEXT,
|
||
procedimento_id TEXT,
|
||
agendamento_id TEXT,
|
||
tipo TEXT, -- fixa | removível | total | sobre implante | provisório...
|
||
material TEXT,
|
||
cor TEXT, -- escala de cor
|
||
dentes TEXT, -- FDI livre (ex: 11,21)
|
||
observacoes TEXT,
|
||
prazo_entrega DATE,
|
||
valor NUMERIC DEFAULT 0, -- preço cobrado pela OS
|
||
custo NUMERIC DEFAULT 0, -- custo de laboratório (elo futuro com comissão)
|
||
status TEXT DEFAULT 'solicitado',-- solicitado|recebido|producao|prova|ajuste|finalizado|entregue|cancelado
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ,
|
||
deleted_at TIMESTAMPTZ
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_clinica ON protese_os (clinica_id, status, created_at)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_lab ON protese_os (protetico_id, status)`,
|
||
`CREATE TABLE IF NOT EXISTS protese_os_evento (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
status TEXT,
|
||
observacao TEXT,
|
||
actor_id TEXT,
|
||
actor_nome TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_evento ON protese_os_evento (os_id, created_at)`,
|
||
`CREATE TABLE IF NOT EXISTS protese_os_foto (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
filename TEXT,
|
||
mimetype TEXT,
|
||
original_name TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_foto ON protese_os_foto (os_id)`,
|
||
// Catálogo de produtos do protético (preço fixo, sem especialidade — geral de prótese) + garantia
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS produto_id TEXT`,
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS tipo_os TEXT DEFAULT 'nova'`, // nova | garantia
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS origem_os_id TEXT`, // OS original (em garantia)
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_meses INTEGER DEFAULT 12`,
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS garantia_ate DATE`,
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_id TEXT`, // DESPESA gerada na entrega
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS gto_item_id TEXT`, // item de GTO que originou a OS (Fase 1 prótese↔GTO)
|
||
// Prótese no Lançar GTO: o item de GTO referencia um produto do catálogo do protético.
|
||
`ALTER TABLE gto_items ADD COLUMN IF NOT EXISTS produto_id TEXT`,
|
||
`ALTER TABLE gto_items ADD COLUMN IF NOT EXISTS protetico_id TEXT`,
|
||
// Especialidade de prótese vinha marcada como 'odontologia' (legado) → re-tag para 'protese'
|
||
// permite o front detectar a especialidade e puxar o catálogo do protético. Idempotente.
|
||
`UPDATE especialidades SET area='protese' WHERE area IS DISTINCT FROM 'protese' AND (nome ILIKE '%protese%' OR nome ILIKE '%prótese%')`,
|
||
// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
|
||
// RECEITA espelhada no lab (preenchida na entrega; idempotência) e catálogo por lab.
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`,
|
||
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
|
||
// (protese_os.laboratorio_id já existe — coluna reservada na criação da tabela.)
|
||
// Re-tag: o workspace pessoal do protético passa a ser 'laboratorio'.
|
||
`UPDATE clinicas SET tipo='laboratorio'
|
||
WHERE tipo='pessoal' AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,
|
||
// Backfill: cada OS herda o laboratorio do workspace do seu protético.
|
||
`UPDATE protese_os o SET laboratorio_id = c.id
|
||
FROM clinicas c
|
||
WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,
|
||
// Backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat).
|
||
`UPDATE protese_produtos p SET laboratorio_id = c.id
|
||
FROM clinicas c
|
||
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
|
||
// ── Profissionalização da OS de prótese ────────────────────────────────────
|
||
// Fotos com autoria + legenda (quem anexou / o quê).
|
||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_by TEXT`,
|
||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS uploaded_nome TEXT`,
|
||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS legenda TEXT`,
|
||
// Componentes/insumos enviados ao laboratório (ex.: implante, pilar, modelo).
|
||
`CREATE TABLE IF NOT EXISTS protese_os_componente (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
nome TEXT NOT NULL,
|
||
quantidade INTEGER DEFAULT 1,
|
||
observacao TEXT,
|
||
enviado_por TEXT,
|
||
enviado_nome TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_comp ON protese_os_componente (os_id, created_at)`,
|
||
// Pagamentos/recebimentos vinculados à OS (dois lados):
|
||
// lado='lab' → a clínica paga o laboratório (a-receber do lab)
|
||
// lado='clinica' → o paciente paga a clínica pela prótese
|
||
`CREATE TABLE IF NOT EXISTS protese_os_pagamento (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
lado TEXT NOT NULL,
|
||
valor NUMERIC DEFAULT 0,
|
||
forma TEXT,
|
||
observacao TEXT,
|
||
data DATE DEFAULT CURRENT_DATE,
|
||
recebido_por TEXT,
|
||
recebido_nome TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_pag ON protese_os_pagamento (os_id, data)`,
|
||
// Custódia: onde o trabalho físico está agora (clinica|laboratorio) + log de movimentações
|
||
// (saber se o protético "pegou" o trabalho ou se está na clínica).
|
||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS local_atual TEXT DEFAULT 'clinica'`,
|
||
`CREATE TABLE IF NOT EXISTS protese_os_custodia (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
de_local TEXT,
|
||
para_local TEXT NOT NULL,
|
||
etapa TEXT,
|
||
observacao TEXT,
|
||
actor_id TEXT,
|
||
actor_nome TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_cust ON protese_os_custodia (os_id, created_at)`,
|
||
// ── FASE 2: Rastreabilidade de componentes + Ocorrências (com contraditório) ──
|
||
// Componentes ganham direção (enviado clínica→lab | devolvido lab→clínica) e conferência.
|
||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS direcao TEXT DEFAULT 'enviado'`,
|
||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS status_conferencia TEXT DEFAULT 'pendente'`, // pendente|ok|divergente|ausente
|
||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_nome TEXT`,
|
||
`ALTER TABLE protese_os_componente ADD COLUMN IF NOT EXISTS conferido_em TIMESTAMPTZ`,
|
||
// Ocorrências técnicas (parafuso trocado, peça danificada, retrabalho…). Contraditório:
|
||
// aberta → respondida → em_analise → resolvida | contestada. Só resolvidas pesam no score (Fase 5).
|
||
`CREATE TABLE IF NOT EXISTS protese_os_ocorrencia (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
componente_id TEXT,
|
||
categoria TEXT NOT NULL,
|
||
descricao TEXT,
|
||
responsavel TEXT,
|
||
resposta TEXT,
|
||
resposta_nome TEXT,
|
||
status TEXT DEFAULT 'aberta',
|
||
autor_id TEXT,
|
||
autor_nome TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
resolved_at TIMESTAMPTZ
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_ocor ON protese_os_ocorrencia (os_id, created_at)`,
|
||
// Fotos de evidência da ocorrência reusam protese_os_foto (com ocorrencia_id).
|
||
`ALTER TABLE protese_os_foto ADD COLUMN IF NOT EXISTS ocorrencia_id TEXT`,
|
||
// Modo 1 — link seguro /p/TOKEN: o protético acompanha a OS sem conta. Token por OS, revogável.
|
||
`CREATE TABLE IF NOT EXISTS protese_os_link (
|
||
token TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
protetico_id TEXT,
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ,
|
||
revoked_at TIMESTAMPTZ,
|
||
last_access_at TIMESTAMPTZ,
|
||
acessos INTEGER DEFAULT 0
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id)`,
|
||
// Marketplace transacional Fatia 3 — Cotação (RFQ): a clínica descreve um trabalho e N
|
||
// laboratórios cotam (preço/prazo); a clínica escolhe e vira OS.
|
||
`CREATE TABLE IF NOT EXISTS protese_rfq (
|
||
id TEXT PRIMARY KEY,
|
||
clinica_id TEXT NOT NULL,
|
||
descricao TEXT NOT NULL,
|
||
dentes TEXT,
|
||
prazo_desejado DATE,
|
||
paciente_id TEXT,
|
||
paciente_nome TEXT,
|
||
dentista_id TEXT,
|
||
dentista_nome TEXT,
|
||
status TEXT DEFAULT 'aberta',
|
||
os_id TEXT,
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_clinica ON protese_rfq (clinica_id, created_at)`,
|
||
`CREATE TABLE IF NOT EXISTS protese_rfq_cotacao (
|
||
id TEXT PRIMARY KEY,
|
||
rfq_id TEXT NOT NULL,
|
||
protetico_id TEXT NOT NULL,
|
||
protetico_nome TEXT,
|
||
valor NUMERIC,
|
||
prazo_dias INTEGER,
|
||
observacao TEXT,
|
||
status TEXT DEFAULT 'convidada',
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
respondida_em TIMESTAMPTZ
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_rfq ON protese_rfq_cotacao (rfq_id)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_rfq_cot_prot ON protese_rfq_cotacao (protetico_id, status)`,
|
||
// Fatia 4 — Avaliação da clínica ao final da OS (satisfação → alimenta a Nota; selo verificado).
|
||
`CREATE TABLE IF NOT EXISTS protese_avaliacao (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL UNIQUE,
|
||
clinica_id TEXT,
|
||
protetico_id TEXT NOT NULL,
|
||
estrelas INTEGER NOT NULL,
|
||
comentario TEXT,
|
||
autor_id TEXT,
|
||
autor_nome TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_aval_prot ON protese_avaliacao (protetico_id)`,
|
||
// Agenda & Coleta do laboratório: coleta (lab busca) / entrega (lab leva). 'realizada' move a custódia.
|
||
`CREATE TABLE IF NOT EXISTS protese_coleta (
|
||
id TEXT PRIMARY KEY,
|
||
os_id TEXT NOT NULL,
|
||
clinica_id TEXT,
|
||
laboratorio_id TEXT,
|
||
protetico_id TEXT,
|
||
tipo TEXT NOT NULL,
|
||
data_hora TIMESTAMPTZ,
|
||
endereco TEXT,
|
||
responsavel TEXT,
|
||
observacao TEXT,
|
||
status TEXT DEFAULT 'agendada',
|
||
created_by TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
|
||
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
|
||
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
|
||
clinica_id TEXT NOT NULL,
|
||
protetico_id TEXT NOT NULL,
|
||
favorito BOOLEAN DEFAULT false,
|
||
padrao BOOLEAN DEFAULT false,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (clinica_id, protetico_id)
|
||
)`,
|
||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||
id TEXT PRIMARY KEY,
|
||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||
nome TEXT NOT NULL,
|
||
preco NUMERIC DEFAULT 0, -- preço fixo cobrado da clínica (= custo de laboratório)
|
||
garantia_meses INTEGER DEFAULT 12,
|
||
ativo BOOLEAN DEFAULT true,
|
||
ordem INTEGER DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
deleted_at TIMESTAMPTZ
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`,
|
||
];
|
||
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', authGuard, 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', authGuard, 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.
|
||
// Lista de workspaces do usuário (clínicas + salas) — p/ refresh sem re-login (ex.: após criar uma sala).
|
||
app.get('/api/me/workspaces', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
|
||
const ws = await listarWorkspaces(req.authUser.userId, rows[0]?.role);
|
||
res.json({ success: true, workspaces: ws });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
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 }); }
|
||
});
|
||
|
||
// Lista as áreas de atuação (referência).
|
||
app.get('/api/areas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT slug, nome, cor, icone, ordem FROM areas ORDER BY ordem');
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Áreas do usuário logado (multi-área do perfil).
|
||
app.get('/api/me/areas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT area FROM usuario_areas WHERE usuario_id = $1', [req.authUser.userId]);
|
||
res.json({ areas: rows.map(r => r.area) });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.put('/api/me/areas', authGuard, async (req, res) => {
|
||
try {
|
||
const areas = Array.isArray(req.body?.areas) ? req.body.areas.filter(a => typeof a === 'string') : [];
|
||
const { rows: valid } = await pool.query('SELECT slug FROM areas');
|
||
const slugs = new Set(valid.map(v => v.slug));
|
||
const ok = [...new Set(areas)].filter(a => slugs.has(a));
|
||
await withTransaction(async (client) => {
|
||
await client.query('DELETE FROM usuario_areas WHERE usuario_id = $1', [req.authUser.userId]);
|
||
for (const a of ok) await client.query('INSERT INTO usuario_areas (usuario_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [req.authUser.userId, a]);
|
||
});
|
||
res.json({ success: true, areas: ok });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Áreas de uma clínica (multi-área da unidade).
|
||
app.get('/api/clinicas/:clinicaId/areas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query('SELECT area FROM clinica_areas WHERE clinica_id = $1', [req.params.clinicaId]);
|
||
res.json({ areas: rows.map(r => r.area) });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
app.put('/api/clinicas/:clinicaId/areas', authGuard, async (req, res) => {
|
||
try {
|
||
const cid = req.params.clinicaId;
|
||
const { rows: own } = await pool.query(
|
||
`SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2
|
||
UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`,
|
||
[cid, req.authUser.userId]);
|
||
if (!own.length) return res.status(403).json({ error: 'Apenas o dono/admin gerencia as áreas da unidade.' });
|
||
const areas = Array.isArray(req.body?.areas) ? req.body.areas.filter(a => typeof a === 'string') : [];
|
||
const { rows: valid } = await pool.query('SELECT slug FROM areas');
|
||
const slugs = new Set(valid.map(v => v.slug));
|
||
const ok = [...new Set(areas)].filter(a => slugs.has(a));
|
||
await withTransaction(async (client) => {
|
||
await client.query('DELETE FROM clinica_areas WHERE clinica_id = $1', [cid]);
|
||
for (const a of ok) await client.query('INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [cid, a]);
|
||
});
|
||
res.json({ success: true, areas: ok });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Semeia o catálogo de biomedicina estética na clínica/consultório do usuário (idempotente).
|
||
// Usado no 1º acesso do biomédico ao catálogo (cobre contas criadas antes do auto-seed).
|
||
app.post('/api/me/seed-biomedicina', authGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = String(req.body?.clinicaId || req.query.clinicaId || '').trim();
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
|
||
// só BIOMÉDICO semeia (evita poluir clínica odonto vazia com catálogo de biomedicina)
|
||
const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
|
||
if (u[0]?.role !== 'biomedico') return res.status(403).json({ error: 'Apenas biomédicos têm o catálogo de biomedicina.' });
|
||
// e precisa ser dono/vinculado da unidade
|
||
const { rows } = await pool.query(
|
||
`SELECT 1 FROM clinicas WHERE id = $1 AND owner_id = $2
|
||
UNION SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1`,
|
||
[clinicaId, req.authUser.userId]);
|
||
if (!rows.length) return res.status(403).json({ error: 'Sem acesso a esta unidade.' });
|
||
const seeded = await seedBiomedicina(clinicaId);
|
||
res.json({ success: true, seeded });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ── Geocoding de CEP: cache + rotação de provedores grátis ──────────────────
|
||
// Economia: cada CEP é geocodado UMA vez (cache cep_geo). Rotação robusta p/ o
|
||
// egress deste servidor: AwesomeAPI → Nominatim(CEP) → ViaCEP+Nominatim(endereço).
|
||
// Nominatim exige User-Agent identificável e é rate-limited (~1 req/s) — o cache
|
||
// torna as chamadas raras. Retorna null se ninguém resolver (não bloqueia o save).
|
||
const GEO_UA = 'ScoreOdonto/1.0 (+https://scoreodonto.com)';
|
||
|
||
async function _geoAwesome(cep) {
|
||
try {
|
||
const r = await fetch(`https://cep.awesomeapi.com.br/json/${cep}`, { signal: AbortSignal.timeout(5000) });
|
||
if (!r.ok) return null;
|
||
const d = await r.json();
|
||
if (d?.lat && d?.lng) return { lat: +d.lat, lng: +d.lng, fonte: 'awesomeapi', precisao: 'exata' };
|
||
return null;
|
||
} catch { return null; }
|
||
}
|
||
async function _geoNominatim(query, fonte, precisao) {
|
||
try {
|
||
const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&${query}`,
|
||
{ headers: { 'User-Agent': GEO_UA, 'Accept-Language': 'pt-BR' }, signal: AbortSignal.timeout(8000) });
|
||
if (!r.ok) return null;
|
||
const d = await r.json();
|
||
if (Array.isArray(d) && d[0]?.lat && d[0]?.lon) return { lat: +d[0].lat, lng: +d[0].lon, fonte, precisao };
|
||
return null;
|
||
} catch { return null; }
|
||
}
|
||
async function _viaCepEndereco(cep) {
|
||
try {
|
||
const r = await fetch(`https://viacep.com.br/ws/${cep}/json/`, { signal: AbortSignal.timeout(5000) });
|
||
if (!r.ok) return null;
|
||
const d = await r.json();
|
||
if (d.erro) return null;
|
||
return [d.logradouro, d.bairro, d.localidade, d.uf, 'Brasil'].filter(Boolean).join(', ');
|
||
} catch { return null; }
|
||
}
|
||
async function geocodarCep(cep) {
|
||
const digits = (cep || '').replace(/\D/g, '');
|
||
if (digits.length !== 8) return null;
|
||
const cached = await pool.query('SELECT lat, lng, fonte, precisao FROM cep_geo WHERE cep = $1', [digits]);
|
||
if (cached.rows.length) return cached.rows[0];
|
||
|
||
let r = await _geoAwesome(digits);
|
||
if (!r) r = await _geoNominatim(`country=Brazil&postalcode=${digits.slice(0, 5)}-${digits.slice(5)}`, 'nominatim-cep', 'exata');
|
||
if (!r) { const addr = await _viaCepEndereco(digits); if (addr) r = await _geoNominatim(`q=${encodeURIComponent(addr)}`, 'nominatim-addr', 'aproximada'); }
|
||
if (!r) return null;
|
||
|
||
await pool.query(
|
||
`INSERT INTO cep_geo (cep, lat, lng, fonte, precisao) VALUES ($1,$2,$3,$4,$5)
|
||
ON CONFLICT (cep) DO UPDATE SET lat=EXCLUDED.lat, lng=EXCLUDED.lng, fonte=EXCLUDED.fonte, precisao=EXCLUDED.precisao, atualizado_em=NOW()`,
|
||
[digits, r.lat, r.lng, r.fonte, r.precisao]);
|
||
return r;
|
||
}
|
||
// Atualiza a coluna geography de um registro pelo CEP (best-effort; `table` é fixo no servidor).
|
||
async function setGeoByCep(table, id, cep) {
|
||
try {
|
||
const g = await geocodarCep(cep);
|
||
if (g) await pool.query(`UPDATE ${table} SET geo = ST_SetSRID(ST_MakePoint($1,$2),4326)::geography WHERE id = $3`, [g.lng, g.lat, id]);
|
||
return g;
|
||
} catch (e) { console.error('[setGeoByCep]', table, e.message); return null; }
|
||
}
|
||
|
||
// Resolve a ORIGEM da busca (de onde medir distância): CEP digitado → clínica ativa
|
||
// (geo da clínica) → fallback no geo do próprio usuário logado. Retorna {lat,lng} ou null.
|
||
async function resolveOrigem(req, cep, clinicaId) {
|
||
if (cep) return await geocodarCep(cep);
|
||
try {
|
||
if (clinicaId) {
|
||
const r = await pool.query('SELECT ST_Y(geo::geometry) AS lat, ST_X(geo::geometry) AS lng FROM clinicas WHERE id=$1 AND geo IS NOT NULL', [clinicaId]);
|
||
if (r.rows.length) return r.rows[0];
|
||
}
|
||
const u = await pool.query('SELECT ST_Y(geo::geometry) AS lat, ST_X(geo::geometry) AS lng FROM usuarios WHERE id=$1 AND geo IS NOT NULL', [req.authUser.userId]);
|
||
if (u.rows.length) return u.rows[0];
|
||
} catch (e) { console.error('[resolveOrigem]', e.message); }
|
||
return null;
|
||
}
|
||
|
||
// Minhas salas (como locador)
|
||
app.get('/api/salas/minhas', authGuard, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT s.*, c.nome_fantasia 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 }); }
|
||
});
|
||
|
||
// Relatório por sala (faturamento de locação + ocupação) no período — escopo: dono.
|
||
app.get('/api/salas/relatorio', authGuard, async (req, res) => {
|
||
try {
|
||
const { inicio, fim } = req.query;
|
||
const i = inicio || '2000-01-01', f = fim || '2100-01-01';
|
||
const { rows } = await pool.query(`
|
||
SELECT s.id, s.nome, s.cidade, s.estado,
|
||
COALESCE(fin.recebido,0) AS recebido,
|
||
COALESCE(fin.a_receber,0) AS a_receber,
|
||
COALESCE(res.qtd,0) AS reservas,
|
||
COALESCE(res.horas,0) AS horas
|
||
FROM salas s
|
||
LEFT JOIN (
|
||
SELECT clinica_id AS sala_id,
|
||
SUM(CASE WHEN status='Pago' THEN valor ELSE 0 END) AS recebido,
|
||
SUM(CASE WHEN status<>'Pago' THEN valor ELSE 0 END) AS a_receber
|
||
FROM financeiro
|
||
WHERE origem='locacao' AND deleted_at IS NULL AND datavencimento BETWEEN $2 AND $3
|
||
GROUP BY clinica_id
|
||
) fin ON fin.sala_id = s.id
|
||
LEFT JOIN (
|
||
SELECT sala_id, COUNT(*) AS qtd, SUM(EXTRACT(EPOCH FROM (fim - inicio))/3600.0) AS horas
|
||
FROM sala_reservas
|
||
WHERE status='confirmada' AND inicio BETWEEN $2 AND $3
|
||
GROUP BY sala_id
|
||
) res ON res.sala_id = s.id
|
||
WHERE s.owner_usuario_id = $1
|
||
ORDER BY recebido DESC, s.nome
|
||
`, [req.authUser.userId, i, f]);
|
||
const num = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, (k === 'nome' || k === 'cidade' || k === 'estado' || k === 'id') ? v : Number(v)]));
|
||
const data = rows.map(num);
|
||
res.json({
|
||
salas: data,
|
||
total_recebido: Number(data.reduce((s, r) => s + r.recebido, 0).toFixed(2)),
|
||
total_a_receber: Number(data.reduce((s, r) => s + r.a_receber, 0).toFixed(2)),
|
||
total_reservas: data.reduce((s, r) => s + r.reservas, 0),
|
||
total_horas: Number(data.reduce((s, r) => s + r.horas, 0).toFixed(1)),
|
||
});
|
||
} 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)}`;
|
||
let area = b.area;
|
||
if (!area) { const { rows: ur } = await pool.query('SELECT role FROM usuarios WHERE id=$1', [req.authUser.userId]); area = roleToArea(ur[0]?.role); }
|
||
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, area,
|
||
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`,
|
||
[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, area,
|
||
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true]);
|
||
await setGeoByCep('salas', id, b.cep);
|
||
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,
|
||
area: b.area, disponivel: b.disponivel,
|
||
hora_consulta: b.horaConsulta, turno_consulta: b.turnoConsulta, diaria_consulta: b.diariaConsulta, semanal_consulta: b.semanalConsulta, mensal_consulta: b.mensalConsulta };
|
||
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]);
|
||
if (b.cep !== undefined) await setGeoByCep('salas', req.params.id, b.cep);
|
||
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, cep, raio, clinicaId, area } = req.query;
|
||
const raioKm = Number(raio);
|
||
// Origem = CEP digitado → clínica ativa → próprio usuário (proximidade automática).
|
||
const origin = await resolveOrigem(req, cep, clinicaId);
|
||
|
||
const params = [];
|
||
let where = `WHERE s.disponivel = true`;
|
||
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}`; }
|
||
if (area) { params.push(String(area)); where += ` AND s.area = $${params.length}`; }
|
||
|
||
let distSelect = 'NULL::float AS dist_km';
|
||
let order = 'ORDER BY s.estado, s.cidade, s.nome';
|
||
if (origin) {
|
||
params.push(origin.lng); const pLng = params.length;
|
||
params.push(origin.lat); const pLat = params.length;
|
||
const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`;
|
||
distSelect = `ROUND((ST_Distance(s.geo, ${pt})/1000)::numeric, 1) AS dist_km`;
|
||
order = `ORDER BY ST_Distance(s.geo, ${pt}) NULLS LAST`;
|
||
if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND s.geo IS NOT NULL AND ST_DWithin(s.geo, ${pt}, $${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.area,
|
||
s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal,
|
||
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta,
|
||
s.owner_usuario_id, u.nome AS owner_nome, c.nome_fantasia AS clinica_nome, ${distSelect}
|
||
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} 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, COALESCE(tipo, 'locacao') AS tipo,
|
||
CASE WHEN $2 OR solicitante_id = $3 THEN solicitante_nome ELSE NULL END AS solicitante_nome,
|
||
CASE WHEN tipo = 'manutencao' THEN observacoes ELSE NULL END AS motivo
|
||
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 }); }
|
||
});
|
||
|
||
// Bloqueio de sala para MANUTENÇÃO: tira a sala da agenda num intervalo, sem cobrança.
|
||
// Quem pode: o dono da sala OU qualquer membro com vínculo na clínica dona da sala
|
||
// (ex.: recepção). Nasce 'confirmada' (não precisa aprovação) e não gera financeiro.
|
||
app.post('/api/salas/:id/manutencao', authGuard, async (req, res) => {
|
||
try {
|
||
const { inicio, fim, motivo } = 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 uid = req.authUser.userId;
|
||
const { rows: sr } = await pool.query('SELECT id, nome, owner_usuario_id, clinica_id FROM salas WHERE id = $1', [req.params.id]);
|
||
if (!sr.length) return res.status(404).json({ error: 'Sala não encontrada.' });
|
||
const sala = sr[0];
|
||
const isOwner = sala.owner_usuario_id === uid;
|
||
let isDaUnidade = false;
|
||
if (!isOwner && sala.clinica_id) {
|
||
const { rows: v } = await pool.query('SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [uid, sala.clinica_id]);
|
||
isDaUnidade = v.length > 0;
|
||
}
|
||
if (!isOwner && !isDaUnidade) return res.status(403).json({ error: 'Sem acesso a esta sala.' });
|
||
const nome = await actorNome(uid);
|
||
const id = `res_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await withTransaction(async (client) => {
|
||
const { rows: conf } = 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 (conf.length) throw { code: 'CONF_SALA', conflito: conf[0] };
|
||
await client.query(
|
||
`INSERT INTO sala_reservas (id, sala_id, solicitante_id, solicitante_nome, inicio, fim, modelo, valor, status, observacoes, clinica_id, tipo)
|
||
VALUES ($1,$2,$3,$4,$5,$6,'hora',0,'confirmada',$7,$8,'manutencao')`,
|
||
[id, req.params.id, uid, nome, inicio, fim, motivo || 'Manutenção', sala.clinica_id || null]);
|
||
await registrarAudit(client, { clinicaId: sala.clinica_id || null, entidade: 'sala_reserva', entidadeId: id, acao: 'manutencao', actorId: uid, actorNome: nome, detalhes: { salaId: req.params.id, inicio, fim, motivo: motivo || null } });
|
||
});
|
||
if (!isOwner) await notificarUsuario(sala.owner_usuario_id, 'Sala bloqueada p/ manutenção',
|
||
`${nome} bloqueou a sala "${sala.nome}" para manutenção (${new Date(inicio).toLocaleString('pt-BR')}).`);
|
||
res.json({ success: true, id, status: 'confirmada', tipo: 'manutencao' });
|
||
} catch (err) {
|
||
if (err.code === 'CONF_SALA') return res.status(409).json({ error: 'CONFLITO: já existe reserva/bloqueio neste horário.', conflito: err.conflito });
|
||
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]);
|
||
// Fatia 1b — reserva confirmada gera RECEITA de locação no financeiro da SALA (workspace = sala_id)
|
||
let financeiroId = r.financeiro_id || null;
|
||
if (novo === 'confirmada' && !r.financeiro_id && Number(r.valor) > 0) {
|
||
financeiroId = `fin_loc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
const quem = r.clinica_nome || r.solicitante_nome || r.profissional_nome || 'Locatário';
|
||
const vencYmd = (r.inicio instanceof Date ? r.inicio.toISOString() : String(r.inicio)).slice(0, 10);
|
||
await pool.query(`
|
||
INSERT INTO financeiro (id, clinica_id, descricao, pacientenome, valor, tipo, status, datavencimento, data_competencia, profissional_id, origem)
|
||
VALUES ($1,$2,$3,$4,$5,'RECEITA','Pendente', $6::date, $6::date, $7, 'locacao')
|
||
`, [financeiroId, r.sala_id, `Locação ${r.sala_nome} — ${quem}`, quem, Number(r.valor), vencYmd, r.owner_usuario_id]);
|
||
await pool.query('UPDATE sala_reservas SET financeiro_id = $1 WHERE id = $2', [financeiroId, r.id]);
|
||
}
|
||
// Cancelar reserva confirmada → estorna (soft-delete) o recebível gerado
|
||
if (novo === 'cancelada' && r.financeiro_id) {
|
||
await pool.query('UPDATE financeiro SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL', [r.financeiro_id]);
|
||
await pool.query('UPDATE sala_reservas SET financeiro_id = NULL WHERE id = $1', [r.id]);
|
||
financeiroId = null;
|
||
}
|
||
await registrarAudit(pool, { clinicaId: r.sala_id, entidade: 'sala_reserva', entidadeId: r.id, acao: novo, actorId: uid, actorNome: await actorNome(uid), detalhes: { salaId: r.sala_id, financeiro_id: financeiroId } });
|
||
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, logradouro, numero, 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, logradouro = $9, numero = $10
|
||
WHERE id = $11`,
|
||
[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,
|
||
b.logradouro?.toUpperCase() || null, b.numero || null, req.authUser.userId]);
|
||
await setGeoByCep('usuarios', req.authUser.userId, b.cep);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Especialidades disponíveis para o dropdown da busca — por área (slug) ou tipo (role).
|
||
// Une o catálogo global da área com as especialidades reais já declaradas pelos profissionais.
|
||
const AREA_TO_ROLE = { odontologia: 'dentista', biomedicina: 'biomedico', protese: 'protetico' };
|
||
const ROLE_TO_AREA = { dentista: 'odontologia', biomedico: 'biomedicina', protetico: 'protese' };
|
||
app.get('/api/profissionais/especialidades', authGuard, async (req, res) => {
|
||
let { area, tipo } = req.query;
|
||
if (!area && tipo && ROLE_TO_AREA[tipo]) area = ROLE_TO_AREA[tipo];
|
||
const role = AREA_TO_ROLE[area];
|
||
if (!area || !role) return res.json([]); // sem área/tipo definido → sem lista
|
||
try {
|
||
const cat = await pool.query(
|
||
`SELECT DISTINCT UPPER(nome) AS nome FROM especialidades
|
||
WHERE area = $1 AND clinica_id IS NULL AND nome IS NOT NULL AND nome <> ''`, [area]);
|
||
const real = await pool.query(
|
||
`SELECT DISTINCT UPPER(especialidade_dir) AS nome FROM usuarios
|
||
WHERE role = $1 AND disponivel_diretorio = true AND especialidade_dir IS NOT NULL AND especialidade_dir <> ''`, [role]);
|
||
const set = new Set([...cat.rows, ...real.rows].map(r => r.nome));
|
||
if (area === 'biomedicina') BIOMED_ESPECIALIDADES.forEach(e => set.add(e.toUpperCase()));
|
||
res.json([...set].sort((a, b) => a.localeCompare(b, 'pt-BR')));
|
||
} 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 { q, tipo, estado, cidade, especialidade, cep, raio, clinicaId, area } = req.query;
|
||
const raioKm = Number(raio);
|
||
// Origem = CEP digitado → clínica ativa → próprio usuário (mostra os mais próximos primeiro).
|
||
const origin = await resolveOrigem(req, cep, clinicaId);
|
||
|
||
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 (q && String(q).trim()) { params.push(`%${String(q).trim()}%`); where += ` AND (nome ILIKE $${params.length} OR especialidade_dir ILIKE $${params.length} OR bio_profissional ILIKE $${params.length})`; }
|
||
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 (area) { params.push(String(area)); where += ` AND EXISTS (SELECT 1 FROM usuario_areas ua WHERE ua.usuario_id = usuarios.id AND ua.area = $${params.length})`; }
|
||
|
||
let distSelect = 'NULL::float AS dist_km';
|
||
let order = 'ORDER BY estado, cidade, nome';
|
||
if (origin) {
|
||
params.push(origin.lng); const pLng = params.length;
|
||
params.push(origin.lat); const pLat = params.length;
|
||
const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`;
|
||
distSelect = `ROUND((ST_Distance(geo, ${pt})/1000)::numeric, 1) AS dist_km`;
|
||
order = `ORDER BY ST_Distance(geo, ${pt}) NULLS LAST`;
|
||
if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND geo IS NOT NULL AND ST_DWithin(geo, ${pt}, $${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, ${distSelect}
|
||
FROM usuarios ${where} ${order} LIMIT 200`, params);
|
||
// Nota ScoreOdonto nos protéticos (score público no marketplace). Mantém a ordem por
|
||
// proximidade; a nota é exibida no card para a clínica comparar.
|
||
const notas = await notasScorePorProtetico(rows.filter(r => r.role === 'protetico').map(r => r.id));
|
||
res.json(rows.map(r => r.role === 'protetico'
|
||
? { ...r, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }
|
||
: r));
|
||
} 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 }); }
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// MOTOR CONTRATUAL UNIVERSAL — Fatia 1: biblioteca de modelos + variáveis
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
const VARIAVEIS_CONTRATO = [
|
||
{ chave: '{{CONTRATANTE}}', desc: 'Nome da parte contratante' },
|
||
{ chave: '{{CONTRATADO}}', desc: 'Nome da parte contratada' },
|
||
{ chave: '{{CPF}}', desc: 'CPF da pessoa física' },
|
||
{ chave: '{{CNPJ}}', desc: 'CNPJ da pessoa jurídica' },
|
||
{ chave: '{{ENDERECO}}', desc: 'Endereço completo' },
|
||
{ chave: '{{CRO}}', desc: 'CRO do dentista' },
|
||
{ chave: '{{CRBM}}', desc: 'CRBM do biomédico' },
|
||
{ chave: '{{ESPECIALIDADE}}', desc: 'Especialidade profissional' },
|
||
{ chave: '{{SALA}}', desc: 'Identificação da sala' },
|
||
{ chave: '{{VALOR}}', desc: 'Valor do contrato' },
|
||
{ chave: '{{PERCENTUAL}}', desc: 'Percentual / comissão' },
|
||
{ chave: '{{DATA_INICIO}}', desc: 'Início da vigência' },
|
||
{ chave: '{{DATA_FIM}}', desc: 'Término da vigência' },
|
||
{ chave: '{{CIDADE}}', desc: 'Cidade do foro' },
|
||
{ chave: '{{DATA_HOJE}}', desc: 'Data de geração' },
|
||
// Atendimento a convênios/planos (dentista credenciado)
|
||
{ chave: '{{CONVENIOS}}', desc: 'Convênios/planos atendidos' },
|
||
{ chave: '{{PERCENTUAL_REPASSE}}', desc: 'Percentual de repasse sobre a produção de convênio' },
|
||
{ chave: '{{BASE_REPASSE}}', desc: 'Base do repasse (recebido/caixa ou produção)' },
|
||
{ chave: '{{PRAZO_REPASSE}}', desc: 'Prazo/dia do repasse ao profissional' },
|
||
{ chave: '{{RESPONSAVEL_GLOSA}}', desc: 'Responsável pelas glosas do convênio' },
|
||
{ chave: '{{CUSTO_LABORATORIO}}', desc: 'Tratamento do custo de laboratório/prótese' },
|
||
];
|
||
|
||
function buildCorpoModelo(objeto) {
|
||
return `CLÁUSULA 1ª – DO OBJETO
|
||
${objeto}
|
||
|
||
CLÁUSULA 2ª – DAS PARTES
|
||
CONTRATANTE: {{CONTRATANTE}} — {{CPF}} {{CNPJ}}, {{ENDERECO}}.
|
||
CONTRATADO: {{CONTRATADO}} — {{CRO}} {{CRBM}}, {{ESPECIALIDADE}}.
|
||
|
||
CLÁUSULA 3ª – DA VIGÊNCIA
|
||
O presente contrato vigora de {{DATA_INICIO}} a {{DATA_FIM}}, podendo ser renovado conforme acordo entre as partes.
|
||
|
||
CLÁUSULA 4ª – DO VALOR E PAGAMENTO
|
||
Pelos serviços/uso ora contratados será devido o valor de {{VALOR}} {{PERCENTUAL}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 5ª – DAS OBRIGAÇÕES
|
||
As partes comprometem-se a cumprir fielmente as obrigações assumidas, com boa-fé e zelo profissional, observada a legislação aplicável e os conselhos de classe.
|
||
|
||
CLÁUSULA 6ª – DA RESCISÃO
|
||
O contrato poderá ser rescindido por qualquer das partes mediante aviso prévio, respeitadas as obrigações pendentes até a data da rescisão.
|
||
|
||
CLÁUSULA 7ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
As partes tratarão os dados pessoais em conformidade com a Lei nº 13.709/2018 (LGPD), restringindo o uso às finalidades deste contrato.
|
||
|
||
CLÁUSULA 8ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}} para dirimir controvérsias oriundas deste contrato, com renúncia a qualquer outro.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`;
|
||
}
|
||
|
||
const MODELOS_CONTRATO_DEFAULT = [
|
||
{ nome: 'Prestação de Serviços Odontológicos', categoria: 'servico', tipo_relacao: 'dentista-clinica', objeto: 'Prestação de serviços odontológicos pelo CONTRATADO em favor do CONTRATANTE, na especialidade {{ESPECIALIDADE}}.' },
|
||
{ nome: 'Prestação de Serviços Biomédicos', categoria: 'servico', tipo_relacao: 'biomedico-clinica', objeto: 'Prestação de serviços biomédicos/estéticos pelo CONTRATADO em favor do CONTRATANTE.' },
|
||
{ nome: 'Prestação de Serviços Protéticos', categoria: 'servico', tipo_relacao: 'protetico-clinica', objeto: 'Confecção de trabalhos protéticos pelo CONTRATADO em favor do CONTRATANTE.' },
|
||
{ nome: 'Locação de Sala por Hora', categoria: 'locacao', tipo_relacao: 'locacao-sala', objeto: 'Locação por hora da sala {{SALA}} pelo LOCADOR ao LOCATÁRIO.' },
|
||
{ nome: 'Locação de Sala por Período', categoria: 'locacao', tipo_relacao: 'locacao-sala', objeto: 'Locação por período (turno/diária) da sala {{SALA}}.' },
|
||
{ nome: 'Locação de Sala Mensal', categoria: 'locacao', tipo_relacao: 'locacao-sala', objeto: 'Locação mensal da sala {{SALA}}, com renovação automática salvo aviso prévio.' },
|
||
{ nome: 'Parceria Profissional', categoria: 'parceria', tipo_relacao: 'profissional-profissional', objeto: 'Parceria profissional entre as partes para atuação conjunta, com partilha de {{PERCENTUAL}}.' },
|
||
{ nome: 'Cooperação Profissional', categoria: 'parceria', tipo_relacao: 'profissional-profissional', objeto: 'Cooperação técnico-profissional entre as partes, sem vínculo empregatício.' },
|
||
{ nome: 'Encaminhamento de Pacientes', categoria: 'parceria', tipo_relacao: 'dentista-dentista', objeto: 'Encaminhamento de pacientes entre os profissionais, observada a ética profissional.' },
|
||
{ nome: 'Uso Compartilhado de Estrutura', categoria: 'locacao', tipo_relacao: 'estrutura', objeto: 'Uso compartilhado de estrutura e equipamentos entre as partes.' },
|
||
{ nome: 'Contrato de Produção', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Remuneração por produção, conforme metas e valores acordados.' },
|
||
{ nome: 'Contrato por Comissão', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Remuneração por comissão de {{PERCENTUAL}} sobre os procedimentos realizados.' },
|
||
{ nome: 'Contrato por Percentual', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Repasse de {{PERCENTUAL}} ao CONTRATADO sobre o faturamento dos procedimentos.' },
|
||
{ nome: 'Contrato por Procedimento', categoria: 'remuneracao', tipo_relacao: 'profissional-clinica', objeto: 'Remuneração por procedimento realizado, conforme tabela acordada.' },
|
||
];
|
||
|
||
// Modelos completos por especialidade (clínica/dentista ↔ paciente). Padrão de mercado;
|
||
// revisar com advogado antes do uso real.
|
||
const MODELOS_CONTRATO_ODONTO = [
|
||
{
|
||
nome: 'Tratamento Ortodôntico', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — TRATAMENTO ORTODÔNTICO
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro lado {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente e domiciliado(a) em {{ENDERECO}}, doravante CONTRATANTE, têm entre si justo e contratado o seguinte:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de tratamento ortodôntico ({{ESPECIALIDADE}}), compreendendo a instalação, o acompanhamento e a manutenção de aparelho ortodôntico, visando à correção do posicionamento dentário e/ou ósseo do(a) CONTRATANTE, conforme plano de tratamento previamente apresentado e aceito.
|
||
|
||
CLÁUSULA 2ª – DAS MANUTENÇÕES PERIÓDICAS
|
||
O tratamento ortodôntico exige o comparecimento periódico do(a) CONTRATANTE às consultas de manutenção, em regra mensais, indispensáveis à sua evolução. O não comparecimento reiterado poderá prolongar o tratamento e/ou comprometer seu resultado, isentando o(a) CONTRATADO(A) de responsabilidade pelos atrasos daí decorrentes.
|
||
|
||
CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se o(a) CONTRATANTE a: (a) manter rigorosa higiene bucal; (b) zelar pela conservação do aparelho, evitando alimentos duros e pegajosos; (c) seguir fielmente as orientações clínicas; (d) comparecer às manutenções agendadas; e (e) efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 4ª – DA REPOSIÇÃO DE PEÇAS
|
||
O reparo ou a reposição de bráquetes, bandas, fios e demais acessórios danificados por mau uso, descuido ou inobservância das orientações correrá por conta exclusiva do(a) CONTRATANTE, mediante valor a ser informado.
|
||
|
||
CLÁUSULA 5ª – DO PRAZO E DA DURAÇÃO
|
||
O tratamento terá início em {{DATA_INICIO}}, com previsão de término em {{DATA_FIM}}. A duração é estimada e poderá variar conforme a resposta biológica e a cooperação do(a) CONTRATANTE, não constituindo o prazo obrigação de resultado.
|
||
|
||
CLÁUSULA 6ª – DA CONTENÇÃO
|
||
Concluída a fase ativa, poderá ser necessária a instalação e o uso de contenção, fixa ou removível, pelo período indicado pelo(a) CONTRATADO(A), a fim de estabilizar os resultados; eventuais custos, se não incluídos, serão informados em separado.
|
||
|
||
CLÁUSULA 7ª – DO VALOR E DA FORMA DE PAGAMENTO
|
||
Pelos serviços ora contratados, o(a) CONTRATANTE pagará o valor de {{VALOR}}, nas condições acordadas entre as partes (entrada e parcelas mensais), sendo a manutenção mensal parte integrante do tratamento.
|
||
|
||
CLÁUSULA 8ª – DA RESCISÃO
|
||
Em caso de desistência, serão devidos os valores correspondentes aos serviços já prestados (aparelhos instalados e manutenções realizadas), restituindo-se eventual saldo pago a maior no prazo de 30 (trinta) dias.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde do(a) CONTRATANTE serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), utilizados exclusivamente para as finalidades deste contrato e do tratamento odontológico.
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}} para dirimir quaisquer controvérsias oriundas do presente contrato, com renúncia a qualquer outro.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Implantodontia (Implante Dentário)', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — IMPLANTODONTIA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, ajustam o seguinte:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de implantodontia ({{ESPECIALIDADE}}), compreendendo a avaliação, o planejamento, a instalação de implante(s) dentário(s) e, quando previsto, a reabilitação protética sobre implante, conforme plano de tratamento aceito pelo(a) CONTRATANTE.
|
||
|
||
CLÁUSULA 2ª – DAS FASES DO TRATAMENTO
|
||
O tratamento desenvolve-se em fases: (a) avaliação e exames de imagem; (b) fase cirúrgica de instalação do(s) implante(s); (c) período de osseointegração; e (d) fase protética, dependendo cada fase do sucesso da anterior.
|
||
|
||
CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO
|
||
O(a) CONTRATANTE declara estar ciente de que se trata de procedimento cirúrgico, sujeito a riscos inerentes (edema, hematoma, infecção, parestesia transitória, entre outros) e de que o sucesso da osseointegração depende de fatores biológicos individuais, não havendo garantia absoluta de resultado.
|
||
|
||
CLÁUSULA 4ª – DAS CONDIÇÕES DE SAÚDE E EXAMES
|
||
Obriga-se o(a) CONTRATANTE a fornecer informações verídicas sobre seu estado de saúde e medicamentos em uso e a apresentar os exames solicitados, sob pena de comprometimento do tratamento e de isenção de responsabilidade do(a) CONTRATADO(A).
|
||
|
||
CLÁUSULA 5ª – DOS CUIDADOS PÓS-OPERATÓRIOS
|
||
Obriga-se o(a) CONTRATANTE a seguir rigorosamente as orientações pós-operatórias, a manter higiene adequada, a abster-se do tabagismo no período indicado e a comparecer aos retornos, indispensáveis à osseointegração.
|
||
|
||
CLÁUSULA 6ª – DA REINTERVENÇÃO
|
||
Na hipótese de não integração do implante por causa não imputável ao(à) CONTRATANTE, o(a) CONTRATADO(A) poderá proceder à reinstalação, observado novo período de cicatrização, nos termos acordados entre as partes.
|
||
|
||
CLÁUSULA 7ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições ajustadas, podendo os valores referentes a implante, componentes protéticos e prótese ser discriminados separadamente.
|
||
|
||
CLÁUSULA 8ª – DO PRAZO
|
||
Os serviços terão início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado em razão do período de osseointegração, que varia conforme a resposta individual.
|
||
|
||
CLÁUSULA 9ª – DA RESCISÃO
|
||
Em caso de desistência após iniciada a fase cirúrgica, serão devidos os valores dos serviços e materiais já empregados (implantes, componentes e atos praticados).
|
||
|
||
CLÁUSULA 10ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades do tratamento.
|
||
|
||
CLÁUSULA 11ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Prótese Dentária', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — PRÓTESE DENTÁRIA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de prótese dentária ({{ESPECIALIDADE}}), compreendendo o planejamento, as moldagens, a confecção e a instalação de prótese (fixa, removível ou total), conforme plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DAS ETAPAS
|
||
O serviço compreende: (a) moldagens e registros; (b) confecção laboratorial; (c) provas; e (d) instalação e ajustes. A confecção laboratorial pode demandar prazo próprio, por depender de terceiro (laboratório de prótese).
|
||
|
||
CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se o(a) CONTRATANTE a comparecer às sessões de moldagem, prova e instalação, a seguir as orientações de uso e higienização e a comunicar qualquer desconforto para os devidos ajustes.
|
||
|
||
CLÁUSULA 4ª – DOS AJUSTES E DA ADAPTAÇÃO
|
||
Após a instalação, poderão ser necessárias sessões de ajuste para adaptação da prótese, incluídas no objeto, dentro do prazo razoável de adaptação.
|
||
|
||
CLÁUSULA 5ª – DA GARANTIA
|
||
A prótese possui garantia contra defeitos de confecção pelo prazo ajustado entre as partes, não abrangendo danos decorrentes de mau uso, acidentes, falta de higiene, alterações da estrutura bucal ou inobservância das orientações.
|
||
|
||
CLÁUSULA 6ª – DA RESPONSABILIDADE POR DANOS
|
||
Danos à prótese causados por mau uso, queda, mordida em alimentos ou objetos inadequados ou falta de cuidado serão de responsabilidade do(a) CONTRATANTE, correndo por sua conta o reparo ou a substituição.
|
||
|
||
CLÁUSULA 7ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições ajustadas entre as partes.
|
||
|
||
CLÁUSULA 8ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de entrega/instalação em {{DATA_FIM}}, podendo variar conforme o prazo laboratorial e as provas necessárias.
|
||
|
||
CLÁUSULA 9ª – DA RESCISÃO
|
||
Em caso de desistência após iniciada a confecção, serão devidos os valores dos serviços e materiais já empregados (moldagens, peças laboratoriais e atos praticados).
|
||
|
||
CLÁUSULA 10ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 11ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Clínico Geral', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CLÍNICO GERAL
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços odontológicos de clínica geral ({{ESPECIALIDADE}}), compreendendo os procedimentos constantes do plano de tratamento previamente apresentado e aceito pelo(a) CONTRATANTE (entre os quais profilaxia, restaurações, exodontias simples e demais procedimentos clínicos).
|
||
|
||
CLÁUSULA 2ª – DO PLANO DE TRATAMENTO
|
||
Os procedimentos, suas etapas e respectivos valores constam do plano de tratamento, que integra este contrato. Eventuais procedimentos não previstos, identificados no curso do atendimento, serão informados e orçados em separado, dependendo de nova anuência.
|
||
|
||
CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se o(a) CONTRATANTE a manter higiene bucal adequada, a seguir as orientações clínicas, a comparecer às sessões e retornos agendados e a efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 4ª – DOS RETORNOS E DA MANUTENÇÃO
|
||
A manutenção dos resultados depende de acompanhamento periódico e de cuidados do(a) CONTRATANTE; a inobservância das orientações ou a ausência aos retornos poderá comprometer o tratamento, isentando o(a) CONTRATADO(A) das consequências daí decorrentes.
|
||
|
||
CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes (entrada e parcelas), conforme o plano de tratamento.
|
||
|
||
CLÁUSULA 6ª – DO PRAZO
|
||
Os serviços terão início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado e sujeito à evolução clínica e à disponibilidade do(a) CONTRATANTE.
|
||
|
||
CLÁUSULA 7ª – DA RESCISÃO
|
||
Em caso de desistência, serão devidos os valores correspondentes aos procedimentos já realizados, restituindo-se eventual saldo pago a maior no prazo de 30 (trinta) dias.
|
||
|
||
CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades do tratamento.
|
||
|
||
CLÁUSULA 9ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Periodontia', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — PERIODONTIA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de periodontia ({{ESPECIALIDADE}}), compreendendo o tratamento das estruturas de suporte dos dentes — raspagem e alisamento radicular, controle de placa bacteriana e, quando indicadas, cirurgias periodontais — conforme plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DA HIGIENE COMO FATOR ESSENCIAL
|
||
O(a) CONTRATANTE declara estar ciente de que o sucesso do tratamento periodontal depende fundamentalmente do controle da higiene bucal e da remoção de fatores de risco (como tabagismo), comprometendo-se a seguir rigorosamente as orientações de higienização.
|
||
|
||
CLÁUSULA 3ª – DAS FASES E DA MANUTENÇÃO
|
||
O tratamento desenvolve-se em fases (terapia básica, reavaliação e, se necessário, fase cirúrgica), seguidas de manutenção periodontal periódica, indispensável à estabilidade dos resultados.
|
||
|
||
CLÁUSULA 4ª – DO PROGNÓSTICO
|
||
O(a) CONTRATANTE está ciente de que o prognóstico depende da resposta individual e da cooperação, não constituindo o tratamento obrigação de resultado, e de que a doença periodontal pode recidivar na ausência de manutenção e higiene adequadas.
|
||
|
||
CLÁUSULA 5ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se o(a) CONTRATANTE a comparecer às sessões e às manutenções, a seguir as orientações clínicas e a efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 7ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão da fase ativa em {{DATA_FIM}}, prazo estimado e sujeito à resposta clínica.
|
||
|
||
CLÁUSULA 8ª – DA RESCISÃO
|
||
Em caso de desistência, serão devidos os valores correspondentes aos procedimentos já realizados.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Endodontia (Tratamento de Canal)', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — ENDODONTIA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de endodontia ({{ESPECIALIDADE}}), consistente no tratamento endodôntico (tratamento de canal) do(s) elemento(s) dentário(s) indicado(s), conforme plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DAS SESSÕES
|
||
O tratamento poderá ser realizado em uma ou mais sessões, conforme a complexidade do caso e a condição do(s) dente(s), obrigando-se o(a) CONTRATANTE a comparecer às sessões agendadas até sua conclusão.
|
||
|
||
CLÁUSULA 3ª – DOS RISCOS E INTERCORRÊNCIAS
|
||
O(a) CONTRATANTE declara estar ciente das possíveis intercorrências inerentes ao procedimento (entre as quais dor pós-operatória, fratura de instrumento endodôntico, perfurações, anatomia complexa e necessidade eventual de retratamento ou de cirurgia paraendodôntica), não constituindo o tratamento obrigação de resultado.
|
||
|
||
CLÁUSULA 4ª – DA RESTAURAÇÃO DEFINITIVA
|
||
O(a) CONTRATANTE está ciente de que, após o tratamento de canal, o dente deve receber restauração definitiva e/ou coroa protética em prazo adequado, sob pena de fratura ou perda do elemento, podendo tais procedimentos não estar incluídos neste objeto.
|
||
|
||
CLÁUSULA 5ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se o(a) CONTRATANTE a seguir as orientações clínicas, a comunicar sintomas e a comparecer aos retornos, bem como a efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 7ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}.
|
||
|
||
CLÁUSULA 8ª – DA RESCISÃO
|
||
Em caso de desistência após iniciado o tratamento, serão devidos os valores correspondentes aos procedimentos já realizados.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Harmonização Orofacial', categoria: 'servico', tipo_relacao: 'biomedico-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS — HARMONIZAÇÃO OROFACIAL / ESTÉTICA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no respectivo conselho de classe sob o nº {{CRO}}{{CRBM}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de harmonização orofacial / estética ({{ESPECIALIDADE}}), compreendendo os procedimentos constantes do plano previamente apresentado e aceito (entre os quais, conforme o caso, toxina botulínica, preenchimento com ácido hialurônico, bioestimuladores de colágeno e demais procedimentos injetáveis).
|
||
|
||
CLÁUSULA 2ª – DA AVALIAÇÃO E DAS CONTRAINDICAÇÕES
|
||
A realização dos procedimentos pressupõe avaliação prévia. O(a) CONTRATANTE obriga-se a informar de forma verídica seu histórico de saúde, alergias, medicamentos em uso, gestação/amamentação e demais condições, declarando ciência de que há contraindicações que podem inviabilizar o procedimento.
|
||
|
||
CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO
|
||
O(a) CONTRATANTE declara estar ciente dos riscos inerentes (entre os quais edema, equimose, hematoma, assimetria, reação ao produto e, raramente, intercorrências vasculares), bem como de que o resultado estético é individual e subjetivo, não constituindo o serviço obrigação de resultado.
|
||
|
||
CLÁUSULA 4ª – DO CARÁTER TEMPORÁRIO E DOS RETOQUES
|
||
O(a) CONTRATANTE está ciente de que os efeitos de parte dos procedimentos são temporários, com duração variável conforme o metabolismo individual, podendo demandar retoques ou novas aplicações, cujos custos, se não incluídos, serão informados em separado.
|
||
|
||
CLÁUSULA 5ª – DOS CUIDADOS PÓS-PROCEDIMENTO
|
||
Obriga-se o(a) CONTRATANTE a seguir rigorosamente as orientações pós-procedimento e a comparecer aos retornos de avaliação, indispensáveis ao acompanhamento.
|
||
|
||
CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 7ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão do protocolo em {{DATA_FIM}}.
|
||
|
||
CLÁUSULA 8ª – DA RESCISÃO
|
||
Em caso de desistência após iniciado o protocolo, serão devidos os valores dos serviços e materiais já empregados.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde, incluindo imagens, serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades aqui previstas.
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Odontopediatria', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — ODONTOPEDIATRIA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, na qualidade de responsável legal pelo(a) paciente menor de idade, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de odontopediatria ({{ESPECIALIDADE}}) ao(à) paciente menor representado(a) pelo(a) CONTRATANTE, compreendendo os procedimentos constantes do plano de tratamento previamente apresentado e aceito (entre os quais prevenção, profilaxia, aplicação de flúor, restaurações e demais procedimentos pertinentes à faixa etária).
|
||
|
||
CLÁUSULA 2ª – DA REPRESENTAÇÃO E DO CONSENTIMENTO
|
||
O(a) CONTRATANTE declara ser o(a) responsável legal pelo(a) paciente menor e consente com os procedimentos descritos no plano de tratamento, comprometendo-se a acompanhar e a autorizar as condutas necessárias.
|
||
|
||
CLÁUSULA 3ª – DO MANEJO COMPORTAMENTAL
|
||
O(a) CONTRATANTE está ciente de que o atendimento infantil pode exigir técnicas de manejo do comportamento e que a colaboração da criança e da família é determinante para a evolução do tratamento.
|
||
|
||
CLÁUSULA 4ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se o(a) CONTRATANTE a zelar pela higiene bucal e dieta do(a) paciente, a seguir as orientações clínicas, a comparecer às sessões e aos retornos preventivos e a efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes, conforme o plano de tratamento.
|
||
|
||
CLÁUSULA 6ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado e sujeito à evolução clínica e à colaboração do(a) paciente.
|
||
|
||
CLÁUSULA 7ª – DA RESCISÃO
|
||
Em caso de desistência, serão devidos os valores correspondentes aos procedimentos já realizados.
|
||
|
||
CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde do(a) paciente serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades do tratamento.
|
||
|
||
CLÁUSULA 9ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Cirurgia Oral', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CIRURGIA ORAL
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de cirurgia oral ({{ESPECIALIDADE}}), compreendendo o(s) procedimento(s) cirúrgico(s) bucal(is) indicado(s) (entre os quais exodontias, remoção de dentes inclusos/retidos, frenectomias e demais procedimentos), conforme plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DA AVALIAÇÃO E DOS EXAMES
|
||
A realização do(s) procedimento(s) pressupõe avaliação e exames de imagem. Obriga-se o(a) CONTRATANTE a fornecer informações verídicas sobre seu estado de saúde e medicamentos em uso e a apresentar os exames solicitados.
|
||
|
||
CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO
|
||
O(a) CONTRATANTE declara estar ciente de que se trata de procedimento cirúrgico, sujeito a riscos inerentes (entre os quais edema, hematoma, sangramento, infecção, dor pós-operatória, parestesia transitória e, conforme o caso, comunicação buco-sinusal), não constituindo o ato obrigação de resultado.
|
||
|
||
CLÁUSULA 4ª – DOS CUIDADOS PÓS-OPERATÓRIOS
|
||
Obriga-se o(a) CONTRATANTE a seguir rigorosamente as orientações pós-operatórias (repouso, medicação, dieta e higiene) e a comparecer aos retornos, indispensáveis à adequada cicatrização.
|
||
|
||
CLÁUSULA 5ª – DAS OBRIGAÇÕES DO(A) CONTRATANTE
|
||
Obriga-se ainda o(a) CONTRATANTE a comunicar intercorrências e a efetuar os pagamentos nos prazos acordados.
|
||
|
||
CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 7ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}.
|
||
|
||
CLÁUSULA 8ª – DA RESCISÃO
|
||
Em caso de desistência após iniciado o procedimento, serão devidos os valores dos serviços e materiais já empregados.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Clareamento Dental', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CLAREAMENTO DENTAL
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de clareamento dental ({{ESPECIALIDADE}}), na modalidade de consultório, caseiro supervisionado ou combinada, conforme plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DO RESULTADO
|
||
O(a) CONTRATANTE declara estar ciente de que o resultado do clareamento é individual e variável, dependente das características dos dentes, e de que não há garantia de alcance de tonalidade específica, não constituindo o serviço obrigação de resultado.
|
||
|
||
CLÁUSULA 3ª – DA SENSIBILIDADE E CONTRAINDICAÇÕES
|
||
O(a) CONTRATANTE está ciente da possibilidade de sensibilidade dentária transitória e de irritação gengival, bem como de que restaurações, próteses e manchas de origem não vital podem não responder ao clareamento, e de que existem contraindicações (gestação, idade, entre outras).
|
||
|
||
CLÁUSULA 4ª – DA MANUTENÇÃO E DOS CUIDADOS
|
||
Os resultados podem regredir com o tempo, podendo demandar retoques. Obriga-se o(a) CONTRATANTE a seguir as orientações (dieta, restrição a alimentos pigmentados e tabagismo) e a utilizar corretamente os materiais entregues, quando for o caso.
|
||
|
||
CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 6ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}.
|
||
|
||
CLÁUSULA 7ª – DA RESCISÃO
|
||
Em caso de desistência após iniciado o tratamento, serão devidos os valores dos serviços e materiais já empregados.
|
||
|
||
CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 9ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'DTM e Dor Orofacial', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — DTM E DOR OROFACIAL
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de diagnóstico e tratamento de disfunção temporomandibular (DTM) e dor orofacial ({{ESPECIALIDADE}}), compreendendo, conforme o caso, avaliação, placa oclusal/miorrelaxante, orientações e acompanhamento, segundo plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DA NATUREZA MULTIFATORIAL
|
||
O(a) CONTRATANTE declara estar ciente de que a DTM possui caráter multifatorial e por vezes crônico, sendo o tratamento voltado ao controle dos sintomas e à melhora da função, sem garantia de cura, não constituindo obrigação de resultado.
|
||
|
||
CLÁUSULA 3ª – DA COOPERAÇÃO E DOS HÁBITOS
|
||
O sucesso do tratamento depende da cooperação do(a) CONTRATANTE, inclusive quanto ao controle de hábitos parafuncionais (apertamento/bruxismo), ao uso correto da placa e ao seguimento das orientações.
|
||
|
||
CLÁUSULA 4ª – DOS AJUSTES E DO ACOMPANHAMENTO
|
||
A placa, quando indicada, demanda ajustes periódicos e acompanhamento. Obriga-se o(a) CONTRATANTE a comparecer aos retornos e a comunicar a evolução dos sintomas.
|
||
|
||
CLÁUSULA 5ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 6ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de reavaliação/conclusão da fase de controle em {{DATA_FIM}}.
|
||
|
||
CLÁUSULA 7ª – DA RESCISÃO
|
||
Em caso de desistência, serão devidos os valores correspondentes aos procedimentos e dispositivos já realizados/confeccionados.
|
||
|
||
CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 9ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Radiologia / Laudo Odontológico', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — RADIOLOGIA E LAUDO
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços de radiologia odontológica e imaginologia ({{ESPECIALIDADE}}), compreendendo a realização do(s) exame(s) de imagem solicitado(s) (entre os quais radiografias, tomografia computadorizada de feixe cônico e documentação) e a emissão do respectivo laudo, quando aplicável.
|
||
|
||
CLÁUSULA 2ª – DA ENTREGA E DO LAUDO
|
||
As imagens e o laudo serão disponibilizados ao(à) CONTRATANTE no prazo informado. O laudo restringe-se à análise das imagens obtidas, conforme a finalidade da solicitação, não substituindo a avaliação clínica do(a) profissional assistente.
|
||
|
||
CLÁUSULA 3ª – DA RESPONSABILIDADE TÉCNICA E PROTEÇÃO RADIOLÓGICA
|
||
Os procedimentos observarão as normas de proteção radiológica e biossegurança aplicáveis. O(a) CONTRATANTE obriga-se a informar condições relevantes (como gestação) previamente à realização do exame.
|
||
|
||
CLÁUSULA 4ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços, o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 5ª – DO PRAZO
|
||
Realização em {{DATA_INICIO}} e entrega do laudo/imagens até {{DATA_FIM}}, conforme disponibilidade técnica.
|
||
|
||
CLÁUSULA 6ª – DA PROTEÇÃO DE DADOS E DAS IMAGENS (LGPD)
|
||
As imagens e os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD), restritos às finalidades aqui previstas, sendo as imagens armazenadas pelo prazo legal aplicável.
|
||
|
||
CLÁUSULA 7ª – DA RESCISÃO
|
||
Em caso de desistência após a realização do exame, será devido o valor do serviço prestado.
|
||
|
||
CLÁUSULA 8ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Cirurgia Ortognática', categoria: 'servico', tipo_relacao: 'dentista-paciente',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS ODONTOLÓGICOS — CIRURGIA ORTOGNÁTICA
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATADO}}, inscrito(a) no CRO sob o nº {{CRO}}, doravante CONTRATADO(A), e de outro {{CONTRATANTE}}, inscrito(a) no CPF sob o nº {{CPF}}, residente em {{ENDERECO}}, doravante CONTRATANTE, têm justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato a prestação de serviços relacionados ao tratamento ortodôntico-cirúrgico ({{ESPECIALIDADE}}), compreendendo o planejamento, o preparo ortodôntico, a cirurgia ortognática e a finalização, com vistas à correção das deformidades dentofaciais, conforme plano de tratamento aceito.
|
||
|
||
CLÁUSULA 2ª – DAS FASES E DA EQUIPE
|
||
O tratamento desenvolve-se em fases (preparo ortodôntico pré-cirúrgico, fase cirúrgica e ortodontia pós-cirúrgica) e pode envolver equipe multidisciplinar, dependendo cada fase do sucesso da anterior.
|
||
|
||
CLÁUSULA 3ª – DOS RISCOS E DO CONSENTIMENTO
|
||
O(a) CONTRATANTE declara estar ciente de que se trata de cirurgia de grande porte, sujeita a riscos inerentes (entre os quais edema acentuado, sangramento, infecção, alterações de sensibilidade e necessidade de revisão), realizada, em regra, em ambiente hospitalar, não constituindo o ato obrigação de resultado.
|
||
|
||
CLÁUSULA 4ª – DOS CUSTOS HOSPITALARES E DE TERCEIROS
|
||
Os custos de internação, ambiente hospitalar, equipe de anestesia, materiais e exames, quando não expressamente incluídos neste objeto, correrão por conta do(a) CONTRATANTE e serão orçados separadamente.
|
||
|
||
CLÁUSULA 5ª – DOS EXAMES E CONDIÇÕES DE SAÚDE
|
||
Obriga-se o(a) CONTRATANTE a fornecer informações verídicas sobre sua saúde, a apresentar os exames pré-operatórios solicitados e a seguir as orientações pré e pós-operatórias.
|
||
|
||
CLÁUSULA 6ª – DO VALOR E DO PAGAMENTO
|
||
Pelos serviços do(a) CONTRATADO(A), o(a) CONTRATANTE pagará {{VALOR}}, nas condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 7ª – DO PRAZO
|
||
Início em {{DATA_INICIO}} e previsão de conclusão em {{DATA_FIM}}, prazo estimado em razão das fases ortodônticas e do período de recuperação.
|
||
|
||
CLÁUSULA 8ª – DA RESCISÃO
|
||
Em caso de desistência, serão devidos os valores correspondentes às fases e procedimentos já realizados.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
Os dados pessoais e de saúde serão tratados em conformidade com a Lei nº 13.709/2018 (LGPD).
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}}.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Credenciamento Profissional', categoria: 'parceria', tipo_relacao: 'clinica-profissional',
|
||
corpo: `CONTRATO DE CREDENCIAMENTO PROFISSIONAL
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATANTE}}, inscrita no CNPJ sob o nº {{CNPJ}}, com sede em {{ENDERECO}}, doravante CONTRATANTE (unidade), e de outro {{CONTRATADO}}, inscrito(a) no respectivo conselho de classe sob o nº {{CRO}}{{CRBM}}, doravante CONTRATADO(A) (profissional), têm entre si justo e contratado:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
Constitui objeto deste contrato o credenciamento do(a) CONTRATADO(A) para a prestação de serviços profissionais ({{ESPECIALIDADE}}) na estrutura da CONTRATANTE, sem vínculo empregatício, atuando com autonomia técnica e responsabilidade por seus atos profissionais.
|
||
|
||
CLÁUSULA 2ª – DA NATUREZA DA RELAÇÃO
|
||
As partes declaram que o presente contrato não gera vínculo empregatício, inexistindo subordinação, habitualidade e demais requisitos da relação de emprego, atuando o(a) CONTRATADO(A) como profissional autônomo(a)/parceiro(a).
|
||
|
||
CLÁUSULA 3ª – DAS OBRIGAÇÕES DO(A) CONTRATADO(A)
|
||
Obriga-se o(a) CONTRATADO(A) a manter regularidade perante o conselho de classe, a observar as normas técnicas e de biossegurança, a zelar pela documentação dos atendimentos e a cumprir a agenda acordada.
|
||
|
||
CLÁUSULA 4ª – DAS OBRIGAÇÕES DA CONTRATANTE
|
||
Obriga-se a CONTRATANTE a disponibilizar a estrutura, os equipamentos e o agendamento necessários ao atendimento, nos termos acordados.
|
||
|
||
CLÁUSULA 5ª – DO REPASSE / REMUNERAÇÃO
|
||
Pela prestação dos serviços, será devido ao(à) CONTRATADO(A) o repasse de {{PERCENTUAL}} sobre os procedimentos efetivamente realizados (ou o valor de {{VALOR}}, conforme ajustado), nas datas e condições acordadas entre as partes.
|
||
|
||
CLÁUSULA 6ª – DA VIGÊNCIA E DA RENOVAÇÃO
|
||
O presente contrato vigora de {{DATA_INICIO}} a {{DATA_FIM}}, podendo ser renovado por igual período mediante acordo entre as partes.
|
||
|
||
CLÁUSULA 7ª – DA RESCISÃO
|
||
O contrato poderá ser rescindido por qualquer das partes mediante aviso prévio, respeitadas as obrigações e os atendimentos em curso até a data da rescisão.
|
||
|
||
CLÁUSULA 8ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
As partes tratarão os dados pessoais e de saúde dos pacientes em conformidade com a Lei nº 13.709/2018 (LGPD), restringindo o uso às finalidades do atendimento.
|
||
|
||
CLÁUSULA 9ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}} para dirimir controvérsias oriundas deste contrato.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
{
|
||
nome: 'Atendimento a Convênios / Repasse (Dentista Credenciado)', categoria: 'remuneracao', tipo_relacao: 'dentista-clinica',
|
||
corpo: `CONTRATO DE PRESTAÇÃO DE SERVIÇOS — ATENDIMENTO A CONVÊNIOS E REPASSE
|
||
|
||
Por este instrumento particular, de um lado {{CONTRATANTE}}, inscrita no CNPJ sob o nº {{CNPJ}}, com sede em {{ENDERECO}}, doravante CONTRATANTE (clínica), e de outro {{CONTRATADO}}, cirurgião(ã)-dentista inscrito(a) no CRO sob o nº {{CRO}}, especialidade {{ESPECIALIDADE}}, doravante CONTRATADO(A) (profissional), têm entre si justo e contratado o seguinte:
|
||
|
||
CLÁUSULA 1ª – DO OBJETO
|
||
O(A) CONTRATADO(A) prestará serviços odontológicos, nas dependências e sob a organização da CONTRATANTE, no atendimento a pacientes vinculados aos convênios e planos odontológicos: {{CONVENIOS}}, observadas as regras, tabelas e o credenciamento de cada operadora.
|
||
|
||
CLÁUSULA 2ª – DAS GUIAS E DO FATURAMENTO
|
||
O(A) CONTRATADO(A) obriga-se a emitir e preencher corretamente as guias de tratamento odontológico (GTO) e demais documentos exigidos pelas operadoras, respondendo pela exatidão das informações clínicas e dos códigos lançados. O faturamento junto às operadoras será conduzido pela CONTRATANTE.
|
||
|
||
CLÁUSULA 3ª – DO REPASSE
|
||
Pela produção realizada para convênios, o(a) CONTRATADO(A) fará jus ao repasse de {{PERCENTUAL_REPASSE}} sobre o valor dos procedimentos, calculado sobre a base de {{BASE_REPASSE}}. O repasse será efetuado {{PRAZO_REPASSE}}, mediante demonstrativo de produção emitido pela CONTRATANTE.
|
||
|
||
CLÁUSULA 4ª – DO CUSTO DE LABORATÓRIO
|
||
Os custos de laboratório/prótese inerentes aos procedimentos serão tratados da seguinte forma: {{CUSTO_LABORATORIO}}, deduzidos da base de cálculo do repasse quando assim ajustado.
|
||
|
||
CLÁUSULA 5ª – DAS GLOSAS
|
||
As glosas aplicadas pelas operadoras (negativa total ou parcial de pagamento) serão suportadas por {{RESPONSAVEL_GLOSA}}. Quando decorrentes de erro no preenchimento ou na execução atribuível ao(à) CONTRATADO(A), os valores glosados poderão ser deduzidos do respectivo repasse. A CONTRATANTE manterá registro das glosas e dos recursos interpostos, com seus protocolos e prazos.
|
||
|
||
CLÁUSULA 6ª – DAS OBRIGAÇÕES DO(A) CONTRATADO(A)
|
||
Obriga-se o(a) CONTRATADO(A) a: (a) atender com zelo técnico e ético os pacientes dos convênios; (b) cumprir as normas e prazos das operadoras; (c) manter regular sua inscrição no CRO e eventuais credenciamentos; (d) registrar adequadamente os atendimentos no prontuário; e (e) observar a biossegurança.
|
||
|
||
CLÁUSULA 7ª – DA AUSÊNCIA DE VÍNCULO EMPREGATÍCIO
|
||
O presente contrato é de natureza civil, não gerando vínculo empregatício, exercendo o(a) CONTRATADO(A) sua atividade com autonomia técnica, sem subordinação, responsabilizando-se por seus tributos e obrigações.
|
||
|
||
CLÁUSULA 8ª – DA VIGÊNCIA E DA RESCISÃO
|
||
O contrato vigora de {{DATA_INICIO}} a {{DATA_FIM}}, podendo ser rescindido por qualquer das partes mediante aviso prévio de 30 (trinta) dias, ressalvado o pagamento da produção já realizada e dos repasses pendentes apurados até a data da rescisão.
|
||
|
||
CLÁUSULA 9ª – DA PROTEÇÃO DE DADOS (LGPD)
|
||
As partes tratarão os dados pessoais e de saúde dos pacientes em conformidade com a Lei nº 13.709/2018 (LGPD), restringindo o uso às finalidades do atendimento e do faturamento junto às operadoras.
|
||
|
||
CLÁUSULA 10ª – DO FORO
|
||
Fica eleito o foro da comarca de {{CIDADE}} para dirimir controvérsias oriundas deste contrato.
|
||
|
||
{{CIDADE}}, {{DATA_HOJE}}.`,
|
||
},
|
||
];
|
||
|
||
async function seedContratoModelos() {
|
||
try {
|
||
for (let i = 0; i < MODELOS_CONTRATO_DEFAULT.length; i++) {
|
||
const m = MODELOS_CONTRATO_DEFAULT[i];
|
||
await pool.query(
|
||
`INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, editavel, ordem)
|
||
VALUES ($1, NULL, $2, $3, $4, $5, $6, false, $7) ON CONFLICT (id) DO NOTHING`,
|
||
[`cmod_def_${i}`, m.nome, m.categoria, m.tipo_relacao, buildCorpoModelo(m.objeto), JSON.stringify(VARIAVEIS_CONTRATO.map(v => v.chave)), i]);
|
||
}
|
||
for (let i = 0; i < MODELOS_CONTRATO_ODONTO.length; i++) {
|
||
const m = MODELOS_CONTRATO_ODONTO[i];
|
||
await pool.query(
|
||
`INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, editavel, ordem)
|
||
VALUES ($1, NULL, $2, $3, $4, $5, $6, false, $7) ON CONFLICT (id) DO NOTHING`,
|
||
[`cmod_odo_${i}`, m.nome, m.categoria, m.tipo_relacao, m.corpo, JSON.stringify(VARIAVEIS_CONTRATO.map(v => v.chave)), 100 + i]);
|
||
}
|
||
console.log('[SEED] contrato_modelos OK');
|
||
} catch (e) { console.error('[SEED contrato_modelos]', e.message); }
|
||
}
|
||
|
||
const MODELO_CONTRATO_ROW = 'id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias, editavel, compartilhavel, ordem';
|
||
|
||
app.get('/api/contratos/variaveis', authGuard, async (_req, res) => res.json(VARIAVEIS_CONTRATO));
|
||
|
||
app.get('/api/contratos/modelos', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
const clinicaId = req.clinicaId || null;
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT ${MODELO_CONTRATO_ROW}, (clinica_id IS NULL) AS global FROM contrato_modelos
|
||
WHERE deleted_at IS NULL AND (clinica_id IS NULL OR clinica_id = $1)
|
||
ORDER BY (clinica_id IS NULL) DESC, ordem, nome`, [clinicaId]);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/contratos/modelos', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {};
|
||
if (!nome?.trim() || !corpo?.trim()) return res.status(400).json({ error: 'Nome e corpo são obrigatórios.' });
|
||
try {
|
||
const id = `cmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias, editavel, compartilhavel)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true,$9)`,
|
||
[id, req.clinicaId, nome.trim(), categoria || null, tipo_relacao || null, corpo, JSON.stringify(VARIAVEIS_CONTRATO.map(v => v.chave)), vigencia_dias || null, req.body?.compartilhavel === true]);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/contratos/modelos/:id', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const { nome, categoria, tipo_relacao, corpo, vigencia_dias } = req.body || {};
|
||
if (!nome?.trim() || !corpo?.trim()) return res.status(400).json({ error: 'Nome e corpo são obrigatórios.' });
|
||
try {
|
||
const { rowCount } = await pool.query(
|
||
`UPDATE contrato_modelos SET nome=$1, categoria=$2, tipo_relacao=$3, corpo=$4, vigencia_dias=$5
|
||
WHERE id=$6 AND clinica_id=$7 AND deleted_at IS NULL`,
|
||
[nome.trim(), categoria || null, tipo_relacao || null, corpo, vigencia_dias || null, req.params.id, req.clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade (modelos do catálogo são read-only — duplique).' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/contratos/modelos/:id', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rowCount } = await pool.query(
|
||
`UPDATE contrato_modelos SET deleted_at=NOW() WHERE id=$1 AND clinica_id=$2 AND deleted_at IS NULL`,
|
||
[req.params.id, req.clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/contratos/modelos/:id/duplicar', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias FROM contrato_modelos
|
||
WHERE id=$1 AND deleted_at IS NULL AND (clinica_id IS NULL OR clinica_id=$2)`, [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Modelo não encontrado.' });
|
||
const m = rows[0];
|
||
const id = `cmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO contrato_modelos (id, clinica_id, nome, categoria, tipo_relacao, corpo, variaveis, vigencia_dias, editavel)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true)`,
|
||
[id, req.clinicaId, `${m.nome} (cópia)`, m.categoria, m.tipo_relacao, m.corpo, JSON.stringify(m.variaveis || []), m.vigencia_dias]);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// ── Motor contratual · Fatia 2: gerador de instâncias ───────────────────────
|
||
// Resolve {{VAR}} no corpo com os valores informados (mantém a variável se vazia).
|
||
function renderContratoCorpo(corpo, valores) {
|
||
const v = valores || {};
|
||
return String(corpo || '').replace(/\{\{(\w+)\}\}/g, (m, k) => (v[k] != null && String(v[k]).trim() !== '') ? String(v[k]) : m);
|
||
}
|
||
|
||
app.post('/api/contratos/instancias', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const b = req.body || {};
|
||
if (!b.titulo?.trim()) return res.status(400).json({ error: 'Título obrigatório.' });
|
||
try {
|
||
let corpoBase = b.corpo, tipoRel = b.tipo_relacao || null;
|
||
if (!corpoBase && b.modelo_id) {
|
||
const { rows } = await pool.query('SELECT corpo, tipo_relacao FROM contrato_modelos WHERE id=$1 AND deleted_at IS NULL AND (clinica_id IS NULL OR clinica_id=$2)', [b.modelo_id, req.clinicaId]);
|
||
corpoBase = rows[0]?.corpo || '';
|
||
tipoRel = tipoRel || rows[0]?.tipo_relacao || null;
|
||
}
|
||
const corpoRend = renderContratoCorpo(corpoBase, b.valores);
|
||
const id = `ctr_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO contratos (id, clinica_origem_id, modelo_id, titulo, tipo_relacao, status, corpo_renderizado, valores, data_inicio, data_fim, renovacao, aviso_dias, created_by, cobranca_automatica, valor_recorrente, periodicidade, dia_cobranca)
|
||
VALUES ($1,$2,$3,$4,$5,'rascunho',$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
|
||
[id, req.clinicaId, b.modelo_id || null, b.titulo.trim(), tipoRel, corpoRend, JSON.stringify(b.valores || {}), b.data_inicio || null, b.data_fim || null, b.renovacao || 'manual', b.aviso_dias ?? 30, req.authUser?.userId,
|
||
!!b.cobranca_automatica, b.valor_recorrente != null ? Number(b.valor_recorrente) : null, b.periodicidade || 'mensal', b.dia_cobranca != null ? parseInt(b.dia_cobranca) : null]);
|
||
const partes = Array.isArray(b.partes) ? b.partes : [];
|
||
for (let i = 0; i < partes.length; i++) {
|
||
const pt = partes[i] || {};
|
||
await pool.query('INSERT INTO contrato_partes (id, contrato_id, papel, entidade_tipo, entidade_id, nome_snapshot, doc_snapshot, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
|
||
[`cpt_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 5)}`, id, pt.papel || 'PARTE', pt.entidade_tipo || null, pt.entidade_id || null, pt.nome || null, pt.doc || null, i]);
|
||
}
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: id, acao: 'gerou', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { titulo: b.titulo } });
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.get('/api/contratos/instancias', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, titulo, tipo_relacao, status, data_inicio, data_fim, created_at, cobranca_automatica, valor_recorrente FROM contratos
|
||
WHERE deleted_at IS NULL AND clinica_origem_id=$1 ORDER BY created_at DESC LIMIT 200`, [req.clinicaId]);
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.get('/api/contratos/instancias/:id', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Contrato não encontrado.' });
|
||
const { rows: partes } = await pool.query('SELECT * FROM contrato_partes WHERE contrato_id=$1 ORDER BY ordem', [req.params.id]);
|
||
const { rows: assinaturas } = await pool.query(
|
||
`SELECT a.id, a.parte_id, a.status, a.assinado_em, a.ip, a.assinante_usuario_id, u.nome AS assinante_nome
|
||
FROM contrato_assinaturas a LEFT JOIN usuarios u ON u.id = a.assinante_usuario_id
|
||
WHERE a.contrato_id=$1`, [req.params.id]);
|
||
res.json({ ...rows[0], partes, assinaturas });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/contratos/instancias/:id', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const b = req.body || {};
|
||
try {
|
||
const own = await pool.query('SELECT status FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]);
|
||
if (!own.rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' });
|
||
if (own.rows[0].status !== 'rascunho') return res.status(409).json({ error: 'Só é possível editar contratos em rascunho.' });
|
||
const corpoRend = b.corpo_renderizado != null ? b.corpo_renderizado : (b.corpo != null ? renderContratoCorpo(b.corpo, b.valores) : null);
|
||
await pool.query(
|
||
`UPDATE contratos SET titulo=COALESCE($1,titulo), corpo_renderizado=COALESCE($2,corpo_renderizado),
|
||
valores=COALESCE($3,valores), data_inicio=$4, data_fim=$5,
|
||
cobranca_automatica=COALESCE($8,cobranca_automatica), valor_recorrente=COALESCE($9,valor_recorrente),
|
||
periodicidade=COALESCE($10,periodicidade), dia_cobranca=COALESCE($11,dia_cobranca)
|
||
WHERE id=$6 AND clinica_origem_id=$7`,
|
||
[b.titulo || null, corpoRend, b.valores ? JSON.stringify(b.valores) : null, b.data_inicio || null, b.data_fim || null, req.params.id, req.clinicaId,
|
||
b.cobranca_automatica != null ? !!b.cobranca_automatica : null, b.valor_recorrente != null ? Number(b.valor_recorrente) : null, b.periodicidade || null, b.dia_cobranca != null ? parseInt(b.dia_cobranca) : null]);
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// H4.2 — gera manualmente a cobrança recorrente da competência atual de um contrato vigente
|
||
app.post('/api/contratos/instancias/:id/gerar-cobranca', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [req.params.id, req.clinicaId]);
|
||
if (!rows.length) return res.status(404).json({ error: 'Contrato não encontrado.' });
|
||
const c = rows[0];
|
||
if (c.status !== 'vigente') return res.status(400).json({ error: 'Contrato precisa estar vigente para gerar cobrança.' });
|
||
if (!(Number(c.valor_recorrente) > 0)) return res.status(400).json({ error: 'Defina um valor recorrente > 0.' });
|
||
const created = await gerarCobrancaContratoCompetencia(c);
|
||
if (created) await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: c.id, acao: 'gerou_cobranca_recorrente', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { valor: Number(c.valor_recorrente) } });
|
||
if (created) schedulePush('financeiro');
|
||
res.json({ success: true, financeiro_id: created || null, ja_existia: !created });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/contratos/instancias/:id', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rowCount } = await pool.query('UPDATE contratos SET deleted_at=NOW() WHERE id=$1 AND clinica_origem_id=$2 AND deleted_at IS NULL', [req.params.id, req.clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Contrato não encontrado nesta unidade.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Carrega um contrato do escopo da clínica (helper anti-IDOR).
|
||
async function loadContratoScoped(id, clinicaId) {
|
||
const { rows } = await pool.query('SELECT * FROM contratos WHERE id=$1 AND deleted_at IS NULL AND clinica_origem_id=$2', [id, clinicaId]);
|
||
return rows[0] || null;
|
||
}
|
||
const reqIp = (req) => (String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket?.remoteAddress || null;
|
||
|
||
// Enviar para assinatura: rascunho → aguardando_assinatura (cria assinaturas pendentes por parte).
|
||
app.post('/api/contratos/instancias/:id/enviar', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const c = await loadContratoScoped(req.params.id, req.clinicaId);
|
||
if (!c) return res.status(404).json({ error: 'Contrato não encontrado.' });
|
||
if (c.status !== 'rascunho') return res.status(409).json({ error: `Contrato já está "${c.status}".` });
|
||
const { rows: partes } = await pool.query('SELECT id FROM contrato_partes WHERE contrato_id=$1', [c.id]);
|
||
if (!partes.length) return res.status(400).json({ error: 'Adicione partes antes de enviar para assinatura.' });
|
||
for (const p of partes) {
|
||
await pool.query(
|
||
`INSERT INTO contrato_assinaturas (id, contrato_id, parte_id, status)
|
||
SELECT $1,$2,$3,'pendente' WHERE NOT EXISTS (SELECT 1 FROM contrato_assinaturas WHERE contrato_id=$2 AND parte_id=$3)`,
|
||
[`csig_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, c.id, p.id]);
|
||
}
|
||
await pool.query("UPDATE contratos SET status='aguardando_assinatura' WHERE id=$1", [c.id]);
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: c.id, acao: 'enviou para assinatura', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: {} });
|
||
res.json({ success: true, status: 'aguardando_assinatura' });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Assinar uma parte (aceite eletrônico simples do usuário logado: ip/hash/user-agent).
|
||
app.post('/api/contratos/instancias/:id/assinar', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
const parteId = req.body?.parteId;
|
||
if (!parteId) return res.status(400).json({ error: 'parteId obrigatório.' });
|
||
try {
|
||
const c = await loadContratoScoped(req.params.id, req.clinicaId);
|
||
if (!c) return res.status(404).json({ error: 'Contrato não encontrado.' });
|
||
if (c.status !== 'aguardando_assinatura') return res.status(409).json({ error: 'Envie o contrato para assinatura primeiro.' });
|
||
const { rows: sig } = await pool.query('SELECT id, status FROM contrato_assinaturas WHERE contrato_id=$1 AND parte_id=$2', [c.id, parteId]);
|
||
if (!sig.length) return res.status(404).json({ error: 'Parte não encontrada.' });
|
||
if (sig[0].status === 'assinado') return res.status(409).json({ error: 'Esta parte já assinou.' });
|
||
const hash = crypto.createHash('sha256').update(String(c.corpo_renderizado || '')).digest('hex');
|
||
await pool.query(
|
||
`UPDATE contrato_assinaturas SET status='assinado', assinado_em=NOW(), assinante_usuario_id=$1, ip=$2, user_agent=$3, hash=$4
|
||
WHERE id=$5`,
|
||
[req.authUser?.userId, reqIp(req), String(req.headers['user-agent'] || '').slice(0, 300), hash, sig[0].id]);
|
||
// todas as partes assinaram? → assinado (ou vigente se já em vigência)
|
||
const { rows: cnt } = await pool.query(
|
||
`SELECT (SELECT COUNT(*) FROM contrato_partes WHERE contrato_id=$1) AS partes,
|
||
(SELECT COUNT(*) FROM contrato_assinaturas WHERE contrato_id=$1 AND status='assinado') AS assinadas`, [c.id]);
|
||
let novoStatus = c.status;
|
||
if (Number(cnt[0].partes) > 0 && Number(cnt[0].partes) === Number(cnt[0].assinadas)) {
|
||
const vigente = !c.data_inicio || new Date(c.data_inicio) <= new Date();
|
||
novoStatus = vigente ? 'vigente' : 'assinado';
|
||
await pool.query('UPDATE contratos SET status=$1 WHERE id=$2', [novoStatus, c.id]);
|
||
}
|
||
await registrarAudit(pool, { clinicaId: req.clinicaId, entidade: 'contrato', entidadeId: c.id, acao: 'assinou', actorId: req.authUser?.userId, actorNome: await actorNome(req.authUser?.userId), detalhes: { parteId } });
|
||
res.json({ success: true, status: novoStatus });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/contratos/instancias/:id/cancelar', tenantGuard, requireCapability('contratos'), async (req, res) => {
|
||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||
try {
|
||
const { rowCount } = await pool.query(
|
||
"UPDATE contratos SET status='cancelado' WHERE id=$1 AND clinica_origem_id=$2 AND deleted_at IS NULL AND status NOT IN ('encerrado','cancelado')",
|
||
[req.params.id, req.clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Contrato não encontrado ou já finalizado.' });
|
||
res.json({ success: true, status: 'cancelado' });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
// Cron: transições de vigência + alerta de vencimento (broadcast por clínica, uma vez).
|
||
// H4.2 — gera a cobrança (RECEITA) de UMA competência de um contrato; idempotente por (contrato_id, competencia).
|
||
async function gerarCobrancaContratoCompetencia(c, refDate = new Date()) {
|
||
const periodic = (c.periodicidade || 'mensal').toLowerCase();
|
||
const ano = refDate.getFullYear(), mes = refDate.getMonth() + 1;
|
||
if (periodic === 'anual' && c.data_inicio) {
|
||
if (new Date(c.data_inicio).getMonth() + 1 !== mes) return false; // anual só no mês de aniversário
|
||
}
|
||
const competencia = `${ano}-${String(mes).padStart(2, '0')}`;
|
||
const ex = await pool.query("SELECT 1 FROM financeiro WHERE contrato_id=$1 AND competencia=$2 AND origem='contrato' AND deleted_at IS NULL", [c.id, competencia]);
|
||
if (ex.rowCount) return false;
|
||
const dia = Math.min(Math.max(1, parseInt(c.dia_cobranca) || 1), 28); // 28 evita data inválida
|
||
const venc = `${competencia}-${String(dia).padStart(2, '0')}`;
|
||
const id = `fin_ctr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||
await pool.query(`
|
||
INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, contrato_id, origem, competencia)
|
||
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',$5,$6,'contrato',$7)`,
|
||
[id, c.clinica_origem_id, `${c.titulo || 'Contrato'} — ${String(mes).padStart(2, '0')}/${ano}`, Number(c.valor_recorrente), venc, c.id, competencia]);
|
||
return id;
|
||
}
|
||
|
||
async function gerarCobrancasRecorrentes() {
|
||
const { rows } = await pool.query(`
|
||
SELECT id, clinica_origem_id, titulo, valor_recorrente, dia_cobranca, periodicidade, data_inicio
|
||
FROM contratos
|
||
WHERE deleted_at IS NULL AND status='vigente' AND cobranca_automatica=true
|
||
AND COALESCE(valor_recorrente,0) > 0 AND clinica_origem_id IS NOT NULL
|
||
AND (data_inicio IS NULL OR data_inicio <= CURRENT_DATE)
|
||
AND (data_fim IS NULL OR data_fim >= date_trunc('month', CURRENT_DATE))`);
|
||
let criados = 0;
|
||
for (const c of rows) { if (await gerarCobrancaContratoCompetencia(c)) criados++; }
|
||
if (criados) console.log(`[CRON contratos] ${criados} cobrança(s) recorrente(s) gerada(s)`);
|
||
}
|
||
|
||
async function processarVigenciasContratos() {
|
||
try {
|
||
// Financeiro: marca lançamentos vencidos como Atrasado (Épico 1).
|
||
await pool.query(`UPDATE financeiro SET status='Atrasado' WHERE status='Pendente' AND deleted_at IS NULL AND datavencimento IS NOT NULL AND datavencimento < CURRENT_DATE`);
|
||
await pool.query(`UPDATE contratos SET status='vigente' WHERE status='assinado' AND deleted_at IS NULL AND (data_inicio IS NULL OR data_inicio <= CURRENT_DATE)`);
|
||
await pool.query(`UPDATE contratos SET status='encerrado' WHERE status='vigente' AND deleted_at IS NULL AND data_fim IS NOT NULL AND data_fim < CURRENT_DATE AND COALESCE(renovacao,'manual') <> 'automatica'`);
|
||
// Renovação automática: estende a vigência por uma nova duração igual à anterior (ou +12 meses se não houver início)
|
||
const renA = await pool.query(`
|
||
UPDATE contratos
|
||
SET data_inicio = data_fim + 1,
|
||
data_fim = data_fim + (data_fim - data_inicio) + 1,
|
||
vencimento_avisado = false
|
||
WHERE status='vigente' AND deleted_at IS NULL AND renovacao='automatica'
|
||
AND data_fim IS NOT NULL AND data_inicio IS NOT NULL AND data_fim > data_inicio AND data_fim < CURRENT_DATE
|
||
RETURNING id`);
|
||
const renB = await pool.query(`
|
||
UPDATE contratos
|
||
SET data_fim = (data_fim + INTERVAL '12 months')::date, vencimento_avisado = false
|
||
WHERE status='vigente' AND deleted_at IS NULL AND renovacao='automatica'
|
||
AND data_fim IS NOT NULL AND (data_inicio IS NULL OR data_fim <= data_inicio) AND data_fim < CURRENT_DATE
|
||
RETURNING id`);
|
||
if (renA.rowCount + renB.rowCount) console.log(`[CRON contratos] ${renA.rowCount + renB.rowCount} renovação(ões) automática(s)`);
|
||
const { rows } = await pool.query(
|
||
`SELECT id, clinica_origem_id, titulo, data_fim FROM contratos
|
||
WHERE deleted_at IS NULL AND vencimento_avisado = false AND status IN ('vigente','assinado')
|
||
AND data_fim IS NOT NULL AND data_fim >= CURRENT_DATE
|
||
AND data_fim <= CURRENT_DATE + ((COALESCE(aviso_dias,30))::text || ' days')::interval`);
|
||
for (const c of rows) {
|
||
if (c.clinica_origem_id) {
|
||
await pool.query(
|
||
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, clinica_id, enviado_por)
|
||
VALUES (gen_random_uuid(), $1, $2, 'info', 0, NOW(), $3, 'sistema')`,
|
||
['Contrato vencendo', `O contrato "${c.titulo}" vence em ${new Date(c.data_fim).toLocaleDateString('pt-BR')}.`, c.clinica_origem_id]);
|
||
}
|
||
await pool.query('UPDATE contratos SET vencimento_avisado = true WHERE id=$1', [c.id]);
|
||
}
|
||
if (rows.length) console.log(`[CRON contratos] ${rows.length} aviso(s) de vencimento`);
|
||
await gerarCobrancasRecorrentes(); // H4.2 — cobrança recorrente de contratos vigentes
|
||
} catch (e) { console.error('[processarVigenciasContratos]', e.message); }
|
||
}
|
||
|
||
// ── Modelos de proposta (editáveis, por clínica) ─────────────────────────────
|
||
const MODELO_CATS = ['dentista', 'biomedico'];
|
||
const MODELOS_PROPOSTA_DEFAULT = [
|
||
{ categoria: 'dentista', titulo: 'Parceria fixa semanal', texto: 'Olá, Dr(a). {prof}! Somos da {clinica} e buscamos um(a) dentista para atendimentos fixos semanais. Temos estrutura completa e fluxo de pacientes ativo. Podemos conversar sobre disponibilidade e valores?' },
|
||
{ categoria: 'dentista', titulo: 'Especialista por demanda', texto: 'Olá, Dr(a). {prof}! Temos demanda recorrente na sua especialidade e gostaríamos de contar com seu atendimento na {clinica}. Trabalhamos com agenda flexível e repasse atrativo. Tem interesse em conhecer a proposta?' },
|
||
{ categoria: 'dentista', titulo: 'Cobertura de agenda', texto: 'Olá, Dr(a). {prof}! Precisamos de um(a) profissional para ampliar horários e reduzir a fila de espera da {clinica}. Sua experiência seria muito bem-vinda. Podemos agendar uma conversa?' },
|
||
{ categoria: 'biomedico', titulo: 'Harmonização orofacial', texto: 'Olá, {prof}! A {clinica} está ampliando os serviços de harmonização orofacial e estética e gostaríamos de uma parceria com você. Oferecemos sala equipada e captação de clientes. Vamos conversar?' },
|
||
{ categoria: 'biomedico', titulo: 'Procedimentos estéticos', texto: 'Olá, {prof}! Buscamos biomédico(a) para procedimentos estéticos em dias específicos na {clinica}. Estrutura completa e divulgação inclusa. Tem interesse na proposta?' },
|
||
{ categoria: 'biomedico', titulo: 'Parceria por sessão', texto: 'Olá, {prof}! Temos demanda de pacientes para estética e gostaríamos de fechar uma parceria por sessão na {clinica}. Podemos alinhar agenda e valores?' },
|
||
];
|
||
|
||
// Lista modelos da clínica; na primeira leitura semeia os padrões.
|
||
app.get('/api/propostas/modelos', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.query.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
|
||
const sel = 'SELECT id, clinica_id, categoria, titulo, texto, ordem FROM propostas_modelos WHERE clinica_id = $1 ORDER BY categoria, ordem, created_at';
|
||
try {
|
||
let { rows } = await pool.query(sel, [clinicaId]);
|
||
if (!rows.length) {
|
||
for (let i = 0; i < MODELOS_PROPOSTA_DEFAULT.length; i++) {
|
||
const m = MODELOS_PROPOSTA_DEFAULT[i];
|
||
await pool.query('INSERT INTO propostas_modelos (id, clinica_id, categoria, titulo, texto, ordem) VALUES ($1,$2,$3,$4,$5,$6)',
|
||
[`pmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, clinicaId, m.categoria, m.titulo, m.texto, i]);
|
||
}
|
||
rows = (await pool.query(sel, [clinicaId])).rows;
|
||
}
|
||
res.json(rows);
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.post('/api/propostas/modelos', tenantGuard, async (req, res) => {
|
||
const { clinicaId, titulo, texto } = req.body || {};
|
||
const categoria = MODELO_CATS.includes(req.body?.categoria) ? req.body.categoria : 'dentista';
|
||
if (!clinicaId || !titulo?.trim() || !texto?.trim()) return res.status(400).json({ error: 'clinicaId, título e texto são obrigatórios.' });
|
||
try {
|
||
const { rows: c } = await pool.query('SELECT COALESCE(MAX(ordem),-1)+1 AS ordem FROM propostas_modelos WHERE clinica_id=$1 AND categoria=$2', [clinicaId, categoria]);
|
||
const id = `pmod_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query('INSERT INTO propostas_modelos (id, clinica_id, categoria, titulo, texto, ordem) VALUES ($1,$2,$3,$4,$5,$6)',
|
||
[id, clinicaId, categoria, titulo.trim(), texto.trim(), c[0].ordem]);
|
||
res.json({ success: true, id });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.put('/api/propostas/modelos/:id', tenantGuard, async (req, res) => {
|
||
const { clinicaId, titulo, texto } = req.body || {};
|
||
const categoria = MODELO_CATS.includes(req.body?.categoria) ? req.body.categoria : 'dentista';
|
||
if (!clinicaId || !titulo?.trim() || !texto?.trim()) return res.status(400).json({ error: 'clinicaId, título e texto são obrigatórios.' });
|
||
try {
|
||
const { rowCount } = await pool.query(
|
||
'UPDATE propostas_modelos SET categoria=$1, titulo=$2, texto=$3 WHERE id=$4 AND clinica_id=$5',
|
||
[categoria, titulo.trim(), texto.trim(), req.params.id, clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade.' });
|
||
res.json({ success: true });
|
||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||
});
|
||
|
||
app.delete('/api/propostas/modelos/:id', tenantGuard, async (req, res) => {
|
||
const clinicaId = req.query.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
|
||
try {
|
||
const { rowCount } = await pool.query('DELETE FROM propostas_modelos WHERE id=$1 AND clinica_id=$2', [req.params.id, clinicaId]);
|
||
if (!rowCount) return res.status(404).json({ error: 'Modelo não encontrado nesta unidade.' });
|
||
res.json({ success: true });
|
||
} 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 });
|
||
}
|
||
}
|
||
|
||
// Admin de plataforma (gestão de tutores/repasses): role admin OU superadmin.
|
||
async function adminGuard(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] || !['admin', 'superadmin'].includes(rows[0].role)) 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', tenantGuard, async (req, res) => {
|
||
try {
|
||
const clinicaId = req.clinicaId;
|
||
if (!clinicaId) return res.status(400).json({ success: false, error: 'clinicaId obrigatório.' });
|
||
const { rows: trats } = await pool.query('SELECT * FROM ortho_tratamento WHERE clinica_id = $1 ORDER BY created_at DESC', [clinicaId]);
|
||
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.' });
|
||
if (!await userHasVinculo(req.authUser?.userId, b.clinica_id)) return res.status(403).json({ success: false, error: 'Acesso negado: sem vínculo com esta unidade.' });
|
||
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 {
|
||
if (!await assertOrtoScoped(req, res)) return;
|
||
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 {
|
||
if (!await assertOrtoScoped(req, res)) return;
|
||
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 {
|
||
if (!await assertOrtoScoped(req, res)) return;
|
||
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;
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM ortho_tratamento WHERE id = $1', [tratamentoId])) return;
|
||
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?t=${signMedia(f.id)}`, 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.' });
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM ortho_tratamento WHERE id = $1', [tratamento_id])) return;
|
||
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?t=${signMedia(f.id)}`, 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.' });
|
||
if (!await assertRowScoped(req, res, 'SELECT clinica_id AS cid FROM ortho_photo_session WHERE id = $1', [sessao_id])) return;
|
||
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?t=${signMedia(id)}`, 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 {
|
||
if (!verifyMedia(req.params.id, req.query.t)) return res.status(403).end();
|
||
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 {
|
||
if (!await assertRowScoped(req, res, 'SELECT s.clinica_id AS cid FROM ortho_photo f JOIN ortho_photo_session s ON s.id = f.sessao_id WHERE f.id = $1', [req.params.id])) return;
|
||
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 {
|
||
if (!await assertRowScoped(req, res, 'SELECT s.clinica_id AS cid FROM ortho_photo f JOIN ortho_photo_session s ON s.id = f.sessao_id WHERE f.id = $1', [req.params.id])) return;
|
||
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 {
|
||
if (!await assertRowScoped(req, res, 'SELECT s.clinica_id AS cid FROM ortho_photo f JOIN ortho_photo_session s ON s.id = f.sessao_id WHERE f.id = $1', [req.params.id])) return;
|
||
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?t=${signMedia(p.foto_id)}`, 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', adminGuard, 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', adminGuard, 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', adminGuard, 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', adminGuard, 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', adminGuard, 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', adminGuard, 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', adminGuard, 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 }); }
|
||
});
|
||
|
||
// ── Integração NewWhats (satélite WhatsApp/Secretária IA) — odonto ──────────
|
||
registerNewwhats(app, pool, { superadminGuard });
|
||
|
||
const httpServer = app.listen(PORT, '0.0.0.0', async () => {
|
||
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
|
||
console.log(`[VERSION] ${BUILD_INFO.name} ${BUILD_INFO.version} · commit ${BUILD_INFO.commit} · build ${BUILD_INFO.build || 'n/a'} · ${BUILD_INFO.environment}`);
|
||
await loadGoogleCredsFromDb();
|
||
await runMigrations();
|
||
await seedContratoModelos();
|
||
await loadSheetsConfig();
|
||
generateIntelligentNotifications();
|
||
setInterval(generateIntelligentNotifications, 5 * 60 * 1000);
|
||
processarVigenciasContratos();
|
||
setInterval(processarVigenciasContratos, 6 * 60 * 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);
|