feat(agenda): Google Calendar como backup + notificação (push one-way)

- escopo OAuth: calendar.readonly -> calendar.events (exige reconectar 1x)
- pushGoogleEvent best-effort (create/update/delete) com convidado=paciente, sendUpdates=all, reminders 1dia+1h
- google_event_id em agendamentos; marcador extendedProperties.private.scoreodonto + filtro na leitura (anti-duplicação)
- liga em criar/reagendar/cancelar/remarcar/restaurar; nunca bloqueia o agendamento
- aviso no AgendaSettingsModal (backup/notificação + reconectar)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-09 16:56:52 +02:00
parent dc753a11b2
commit 0ca0aa4284
2 changed files with 81 additions and 2 deletions
+80 -2
View File
@@ -257,6 +257,64 @@ async function notificarInteressesSlotLivre(a) {
} catch (e) { console.error('[interesses]', e.message); } } 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;
if (ag.pacienteid) {
const { rows: pr } = await pool.query('SELECT email FROM pacientes WHERE id = $1', [ag.pacienteid]);
const email = pr[0]?.email;
if (email && /@/.test(email)) attendees = [{ email }];
}
const resource = {
summary: `${ag.pacientenome || 'Paciente'}${ag.procedimento ? ' — ' + ag.procedimento : ''}`,
description: 'Agendamento ScoreOdonto (cópia de backup).',
start: { dateTime: new Date(ag.start_time).toISOString() },
end: { dateTime: new Date(ag.end_time || ag.start_time).toISOString() },
attendees,
reminders: { useDefault: false, overrides: [{ method: 'email', minutes: 1440 }, { method: 'popup', minutes: 60 }] },
extendedProperties: { private: { scoreodonto: ag.id } },
};
if (action === 'update' && ag.google_event_id) {
try {
await calendar.events.patch({ calendarId: 'primary', eventId: ag.google_event_id, sendUpdates: 'all', requestBody: resource });
} catch {
const ev = await calendar.events.insert({ calendarId: 'primary', sendUpdates: 'all', requestBody: resource });
await pool.query('UPDATE agendamentos SET google_event_id = $1 WHERE id = $2', [ev.data.id, ag.id]);
}
} else {
const ev = await calendar.events.insert({ calendarId: 'primary', sendUpdates: 'all', requestBody: resource });
await pool.query('UPDATE agendamentos SET google_event_id = $1 WHERE id = $2', [ev.data.id, ag.id]);
}
} catch (e) { console.error('[google-push]', e.message); }
}
// --- REGISTRO DE USUÁRIOS --- // --- REGISTRO DE USUÁRIOS ---
app.post('/api/register', async (req, res) => { app.post('/api/register', async (req, res) => {
const { nome, email, senha, role, workspaceNome } = req.body; const { nome, email, senha, role, workspaceNome } = req.body;
@@ -978,7 +1036,8 @@ app.get('/api/auth/google/url', (req, res) => {
access_type: 'offline', access_type: 'offline',
scope: [ scope: [
'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/calendar.readonly', // leitura+escrita de EVENTOS (criar/editar/convidar) → backup + notificação
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/spreadsheets' 'https://www.googleapis.com/auth/spreadsheets'
], ],
state: `${resolvedOwner}|${resolvedClinica}`, state: `${resolvedOwner}|${resolvedClinica}`,
@@ -1182,7 +1241,10 @@ app.get('/api/google/events', async (req, res) => {
if (cl[0]?.nome_fantasia) ownerName = cl[0].nome_fantasia; if (cl[0]?.nome_fantasia) ownerName = cl[0].nome_fantasia;
} }
const mapped = response.data.items.map(item => ({ // Ignora eventos que o PRÓPRIO sistema empurrou (backup) → não duplica na agenda.
const mapped = response.data.items
.filter(item => !item.extendedProperties?.private?.scoreodonto)
.map(item => ({
id: `google_${item.id}`, id: `google_${item.id}`,
title: `[${ownerName}] ${item.summary || '(Sem Título)'}`, title: `[${ownerName}] ${item.summary || '(Sem Título)'}`,
start: item.start.dateTime || item.start.date, start: item.start.dateTime || item.start.date,
@@ -1653,6 +1715,11 @@ app.post('/api/agendamentos', authGuard, async (req, res) => {
}); });
schedulePush('agendamentos'); schedulePush('agendamentos');
// 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,
procedimento: rest.procedimento, start_time: start, end_time: end
});
res.json({ success: true, id }); res.json({ success: true, id });
} catch (err) { } catch (err) {
if (err.code === 'CONF_OVERLAP') { if (err.code === 'CONF_OVERLAP') {
@@ -1719,6 +1786,13 @@ app.put('/api/agendamentos/:id', authGuard, async (req, res) => {
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) } : { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
}); });
schedulePush('agendamentos'); schedulePush('agendamentos');
// 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,
procedimento: rest.procedimento || before.procedimento,
start_time: start || before.start_time, end_time: end || before.end_time
});
res.json({ success: true, version: updateData.version }); res.json({ success: true, version: updateData.version });
} catch (err) { } catch (err) {
if (err.code === '23505') { if (err.code === '23505') {
@@ -1750,6 +1824,7 @@ app.delete('/api/agendamentos/:id', authGuard, async (req, res) => {
}); });
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'cancelado' }); await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'cancelado' });
await notificarInteressesSlotLivre(a); await notificarInteressesSlotLivre(a);
pushGoogleEvent('delete', a); // remove a cópia/notificação do Google
res.json({ success: true }); res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -1782,6 +1857,7 @@ app.post('/api/agendamentos/:id/remarcar', authGuard, async (req, res) => {
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'remarcou', actorId: actor, actorNome: actorNm, detalhes: { motivo: req.body?.motivo || null, start: a.start_time } }); await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'remarcou', actorId: actor, actorNome: actorNm, detalhes: { motivo: req.body?.motivo || null, start: a.start_time } });
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' }); await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' });
await notificarInteressesSlotLivre(a); await notificarInteressesSlotLivre(a);
pushGoogleEvent('delete', a);
res.json({ success: true }); res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -1797,6 +1873,7 @@ app.post('/api/agendamentos/:id/restaurar', authGuard, async (req, res) => {
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 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 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]); 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 }); res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -3174,6 +3251,7 @@ async function runMigrations() {
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS canceled_by_nome 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 canceled_at TIMESTAMPTZ`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`, `ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`,
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS google_event_id TEXT`,
`CREATE TABLE IF NOT EXISTS audit_log ( `CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
clinica_id TEXT, clinica_id TEXT,
@@ -154,6 +154,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 mb-2"> <div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 mb-2">
<p className="text-[10px] text-blue-800 font-bold mb-1 uppercase tracking-wider">Conectar Google Agenda</p> <p className="text-[10px] text-blue-800 font-bold mb-1 uppercase tracking-wider">Conectar Google Agenda</p>
<p className="text-[10px] text-blue-600 italic">É clicar e autorizar com a conta Google. Você NÃO precisa criar projeto, ativar API nem colar nenhum ID a integração está pronta.</p> <p className="text-[10px] text-blue-600 italic">É clicar e autorizar com a conta Google. Você NÃO precisa criar projeto, ativar API nem colar nenhum ID a integração está pronta.</p>
<p className="text-[10px] text-blue-700 font-bold mt-1.5">Os agendamentos são copiados para o Google (backup) e o paciente recebe convite + lembretes (1 dia e 1h antes). Se estava conectado, <u>reconecte uma vez</u> para liberar a escrita.</p>
</div> </div>
{/* ADMIN / CLINIC CONNECTION */} {/* ADMIN / CLINIC CONNECTION */}