Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
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>
236 lines
8.4 KiB
JavaScript
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;
|
|
}
|