From 63962d51c10cbaad6c4460aa325ad764e19d2b8a Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 14 May 2026 05:44:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20Google=20Sheets=20bidirectional?= =?UTF-8?q?=20backup=20(PG=20=E2=86=94=20Sheets)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add SYNC_TABLES config (pacientes, agendamentos, financeiro, leads, guias) - Add getSheetsClient(), ensureSheetTab(), pushTableToSheet(), pullTableFromSheet() - Add schedulePush() debounced 5s auto-push after every write to main tables - Add GET /api/sync/status, POST /api/sync/push, POST /api/sync/pull - Update OAuth scope to include spreadsheets Frontend: - Redesign SyncView: per-table status grid, two-button operation (Backup/Restore), terminal log with color-coded lines, auto-backup info notice Co-Authored-By: Claude Sonnet 4.6 --- backend/server.js | 262 +++++++++++++++++++++++++++++++++- frontend/views/AdminViews.tsx | 166 ++++++++++++++++----- 2 files changed, 388 insertions(+), 40 deletions(-) diff --git a/backend/server.js b/backend/server.js index fb13b23..a2c6aa8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -176,6 +176,184 @@ const createOAuth2Client = () => new google.auth.OAuth2( 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 }); +} + +async function ensureSheetTab(sheets, tabName) { + const meta = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID }); + const exists = meta.data.sheets.some(s => s.properties.title === tabName); + if (!exists) { + await sheets.spreadsheets.batchUpdate({ + spreadsheetId: SHEETS_BACKUP_ID, + requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] } + }); + } +} + +async function pushTableToSheet(tableDef, sheets) { + const { rows } = await pool.query(tableDef.query); + await ensureSheetTab(sheets, tableDef.tab); + await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${tableDef.tab}!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: `${tableDef.tab}!A1`, + valueInputOption: 'RAW', + requestBody: { values: sheetRows } + }); + return rows.length; +} + +async function pullTableFromSheet(tableDef, sheets) { + await ensureSheetTab(sheets, tableDef.tab); + const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${tableDef.tab}!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) => { const { dentistId } = req.query; @@ -184,7 +362,8 @@ app.get('/api/auth/google/url', (req, res) => { access_type: 'offline', scope: [ 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/calendar.readonly' + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/spreadsheets' ], state: dentistId || 'admin', prompt: 'consent' @@ -501,6 +680,7 @@ app.post('/api/pacientes', async (req, res) => { 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 }); @@ -511,6 +691,7 @@ 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 }); @@ -718,12 +899,12 @@ app.post('/api/agendamentos', async (req, res) => { 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 }); } - // PostgreSQL unique violation code is '23505' if (err.code === '23505') { return res.status(409).json({ success: false, @@ -749,6 +930,7 @@ app.put('/api/agendamentos/:id', async (req, res) => { if (updateQuery) { await pool.query(updateQuery.text, updateQuery.values); } + schedulePush('agendamentos'); res.json({ success: true }); } catch (err) { if (err.code === '23505') { @@ -968,6 +1150,7 @@ 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 }); } }); @@ -976,6 +1159,7 @@ 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 }); } }); @@ -983,6 +1167,7 @@ app.put('/api/financeiro/:id', async (req, res) => { 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 }); } }); @@ -999,6 +1184,7 @@ 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 }); } }); @@ -1174,6 +1360,78 @@ app.put('/api/gto-builder/:id/finalizar', async (req, res) => { } 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 }); + } +}); + // initTables() was removed as database is native PostgreSQL and schema DDL is maintained externally. async function generateIntelligentNotifications() { if (!pool) return; diff --git a/frontend/views/AdminViews.tsx b/frontend/views/AdminViews.tsx index 89900a1..6600ae7 100644 --- a/frontend/views/AdminViews.tsx +++ b/frontend/views/AdminViews.tsx @@ -849,59 +849,149 @@ export const PlanosView: React.FC = () => { // --- SYNC --- +const SYNC_TABS = ['pacientes', 'agendamentos', 'financeiro', 'leads', 'guias']; + export const SyncView: React.FC = () => { const [logs, setLogs] = useState([]); - const [syncing, setSyncing] = useState(false); + const [operation, setOperation] = useState<'push' | 'pull' | null>(null); + const [status, setStatus] = useState(null); const toast = useToast(); - const startSync = async () => { - setSyncing(true); - setLogs([]); + const authHeaders = { + 'Authorization': `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`, + 'Content-Type': 'application/json' + }; + + const loadStatus = async () => { try { - await HybridBackend.syncGoogleSheetsToPg((log) => { - setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${log}`]); - }); - toast.success("SINCRONIZAÇÃO CONCLUÍDA COM SUCESSO!"); - } catch { - toast.error("FALHA NA SINCRONIZAÇÃO."); + const res = await fetch('/api/sync/status', { headers: authHeaders }); + if (res.ok) setStatus(await res.json()); + } catch {} + }; + + useEffect(() => { loadStatus(); }, []); + + const runSync = async (dir: 'push' | 'pull') => { + setOperation(dir); + setLogs([`>>> ${dir === 'push' ? 'BACKUP PostgreSQL → Google Sheets' : 'RESTAURAR Google Sheets → PostgreSQL'}...`]); + try { + const res = await fetch(`/api/sync/${dir}`, { method: 'POST', headers: authHeaders }); + const data = await res.json(); + setLogs(data.logs || []); + if (data.success) { + toast.success(dir === 'push' ? 'BACKUP REALIZADO!' : 'DADOS RESTAURADOS!'); + loadStatus(); + } else { + toast.error('ERRO NA SINCRONIZAÇÃO. VERIFIQUE OS LOGS.'); + } + } catch (e: any) { + setLogs(prev => [...prev, `[ERRO] ${e.message}`]); + toast.error('FALHA NA CONEXÃO.'); } finally { - setSyncing(false); + setOperation(null); } }; + const fmtDate = (iso: string | undefined) => iso + ? new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '—'; + return ( -
- - +
+ +
+ + +
-
-
-
- {logs.length === 0 && AGUARDANDO INÍCIO DO PROCESSO...} - {logs.map((log, i) => ( -
{log}
- ))} - {syncing &&
_
} + {/* Status por tabela */} +
+
+

ESTADO DAS TABELAS

+
+
+ {SYNC_TABS.map(tab => { + const s = status?.syncStatus?.[tab]; + const count = status?.counts?.[tab]; + return ( +
+
+
+ {tab} +
+
+
+

No banco

+

{count ?? '—'}

+
+
+

Último backup

+

{fmtDate(s?.lastPush)}

+
+
+

Registros no backup

+

{s?.count ?? '—'}

+
+
+
+ ); + })} +
+ {status?.spreadsheetId && ( +
+ PLANILHA: + {status.spreadsheetId} +
+ )} +
+ + {/* Terminal */} +
+
+

LOG DE OPERAÇÃO

+ {logs.length > 0 && ( + + )} +
+
+
+ {logs.length === 0 + ? AGUARDANDO OPERAÇÃO... + : logs.map((log, i) => ( +
+ {log} +
+ )) + } + {operation &&
}
-
-
-
-

NOTA DE SEGURANÇA

-

- ESTA AÇÃO LÊ TODOS OS DADOS DA PLANILHA MESTRE E ATUALIZA O BANCO DE DADOS POSTGRESQL. - O GOOGLE SHEETS PERMANECE COMO FONTE SEGURA DE BACKUP. CONFLITOS SERÃO RESOLVIDOS PRIORIZANDO A DATA DE MODIFICAÇÃO MAIS RECENTE. -

-
+
+ +

+ BACKUP AUTOMÁTICO: cada alteração de paciente, agendamento, financeiro ou lead é enviada + para a planilha automaticamente (com delay de 5s para agrupar alterações). Use "Restaurar" para + recuperar dados da planilha caso o banco seja reiniciado. +