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
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:
parent
21b91b3522
commit
ae09a5cce0
5 changed files with 759 additions and 6 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
236
server/src/google-calendar.js
Normal file
236
server/src/google-calendar.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue