app.bettersight.io/appscript/Validation.gs

113 lines
4.1 KiB
JavaScript

/**
* 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;
}