// Google Calendar bidirectional sync — graceful-disabled if env vars empty. // OAuth 2.0 authorization-code flow (with PKCE recommended pra mobile mas web client tá ok). // // Pra ativar em produção: // GOOGLE_CLIENT_ID=...apps.googleusercontent.com // GOOGLE_CLIENT_SECRET=... // GOOGLE_REDIRECT_URI=https://shivao.pontualtech.work/api/google/callback // // Sem essas env vars, todos os endpoints retornam 503 com mensagem clara. import * as db from './db.js'; const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || ''; const SCOPES = [ 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/userinfo.email', ]; export function isEnabled() { return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI); } export function disabledResponse(res) { return res.status(503).json({ error: 'google_calendar_disabled', detail: 'Backend não configurado. Falta GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI no servidor.', }); } // Inicia o OAuth: gera URL de autorização do Google e devolve pra cliente export function buildAuthUrl(userId, returnTo = '/') { const state = Buffer.from(JSON.stringify({ uid: userId, rt: returnTo, n: Math.random().toString(36).slice(2) })).toString('base64url'); const u = new URL('https://accounts.google.com/o/oauth2/v2/auth'); u.searchParams.set('client_id', CLIENT_ID); u.searchParams.set('redirect_uri', REDIRECT_URI); u.searchParams.set('response_type', 'code'); u.searchParams.set('scope', SCOPES.join(' ')); u.searchParams.set('access_type', 'offline'); u.searchParams.set('prompt', 'consent'); u.searchParams.set('state', state); return u.toString(); } // Troca authorization code por tokens export async function exchangeCodeForTokens(code) { const body = new URLSearchParams({ code, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, redirect_uri: REDIRECT_URI, grant_type: 'authorization_code', }); const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!r.ok) { const t = await r.text(); throw new Error(`Google token exchange failed (${r.status}): ${t.slice(0, 200)}`); } return r.json(); // { access_token, refresh_token, expires_in, scope, token_type, id_token } } export async function refreshAccessToken(refreshToken) { const body = new URLSearchParams({ refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, grant_type: 'refresh_token', }); const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!r.ok) { const t = await r.text(); throw new Error(`Google token refresh failed (${r.status}): ${t.slice(0, 200)}`); } return r.json(); } export async function getUserInfo(accessToken) { const r = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!r.ok) throw new Error(`userinfo failed (${r.status})`); return r.json(); } // Wrapper que renova token automaticamente se expirado (401) export async function withFreshToken(userId, fn) { const conn = db.getGoogleConnection(userId); if (!conn) throw new Error('not_connected'); let accessToken = conn.access_token; // Se expirou (com 60s de margem), renova proativamente if (conn.expires_at && conn.expires_at < Date.now() + 60000) { const fresh = await refreshAccessToken(conn.refresh_token); accessToken = fresh.access_token; db.saveGoogleConnection(userId, { ...conn, access_token: accessToken, expires_at: Date.now() + (fresh.expires_in * 1000), }); } try { return await fn(accessToken); } catch (e) { if (String(e.message).includes('401')) { const fresh = await refreshAccessToken(conn.refresh_token); db.saveGoogleConnection(userId, { ...conn, access_token: fresh.access_token, expires_at: Date.now() + (fresh.expires_in * 1000), }); return fn(fresh.access_token); } throw e; } } // Cria/atualiza um evento no Google Calendar a partir de uma pendência // pending: { id, title, notes?, dueDate?, completed?, googleEventId? } export async function upsertEventForPending(userId, pending, calendarId = 'primary') { return withFreshToken(userId, async (accessToken) => { const event = pendingToEvent(pending); let url, method; if (pending.googleEventId) { url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(pending.googleEventId)}`; method = 'PATCH'; } else { url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`; method = 'POST'; } const r = await fetch(url, { method, headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(event), }); if (!r.ok) { const t = await r.text(); throw new Error(`Google event ${method} failed (${r.status}): ${t.slice(0, 200)}`); } return r.json(); }); } export async function deleteEvent(userId, eventId, calendarId = 'primary') { return withFreshToken(userId, async (accessToken) => { const r = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` }, }); if (r.status !== 204 && r.status !== 410 && r.status !== 404) { const t = await r.text(); throw new Error(`Google delete failed (${r.status}): ${t.slice(0, 200)}`); } return { ok: true }; }); } // Lista eventos modificados desde um momento (pra pull periódico) export async function listChangedEvents(userId, syncToken, calendarId = 'primary') { return withFreshToken(userId, async (accessToken) => { const u = new URL(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`); if (syncToken) u.searchParams.set('syncToken', syncToken); else { // Primeira sync: pega eventos do "Shivao" criados nos últimos 90 dias u.searchParams.set('q', 'Shivão'); u.searchParams.set('timeMin', new Date(Date.now() - 90 * 86400 * 1000).toISOString()); } const r = await fetch(u.toString(), { headers: { Authorization: `Bearer ${accessToken}` } }); if (!r.ok) { const t = await r.text(); throw new Error(`Google list failed (${r.status}): ${t.slice(0, 200)}`); } return r.json(); // { items: [...], nextSyncToken } }); } function pendingToEvent(p) { const ev = { summary: `⚓ ${p.title || 'Pendência'}`, description: (p.notes || '') + '\n\n— do Diário do Shivão', extendedProperties: { private: { shivaoPendingId: String(p.id), shivaoCompleted: p.completed ? '1' : '0', }, }, }; if (p.completed) { ev.summary = '✅ ' + ev.summary; } if (p.dueDate) { // dueDate formato YYYY-MM-DD vira evento all-day ev.start = { date: p.dueDate }; const d = new Date(p.dueDate + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + 1); ev.end = { date: d.toISOString().slice(0, 10) }; } else { // Sem data: defaulta pra hoje all-day const today = new Date().toISOString().slice(0, 10); ev.start = { date: today }; const d = new Date(today + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + 1); ev.end = { date: d.toISOString().slice(0, 10) }; } return ev; } export function eventToPending(ev) { // Reconstrói pending parcial a partir de evento Google. Cliente faz merge. const p = { googleEventId: ev.id, googleUpdated: ev.updated, }; // Tira emoji do começo do summary se houver let title = ev.summary || ''; title = title.replace(/^(✅\s*)?⚓\s*/, '').trim(); p.title = title; if (ev.description) { p.notes = ev.description.replace(/\n\n— do Diário do Shivão$/, ''); } if (ev.start?.date) p.dueDate = ev.start.date; if (ev.extendedProperties?.private?.shivaoPendingId) { p.id = ev.extendedProperties.private.shivaoPendingId; } if (ev.extendedProperties?.private?.shivaoCompleted === '1') p.completed = true; if (ev.status === 'cancelled') p.deleted = true; return p; }