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:
+80
-2
@@ -257,6 +257,64 @@ async function notificarInteressesSlotLivre(a) {
|
||||
} 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 ---
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const { nome, email, senha, role, workspaceNome } = req.body;
|
||||
@@ -978,7 +1036,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',
|
||||
// leitura+escrita de EVENTOS (criar/editar/convidar) → backup + notificação
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/spreadsheets'
|
||||
],
|
||||
state: `${resolvedOwner}|${resolvedClinica}`,
|
||||
@@ -1182,7 +1241,10 @@ app.get('/api/google/events', async (req, res) => {
|
||||
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}`,
|
||||
title: `[${ownerName}] ${item.summary || '(Sem Título)'}`,
|
||||
start: item.start.dateTime || item.start.date,
|
||||
@@ -1653,6 +1715,11 @@ app.post('/api/agendamentos', authGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
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 });
|
||||
} catch (err) {
|
||||
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)) }
|
||||
});
|
||||
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 });
|
||||
} catch (err) {
|
||||
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 notificarInteressesSlotLivre(a);
|
||||
pushGoogleEvent('delete', a); // remove a cópia/notificação do Google
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
@@ -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 abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' });
|
||||
await notificarInteressesSlotLivre(a);
|
||||
pushGoogleEvent('delete', a);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
@@ -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 registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'restaurou', actorId: actor, actorNome: actorNm, detalhes: {} });
|
||||
await pool.query(`UPDATE agenda_pendencias SET status='resolvida', resolved_at=NOW(), resolved_by=$2, resolved_by_nome=$3, resolucao='restaurado' WHERE origem_agendamento_id=$1 AND status='aberta'`, [a.id, actor, actorNm]);
|
||||
pushGoogleEvent('create', { ...a, google_event_id: null }); // recria a cópia no Google
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
@@ -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_at TIMESTAMPTZ`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS google_event_id TEXT`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
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">
|
||||
<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">É só clicar e autorizar com a conta Google. Você NÃO precisa criar projeto, ativar API nem colar nenhum ID — a integração já 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 já estava conectado, <u>reconecte uma vez</u> para liberar a escrita.</p>
|
||||
</div>
|
||||
|
||||
{/* ADMIN / CLINIC CONNECTION */}
|
||||
|
||||
Reference in New Issue
Block a user