feat(gcal): integração Google Agenda bidirecional (graceful-disabled se sem env)
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>
This commit is contained in:
PontualTech / Karlão 2026-04-28 06:56:56 -03:00
parent 21b91b3522
commit ae09a5cce0
5 changed files with 759 additions and 6 deletions

View file

@ -1479,6 +1479,14 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div>
<!-- Google Calendar -->
<div class="export-card" id="gcal-card" style="display:none;border-color:#4285F4;background:linear-gradient(180deg,var(--bg-paper),rgba(66,133,244,.05))">
<div class="export-card-title">📅 Google Agenda · sincronizar pendências</div>
<div class="export-card-text" style="margin-bottom:10px" id="gcal-status">Verificando…</div>
<div id="gcal-actions" style="display:flex;flex-direction:column;gap:6px"></div>
<div id="gcal-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
</div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div>
<div class="export-card-text" style="margin-bottom:10px">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.</div>
@ -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<today)o=true;else if(daysBetween(today,p.dueDate)<=7)s=true}if(p.dueHours!=null&&cur!=null){if(cur>=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 <strong>${escapeHtml(_gcalEmail||'')}</strong>. Pendências com prazo viram eventos no Google Agenda automaticamente.`;
actionsEl.innerHTML=`
<button class="btn btn-block" onclick="googleSyncAllPending()">⟳ Sincronizar todas pendências agora</button>
<button class="btn btn-block" onclick="googlePullNow()">↓ Buscar mudanças do Google</button>
<button class="btn btn-block btn-danger" onclick="googleDisconnect()">Desconectar Google</button>
`;
}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='<button class="btn btn-block btn-primary" onclick="googleConnect()">🔗 Conectar Google Agenda</button>';
}
}
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');

View file

@ -1479,6 +1479,14 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<div id="cloud-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
<div id="auth-box" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule)"></div>
</div>
<!-- Google Calendar -->
<div class="export-card" id="gcal-card" style="display:none;border-color:#4285F4;background:linear-gradient(180deg,var(--bg-paper),rgba(66,133,244,.05))">
<div class="export-card-title">📅 Google Agenda · sincronizar pendências</div>
<div class="export-card-text" style="margin-bottom:10px" id="gcal-status">Verificando…</div>
<div id="gcal-actions" style="display:flex;flex-direction:column;gap:6px"></div>
<div id="gcal-info" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:10px;letter-spacing:.04em;line-height:1.6"></div>
</div>
<div class="export-card" style="border-color:var(--algae);background:linear-gradient(180deg,var(--bg-paper),var(--algae-soft))">
<div class="export-card-title">📡 Compartilhar posição em tempo real</div>
<div class="export-card-text" style="margin-bottom:10px">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.</div>
@ -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<today)o=true;else if(daysBetween(today,p.dueDate)<=7)s=true}if(p.dueHours!=null&&cur!=null){if(cur>=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 <strong>${escapeHtml(_gcalEmail||'')}</strong>. Pendências com prazo viram eventos no Google Agenda automaticamente.`;
actionsEl.innerHTML=`
<button class="btn btn-block" onclick="googleSyncAllPending()">⟳ Sincronizar todas pendências agora</button>
<button class="btn btn-block" onclick="googlePullNow()">↓ Buscar mudanças do Google</button>
<button class="btn btn-block btn-danger" onclick="googleDisconnect()">Desconectar Google</button>
`;
}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='<button class="btn btn-block btn-primary" onclick="googleConnect()">🔗 Conectar Google Agenda</button>';
}
}
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');

View file

@ -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;

View file

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

View file

@ -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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a}</style></head>
<body><div><h1> Google Agenda conectado</h1>
<p>Conectado como <strong>${userInfo.email}</strong></p>
<p>Pode fechar esta janela e voltar pro app.</p>
<script>setTimeout(()=>{try{window.close()}catch(e){};location.href=${JSON.stringify(returnTo)}},2000)</script>
</div></body></html>`);
} 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);