diff --git a/backend/server.js b/backend/server.js index c997848..f0b6655 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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, diff --git a/frontend/components/AgendaSettingsModal.tsx b/frontend/components/AgendaSettingsModal.tsx index 383410e..fb5b1c2 100644 --- a/frontend/components/AgendaSettingsModal.tsx +++ b/frontend/components/AgendaSettingsModal.tsx @@ -154,6 +154,7 @@ export const AgendaSettingsModal: React.FC = ({ isOpen

Conectar Google Agenda

É 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.

+

Os agendamentos são copiados para o Google (backup) e o paciente recebe convite + lembretes (1 dia e 1h antes). Se já estava conectado, reconecte uma vez para liberar a escrita.

{/* ADMIN / CLINIC CONNECTION */}