diff --git a/app/diario-bordo.html b/app/diario-bordo.html index cf56ff6..923f690 100644 --- a/app/diario-bordo.html +++ b/app/diario-bordo.html @@ -1479,6 +1479,14 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
+ + +
📡 Compartilhar posição em tempo real
Crie um link público temporário. Sua tripulação vê a posição do Shivao no mapa, sem precisar de login. Requer servidor na nuvem.
@@ -2608,7 +2616,7 @@ function syncUnitsToggle(){ }); } -document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); +document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus()}if(t.dataset.panel==='pending'&&_gcalConnected&&Date.now()-_gcalLastPullAt>GCAL_PULL_INTERVAL_MS)googlePullNow();if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()} function openModal(id){document.getElementById(id).classList.add('show')} function closeModal(id){document.getElementById(id).classList.remove('show')} @@ -2749,11 +2757,28 @@ function openPendingModal(id){ function savePending(){ const id=document.getElementById('pending-id').value,t=document.getElementById('pending-title').value.trim(); if(!t){toast('Informe o que precisa fazer');return} + const existing=id?state.pending.find(x=>x.id===id):null; const data={id:id||uid(),title:t,category:document.getElementById('pending-category').value,dueDate:document.getElementById('pending-due-date').value||null,dueHours:parseFloat(document.getElementById('pending-due-hours').value)||null,estimatedCost:parseFloat(document.getElementById('pending-est-cost').value)||null,priority:document.querySelector('input[name="pending-priority"]:checked').value,notes:document.getElementById('pending-notes').value.trim(),done:false,createdAt:Date.now()}; + // Preserva googleEventId em edições + if(existing?.googleEventId){data.googleEventId=existing.googleEventId;data.googleHtmlLink=existing.googleHtmlLink} if(id){const idx=state.pending.findIndex(x=>x.id===id);if(idx>=0)Object.assign(state.pending[idx],data)}else state.pending.unshift(data); saveState();closeModal('pending-modal');renderAll();toast('Anotado'); + // Auto-sync com Google Agenda (se conectado, com prazo) + if(_gcalConnected&&data.dueDate){ + const p=state.pending.find(x=>x.id===data.id); + if(p)googleSyncPending(p); + } +} +function deletePending(id){ + if(!confirm('Apagar pendência?'))return; + const p=state.pending.find(x=>x.id===id); + state.pending=state.pending.filter(p=>p.id!==id); + saveState();renderAll();toast('Removido'); + // Auto-delete no Google Agenda + if(_gcalConnected&&p?.googleEventId){ + googleSyncPending({...p,deleted:true,id:p.id}); + } } -function deletePending(id){if(!confirm('Apagar pendência?'))return;state.pending=state.pending.filter(p=>p.id!==id);saveState();renderAll();toast('Removido')} function markPendingDone(id){openMaintModal(null,id)} function filterPending(f){pendingFilter=f;document.querySelectorAll('.pending-toggle button').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));renderPending()} function pendingStatus(p){if(p.done)return{kind:'done',label:'Feito'};const today=todayISO(),cur=currentEngineHours();let o=false,s=false;if(p.dueDate){if(p.dueDate=p.dueHours)o=true;else if(p.dueHours-cur<=10)s=true}if(o)return{kind:'overdue',label:'Atrasado'};if(s)return{kind:'soon',label:'Em breve'};return{kind:'ok',label:'Em dia'}} @@ -2996,7 +3021,7 @@ async function updateStorageInfo(){ initSensorWidget(); // Realtime sync: conecta WebSocket se cloud configurada setSyncStatus(cloudConfigured()?'syncing':'disabled'); - if(cloudConfigured())rtConnect(); + if(cloudConfigured()){rtConnect();refreshGoogleStatus()} // tenta auto-fetch do tempo após pequeno delay setTimeout(maybeAutoFetchWeather,3000); })(); @@ -3833,6 +3858,165 @@ async function autoPullNow(){ } } +// ============ GOOGLE CALENDAR (sync pendencias <-> Google Agenda) ============ +let _gcalEnabled=false,_gcalConnected=false,_gcalEmail=null,_gcalLastPullAt=0; +const GCAL_PULL_INTERVAL_MS=2*60*1000; // pull a cada 2min quando aba ativa + +async function refreshGoogleStatus(){ + if(!cloudConfigured()){ + _gcalEnabled=false; + renderGoogleCard(); + return; + } + try{ + const r=await cloudFetch('/api/google/status'); + const j=await r.json(); + _gcalEnabled=!!j.enabled; + _gcalConnected=!!j.connected; + _gcalEmail=j.email||null; + renderGoogleCard(); + }catch(e){ + _gcalEnabled=false; + renderGoogleCard(); + } +} + +function renderGoogleCard(){ + const card=document.getElementById('gcal-card'); + const statusEl=document.getElementById('gcal-status'); + const actionsEl=document.getElementById('gcal-actions'); + if(!card)return; + if(!cloudConfigured()){card.style.display='none';return} + if(!_gcalEnabled){ + card.style.display='block'; + statusEl.textContent='Funcionalidade desativada no servidor — admin precisa configurar GOOGLE_CLIENT_ID/SECRET.'; + actionsEl.innerHTML=''; + return; + } + card.style.display='block'; + if(_gcalConnected){ + statusEl.innerHTML=`Conectado como ${escapeHtml(_gcalEmail||'')}. Pendências com prazo viram eventos no Google Agenda automaticamente.`; + actionsEl.innerHTML=` + + + + `; + }else{ + statusEl.textContent='Conecte sua conta Google pra que pendências com prazo virem eventos na sua agenda — e mudanças no Google voltem pro Shivão.'; + actionsEl.innerHTML=''; + } +} + +async function googleConnect(){ + try{ + const r=await cloudFetch('/api/google/auth-url?return_to='+encodeURIComponent(location.href)); + const j=await r.json(); + if(!j.url)throw new Error('sem URL'); + // Abre numa nova aba (popup pode ser bloqueado, então _blank) + const w=window.open(j.url,'_blank'); + if(!w)toast('Permita popups e tente de novo'); + // Re-checa status a cada 3s por até 2min + let tries=0; + const iv=setInterval(async()=>{ + tries++; + await refreshGoogleStatus(); + if(_gcalConnected){clearInterval(iv);toast('✓ Google conectado');try{w.close()}catch(e){}} + if(tries>40){clearInterval(iv)} + },3000); + }catch(e){toast('Erro: '+e.message)} +} + +async function googleDisconnect(){ + if(!confirm('Desconectar Google Agenda? Eventos já criados continuam lá, mas não recebem mais updates.'))return; + try{ + await cloudFetch('/api/google/disconnect',{method:'POST'}); + toast('Google desconectado'); + await refreshGoogleStatus(); + }catch(e){toast('Erro: '+e.message)} +} + +async function googleSyncAllPending(){ + if(!_gcalConnected)return toast('Conecte o Google primeiro'); + let ok=0,fail=0; + toast('Sincronizando pendências...'); + for(const p of state.pending){ + if(!p.title||p.archived)continue; + try{ + const r=await cloudFetch('/api/google/sync-pending',{ + method:'POST', + body:JSON.stringify({pending:p}), + }); + const j=await r.json(); + if(j.event?.id){p.googleEventId=j.event.id;p.googleHtmlLink=j.event.htmlLink} + ok++; + }catch(e){fail++} + } + saveState(); + toast(`Google: ${ok} ok${fail?', '+fail+' falhas':''}`); +} + +async function googleSyncPending(p){ + // Auto-sync de uma única pendência (chamada após criar/editar/deletar) + if(!_gcalConnected||!cloudConfigured())return; + try{ + const r=await cloudFetch('/api/google/sync-pending',{ + method:'POST', + body:JSON.stringify({pending:p}), + }); + const j=await r.json(); + if(j.deleted)return; + if(j.event?.id&&j.event.id!==p.googleEventId){ + p.googleEventId=j.event.id; + p.googleHtmlLink=j.event.htmlLink; + saveState(); + } + }catch(e){console.warn('[gcal] sync pending failed',e.message)} +} + +async function googlePullNow(){ + if(!_gcalConnected)return; + try{ + const r=await cloudFetch('/api/google/pull'); + const j=await r.json(); + let touched=0; + for(const ev of(j.items||[])){ + if(!ev.googleEventId)continue; + // Localiza pendência local pelo googleEventId + let p=state.pending.find(x=>x.googleEventId===ev.googleEventId); + if(p){ + if(ev.deleted){ + state.pending=state.pending.filter(x=>x!==p); + touched++; + continue; + } + if(ev.title)p.title=ev.title; + if(ev.notes!==undefined)p.notes=ev.notes; + if(ev.dueDate)p.dueDate=ev.dueDate; + if(ev.completed!==undefined)p.completed=ev.completed; + touched++; + }else if(!ev.deleted){ + // Evento criado direto no Google: cria pendência nova + state.pending.push({ + id:'p_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), + title:ev.title||'(sem título)', + notes:ev.notes||'', + dueDate:ev.dueDate||'', + completed:!!ev.completed, + googleEventId:ev.googleEventId, + createdAt:Date.now(), + }); + touched++; + } + } + if(touched>0){ + saveState(); + if(typeof renderPending==='function')renderPending(); + toast(`📅 ${touched} pendências sincronizadas do Google`); + } + _gcalLastPullAt=Date.now(); + }catch(e){console.warn('[gcal] pull failed',e.message)} +} + // ===== Auth (multi-tenant SaaS — Login/Signup) ===== async function authSignup(email,password,name){ if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro'); diff --git a/server/public/index.html b/server/public/index.html index cf56ff6..923f690 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -1479,6 +1479,14 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
+ + +
📡 Compartilhar posição em tempo real
Crie um link público temporário. Sua tripulação vê a posição do Shivao no mapa, sem precisar de login. Requer servidor na nuvem.
@@ -2608,7 +2616,7 @@ function syncUnitsToggle(){ }); } -document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox()}if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); +document.querySelectorAll('.tab').forEach(t=>{t.addEventListener('click',()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');document.getElementById('panel-'+t.dataset.panel).classList.add('active');document.getElementById('fab').style.display=['trips','maintenance','pending','zones'].includes(t.dataset.panel)?'flex':'none';if(t.dataset.panel==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus()}if(t.dataset.panel==='pending'&&_gcalConnected&&Date.now()-_gcalLastPullAt>GCAL_PULL_INTERVAL_MS)googlePullNow();if(t.dataset.panel==='zones')renderZones();window.scrollTo(0,0)})}); function quickAdd(){const a=document.querySelector('.tab.active').dataset.panel;if(a==='maintenance')openMaintModal();else if(a==='pending')openPendingModal();else if(a==='zones')openZoneEditor();else openTripModal()} function openModal(id){document.getElementById(id).classList.add('show')} function closeModal(id){document.getElementById(id).classList.remove('show')} @@ -2749,11 +2757,28 @@ function openPendingModal(id){ function savePending(){ const id=document.getElementById('pending-id').value,t=document.getElementById('pending-title').value.trim(); if(!t){toast('Informe o que precisa fazer');return} + const existing=id?state.pending.find(x=>x.id===id):null; const data={id:id||uid(),title:t,category:document.getElementById('pending-category').value,dueDate:document.getElementById('pending-due-date').value||null,dueHours:parseFloat(document.getElementById('pending-due-hours').value)||null,estimatedCost:parseFloat(document.getElementById('pending-est-cost').value)||null,priority:document.querySelector('input[name="pending-priority"]:checked').value,notes:document.getElementById('pending-notes').value.trim(),done:false,createdAt:Date.now()}; + // Preserva googleEventId em edições + if(existing?.googleEventId){data.googleEventId=existing.googleEventId;data.googleHtmlLink=existing.googleHtmlLink} if(id){const idx=state.pending.findIndex(x=>x.id===id);if(idx>=0)Object.assign(state.pending[idx],data)}else state.pending.unshift(data); saveState();closeModal('pending-modal');renderAll();toast('Anotado'); + // Auto-sync com Google Agenda (se conectado, com prazo) + if(_gcalConnected&&data.dueDate){ + const p=state.pending.find(x=>x.id===data.id); + if(p)googleSyncPending(p); + } +} +function deletePending(id){ + if(!confirm('Apagar pendência?'))return; + const p=state.pending.find(x=>x.id===id); + state.pending=state.pending.filter(p=>p.id!==id); + saveState();renderAll();toast('Removido'); + // Auto-delete no Google Agenda + if(_gcalConnected&&p?.googleEventId){ + googleSyncPending({...p,deleted:true,id:p.id}); + } } -function deletePending(id){if(!confirm('Apagar pendência?'))return;state.pending=state.pending.filter(p=>p.id!==id);saveState();renderAll();toast('Removido')} function markPendingDone(id){openMaintModal(null,id)} function filterPending(f){pendingFilter=f;document.querySelectorAll('.pending-toggle button').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));renderPending()} function pendingStatus(p){if(p.done)return{kind:'done',label:'Feito'};const today=todayISO(),cur=currentEngineHours();let o=false,s=false;if(p.dueDate){if(p.dueDate=p.dueHours)o=true;else if(p.dueHours-cur<=10)s=true}if(o)return{kind:'overdue',label:'Atrasado'};if(s)return{kind:'soon',label:'Em breve'};return{kind:'ok',label:'Em dia'}} @@ -2996,7 +3021,7 @@ async function updateStorageInfo(){ initSensorWidget(); // Realtime sync: conecta WebSocket se cloud configurada setSyncStatus(cloudConfigured()?'syncing':'disabled'); - if(cloudConfigured())rtConnect(); + if(cloudConfigured()){rtConnect();refreshGoogleStatus()} // tenta auto-fetch do tempo após pequeno delay setTimeout(maybeAutoFetchWeather,3000); })(); @@ -3833,6 +3858,165 @@ async function autoPullNow(){ } } +// ============ GOOGLE CALENDAR (sync pendencias <-> Google Agenda) ============ +let _gcalEnabled=false,_gcalConnected=false,_gcalEmail=null,_gcalLastPullAt=0; +const GCAL_PULL_INTERVAL_MS=2*60*1000; // pull a cada 2min quando aba ativa + +async function refreshGoogleStatus(){ + if(!cloudConfigured()){ + _gcalEnabled=false; + renderGoogleCard(); + return; + } + try{ + const r=await cloudFetch('/api/google/status'); + const j=await r.json(); + _gcalEnabled=!!j.enabled; + _gcalConnected=!!j.connected; + _gcalEmail=j.email||null; + renderGoogleCard(); + }catch(e){ + _gcalEnabled=false; + renderGoogleCard(); + } +} + +function renderGoogleCard(){ + const card=document.getElementById('gcal-card'); + const statusEl=document.getElementById('gcal-status'); + const actionsEl=document.getElementById('gcal-actions'); + if(!card)return; + if(!cloudConfigured()){card.style.display='none';return} + if(!_gcalEnabled){ + card.style.display='block'; + statusEl.textContent='Funcionalidade desativada no servidor — admin precisa configurar GOOGLE_CLIENT_ID/SECRET.'; + actionsEl.innerHTML=''; + return; + } + card.style.display='block'; + if(_gcalConnected){ + statusEl.innerHTML=`Conectado como ${escapeHtml(_gcalEmail||'')}. Pendências com prazo viram eventos no Google Agenda automaticamente.`; + actionsEl.innerHTML=` + + + + `; + }else{ + statusEl.textContent='Conecte sua conta Google pra que pendências com prazo virem eventos na sua agenda — e mudanças no Google voltem pro Shivão.'; + actionsEl.innerHTML=''; + } +} + +async function googleConnect(){ + try{ + const r=await cloudFetch('/api/google/auth-url?return_to='+encodeURIComponent(location.href)); + const j=await r.json(); + if(!j.url)throw new Error('sem URL'); + // Abre numa nova aba (popup pode ser bloqueado, então _blank) + const w=window.open(j.url,'_blank'); + if(!w)toast('Permita popups e tente de novo'); + // Re-checa status a cada 3s por até 2min + let tries=0; + const iv=setInterval(async()=>{ + tries++; + await refreshGoogleStatus(); + if(_gcalConnected){clearInterval(iv);toast('✓ Google conectado');try{w.close()}catch(e){}} + if(tries>40){clearInterval(iv)} + },3000); + }catch(e){toast('Erro: '+e.message)} +} + +async function googleDisconnect(){ + if(!confirm('Desconectar Google Agenda? Eventos já criados continuam lá, mas não recebem mais updates.'))return; + try{ + await cloudFetch('/api/google/disconnect',{method:'POST'}); + toast('Google desconectado'); + await refreshGoogleStatus(); + }catch(e){toast('Erro: '+e.message)} +} + +async function googleSyncAllPending(){ + if(!_gcalConnected)return toast('Conecte o Google primeiro'); + let ok=0,fail=0; + toast('Sincronizando pendências...'); + for(const p of state.pending){ + if(!p.title||p.archived)continue; + try{ + const r=await cloudFetch('/api/google/sync-pending',{ + method:'POST', + body:JSON.stringify({pending:p}), + }); + const j=await r.json(); + if(j.event?.id){p.googleEventId=j.event.id;p.googleHtmlLink=j.event.htmlLink} + ok++; + }catch(e){fail++} + } + saveState(); + toast(`Google: ${ok} ok${fail?', '+fail+' falhas':''}`); +} + +async function googleSyncPending(p){ + // Auto-sync de uma única pendência (chamada após criar/editar/deletar) + if(!_gcalConnected||!cloudConfigured())return; + try{ + const r=await cloudFetch('/api/google/sync-pending',{ + method:'POST', + body:JSON.stringify({pending:p}), + }); + const j=await r.json(); + if(j.deleted)return; + if(j.event?.id&&j.event.id!==p.googleEventId){ + p.googleEventId=j.event.id; + p.googleHtmlLink=j.event.htmlLink; + saveState(); + } + }catch(e){console.warn('[gcal] sync pending failed',e.message)} +} + +async function googlePullNow(){ + if(!_gcalConnected)return; + try{ + const r=await cloudFetch('/api/google/pull'); + const j=await r.json(); + let touched=0; + for(const ev of(j.items||[])){ + if(!ev.googleEventId)continue; + // Localiza pendência local pelo googleEventId + let p=state.pending.find(x=>x.googleEventId===ev.googleEventId); + if(p){ + if(ev.deleted){ + state.pending=state.pending.filter(x=>x!==p); + touched++; + continue; + } + if(ev.title)p.title=ev.title; + if(ev.notes!==undefined)p.notes=ev.notes; + if(ev.dueDate)p.dueDate=ev.dueDate; + if(ev.completed!==undefined)p.completed=ev.completed; + touched++; + }else if(!ev.deleted){ + // Evento criado direto no Google: cria pendência nova + state.pending.push({ + id:'p_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), + title:ev.title||'(sem título)', + notes:ev.notes||'', + dueDate:ev.dueDate||'', + completed:!!ev.completed, + googleEventId:ev.googleEventId, + createdAt:Date.now(), + }); + touched++; + } + } + if(touched>0){ + saveState(); + if(typeof renderPending==='function')renderPending(); + toast(`📅 ${touched} pendências sincronizadas do Google`); + } + _gcalLastPullAt=Date.now(); + }catch(e){console.warn('[gcal] pull failed',e.message)} +} + // ===== Auth (multi-tenant SaaS — Login/Signup) ===== async function authSignup(email,password,name){ if(!cloudConfigured())throw new Error('Configure URL do servidor primeiro'); diff --git a/server/src/db.js b/server/src/db.js index 4dbbe90..b21dda7 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -141,6 +141,18 @@ db.exec(` ip TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC); + + CREATE TABLE IF NOT EXISTS google_connections ( + user_id INTEGER PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER, + email TEXT, + sync_token TEXT, + last_sync_at INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); `); // migration: add zones column if missing @@ -425,5 +437,41 @@ export function recentAudit(userId, limit = 100) { return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit); } +// ===== Google Calendar connections ===== +export function getGoogleConnection(userId) { + return db.prepare('SELECT * FROM google_connections WHERE user_id = ?').get(userId); +} +export function saveGoogleConnection(userId, data) { + const now = Date.now(); + const existing = getGoogleConnection(userId); + if (existing) { + db.prepare(`UPDATE google_connections SET + access_token=?, refresh_token=?, expires_at=?, email=?, sync_token=?, last_sync_at=? + WHERE user_id=?`).run( + data.access_token, + data.refresh_token || existing.refresh_token, // refresh_token may not come on every refresh + data.expires_at || null, + data.email || existing.email || null, + data.sync_token || existing.sync_token || null, + data.last_sync_at || existing.last_sync_at || now, + userId, + ); + } else { + db.prepare(`INSERT INTO google_connections + (user_id, access_token, refresh_token, expires_at, email, sync_token, last_sync_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run( + userId, data.access_token, data.refresh_token, data.expires_at || null, + data.email || null, data.sync_token || null, data.last_sync_at || now, now, + ); + } +} +export function deleteGoogleConnection(userId) { + db.prepare('DELETE FROM google_connections WHERE user_id = ?').run(userId); +} +export function setGoogleSyncToken(userId, syncToken, lastSyncAt) { + db.prepare('UPDATE google_connections SET sync_token=?, last_sync_at=? WHERE user_id=?') + .run(syncToken, lastSyncAt || Date.now(), userId); +} + export const dataDir = DATA_DIR; export default db; diff --git a/server/src/google-calendar.js b/server/src/google-calendar.js new file mode 100644 index 0000000..82f937a --- /dev/null +++ b/server/src/google-calendar.js @@ -0,0 +1,236 @@ +// 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; +} diff --git a/server/src/index.js b/server/src/index.js index 05a0294..10f7beb 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -11,6 +11,7 @@ import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema, import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js'; import * as billing from './billing.js'; import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js'; +import * as gcal from './google-calendar.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || '3000'); @@ -553,10 +554,110 @@ app.get('/api/info', requireAuth, (req, res) => { res.json({ channels: listConfiguredChannels(), heartbeatTimeoutSec: HEARTBEAT_TIMEOUT / 1000, + googleCalendar: gcal.isEnabled(), version: '1.0' }); }); +// ===== Google Calendar OAuth + sync ===== +app.get('/api/google/status', requireAuth, (req, res) => { + if (!gcal.isEnabled()) return res.json({ enabled: false }); + const conn = db.getGoogleConnection(req.user.id); + res.json({ + enabled: true, + connected: !!conn, + email: conn?.email || null, + last_sync_at: conn?.last_sync_at || null, + }); +}); + +app.get('/api/google/auth-url', requireAuth, (req, res) => { + if (!gcal.isEnabled()) return gcal.disabledResponse(res); + const returnTo = (req.query.return_to || '/').toString().slice(0, 200); + const url = gcal.buildAuthUrl(req.user.id, returnTo); + res.json({ url }); +}); + +app.get('/api/google/callback', async (req, res) => { + if (!gcal.isEnabled()) return gcal.disabledResponse(res); + const { code, state, error } = req.query; + if (error) return res.status(400).send(`Erro do Google: ${error}`); + if (!code || !state) return res.status(400).send('Faltam parâmetros code/state.'); + let parsed; + try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); } + catch (e) { return res.status(400).send('state inválido'); } + const userId = parsed.uid; + if (!userId) return res.status(400).send('state sem uid'); + try { + const tokens = await gcal.exchangeCodeForTokens(code); + const userInfo = await gcal.getUserInfo(tokens.access_token); + db.saveGoogleConnection(userId, { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: Date.now() + (tokens.expires_in * 1000), + email: userInfo.email, + }); + db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip); + // Redireciona pra app com flag de sucesso + const returnTo = parsed.rt || '/'; + res.send(`Google conectado + + +

✓ Google Agenda conectado

+

Conectado como ${userInfo.email}

+

Pode fechar esta janela e voltar pro app.

+ +
`); + } catch (e) { + console.warn('[google] callback failed', e.message); + res.status(500).send('Erro ao conectar Google: ' + e.message); + } +}); + +app.post('/api/google/disconnect', requireAuth, (req, res) => { + if (!gcal.isEnabled()) return gcal.disabledResponse(res); + db.deleteGoogleConnection(req.user.id); + db.audit(req.user.id, 'google_disconnected', 'google_calendar', null, null, req.ip); + res.json({ ok: true }); +}); + +// Push: envia/atualiza um evento no Google a partir de uma pendência local +app.post('/api/google/sync-pending', requireAuth, async (req, res) => { + if (!gcal.isEnabled()) return gcal.disabledResponse(res); + const { pending } = req.body || {}; + if (!pending || !pending.id) return res.status(400).json({ error: 'pending.id required' }); + try { + if (pending.deleted) { + if (pending.googleEventId) await gcal.deleteEvent(req.user.id, pending.googleEventId); + return res.json({ ok: true, deleted: true }); + } + const ev = await gcal.upsertEventForPending(req.user.id, pending); + res.json({ ok: true, event: { id: ev.id, htmlLink: ev.htmlLink, updated: ev.updated } }); + } catch (e) { + if (String(e.message).includes('not_connected')) { + return res.status(409).json({ error: 'not_connected' }); + } + res.status(500).json({ error: e.message }); + } +}); + +// Pull: lista eventos modificados no Google (cliente faz merge) +app.get('/api/google/pull', requireAuth, async (req, res) => { + if (!gcal.isEnabled()) return gcal.disabledResponse(res); + try { + const conn = db.getGoogleConnection(req.user.id); + if (!conn) return res.status(409).json({ error: 'not_connected' }); + const result = await gcal.listChangedEvents(req.user.id, conn.sync_token); + if (result.nextSyncToken) { + db.setGoogleSyncToken(req.user.id, result.nextSyncToken, Date.now()); + } + const items = (result.items || []).map(gcal.eventToPending); + res.json({ items }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + // --- State sync (whole JSON blob, per-user) --- app.get('/api/data', requireAuth, (req, res) => { const s = db.getState(req.user.id);