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
+
+
+
+
📅 Google Agenda · sincronizar pendências
+
Verificando…
+
+
+
📡 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
+
+
+
+
📅 Google Agenda · sincronizar pendências
+
Verificando…
+
+
+
📡 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);