/** * Shared config — same values as the dashboard frontend's * VITE_API_BASE_URL / VITE_API_KEY (CLAUDE.md §9: every route requires * X-API-Key). This is a single app-wide key baked into the published * script, not a per-PM secret — same tradeoff as embedding it in the * frontend bundle. Real per-PM authorization is the email check inside * validateLicence() below, not this header. * * TEMPORARY — pointed at a local dev cloudflared quick tunnel for * testing the pull flow end-to-end before a real production domain * exists. Quick tunnels are not stable: the hostname changes every * time cloudflared restarts, and Cloudflare gives no uptime guarantee * on the free, account-less tier. Replace both values with the real * production API base URL + API_SECRET_KEY before publishing the * add-on for real PM use. */ const BETTERSIGHT_API_BASE_URL = 'https://api.bettersight.io'; const BETTERSIGHT_API_KEY = 'REPLACE_WITH_PRODUCTION_API_KEY'; const CACHE_KEY_VALID = 'BETTERSIGHT_VALID'; const CACHE_KEY_TIER = 'BETTERSIGHT_TIER'; const CACHE_DURATION_SECONDS = 21600; // 6 hours /** * Flips a single onboarding_checklist flag to true on the PM's tenant, * exactly once per install (tracked locally via UserProperties so a * repeat success — e.g. every pull, or every cache expiry — doesn't * re-send the same PATCH). The Apps Script has no PocketBase JWT, so * it authenticates this call by email only, same as validateLicence() * — the backend's PATCH /account/onboarding accepts either. * * A failure here is a side-effect failure only: it never blocks the * primary action (validating a licence, pulling results) that * triggered it. */ function markOnboardingStepOnce_(flagName) { const properties = PropertiesService.getUserProperties(); const sentKey = 'ONBOARDING_SENT_' + flagName; if (properties.getProperty(sentKey) === 'true') { return; } const email = Session.getActiveUser().getEmail(); if (!email) return; try { const payload = { email: email }; payload[flagName] = true; UrlFetchApp.fetch(`${BETTERSIGHT_API_BASE_URL}/account/onboarding`, { method: 'patch', contentType: 'application/json', headers: { 'X-API-Key': BETTERSIGHT_API_KEY }, payload: JSON.stringify(payload), muteHttpExceptions: true }); properties.setProperty(sentKey, 'true'); } catch (e) { Logger.log('markOnboardingStepOnce_ failed for ' + flagName + ': ' + e.message); } } /** * Validates the current user's licence against the Bettersight API. * Standalone script — validates by email only, no sheet binding of * any kind (CLAUDE.md §15). * * Data flow: * check 6-hour cache → cache hit: return true immediately → * cache miss: Session.getActiveUser().getEmail() → * POST {BETTERSIGHT_API_BASE_URL}/validate-email → * valid: cache result + tier for 6 hours, return true → * invalid: show a UI alert with the rejection reason, return false */ function validateLicence() { const cache = CacheService.getUserCache(); const cachedValid = cache.get(CACHE_KEY_VALID); if (cachedValid === 'true') { return true; } const email = Session.getActiveUser().getEmail(); if (!email) { SpreadsheetApp.getUi().alert('Could not determine your Google account email. Please try again.'); return false; } let response; try { response = UrlFetchApp.fetch(`${BETTERSIGHT_API_BASE_URL}/validate-email`, { method: 'post', contentType: 'application/json', headers: { 'X-API-Key': BETTERSIGHT_API_KEY }, payload: JSON.stringify({ email: email }), muteHttpExceptions: true }); } catch (e) { SpreadsheetApp.getUi().alert('Could not reach Bettersight — check your connection and try again.'); return false; } const body = JSON.parse(response.getContentText()); if (body.valid) { cache.put(CACHE_KEY_VALID, 'true', CACHE_DURATION_SECONDS); cache.put(CACHE_KEY_TIER, body.tier || '', CACHE_DURATION_SECONDS); markOnboardingStepOnce_('sidebar_installed'); return true; } SpreadsheetApp.getUi().alert(`Bettersight access denied: ${body.reason || 'Unknown reason'}`); return false; }