[object Object]

← back to Dw Activation Calendar

Add Google Calendar read + editable CRUD backend (OAuth + events.list/insert/patch/delete), graceful not-connected guards, hardened .gitignore

b9843c655c59079d32ed4ce2b6cddb7fd73ce6ce · 2026-06-24 13:38:12 -0700 · Steve

Files touched

Diff

commit b9843c655c59079d32ed4ce2b6cddb7fd73ce6ce
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 13:38:12 2026 -0700

    Add Google Calendar read + editable CRUD backend (OAuth + events.list/insert/patch/delete), graceful not-connected guards, hardened .gitignore
---
 .gitignore    |  10 ++-
 google-cal.js | 239 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 node_modules  |   1 +
 server.js     | 100 ++++++++++++++++++++++++
 4 files changed, 349 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index ac1e8f7..dc2b9bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,11 @@
-node_modules
+node_modules/
+.env*
+tmp/
 *.log
 .DS_Store
+dist/
+build/
+.next/
+# OAuth tokens for the Google Calendar layer — NEVER commit
+*.local.json
+.google-tokens.local.json
diff --git a/google-cal.js b/google-cal.js
new file mode 100644
index 0000000..f2000ee
--- /dev/null
+++ b/google-cal.js
@@ -0,0 +1,239 @@
+'use strict';
+/*
+ * google-cal.js — Google Calendar OAuth + Calendar v3 layer for the DW
+ * Activation Calendar.  fetch-only, NO googleapis/SDK dependency (so it
+ * needs zero npm installs and is immune to the symlinked node_modules).
+ *
+ * Pattern copied from ventura-corridor appointment-agent (oauth.ts +
+ * calendar.ts), simplified to a SINGLE shared DW calendar rather than a
+ * per-owner token table:
+ *   - one OAuth connection (a single Google account owns/has-access-to the
+ *     shared DW calendar), tokens persisted to a local JSON file;
+ *   - read events (events.list) for a month;
+ *   - create (events.insert), update (events.update / events.patch),
+ *     delete (events.delete).
+ *
+ * GATED: this module never *initiates* the OAuth grant on its own. The
+ * client-id/secret come from env (routed by the secrets skill — Steve-gated);
+ * the consent click is Steve's. With no credential present, every read/write
+ * returns a clean "not connected" signal so the UI degrades gracefully and
+ * NEVER 500s.
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const TOKEN_ENDPOINT  = 'https://oauth2.googleapis.com/token';
+const AUTH_ENDPOINT   = 'https://accounts.google.com/o/oauth2/v2/auth';
+const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
+const CAL_BASE        = 'https://www.googleapis.com/calendar/v3';
+
+const SCOPES = [
+  'openid', 'email', 'profile',
+  'https://www.googleapis.com/auth/calendar.events',
+  'https://www.googleapis.com/auth/calendar.readonly',
+];
+
+// Which calendar the modal edits. Default = the connected account's "primary".
+// Override with GOOGLE_DW_CALENDAR_ID to point at a dedicated shared calendar.
+const CALENDAR_ID = process.env.GOOGLE_DW_CALENDAR_ID || 'primary';
+
+// Token + state stored outside git (see .gitignore: *.local.json).
+const TOKEN_FILE = process.env.GOOGLE_CAL_TOKEN_FILE
+  || path.join(__dirname, '.google-tokens.local.json');
+// In-memory single-use state nonces for the OAuth handshake.
+const _states = new Map(); // state -> createdAt
+
+function getOAuthConfig() {
+  const clientId     = process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID || '';
+  const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET || '';
+  const redirectUri  = process.env.GOOGLE_OAUTH_REDIRECT_URI
+    || `http://127.0.0.1:${process.env.PORT || 9765}/api/oauth/callback`;
+  return { clientId, clientSecret, redirectUri };
+}
+
+function isOAuthConfigured() {
+  const { clientId, clientSecret } = getOAuthConfig();
+  return Boolean(clientId && clientSecret);
+}
+
+// --- token persistence ------------------------------------------------------
+function _readTokens() {
+  try { return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')); }
+  catch { return null; }
+}
+function _writeTokens(t) {
+  fs.writeFileSync(TOKEN_FILE, JSON.stringify(t, null, 2), { mode: 0o600 });
+}
+function isConnected() {
+  const t = _readTokens();
+  return Boolean(t && (t.access_token || t.refresh_token));
+}
+
+// --- OAuth handshake --------------------------------------------------------
+function buildAuthUrl() {
+  const { clientId, redirectUri } = getOAuthConfig();
+  const state = crypto.randomBytes(16).toString('hex');
+  _states.set(state, Date.now());
+  // prune nonces older than 10 min
+  for (const [s, at] of _states) if (Date.now() - at > 600_000) _states.delete(s);
+  const params = new URLSearchParams({
+    client_id: clientId, redirect_uri: redirectUri, response_type: 'code',
+    scope: SCOPES.join(' '), access_type: 'offline', prompt: 'consent', state,
+  });
+  return `${AUTH_ENDPOINT}?${params.toString()}`;
+}
+
+async function exchangeCodeAndStore(code, state) {
+  if (!_states.has(state)) throw new Error('Unknown or expired OAuth state');
+  _states.delete(state);
+  const { clientId, clientSecret, redirectUri } = getOAuthConfig();
+  const body = new URLSearchParams({
+    code, client_id: clientId, client_secret: clientSecret,
+    redirect_uri: redirectUri, grant_type: 'authorization_code',
+  });
+  const tok = await fetch(TOKEN_ENDPOINT, {
+    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: body.toString(),
+  });
+  if (!tok.ok) throw new Error(`Google token exchange failed: ${tok.status} ${await tok.text()}`);
+  const j = await tok.json();
+  _writeTokens({
+    access_token:  j.access_token,
+    refresh_token: j.refresh_token || (_readTokens() || {}).refresh_token || null,
+    expires_at:    Date.now() + (Number(j.expires_in || 3600) * 1000),
+    scope:         j.scope || null,
+    token_type:    j.token_type || 'Bearer',
+  });
+  return true;
+}
+
+/** Valid access token, refreshing if needed; null if not connected. */
+async function getValidAccessToken() {
+  const t = _readTokens();
+  if (!t) return null;
+  if (t.access_token && t.expires_at - Date.now() > 120_000) return t.access_token;
+  if (!t.refresh_token) return t.access_token || null;
+  const { clientId, clientSecret } = getOAuthConfig();
+  const body = new URLSearchParams({
+    client_id: clientId, client_secret: clientSecret,
+    grant_type: 'refresh_token', refresh_token: t.refresh_token,
+  });
+  const tok = await fetch(TOKEN_ENDPOINT, {
+    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: body.toString(),
+  });
+  if (!tok.ok) { console.error('[google-cal] refresh failed', tok.status, await tok.text()); return null; }
+  const j = await tok.json();
+  _writeTokens({ ...t, access_token: j.access_token, expires_at: Date.now() + (Number(j.expires_in || 3600) * 1000) });
+  return j.access_token;
+}
+
+async function revoke() {
+  const tok = await getValidAccessToken();
+  if (tok) {
+    try { await fetch(`${REVOKE_ENDPOINT}?token=${encodeURIComponent(tok)}`, { method: 'POST' }); }
+    catch (e) { console.error('[google-cal] revoke error (continuing):', e); }
+  }
+  try { fs.unlinkSync(TOKEN_FILE); } catch {}
+}
+
+// --- Calendar v3 CRUD -------------------------------------------------------
+function _calUrl(suffix = '') {
+  return `${CAL_BASE}/calendars/${encodeURIComponent(CALENDAR_ID)}${suffix}`;
+}
+
+/** Status object the UI uses to render the "connected / not connected" banner. */
+function status() {
+  return {
+    oauth_configured: isOAuthConfigured(),
+    connected:        isConnected(),
+    calendar_id:      CALENDAR_ID,
+  };
+}
+
+/** events.list for a window. Returns [] (never throws) when not connected. */
+async function listEvents(timeMinISO, timeMaxISO) {
+  const token = await getValidAccessToken();
+  if (!token) return { connected: false, events: [] };
+  const qs = new URLSearchParams({
+    timeMin: timeMinISO, timeMax: timeMaxISO,
+    singleEvents: 'true', orderBy: 'startTime', maxResults: '2500',
+  });
+  const r = await fetch(`${_calUrl('/events')}?${qs}`, {
+    headers: { 'Authorization': `Bearer ${token}` },
+  });
+  if (!r.ok) { console.error('[google-cal] events.list failed', r.status, await r.text()); return { connected: true, events: [], error: r.status }; }
+  const j = await r.json();
+  return { connected: true, events: (j.items || []).map(_normalize) };
+}
+
+function _normalize(e) {
+  return {
+    id: e.id,
+    summary: e.summary || '(no title)',
+    description: e.description || '',
+    location: e.location || '',
+    start: e.start?.dateTime || e.start?.date || null,
+    end:   e.end?.dateTime   || e.end?.date   || null,
+    allDay: Boolean(e.start?.date && !e.start?.dateTime),
+    htmlLink: e.htmlLink || null,
+    created: e.created || null,
+    updated: e.updated || null,
+    colorId: e.colorId || null,
+  };
+}
+
+function _toGoogleBody(p) {
+  const body = { summary: p.summary, description: p.description || '', location: p.location || '' };
+  if (p.allDay) {
+    body.start = { date: (p.start || '').slice(0, 10) };
+    body.end   = { date: (p.end || p.start || '').slice(0, 10) };
+  } else {
+    body.start = { dateTime: p.start };
+    body.end   = { dateTime: p.end };
+  }
+  if (p.colorId) body.colorId = String(p.colorId);
+  return body;
+}
+
+async function insertEvent(p) {
+  const token = await getValidAccessToken();
+  if (!token) return { ok: false, reason: 'not_connected' };
+  const r = await fetch(`${_calUrl('/events')}?sendUpdates=none`, {
+    method: 'POST',
+    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify(_toGoogleBody(p)),
+  });
+  if (!r.ok) return { ok: false, reason: 'google_error', status: r.status, detail: await r.text() };
+  return { ok: true, event: _normalize(await r.json()) };
+}
+
+async function updateEvent(eventId, p) {
+  const token = await getValidAccessToken();
+  if (!token) return { ok: false, reason: 'not_connected' };
+  // patch (partial) is safer than full update — only send changed fields.
+  const r = await fetch(`${_calUrl('/events/' + encodeURIComponent(eventId))}?sendUpdates=none`, {
+    method: 'PATCH',
+    headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify(_toGoogleBody(p)),
+  });
+  if (!r.ok) return { ok: false, reason: 'google_error', status: r.status, detail: await r.text() };
+  return { ok: true, event: _normalize(await r.json()) };
+}
+
+async function deleteEvent(eventId) {
+  const token = await getValidAccessToken();
+  if (!token) return { ok: false, reason: 'not_connected' };
+  const r = await fetch(`${_calUrl('/events/' + encodeURIComponent(eventId))}?sendUpdates=none`, {
+    method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` },
+  });
+  return { ok: r.ok || r.status === 410 }; // 410 = already gone
+}
+
+module.exports = {
+  SCOPES, CALENDAR_ID,
+  getOAuthConfig, isOAuthConfigured, isConnected, status,
+  buildAuthUrl, exchangeCodeAndStore, getValidAccessToken, revoke,
+  listEvents, insertEvent, updateEvent, deleteEvent,
+};
diff --git a/node_modules b/node_modules
new file mode 120000
index 0000000..6052835
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+../dw-launches/node_modules
\ No newline at end of file
diff --git a/server.js b/server.js
index 18af79c..401a0d9 100644
--- a/server.js
+++ b/server.js
@@ -12,8 +12,10 @@
 const express = require('express');
 const path = require('path');
 const { Pool } = require('pg');
+const gcal = require('./google-cal');
 
 const app = express();
+app.use(express.json({ limit: '256kb' }));
 const PORT = process.env.PORT || 9765;
 const pool = process.env.DATABASE_URL
   ? new Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
@@ -102,4 +104,102 @@ app.get('/api/schedule', async (req, res) => {
 });
 app.get('/api/health', (_q, res) => res.json({ ok: true, port: PORT }));
 
+// ============================================================================
+// Google Calendar layer — read + editable CRUD.
+// Every route degrades gracefully when no OAuth credential is present:
+// it returns a clean { connected:false } / 409 'not_connected' signal,
+// NEVER a 500. The OAuth credential + the consent click are Steve-gated.
+// ============================================================================
+
+// Connection status — drives the UI banner ("Calendar not connected — Steve
+// must authorize" vs live).
+app.get('/api/calendar/status', (_q, res) => res.json(gcal.status()));
+
+// Begin the OAuth grant (302 → Google consent). 503 if no client-id/secret yet.
+app.get('/api/oauth/start', (req, res) => {
+  if (!gcal.isOAuthConfigured()) {
+    return res.status(503).json({ error: 'oauth_not_configured',
+      message: 'Calendar not connected — Steve must add the Google OAuth credential.' });
+  }
+  try { res.redirect(gcal.buildAuthUrl()); }
+  catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// OAuth callback — exchanges ?code for tokens, persists, then bounces to the UI.
+app.get('/api/oauth/callback', async (req, res) => {
+  const { code, state, error } = req.query;
+  if (error) return res.status(400).send(`OAuth denied: ${error}`);
+  if (!code || !state) return res.status(400).send('Missing code/state');
+  try {
+    await gcal.exchangeCodeAndStore(String(code), String(state));
+    res.redirect('/?connected=1');
+  } catch (e) {
+    res.status(400).send(`OAuth exchange failed: ${e.message}`);
+  }
+});
+
+// Disconnect (revoke + drop tokens).
+app.post('/api/oauth/revoke', async (_q, res) => {
+  try { await gcal.revoke(); res.json({ ok: true }); }
+  catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// --- events read (events.list for a window) ---------------------------------
+app.get('/api/events', async (req, res) => {
+  try {
+    // Default window = the visible month ±1 week; client passes ISO bounds.
+    const now = new Date();
+    const timeMin = req.query.timeMin || new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString();
+    const timeMax = req.query.timeMax || new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString();
+    const out = await gcal.listEvents(String(timeMin), String(timeMax));
+    res.json(out);
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+function validateEventBody(b) {
+  if (!b || typeof b !== 'object') return 'missing body';
+  if (!b.summary || !String(b.summary).trim()) return 'summary required';
+  if (!b.start) return 'start required';
+  if (!b.allDay && !b.end) return 'end required for timed events';
+  return null;
+}
+
+// --- events create (events.insert) ------------------------------------------
+app.post('/api/events', async (req, res) => {
+  const err = validateEventBody(req.body);
+  if (err) return res.status(400).json({ ok: false, error: err });
+  try {
+    const r = await gcal.insertEvent(req.body);
+    if (!r.ok && r.reason === 'not_connected')
+      return res.status(409).json({ ok: false, error: 'not_connected',
+        message: 'Calendar not connected — Steve must authorize.' });
+    if (!r.ok) return res.status(502).json({ ok: false, error: r.reason, status: r.status, detail: r.detail });
+    res.status(201).json(r);
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// --- events update (events.patch) -------------------------------------------
+app.patch('/api/events/:id', async (req, res) => {
+  const err = validateEventBody(req.body);
+  if (err) return res.status(400).json({ ok: false, error: err });
+  try {
+    const r = await gcal.updateEvent(req.params.id, req.body);
+    if (!r.ok && r.reason === 'not_connected')
+      return res.status(409).json({ ok: false, error: 'not_connected',
+        message: 'Calendar not connected — Steve must authorize.' });
+    if (!r.ok) return res.status(502).json({ ok: false, error: r.reason, status: r.status, detail: r.detail });
+    res.json(r);
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// --- events delete (events.delete) ------------------------------------------
+app.delete('/api/events/:id', async (req, res) => {
+  try {
+    const r = await gcal.deleteEvent(req.params.id);
+    if (!r.ok && r.reason === 'not_connected')
+      return res.status(409).json({ ok: false, error: 'not_connected' });
+    res.json(r);
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
 app.listen(PORT, () => console.log(`DW Activation Calendar on http://127.0.0.1:${PORT}`));

← a04151c auto-save: 2026-06-21T18:53:00 (1 files) — screenshot-calend  ·  back to Dw Activation Calendar  ·  Add two-tab DW Calendar UI: editable Google Calendar modal ( 5498582 →