Files
scoreodonto.com/backend/server.js
T
VPS 4 Builder 15a8527f1c feat(pacientes): modais de receita, pedido de exame e edição em 4 páginas
- ReceitaModal: gerenciamento de modelos de receita por especialidade com
  drag-and-drop para reordenar medicamentos e preview de impressão
- PedidoExameModal: pedido de exames por catálogo (Raio-X / Tomografia /
  Outros) com DnD para ordenar, campos de região/observação por item e
  preview de impressão
- EditarPacienteModal: reformulado em 4 páginas (Dados / Família /
  Endereço / Histórico) com navegação anterior/próximo e modal 2xl
- Backend: tabelas modelos_receita, items_receita, pedidos_exame,
  items_pedido_exame criadas na migração; colunas de endereço adicionadas
  em pacientes; endpoints CRUD para receitas e pedidos de exame
- Integração: botões Receita e Raio-X no menu clínico agora abrem os
  novos modais reais em vez do placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:53:28 +02:00

1837 lines
79 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';
const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
// =============================================================================
// DATABASE — PostgreSQL nativo (pg Pool)
// =============================================================================
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_URL && !process.env.DATABASE_URL.includes('localhost')
? { rejectUnauthorized: false } : false,
max: 10,
idleTimeoutMillis: 30000
});
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
// Helper: 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
// =============================================================================
// DRAGONFLY / REDIS CACHE
// =============================================================================
let redis = null;
try {
const DRAGONFLY_URL = process.env.DRAGONFLY_URL || 'redis://localhost:6379';
redis = new Redis(DRAGONFLY_URL, { lazyConnect: true });
redis.on('connect', () => console.log('[DRAGONFLY] Connected'));
redis.on('error', (err) => console.error('[DRAGONFLY] Error:', err.message));
await redis.connect().catch(() => { redis = null; });
} catch (e) {
console.error('[DRAGONFLY] Failed to initialize:', e.message);
redis = null;
}
// =============================================================================
// EXPRESS
// =============================================================================
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}));
// --- HEALTH CHECK ---
app.get('/api/ping', (req, res) => res.json({ status: 'ok', ts: new Date().toISOString() }));
// =============================================================================
// SECURITY MIDDLEWARE: Tenant Guard
// =============================================================================
async function tenantGuard(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'TOKEN DE AUTENTICAÇÃO NÃO ENVIADO.' });
}
let payload;
try {
payload = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
} catch {
return res.status(401).json({ success: false, message: 'TOKEN INVÁLIDO OU EXPIRADO. FAÇA LOGIN NOVAMENTE.' });
}
const clinicaId = req.params.clinicaId || req.body?.clinica_id || req.query?.clinicaId || req.body?.clinicaId;
if (clinicaId) {
try {
const { rows } = await pool.query(
'SELECT id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2',
[payload.userId, clinicaId]
);
if (rows.length === 0) {
console.warn(`[TENANT VIOLATION] User ${payload.userId} -> clinica ${clinicaId}`);
return res.status(403).json({ success: false, message: 'ACESSO NEGADO: VOCÊ NÃO TEM VÍNCULO COM ESTA UNIDADE.' });
}
} catch (err) {
return res.status(500).json({ success: false, message: 'ERRO INTERNO DE AUTENTICAÇÃO.' });
}
}
req.authUser = payload;
next();
}
// --- CLINICAS ---
app.put('/api/clinicas/:id/color', async (req, res) => {
const { cor } = req.body;
if (!cor) return res.status(400).json({ error: 'Campo cor obrigatório.' });
try {
await pool.query('UPDATE clinicas SET cor = $1 WHERE id = $2', [cor, req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LOGIN ROUTE ---
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ success: false, message: 'EMAIL E SENHA SÃO OBRIGATÓRIOS.' });
console.log(`[LOGIN ATTEMPT] Email: ${email}`);
try {
const { rows: users } = await pool.query(
'SELECT id, nome, email FROM usuarios WHERE email = $1 AND senha = $2',
[email.toLowerCase().trim(), password]
);
if (users.length === 0) {
console.log(`[LOGIN FAILED] Invalid credentials for: ${email}`);
return res.status(401).json({ success: false, message: 'CREDENCIAIS INVÁLIDAS' });
}
const user = users[0];
const { rows: workspaces } = await pool.query(`
SELECT v.clinica_id as id, c.nome_fantasia as nome, c.cor, v.role
FROM vinculos v
JOIN clinicas c ON v.clinica_id = c.id
WHERE v.usuario_id = $1
`, [user.id]);
if (workspaces.length === 0) {
console.log(`[LOGIN FORBIDDEN] No workspaces for User ID: ${user.id}`);
return res.status(403).json({ success: false, message: 'USUÁRIO SEM VÍNCULO COM CLÍNICAS' });
}
console.log(`[LOGIN SUCCESS] User: ${user.email}, Workspaces: ${workspaces.length}`);
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '12h' });
res.json({ success: true, token, user: { id: user.id, nome: user.nome, email: user.email }, workspaces });
} catch (err) {
console.error(`[LOGIN ERROR]: ${err.message}`);
res.status(500).json({ success: false, error: 'ERRO INTERNO DO SERVIDOR.' });
}
});
const PORT = process.env.PORT || 3005;
// --- GOOGLE OAUTH2 SETUP ---
// 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
// =============================================================================
const SHEETS_BACKUP_ID = process.env.SHEETS_BACKUP_ID || '1q1sf1GOji2u3qIJEToWfbLCb4rzb_pKLOX_AylMDT4g';
// Which tables sync, how to export from PG and how to import back
const SYNC_TABLES = [
{
tab: 'pacientes',
pgTable: 'pacientes',
query: `SELECT id, nome, cpf, telefone, email,
ultimotratamento as "ultimoTratamento",
convenio, datanascimento as "dataNascimento"
FROM pacientes ORDER BY nome`,
headers: ['id', 'nome', 'cpf', 'telefone', 'email', 'ultimoTratamento', 'convenio', 'dataNascimento'],
upsertSql: `INSERT INTO pacientes (id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (id) DO UPDATE SET
nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,
email=EXCLUDED.email,ultimotratamento=EXCLUDED.ultimotratamento,
convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
upsertParams: r => [r.id,r.nome,r.cpf,r.telefone,r.email,r.ultimoTratamento,r.convenio,r.dataNascimento]
},
{
tab: 'agendamentos',
pgTable: 'agendamentos',
query: `SELECT id,clinica_id,pacientenome as "pacienteNome",dentistaid as "dentistaId",
start_time::text,end_time::text,status,procedimento,observacoes
FROM agendamentos ORDER BY start_time DESC`,
headers: ['id','clinica_id','pacienteNome','dentistaId','start_time','end_time','status','procedimento','observacoes'],
upsertSql: `INSERT INTO agendamentos (id,clinica_id,pacientenome,dentistaid,start_time,end_time,status,procedimento,observacoes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
pacientenome=EXCLUDED.pacientenome,status=EXCLUDED.status,
procedimento=EXCLUDED.procedimento,observacoes=EXCLUDED.observacoes`,
upsertParams: r => [r.id,r.clinica_id,r.pacienteNome,r.dentistaId,r.start_time,r.end_time,r.status,r.procedimento,r.observacoes]
},
{
tab: 'financeiro',
pgTable: 'financeiro',
query: `SELECT id,clinica_id,pacientenome as "pacienteNome",descricao,
valor::text,datavencimento::text as "dataVencimento",status,
formapagamento as "formaPagamento",tipo
FROM financeiro ORDER BY datavencimento DESC`,
headers: ['id','clinica_id','pacienteNome','descricao','valor','dataVencimento','status','formaPagamento','tipo'],
upsertSql: `INSERT INTO financeiro (id,clinica_id,pacientenome,descricao,valor,datavencimento,status,formapagamento,tipo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
pacientenome=EXCLUDED.pacientenome,descricao=EXCLUDED.descricao,
valor=EXCLUDED.valor,datavencimento=EXCLUDED.datavencimento,
status=EXCLUDED.status,formapagamento=EXCLUDED.formapagamento`,
upsertParams: r => [r.id,r.clinica_id,r.pacienteNome,r.descricao,r.valor,r.dataVencimento,r.status,r.formaPagamento,r.tipo]
},
{
tab: 'leads',
pgTable: 'leads',
query: `SELECT id,nome,sobrenome,cpf,whatsapp,
tipointeresse as "tipoInteresse",
datacadastro::text as "dataCadastro",status
FROM leads ORDER BY datacadastro DESC`,
headers: ['id','nome','sobrenome','cpf','whatsapp','tipoInteresse','dataCadastro','status'],
upsertSql: `INSERT INTO leads (id,nome,sobrenome,cpf,whatsapp,tipointeresse,datacadastro,status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (id) DO UPDATE SET
nome=EXCLUDED.nome,whatsapp=EXCLUDED.whatsapp,status=EXCLUDED.status`,
upsertParams: r => [r.id,r.nome,r.sobrenome,r.cpf,r.whatsapp,r.tipoInteresse,r.dataCadastro,r.status]
},
{
tab: 'guias',
pgTable: 'guias_odontologicas',
query: `SELECT id,numeroguiaprestador as "numeroGuiaPrestador",
numeroguiaoperadora as "numeroGuiaOperadora",
datasolicitacao::text as "dataSolicitacao",
tipotratamento as "tipoTratamento",status,
beneficiarioid as "beneficiarioId",
beneficiarionome as "beneficiarioNome",
beneficiarioidentificacao as "beneficiarioIdentificacao"
FROM guias_odontologicas ORDER BY datasolicitacao DESC`,
headers: ['id','numeroGuiaPrestador','numeroGuiaOperadora','dataSolicitacao','tipoTratamento','status','beneficiarioId','beneficiarioNome','beneficiarioIdentificacao'],
upsertSql: `INSERT INTO guias_odontologicas (id,numeroguiaprestador,numeroguiaoperadora,datasolicitacao,tipotratamento,status,beneficiarioid,beneficiarionome,beneficiarioidentificacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
status=EXCLUDED.status,numeroguiaoperadora=EXCLUDED.numeroguiaoperadora`,
upsertParams: r => [r.id,r.numeroGuiaPrestador,r.numeroGuiaOperadora,r.dataSolicitacao,r.tipoTratamento,r.status,r.beneficiarioId,r.beneficiarioNome,r.beneficiarioIdentificacao]
}
];
async function getSheetsClient() {
const { rows } = await pool.query('SELECT * FROM google_tokens LIMIT 1');
if (rows.length === 0) throw new Error('NO_GOOGLE_TOKEN');
const t = rows[0];
const auth = createOAuth2Client();
auth.setCredentials({ refresh_token: t.refresh_token, access_token: t.access_token, expiry_date: t.expiry_date });
return google.sheets({ version: 'v4', auth });
}
// Returns the actual tab title as it exists in the sheet (case-preserving).
async function ensureSheetTab(sheets, tabName) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
const existing = meta.data.sheets.find(s =>
s.properties.title.toLowerCase() === tabName.toLowerCase()
);
if (existing) return existing.properties.title; // already exists, return real name
try {
await sheets.spreadsheets.batchUpdate({
spreadsheetId: SHEETS_BACKUP_ID,
requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] }
});
} catch (e) {
if (!e.message?.includes('already exists') && !e.message?.includes('Já existe')) throw e;
// Re-fetch to get the real name after race-condition create
const meta2 = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
const found = meta2.data.sheets.find(s => s.properties.title.toLowerCase() === tabName.toLowerCase());
if (found) return found.properties.title;
}
return tabName;
}
async function pushTableToSheet(tableDef, sheets) {
const { rows } = await pool.query(tableDef.query);
const realTab = await ensureSheetTab(sheets, tableDef.tab);
await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
const sheetRows = [tableDef.headers, ...rows.map(row =>
tableDef.headers.map(h => {
const v = row[h] ?? row[h.toLowerCase()];
return v === null || v === undefined ? '' : String(v);
})
)];
await sheets.spreadsheets.values.update({
spreadsheetId: SHEETS_BACKUP_ID,
range: `${realTab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: sheetRows }
});
return rows.length;
}
async function pullTableFromSheet(tableDef, sheets) {
const realTab = await ensureSheetTab(sheets, tableDef.tab);
const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${realTab}!A:ZZ` });
const sheetRows = resp.data.values || [];
if (sheetRows.length < 2) return 0;
const headers = sheetRows[0];
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c !== ''));
let upserted = 0;
for (const row of dataRows) {
const obj = {};
headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
if (!obj.id) continue;
try {
await pool.query(tableDef.upsertSql, tableDef.upsertParams(obj));
upserted++;
} catch (e) {
console.error(`[SHEETS PULL] ${tableDef.tab} row ${obj.id}:`, e.message);
}
}
return upserted;
}
async function saveSyncStatus(updates) {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
const current = rows[0] ? JSON.parse(rows[0].data) : {};
const merged = { ...current, ...updates };
await pool.query(
`INSERT INTO settings (id,category,data) VALUES ('sheets_sync_status','sync',$1)
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
[JSON.stringify(merged)]
);
}
// Debounced auto-push: coalesces rapid writes into one sheet update per table
const _pushTimers = new Map();
function schedulePush(tabName) {
if (_pushTimers.has(tabName)) clearTimeout(_pushTimers.get(tabName));
_pushTimers.set(tabName, setTimeout(async () => {
_pushTimers.delete(tabName);
try {
const tableDef = SYNC_TABLES.find(t => t.tab === tabName);
if (!tableDef) return;
const sheets = await getSheetsClient();
const count = await pushTableToSheet(tableDef, sheets);
await saveSyncStatus({ [tabName]: { lastPush: new Date().toISOString(), count } });
console.log(`[SHEETS] Auto-pushed ${count} rows → "${tabName}"`);
} catch (e) {
if (e.message !== 'NO_GOOGLE_TOKEN') console.error(`[SHEETS] Auto-push error for ${tabName}:`, e.message);
}
}, 5000));
}
// --- 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 } = req.query;
const oauth2Client = createOAuth2Client();
const url = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/spreadsheets'
],
state: dentistId || 'admin',
prompt: 'consent'
});
res.json({ url });
});
app.get('/api/oauth2callback', async (req, res) => {
const { code, state } = req.query;
if (!code) return res.status(400).send('Código não fornecido');
try {
const oauth2Client = createOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
let ownerId = state || 'admin';
if (ownerId === 'admin') {
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const { data } = await oauth2.userinfo.get();
ownerId = data.email;
}
await pool.query(`
INSERT INTO google_tokens (owner_id, refresh_token, access_token, expiry_date)
VALUES ($1, $2, $3, $4)
ON CONFLICT (owner_id) DO UPDATE SET
refresh_token = EXCLUDED.refresh_token,
access_token = EXCLUDED.access_token,
expiry_date = EXCLUDED.expiry_date
`, [ownerId, tokens.refresh_token, tokens.access_token, tokens.expiry_date]);
res.send(`<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 { rows } = await pool.query('SELECT owner_id, updated_at FROM google_tokens');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/auth/google/:ownerId', async (req, res) => {
try {
await pool.query('DELETE FROM google_tokens WHERE owner_id = $1', [req.params.ownerId]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- SETTINGS ---
app.get('/api/settings', async (req, res) => {
try {
const { rows } = await pool.query("SELECT * FROM settings WHERE id = 'main'");
res.json(rows[0] ? JSON.parse(rows[0].data) : {});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/settings', async (req, res) => {
try {
const data = JSON.stringify(req.body);
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('main', 'general', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[data]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/settings/google-credentials — returns configured status + masked clientId only
app.get('/api/settings/google-credentials', tenantGuard, async (req, res) => {
const fromEnv = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const configured = !!(googleCreds.clientId && googleCreds.clientSecret);
const clientIdHint = googleCreds.clientId
? googleCreds.clientId.substring(0, 12) + '...' + googleCreds.clientId.slice(-8)
: null;
res.json({ configured, clientIdHint, fromEnv });
});
// POST /api/settings/google-credentials — save to DB + update in-memory cache
app.post('/api/settings/google-credentials', tenantGuard, async (req, res) => {
const { clientId, clientSecret } = req.body;
if (!clientId || !clientSecret) return res.status(400).json({ error: 'clientId e clientSecret são obrigatórios.' });
try {
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('google_credentials', 'oauth', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[JSON.stringify({ clientId, clientSecret })]
);
// Update in-memory cache immediately (no restart needed)
googleCreds.clientId = clientId;
googleCreds.clientSecret = clientSecret;
console.log('[GOOGLE] Credentials updated via UI');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- MAGIC LINK & DENTIST SELF-REGISTRATION ---
app.post('/api/dentistas/magic-link', async (req, res) => {
const { clinicaId, dentistName } = req.body;
try {
const token = Math.random().toString(36).substring(2, 15);
const appUrl = process.env.APP_URL || `http://localhost:${process.env.FRONTEND_PORT || 3000}`;
const magicLink = `${appUrl}/cadastro-dentista?token=${token}&clinica=${clinicaId}&nome=${encodeURIComponent(dentistName)}`;
res.json({ success: true, link: magicLink });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas/register', async (req, res) => {
const { id, nome, email, senha, cro, cro_uf, celular, especialidade, clinicaId } = req.body;
try {
await withTransaction(async (client) => {
// 1. Create or update User
await client.query(`
INSERT INTO usuarios (id, nome, email, senha, cro, cro_uf, celular, especialidade)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, cro = EXCLUDED.cro, cro_uf = EXCLUDED.cro_uf,
celular = EXCLUDED.celular, especialidade = EXCLUDED.especialidade
`, [id, nome, email, senha, cro, cro_uf, celular, especialidade]);
// 2. Create or update link to clinic
const vinculoId = `v_${id}_${clinicaId}`;
await client.query(`
INSERT INTO vinculos (id, usuario_id, clinica_id, role)
VALUES ($1, $2, $3, 'dentista')
ON CONFLICT (usuario_id, clinica_id) DO UPDATE SET role = 'dentista'
`, [vinculoId, id, clinicaId]);
// 3. Create or update Dentist record for agenda
await client.query(`
INSERT INTO dentistas (id, clinica_id, nome, email, telefone, especialidade, coragenda)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
nome = EXCLUDED.nome, especialidade = EXCLUDED.especialidade
`, [id, clinicaId, nome, email, celular, especialidade, '#2563eb']);
});
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/google/events', async (req, res) => {
try {
// 1. Check for connected accounts in google_tokens
const { rows: tokenRows } = await pool.query('SELECT * FROM google_tokens');
const allEvents = [];
const now = new Date();
const timeMin = now.toISOString();
for (const tokenData of tokenRows) {
try {
const oauth2Client = createOAuth2Client();
oauth2Client.setCredentials({
refresh_token: tokenData.refresh_token,
access_token: tokenData.access_token,
expiry_date: tokenData.expiry_date
});
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
const response = await calendar.events.list({
calendarId: 'primary',
timeMin: timeMin,
singleEvents: true,
orderBy: 'startTime',
maxResults: 100
});
if (response.data.items) {
// Try to resolve owner name
let ownerName = tokenData.owner_id;
const { rows: dent } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [tokenData.owner_id]);
if (dent[0]) ownerName = dent[0].nome;
const mapped = response.data.items.map(item => ({
id: `google_${item.id}`,
title: `[${ownerName}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date,
end: item.end.dateTime || item.end.date,
isGoogle: true,
owner: ownerName,
backgroundColor: '#3b82f6',
borderColor: '#3b82f6'
}));
allEvents.push(...mapped);
}
} catch (err) {
console.error(`Error fetching calendar for ${tokenData.owner_id}:`, err.message);
}
}
// 2. Legacy Support: Check Settings for Public Calendars (API KEY)
const { rows: settingsRows } = await pool.query("SELECT data FROM settings WHERE id = 'main'");
if (settingsRows[0]) {
const settings = JSON.parse(settingsRows[0].data);
const apiKey = settings.googleApiKey;
if (apiKey) {
const legacyIds = [];
if (settings.clinicCalendarId) legacyIds.push({ id: settings.clinicCalendarId, owner: 'CLÍNICA' });
// We only add dentists that DON'T have a token yet to avoid duplication
if (settings.googleCalendarIds) {
for (const [dentistId, calId] of Object.entries(settings.googleCalendarIds)) {
if (calId && !tokenRows.find(t => t.owner_id === dentistId)) {
const { rows: dName } = await pool.query('SELECT nome FROM dentistas WHERE id = $1', [dentistId]);
legacyIds.push({ id: calId, owner: dName[0]?.nome || 'DENTISTA' });
}
}
}
for (const { id, owner } of legacyIds) {
try {
const cleanId = id.trim().toLowerCase();
const response = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(cleanId)}/events?key=${apiKey}&timeMin=${timeMin}&singleEvents=true&orderBy=startTime`);
const data = await response.json();
if (data.items) {
const mapped = data.items.map(item => ({
id: `google_legacy_${item.id}`,
title: `[${owner}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date,
end: item.end.dateTime || item.end.date,
isGoogle: true,
owner: owner,
backgroundColor: owner === 'CLÍNICA' ? '#10b981' : '#6b7280',
borderColor: owner === 'CLÍNICA' ? '#10b981' : '#6b7280'
}));
allEvents.push(...mapped);
}
} catch (err) { console.error(`Legacy API Error for ${id}:`, err.message); }
}
}
}
res.json(allEvents);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- DASHBOARD STATS ---
app.get('/api/dashboard/stats', async (req, res) => {
try {
const cacheKey = 'dashboard:stats';
if (redis) {
const cachedStats = await redis.get(cacheKey);
if (cachedStats) {
console.log('[CACHE HIT] Serving dashboard stats from DragonflyDB');
return res.json(JSON.parse(cachedStats));
}
}
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos');
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads');
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'RECEITA' OR tipo IS NULL");
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA'");
// Growth data (last 6 months)
const { rows: growth } = await pool.query(`
SELECT
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
FROM financeiro
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
ORDER BY mes ASC
`);
// Recent appointments with patient and dentist names
const { rows: recent } = await pool.query(`
SELECT a.id, a.pacientenome, a.start_time, a.status,
d.nome as "dentistaNome"
FROM agendamentos a
LEFT JOIN dentistas d ON a.dentistaid = d.id
ORDER BY a.start_time DESC
LIMIT 5
`);
const responseData = {
stats: {
totalPacientes: parseInt(pacientes[0].count),
totalAgendamentos: parseInt(agendamentos[0].count),
totalLeads: parseInt(leads[0].count),
faturamentoTotal: parseFloat(faturamento[0].total),
despesasTotal: parseFloat(pendente[0].total)
},
growth: growth,
recentAgendamentos: recent
};
if (redis) {
await redis.setex(cacheKey, 60, JSON.stringify(responseData)); // Cache for 60s
console.log('[CACHE MISS] Cached dashboard stats in DragonflyDB');
}
res.json(responseData);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- NOTIFICACOES ---
app.get('/api/notificacoes', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM notificacoes ORDER BY data DESC LIMIT 100');
res.json(rows.map(n => ({ ...n, lida: !!n.lida })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/notificacoes/read-all', async (req, res) => {
try {
await pool.query('UPDATE notificacoes SET lida = true WHERE lida = false');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/notificacoes/:id', async (req, res) => {
try {
await pool.query('DELETE FROM notificacoes WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/notificacoes/:id/read', async (req, res) => {
try {
await pool.query('UPDATE notificacoes SET lida = true WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PACIENTES ---
app.get('/api/pacientes', async (req, res) => {
try {
const { q, sort } = req.query;
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`;
let text, params;
if (q && q.trim()) {
const term = `%${q.trim().toUpperCase()}%`;
text = `${base} WHERE UPPER(p.nome) LIKE $1 OR p.cpf LIKE $1 ORDER BY ${orderBy} LIMIT 100`;
params = [term];
} else {
text = `${base} ORDER BY ${orderBy} LIMIT 50`;
params = [];
}
const { rows } = await pool.query(text, params);
const { rows: total } = await pool.query('SELECT COUNT(*) as total FROM pacientes');
res.json({ data: rows, total: parseInt(total[0].total) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/pacientes', async (req, res) => {
try {
const p = req.body;
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', async (req, res) => {
try {
const upd = buildUpdate('pacientes', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
schedulePush('pacientes');
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- DENTISTAS ---
app.get('/api/dentistas', async (req, res) => {
try {
const { clinicaId } = req.query;
let query = 'SELECT * FROM dentistas';
const params = [];
if (clinicaId) {
query += ' WHERE clinica_id = $1';
params.push(clinicaId);
}
query += ' ORDER BY ordem ASC, nome ASC';
const { rows } = await pool.query(query, params);
res.json(rows.map(d => ({ ...d, ativo: !!d.ativo, corAgenda: d.coragenda })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas', async (req, res) => {
try {
const d = req.body;
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM dentistas');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('dentistas', { ...d, ordem });
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/dentistas/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE dentistas SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/dentistas/:id', async (req, res) => {
try {
const upd = buildUpdate('dentistas', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/:id', async (req, res) => {
try {
const { id } = req.params;
await withTransaction(async (client) => {
// 1. Remove Google Tokens associated with this dentist
await client.query('DELETE FROM google_tokens WHERE owner_id = $1', [id]);
// 2. Remove clinical links (if any)
await client.query('DELETE FROM vinculos WHERE usuario_id = $1', [id]);
// 3. Set to NULL in agendamentos if it exists
await client.query('UPDATE agendamentos SET dentistaid = NULL WHERE dentistaid = $1', [id]);
// 4. Delete from dentistas table
await client.query('DELETE FROM dentistas WHERE id = $1', [id]);
});
res.json({ success: true });
} catch (err) {
console.error('Error deleting dentist:', err);
res.status(500).json({ error: err.message });
}
});
// --- GESTÃO DE HORÁRIOS ---
app.get('/api/dentistas/:dentistaId/horarios', async (req, res) => {
try {
const { dentistaId } = req.params;
const { clinicaId } = req.query;
let query = 'SELECT * FROM dentistas_horarios WHERE dentista_id = $1';
const params = [dentistaId];
if (clinicaId) {
query += ' AND clinica_id = $2';
params.push(clinicaId);
}
query += ' ORDER BY dia_semana ASC, hora_inicio ASC';
const { rows } = await pool.query(query, params);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/dentistas/horarios', async (req, res) => {
try {
const { id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo } = req.body;
const horaId = id || `h_${Math.random().toString(36).substring(2, 9)}`;
await pool.query(`
INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO UPDATE SET
dia_semana = EXCLUDED.dia_semana, hora_inicio = EXCLUDED.hora_inicio,
hora_fim = EXCLUDED.hora_fim, ativo = EXCLUDED.ativo
`, [horaId, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo ?? 1]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/dentistas/horarios/:id', async (req, res) => {
try {
await pool.query('DELETE FROM dentistas_horarios WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- RELATÓRIOS ---
app.get('/api/reports/productivity', async (req, res) => {
try {
const { clinicaId, start, end } = req.query;
if (!clinicaId) return res.status(400).json({ error: "Missing clinicaId" });
// Financial Totals
const { rows: finRows } = await pool.query(`
SELECT
SUM(CASE WHEN tipo = 'RECEITA' AND status = 'Pago' THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' AND status = 'Pago' THEN valor ELSE 0 END) as despesa,
COUNT(*) as totalTransacoes
FROM financeiro
WHERE clinica_id = $1 AND datavencimento BETWEEN $2 AND $3
`, [clinicaId, start || '2000-01-01', end || '2100-01-01']);
// Appointments by Dentist
const { rows: agRows } = await pool.query(`
SELECT
d.nome as dentista,
COUNT(a.id) as totalAgendamentos,
SUM(CASE WHEN a.status = 'Concluido' THEN 1 ELSE 0 END) as concluidos,
SUM(CASE WHEN a.status = 'Faltou' THEN 1 ELSE 0 END) as faltas
FROM dentistas d
LEFT JOIN agendamentos a ON d.id = a.dentistaid AND a.start_time BETWEEN $1 AND $2
WHERE d.clinica_id = $3
GROUP BY d.id, d.nome
`, [start || '2000-01-01', end || '2100-01-01', clinicaId]);
res.json({
financeiro: finRows[0],
producao: agRows
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- AGENDAMENTOS ---
app.get('/api/agendamentos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM agendamentos');
res.json(rows.map(r => ({
...r,
dentistaId: r.dentistaid,
pacienteNome: r.pacientenome,
pacienteCelular: r.pacientecelular,
start: r.start_time,
end: r.end_time
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST with concurrency lock: prevents double-booking the same dentist+time slot
app.post('/api/agendamentos', async (req, res) => {
try {
const { start, end, ...rest } = req.body;
const { dentistaId } = rest;
await withTransaction(async (client) => {
// Lock check: SELECT the conflicting row FOR UPDATE to prevent race conditions
if (dentistaId && start) {
const { rows: conflict } = await client.query(
`SELECT id FROM agendamentos
WHERE dentistaid = $1 AND start_time = $2
LIMIT 1 FOR UPDATE`,
[dentistaId, start]
);
if (conflict.length > 0) {
throw { code: 'CONF_ERROR', message: 'ESTE HORÁRIO ACABOU DE SER RESERVADO POR OUTRA PESSOA. POR FAVOR, ESCOLHA OUTRO HORÁRIO.' };
}
}
const id = rest.id || `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
// 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
};
const insertQuery = buildInsert('agendamentos', payload);
await client.query(insertQuery.text, insertQuery.values);
});
schedulePush('agendamentos');
res.json({ success: true });
} catch (err) {
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 });
}
});
app.put('/api/agendamentos/:id', async (req, res) => {
try {
const { start, end, ...rest } = req.body;
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;
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
if (updateQuery) {
await pool.query(updateQuery.text, updateQuery.values);
}
schedulePush('agendamentos');
res.json({ success: true });
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({
success: false,
message: 'ESTE HORÁRIO JÁ ESTÁ OCUPADO PARA ESTE DENTISTA.'
});
}
res.status(500).json({ error: err.message });
}
});
app.delete('/api/agendamentos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM agendamentos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- ESPECIALIDADES ---
app.get('/api/especialidades', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM especialidades ORDER BY ordem ASC, nome ASC');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/especialidades', async (req, res) => {
try {
// Automatically set order to the end
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM especialidades');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('especialidades', { ...req.body, ordem });
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/especialidades/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE especialidades SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/especialidades/:id', async (req, res) => {
try {
const upd = buildUpdate('especialidades', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/especialidades/:id', async (req, res) => {
try {
await pool.query('DELETE FROM especialidades WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PLANOS ---
app.get('/api/planos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM planos');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/planos', async (req, res) => {
try {
const ins = buildInsert('planos', req.body);
await pool.query(ins.text, ins.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/planos/:id', async (req, res) => {
try {
const upd = buildUpdate('planos', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/planos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM planos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PROCEDIMENTOS ---
app.get('/api/procedimentos', async (req, res) => {
try {
const { rows: procs } = await pool.query('SELECT * FROM procedimentos ORDER BY ordem ASC, nome ASC');
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', async (req, res) => {
try {
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,
codigoInterno: codigoInternoVal,
ordem: ordemValue,
tipo_regiao: proc.tipo_regiao || 'GERAL',
exige_face: proc.exige_face ? 1 : 0
};
if (!payload.id) payload.id = Math.random().toString(36).substring(2, 10).toUpperCase();
await withTransaction(async (client) => {
const ins = buildInsert('procedimentos', payload);
await client.query(ins.text, ins.values);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
const vIns = buildInsert('procedimentos_valores_planos', { ...v, procedimentoId: payload.id });
await client.query(vIns.text, vIns.values);
}
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/procedimentos/reorder', async (req, res) => {
const { orders } = req.body; // Array of {id, ordem}
try {
await withTransaction(async (client) => {
for (const item of orders) {
await client.query('UPDATE procedimentos SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.put('/api/procedimentos/:id', async (req, res) => {
try {
const { valoresPlanos, ...proc } = req.body;
const updateData = { ...proc, exige_face: proc.exige_face ? 1 : 0 };
await withTransaction(async (client) => {
const upd = buildUpdate('procedimentos', updateData, 'id', req.params.id);
await client.query(upd.text, upd.values);
await client.query('DELETE FROM procedimentos_valores_planos WHERE procedimentoid = $1', [req.params.id]);
if (valoresPlanos && valoresPlanos.length > 0) {
for (const v of valoresPlanos) {
const vIns = buildInsert('procedimentos_valores_planos', { ...v, procedimentoId: req.params.id });
await client.query(vIns.text, vIns.values);
}
}
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/procedimentos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM procedimentos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- FINANCEIRO ---
app.get('/api/financeiro', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM financeiro');
res.json(rows.map(r => ({ ...r, valor: parseFloat(r.valor || 0) })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/financeiro', async (req, res) => {
try {
const ins = buildInsert('financeiro', req.body);
await pool.query(ins.text, ins.values);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/financeiro/:id', async (req, res) => {
try {
const upd = buildUpdate('financeiro', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/financeiro/:id', async (req, res) => {
try {
await pool.query('DELETE FROM financeiro WHERE id = $1', [req.params.id]);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LEADS ---
app.get('/api/leads', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM leads');
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/leads', async (req, res) => {
try {
const ins = buildInsert('leads', req.body);
await pool.query(ins.text, ins.values);
schedulePush('leads');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- GUIAS ODONTOLÓGICAS (TRATAMENTOS) ---
app.get('/api/guias', async (req, res) => {
try {
const {
page = 1,
pageSize = 10,
search = '',
status = '',
dataInicio = '',
dataFim = '',
sortField = 'dataSolicitacao',
sortOrder = 'DESC',
gto = ''
} = req.query;
const offset = (page - 1) * pageSize;
const limit = parseInt(pageSize);
let where = ' WHERE 1=1';
const params = [];
let paramIdx = 1;
if (gto) {
where += ` AND (numeroguiaprestador LIKE $${paramIdx} OR numeroguiaoperadora LIKE $${paramIdx + 1})`;
params.push(`%${gto}%`, `%${gto}%`);
paramIdx += 2;
}
if (search) {
where += ` AND (beneficiarionome LIKE $${paramIdx} OR beneficiarioidentificacao LIKE $${paramIdx + 1})`;
params.push(`%${search}%`, `%${search}%`);
paramIdx += 2;
}
if (status) {
where += ` AND status = $${paramIdx}`;
params.push(status);
paramIdx++;
}
if (dataInicio) {
where += ` AND datasolicitacao >= $${paramIdx}`;
params.push(dataInicio);
paramIdx++;
}
if (dataFim) {
where += ` AND datasolicitacao <= $${paramIdx}`;
params.push(dataFim);
paramIdx++;
}
// Validate sortField and sortOrder to prevent SQL injection
const allowedSortFields = ['datasolicitacao', 'beneficiarionome', 'status', 'numeroguiaprestador'];
const normalizedSort = sortField.toLowerCase();
const finalSortField = allowedSortFields.includes(normalizedSort) ? normalizedSort : 'datasolicitacao';
const finalSortOrder = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const query = `
SELECT * FROM guias_odontologicas
${where}
ORDER BY ${finalSortField} ${finalSortOrder}
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`;
const countQuery = `SELECT COUNT(*) as total FROM guias_odontologicas ${where}`;
const { rows } = await pool.query(query, [...params, limit, offset]);
const { rows: totalRows } = await pool.query(countQuery, params);
const mapGuia = (g) => ({
...g,
numeroGuiaPrestador: g.numeroguiaprestador,
numeroGuiaOperadora: g.numeroguiaoperadora,
dataSolicitacao: g.datasolicitacao,
tipoTratamento: g.tipotratamento,
beneficiarioId: g.beneficiarioid,
beneficiarioNome: g.beneficiarionome,
beneficiarioIdentificacao: g.beneficiarioidentificacao,
createdAt: g.createdat
});
res.json({
data: rows.map(mapGuia),
total: totalRows[0].total,
page: parseInt(page),
pageSize: limit
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- GTO BUILDER (Lançar GTO) ---
app.post('/api/gto-builder', async (req, res) => {
try {
const { pacienteId, dentistaId, credenciadaId } = req.body;
const id = Math.random().toString(36).substring(2, 9).toUpperCase();
await pool.query(
"INSERT INTO gto_builders (id, pacienteid, dentistaid, credenciadaid, status, total) VALUES ($1, $2, $3, $4, 'RASCUNHO', 0)",
[id, pacienteId, dentistaId, credenciadaId]
);
res.json({ id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/gto-builder/:id', async (req, res) => {
try {
const { rows: builder } = await pool.query('SELECT * FROM gto_builders WHERE id = $1', [req.params.id]);
const { rows: items } = await pool.query('SELECT * FROM gto_items WHERE builderid = $1', [req.params.id]);
const mappedItems = items.map(i => ({
...i,
builderId: i.builderid,
procedimentoId: i.procedimentoid,
codigoTUSS: i.codigotuss,
codigoInterno: i.codigointerno,
valorUnitario: parseFloat(i.valorunitario || 0),
valorTotal: parseFloat(i.valortotal || 0),
anexosCount: i.anexoscount
}));
const b = builder[0];
res.json({
...b,
pacienteId: b?.pacienteid,
dentistaId: b?.dentistaid,
credenciadaId: b?.credenciadaid,
items: mappedItems
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/gto-builder/:id/item', async (req, res) => {
try {
const itemId = Math.random().toString(36).substring(2, 9).toUpperCase();
const item = { ...req.body, id: itemId, builderId: req.params.id };
const ins = buildInsert('gto_items', item);
await pool.query(ins.text, ins.values);
// Update total
await pool.query(
'UPDATE gto_builders SET total = (SELECT SUM(valortotal) FROM gto_items WHERE builderid = $1) WHERE id = $2',
[req.params.id, req.params.id]
);
res.json({ success: true, item });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/gto-builder/:id/item/:itemId', async (req, res) => {
try {
await pool.query('DELETE FROM gto_items WHERE id = $1', [req.params.itemId]);
// Update total
await pool.query(
'UPDATE gto_builders SET total = COALESCE((SELECT SUM(valortotal) FROM gto_items WHERE builderid = $1), 0) WHERE id = $2',
[req.params.id, req.params.id]
);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/gto-builder/:id/finalizar', async (req, res) => {
try {
await pool.query("UPDATE gto_builders SET status = 'FINALIZADA' WHERE id = $1", [req.params.id]);
// Logic to migrate to guias_odontologicas would go here in a real app
// For now, we just mark as finished.
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// =============================================================================
// SYNC ROUTES — Google Sheets ↔ PostgreSQL
// =============================================================================
app.get('/api/sync/status', tenantGuard, async (req, res) => {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
const syncStatus = rows[0] ? JSON.parse(rows[0].data) : {};
const counts = {};
for (const t of SYNC_TABLES) {
const { rows: c } = await pool.query(`SELECT COUNT(*) as n FROM ${t.pgTable}`);
counts[t.tab] = parseInt(c[0].n);
}
res.json({ syncStatus, counts, spreadsheetId: SHEETS_BACKUP_ID });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/sync/push — PG → Google Sheets (backup)
app.post('/api/sync/push', tenantGuard, async (req, res) => {
const logs = [];
try {
const sheets = await getSheetsClient();
logs.push(`[OK] AUTENTICADO NO GOOGLE`);
const statusUpdate = {};
for (const tableDef of SYNC_TABLES) {
logs.push(`[→] ENVIANDO: ${tableDef.tab.toUpperCase()}...`);
try {
const count = await pushTableToSheet(tableDef, sheets);
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS`);
statusUpdate[tableDef.tab] = { lastPush: new Date().toISOString(), count };
} catch (e) {
logs.push(`[ERRO] ${tableDef.tab}: ${e.message}`);
}
}
await saveSyncStatus(statusUpdate);
logs.push(`[✓] BACKUP CONCLUÍDO — ${new Date().toLocaleString('pt-BR')}`);
res.json({ success: true, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA. VÁ EM CONFIGURAÇÕES E CONECTE O GOOGLE.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// POST /api/sync/pull — Google Sheets → PG (restaurar backup)
app.post('/api/sync/pull', tenantGuard, async (req, res) => {
const logs = [];
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);
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS IMPORTADOS`);
} catch (e) {
logs.push(`[ERRO] ${tableDef.tab}: ${e.message}`);
}
}
logs.push(`[✓] RESTAURAÇÃO CONCLUÍDA — ${new Date().toLocaleString('pt-BR')}`);
res.json({ success: true, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA. VÁ EM CONFIGURAÇÕES E CONECTE O GOOGLE.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// Import patients from Apps Script legacy sheet (PACIENTES-CONSULTT-CLINIC tab)
app.post('/api/sync/import-legacy', tenantGuard, async (req, res) => {
const logs = [];
try {
const sheets = await getSheetsClient();
logs.push('[←] LENDO ABA PACIENTES-CONSULTT-CLINIC...');
const realTab = await ensureSheetTab(sheets, 'PACIENTES-CONSULTT-CLINIC');
const resp = await sheets.spreadsheets.values.get({
spreadsheetId: SHEETS_BACKUP_ID,
range: `${realTab}!A:ZZ`
});
const sheetRows = resp.data.values || [];
if (sheetRows.length < 2) {
logs.push('[AVISO] ABA VAZIA OU SEM DADOS.');
return res.json({ success: true, logs, imported: 0 });
}
const headers = sheetRows[0].map(h => h.trim());
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c && c.trim() !== ''));
logs.push(`[OK] ${dataRows.length} LINHAS ENCONTRADAS`);
const idx = h => headers.indexOf(h);
const iNome = idx('Nome'); const iSobre = idx('SobreNome');
const iCpf = idx('CPF'); const iCred = idx('Credenciada');
const iNasci = idx('DataNasci'); const iTel = idx('TefefonePrincipal');
let imported = 0, skipped = 0;
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
const get = j => (j >= 0 && j < row.length ? (row[j] || '').trim() : '');
const nome = [get(iNome), get(iSobre)].filter(Boolean).join(' ').toUpperCase();
if (!nome) { skipped++; continue; }
const cpfRaw = get(iCpf);
const cpfDigits = cpfRaw.replace(/\D/g, '');
const id = cpfDigits || `legacy_${i + 1}`;
try {
const trunc = (v, n) => v ? v.substring(0, n) : null;
await pool.query(
`INSERT INTO pacientes (id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (id) DO UPDATE SET
nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,
convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
[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]
);
imported++;
} catch (e) {
skipped++;
if (skipped <= 3) logs.push(`[AVISO] linha ${i + 2}: ${e.message}`);
}
}
logs.push(`[✓] ${imported} PACIENTES IMPORTADOS, ${skipped} IGNORADOS`);
if (imported > 0) {
await saveSyncStatus({ pacientes: { lastPush: new Date().toISOString(), count: imported, source: 'legacy' } });
schedulePush('pacientes');
}
res.json({ success: true, logs, imported, skipped });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// initTables() was removed as database is native PostgreSQL and schema DDL is maintained externally.
async function generateIntelligentNotifications() {
if (!pool) return;
try {
// 1. Check for upcoming appointments (next 2 hours)
const { rows: upcoming } = await pool.query(`
SELECT a.*, p.nome as "pacienteNome"
FROM agendamentos a
LEFT JOIN pacientes p ON a.pacientenome = p.nome
WHERE a.start_time BETWEEN NOW() AND NOW() + INTERVAL '2 hours'
AND a.status = 'Pendente'
`);
for (const appt of upcoming) {
const id = `notif_appt_${appt.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'PRÓXIMO ATENDIMENTO',
`Paciente ${appt.pacientenome} às ${new Date(appt.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
'agenda'
]);
}
}
// 2. Check for overdue bills
const { rows: overdue } = await pool.query(`
SELECT * FROM financeiro
WHERE datavencimento < CURRENT_DATE AND status = 'Pendente'
`);
for (const bill of overdue) {
const id = `notif_fin_overdue_${bill.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'FATURA ATRASADA',
`A conta "${bill.descricao}" de R$ ${bill.valor} venceu em ${new Date(bill.datavencimento).toLocaleDateString('pt-BR')}`,
'financeiro'
]);
}
}
// 3. Check for bills due today
const { rows: dueToday } = await pool.query(`
SELECT * FROM financeiro
WHERE datavencimento = CURRENT_DATE AND status = 'Pendente'
`);
for (const bill of dueToday) {
const id = `notif_fin_today_${bill.id}`;
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'VENCIMENTO HOJE',
`A conta "${bill.descricao}" de R$ ${bill.valor} vence hoje!`,
'financeiro'
]);
}
}
// 4. System Events (to Dev) - Mocking a system event if table counts are low
const { rows: pCount } = await pool.query('SELECT COUNT(*) as count FROM pacientes');
if (parseInt(pCount[0].count) < 5) {
const id = 'notif_sys_setup';
const { rows: exists } = await pool.query('SELECT id FROM notificacoes WHERE id = $1', [id]);
if (exists.length === 0) {
await pool.query('INSERT INTO notificacoes (id, titulo, mensagem, tipo) VALUES ($1, $2, $3, $4)', [
id,
'CONFIGURAÇÃO DO SISTEMA',
'O sistema possui poucos pacientes cadastrados. Considere importar dados.',
'sistema'
]);
}
}
} catch (err) {
console.error('Error generating notifications:', err);
}
}
// --- MODELOS DE RECEITA ---
app.get('/api/modelos-receita', async (req, res) => {
try {
const { especialidadeId } = req.query;
let q = `SELECT m.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items
FROM modelos_receita m LEFT JOIN items_receita i ON i.modelo_id = m.id`;
const params = [];
if (especialidadeId) { q += ` WHERE m.especialidade_id = $1`; params.push(especialidadeId); }
q += ` GROUP BY m.id ORDER BY m.nome`;
const { rows } = await pool.query(q, params);
res.json(rows.map(r => ({
id: r.id, nome: r.nome,
especialidadeId: r.especialidade_id, especialidadeNome: r.especialidade_nome,
instrucoes: r.instrucoes, items: r.items || []
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/modelos-receita', async (req, res) => {
try {
const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body;
const id = `mr_${Date.now()}`;
await pool.query(
`INSERT INTO modelos_receita (id, nome, especialidade_id, especialidade_nome, instrucoes) VALUES ($1,$2,$3,$4,$5)`,
[id, nome, especialidadeId || null, especialidadeNome || null, instrucoes || null]
);
for (let i = 0; i < items.length; i++) {
const it = items[i];
await pool.query(
`INSERT INTO items_receita (id, modelo_id, medicamento, dose, via, frequencia, duracao, instrucoes, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[`ir_${Date.now()}_${i}`, id, it.medicamento, it.dose || '', it.via || 'VO', it.frequencia || '', it.duracao || '', it.instrucoes || null, i]
);
}
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/modelos-receita/:id', async (req, res) => {
try {
const { nome, especialidadeId, especialidadeNome, instrucoes, items = [] } = req.body;
await pool.query(
`UPDATE modelos_receita SET nome=$1, especialidade_id=$2, especialidade_nome=$3, instrucoes=$4 WHERE id=$5`,
[nome, especialidadeId || null, especialidadeNome || null, instrucoes || null, req.params.id]
);
await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]);
for (let i = 0; i < items.length; i++) {
const it = items[i];
await pool.query(
`INSERT INTO items_receita (id, modelo_id, medicamento, dose, via, frequencia, duracao, instrucoes, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[`ir_${Date.now()}_${i}`, req.params.id, it.medicamento, it.dose || '', it.via || 'VO', it.frequencia || '', it.duracao || '', it.instrucoes || null, i]
);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/modelos-receita/:id', async (req, res) => {
try {
await pool.query(`DELETE FROM items_receita WHERE modelo_id = $1`, [req.params.id]);
await pool.query(`DELETE FROM modelos_receita WHERE id = $1`, [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- PEDIDOS DE EXAME ---
app.get('/api/pedidos-exame', async (req, res) => {
try {
const { pacienteId } = req.query;
let q = `SELECT p.*, COALESCE(json_agg(i ORDER BY i.ordem) FILTER (WHERE i.id IS NOT NULL), '[]') AS items
FROM pedidos_exame p LEFT JOIN items_pedido_exame i ON i.pedido_id = p.id`;
const params = [];
if (pacienteId) { q += ` WHERE p.paciente_id = $1`; params.push(pacienteId); }
q += ` GROUP BY p.id ORDER BY p.created_at DESC LIMIT 50`;
const { rows } = await pool.query(q, params);
res.json(rows.map(r => ({
id: r.id, pacienteId: r.paciente_id, pacienteNome: r.paciente_nome,
profissionalId: r.profissional_id, profissionalNome: r.profissional_nome,
data: r.data, observacoes: r.observacoes, items: r.items || []
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/pedidos-exame', async (req, res) => {
try {
const { pacienteId, pacienteNome, profissionalId, profissionalNome, data, observacoes, items = [] } = req.body;
const id = `pe_${Date.now()}`;
await pool.query(
`INSERT INTO pedidos_exame (id, paciente_id, paciente_nome, profissional_id, profissional_nome, data, observacoes) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, pacienteId || null, pacienteNome, profissionalId || null, profissionalNome || null, data, observacoes || null]
);
for (let i = 0; i < items.length; i++) {
const it = items[i];
await pool.query(
`INSERT INTO items_pedido_exame (id, pedido_id, nome, categoria, regiao, observacao, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[`ie_${Date.now()}_${i}`, id, it.nome, it.categoria, it.regiao || null, it.observacao || null, i]
);
}
res.json({ success: true, id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
async function runMigrations() {
const migrations = [
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarid TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarrelacao TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS indicadorporid TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS observacoes TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS logradouro TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS numero TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS complemento TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS bairro TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cidade TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS estado TEXT`,
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cep TEXT`,
`CREATE TABLE IF NOT EXISTS modelos_receita (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
especialidade_id TEXT,
especialidade_nome TEXT,
instrucoes TEXT,
clinica_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS items_receita (
id TEXT PRIMARY KEY,
modelo_id TEXT NOT NULL,
medicamento TEXT NOT NULL,
dose TEXT NOT NULL DEFAULT '',
via TEXT NOT NULL DEFAULT 'VO',
frequencia TEXT NOT NULL DEFAULT '',
duracao TEXT NOT NULL DEFAULT '',
instrucoes TEXT,
ordem INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS pedidos_exame (
id TEXT PRIMARY KEY,
paciente_id TEXT,
paciente_nome TEXT,
profissional_id TEXT,
profissional_nome TEXT,
data TEXT NOT NULL,
observacoes TEXT,
clinica_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS items_pedido_exame (
id TEXT PRIMARY KEY,
pedido_id TEXT NOT NULL,
nome TEXT NOT NULL,
categoria TEXT NOT NULL,
regiao TEXT,
observacao TEXT,
ordem INTEGER DEFAULT 0
)`,
];
for (const sql of migrations) {
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
}
console.log('[MIGRATION] all migrations OK');
}
// Start server after all routes are registered
app.listen(PORT, '0.0.0.0', async () => {
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
await loadGoogleCredsFromDb();
await runMigrations();
generateIntelligentNotifications();
setInterval(generateIntelligentNotifications, 5 * 60 * 1000);
});