shivao-projeto/server/src/google-calendar.js
PontualTech / Karlão ae09a5cce0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
feat(gcal): integração Google Agenda bidirecional (graceful-disabled se sem env)
Backend (server/src/google-calendar.js + endpoints):
- OAuth 2.0 authorization-code flow completo
- Endpoints: /api/google/{status,auth-url,callback,disconnect,sync-pending,pull}
- Auto-refresh de access_token quando expira (com retry 401→refresh→retry)
- Token storage em google_connections (better-sqlite3)
- syncToken pra delta sync eficiente do Google
- Pendência↔evento: / no summary, dueDate→start.date all-day,
  shivaoPendingId/shivaoCompleted em extendedProperties.private
- Graceful disable: 503 + flag isEnabled() se env vars não setadas

Frontend (Arquivo › Google Agenda):
- Card só aparece quando feature ativa no servidor
- Connect: abre OAuth em nova aba + polling 3s pra detectar sucesso
- Auto-sync na criação/edição/deleção de pendência (se conectada + tem dueDate)
- Botão "Sincronizar todas pendências" + "Buscar mudanças do Google"
- Pull automático ao abrir aba Pendências (se passou >2min do último)
- Pendências criadas direto no Google viram pendências locais

Pra ativar em produção, adicionar no Coolify shivao-cloud:
  GOOGLE_CLIENT_ID=...apps.googleusercontent.com
  GOOGLE_CLIENT_SECRET=...
  GOOGLE_REDIRECT_URI=https://shivao.pontualtech.work/api/google/callback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 06:56:56 -03:00

236 lines
8.4 KiB
JavaScript

// 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;
}