Overview

No data loaded
Checking cloud…

Monthly Trajectory

Daily Leads vs Target

Channel Contribution

Channel Performance
Track each channel against its monthly target

Channel Trajectories

Channel Trend — All Months

Granularity
from last
Team Performance
View each team's leads performance independently

Falcon Health — Monthly Trajectory

Falcon Health — Channel Breakdown

Budget & Cost per Lead
Monitor budget utilization and CPL for paid channels

Budget vs Spent

Budget Utilization

Spend Projection
Import a Weekly Spent CSV with date-range headers (e.g. "06 May 2026 - 12 May 2026") to see projections.

Per-Channel Projection

Cost Per Lead (CPL)

Weekly CPL Trend

Lead Growth Analytics
Cross-channel correlations, growth rates, and scaling signals within the selected date range.
Excluded Channels click a channel to toggle exclusion
Excluded channels are hidden from KPIs, leaderboard, insights, correlation matrix, mix chart, and quadrant. Use this to drop junk/low-signal sources (e.g. one-off referrals) so they don't pollute the analysis.

Growth Leaderboard

Auto-Insights

Rules-driven signals refreshed from the latest data

Channel Correlation Matrix

Pearson r between daily lead counts. +1 = channels rise and fall in lockstep. 0 = no relationship. −1 = inversely related (one up, other down). Sample size shown in subtitle.

Channel Mix Over Time

% share of leads per month

Scaling Quadrant — Growth vs Volatility

Top-right = high growth + low volatility (best scale candidates)
Target & Budget Planner Reverse-engineer month-by-month plans by ROI, cost, or conversion.
Its own state — nothing here writes to your dashboard numbers. Use "Full screen" for the two-tab planning workflow.
Term Leads — Daily by Channel
Actual lead counts per channel, day by day, with what changed on the latest imported day.

What changed

Latest day vs each channel’s 7-day average

Leads by channel, day by day

Newest day first · green / red cells moved ≥30% and ≥5 leads vs the previous day · — = not imported

Daily leads, stacked by channel

Lead Estimation Model
Set an end-goal per channel. The model reads your historical monthly totals + seasonal patterns and reverse-engineers a month-by-month path from now to your target.

Historical Monthly Data

Data & Admin
Everything Owners and admins need — uploads, access management, sharing, and destructive actions — split into focused subsections.

Targets (Monthly — per Team)

Daily Leads (per Team)

Budget & Spend

Monthly budget file tagged to this month. Weekly spent auto-detects from header dates.
Data Management
Remove specific data if you uploaded something wrong. This cannot be undone.

Clear Targets

Clear Daily Leads (by day or all)

Clear Weekly Spends (by week or all)

Clear Budgets

Cloud Sync (Live Sharing)

Share the dashboard once — everyone always sees your latest data. Uses Google Apps Script (free, your Google account).

One-time setup (3 minutes) — click to expand Step 1: Go to script.google.com → click New Project
Step 2: Delete everything in the editor and paste this code:
// God's View — hardened Apps Script backend.
//
// FOLDER_ID = your GodsViewDashboard folder's Drive ID (right-click the folder
// → Get link → the `folders/<ID>` fragment). Prefer a Shared Drive so the file
// survives if a personal Google account is off-boarded.
//
// Auth model — the dashboard ID token is the only credential. The web app is
// deployed with "Execute as: Me" + "Who has access: Anyone", and every request
// must carry `t`: the Google Sign-In JWT the dashboard received for
// GOOGLE_CLIENT_ID, verified locally against Google's public keys. The caller's
// identity is the token's email.
//
// Why not Workspace cookies ("Anyone within joinditto.in" +
// Session.getActiveUser)? The browser has to send Google's cookies on a
// cross-site <script> request, which fails whenever the user is signed into
// several Google accounts (personal one as default) or blocks third-party
// cookies — Google answers with a login page and the dashboard shows "Script
// load error". Cookies also ride along on requests from any other website, so
// they never proved the request came from the dashboard. Only the dashboard's
// own origin can obtain a token for GOOGLE_CLIENT_ID.
//
// Token expiry: Google ID tokens live ~1 hour; the dashboard refreshes them via
// One Tap. Expired tokens are still accepted for TOKEN_GRACE_SECONDS so a tab
// that missed a refresh doesn't flip to "Cloud unreachable". Keep the window
// short — a token on its own is enough to read the data until it runs out.
// Signatures are checked locally (no tokeninfo fetch); Google's signing keys
// are fetched only when an unknown key id appears, and stored in Script
// Properties so the grace window survives Google's key rotation.
//
// Requires the V8 runtime (Project Settings → "Enable Chrome V8 runtime").
// After pasting, run checkSetup() once from the editor to grant the
// external-request permission and confirm everything is wired up.
//
// Allow-list: only people on the dashboard's allow-list can read data or ping.
// It's resolved exactly like getAllUsers() in public/js/auth-config.js:
// BASE_USERS (a copy of USERS) + Owner-approved STATE.dynamicUsers, with
// STATE.userOverrides applied and STATE.removedUsers filtered out. Anyone else
// with a valid joinditto.in token can only file an access request.
//
// Team budget filtering: Owners get the whole file. Everyone else gets budgets
// and weekly spends cut down to their team's channels (TEAM_CHANNELS), or none
// if their team can't see the Budget tab (BUDGET_ALLOWED_TEAMS), and no
// Owner-only admin data (access requests, session activity, other users'
// records). Mirrors the Budget-tab filtering in auth-permissions.js, so the
// dashboard renders the same thing — it just can't be bypassed any more.
//
// BASE_USERS / TEAM_CHANNELS / BUDGET_ALLOWED_TEAMS must stay in sync with
// public/js/auth-config.js. checkSetup() compares them against the live
// dashboard and logs any drift — run it after every USERS change.
//
// OWNER_EMAILS lists who can push a full state overwrite or trigger emails.
// Everyone else (member/admin/superadmin) can still ping and file access
// requests, but can't clobber the data file or spam through MailApp.
var FILE_NAME = 'gods_view_state.json';
var FOLDER_ID = '1qKIcAWX-mTQuAomItgCfl0994Nr7199p';
var OWNER_EMAILS = [
  'gowtham.shetty@joinditto.in',
  'ishaan.sharma@joinditto.in'
];
var BASE_USERS = {
  'gowtham.shetty@joinditto.in':          { name: 'Gowtham Shetty',      role: 'owner',      team: 'Central Team' },
  'ishaan.sharma@joinditto.in':           { name: 'Ishaan Sharma',       role: 'owner',      team: 'Central Team' },
  'dhruv@joinditto.in':                   { name: 'Dhruv Shah',          role: 'superadmin', team: 'Central Team' },
  'shrehith.k@joinditto.in':              { name: 'Shrehith Karkera',    role: 'superadmin', team: 'Central Team' },
  'don@joinditto.in':                     { name: 'Don Joseph',          role: 'admin',      team: 'Central Team' },
  'siddalingayya.mathapati@joinditto.in': { name: 'Siddu',               role: 'member',     team: 'Performance Marketing' },
  'dheeraj.pulavarthy@joinditto.in':      { name: 'Dheeraj Pulavarthy',  role: 'member',     team: 'Performance Marketing' },
  'samarth.pujar@joinditto.in':           { name: 'Samarth Pujar',       role: 'member',     team: 'Influencer Marketing' },
  'ananya.manvi@joinditto.in':            { name: 'Ananya Manvi',        role: 'member',     team: 'Influencer Marketing' },
  'shaun.menezes@joinditto.in':           { name: 'Shaun Menezes',       role: 'member',     team: 'Webinar' },
  'varun.sharma@joinditto.in':            { name: 'Varun Sharma',        role: 'member',     team: 'SEO' },
  'monish.vora@joinditto.in':             { name: 'Monish Vora',         role: 'member',     team: 'SEO' },
  'arvind.vasudevan@joinditto.in':        { name: 'Arvind Vasudevan',    role: 'member',     team: 'Affiliates' },
  'shashank.vattam@joinditto.in':         { name: 'Shashank Vattam',     role: 'member',     team: 'Youtube - Ditto' }
};
var TEAM_CHANNELS = {
  'Central Team':          null,
  'Performance Marketing': ['Performance Ads', 'Ads'],
  'Influencer Marketing':  ['Influencers'],
  'SEO':                   ['SEO', 'Organic'],
  'Webinar':               ['Webinar'],
  'Affiliates':            ['Affiliates', 'Affiliate + Offline'],
  'Youtube - Ditto':       ['YouTube - Ditto', 'YouTube - Finshots', 'NA/YouTube', 'YouTube/NA']
};
var BUDGET_ALLOWED_TEAMS = ['Performance Marketing', 'Influencer Marketing', 'Webinar'];
var DASHBOARD_URL = 'https://ditto-gods-view.pages.dev';
var GOOGLE_CLIENT_ID ='392174935409-rh00ro3nioqeo5kq1s922f40ckfi4h0p.apps.googleusercontent.com';
var HOSTED_DOMAIN = 'joinditto.in';
var TOKEN_GRACE_SECONDS = 24 * 3600;
var GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
var JWK_PROP_PREFIX = 'gv_jwk_';
var JWKS_FETCHED_AT_PROP = 'gv_jwks_fetched_at';
var SHA256_DIGEST_INFO_HEX = '3031300d060960864801650304020105000420';

function getFolder() { return DriveApp.getFolderById(FOLDER_ID); }

function isOwnerEmail(email) {
  return OWNER_EMAILS.indexOf(String(email || '').toLowerCase()) >= 0;
}

function isBaseOwner(email) {
  return !!(BASE_USERS[email] && BASE_USERS[email].role === 'owner');
}

function readStateText() {
  var files = getFolder().getFilesByName(FILE_NAME);
  return files.hasNext() ? files.next().getBlob().getDataAsString() : '{}';
}

function parseState(text) {
  try {
    var s = JSON.parse(text);
    return (s && typeof s === 'object') ? s : {};
  } catch (x) { return {}; }
}

// Read-merge-write helper for ping / accessRequest. Returns the parsed state,
// or null when the file is missing or can't be read/parsed. Callers must NOT
// write on null: falling back to {} here once overwrote the whole dashboard
// with just the session list after a transient Drive read failure.
function readStateForMerge(file) {
  if (!file) return null;
  try {
    var text = file.getBlob().getDataAsString();
    if (!text) return null;
    var s = JSON.parse(text);
    return (s && typeof s === 'object' && !Array.isArray(s)) ? s : null;
  } catch (x) { return null; }
}

function unreadable(e) {
  return ContentService.createTextOutput(JSON.stringify({ status: 'error', reason: 'state-unreadable' }))
    .setMimeType(ContentService.MimeType.JSON);
}

/* ===== Allow-list (mirror of getAllUsers + migrateState invariants) ===== */
function isObj(v) { return !!v && typeof v === 'object'; }

function allUsers(state) {
  var all = {};
  Object.keys(BASE_USERS).forEach(function (k) {
    var u = BASE_USERS[k];
    all[k] = { name: u.name, role: u.role, team: u.team };
  });
  var dyn = isObj(state.dynamicUsers) ? state.dynamicUsers : {};
  Object.keys(dyn).forEach(function (k) {
    if (!isObj(dyn[k]) || isBaseOwner(k)) return;
    all[k] = { name: dyn[k].name || '', role: dyn[k].role || 'member', team: dyn[k].team || 'Central Team' };
  });
  var ovr = isObj(state.userOverrides) ? state.userOverrides : {};
  Object.keys(ovr).forEach(function (k) {
    if (!all[k] || !isObj(ovr[k]) || isBaseOwner(k)) return;
    if (ovr[k].role) all[k].role = ovr[k].role;
    if (ovr[k].team) all[k].team = ovr[k].team;
  });
  var removed = isObj(state.removedUsers) ? state.removedUsers : {};
  Object.keys(removed).forEach(function (k) { if (!isBaseOwner(k)) delete all[k]; });
  // Only Central Team can hold roles above member.
  Object.keys(all).forEach(function (k) {
    if (all[k].team !== 'Central Team' && all[k].role !== 'member') all[k].role = 'member';
  });
  return all;
}

/* ===== Team budget filtering (mirror of auth-permissions.js) ===== */
function canSeeBudget(user) {
  if (user.role === 'owner' || user.role === 'superadmin') return true;
  if (user.team === 'Central Team') return true;
  return BUDGET_ALLOWED_TEAMS.indexOf(user.team) >= 0;
}

// null = every channel; [] = none (unknown team fails closed).
function allowedChannels(user) {
  if (!Object.prototype.hasOwnProperty.call(TEAM_CHANNELS, user.team)) return [];
  return TEAM_CHANNELS[user.team];
}

function resolveAlias(name, aliases) {
  if (aliases && Object.prototype.hasOwnProperty.call(aliases, name) && aliases[name]) return aliases[name];
  return name;
}

function channelAllowed(ch, allowed, aliases) {
  if (allowed === null) return true;
  var resolved = resolveAlias(ch, aliases);
  return allowed.some(function (c) { return c === ch || c === resolved || resolveAlias(c, aliases) === resolved; });
}

function filterChannelMap(obj, allowed, aliases) {
  var out = {};
  Object.keys(isObj(obj) ? obj : {}).forEach(function (ch) {
    if (channelAllowed(ch, allowed, aliases)) out[ch] = obj[ch];
  });
  return out;
}

// The slice of the state file this user may receive. Never written back —
// only Owners push, and they always get the full file.
function viewForUser(state, email, user) {
  var out = {};
  Object.keys(state).forEach(function (k) { out[k] = state[k]; });
  var aliases = isObj(state.channelAliases) ? state.channelAliases : {};
  if (!canSeeBudget(user)) {
    out.budgets = {};
    out.weeklySpents = {};
  } else {
    var allowed = allowedChannels(user);
    if (allowed !== null) {
      var budgets = {};
      Object.keys(isObj(state.budgets) ? state.budgets : {}).forEach(function (mk) {
        budgets[mk] = filterChannelMap(state.budgets[mk], allowed, aliases);
      });
      out.budgets = budgets;
      var spends = {};
      Object.keys(isObj(state.weeklySpents) ? state.weeklySpents : {}).forEach(function (mk) {
        var weeks = Array.isArray(state.weeklySpents[mk]) ? state.weeklySpents[mk] : [];
        spends[mk] = weeks.map(function (w, i) {
          var data = isObj(w) && isObj(w.data) ? w.data : w;
          return { label: (isObj(w) && w.label) || ('Week ' + (i + 1)), data: filterChannelMap(data, allowed, aliases) };
        });
      });
      out.weeklySpents = spends;
    }
  }
  // Owner-only admin data. Keep the viewer's own user records so the
  // dashboard can still resolve their role and team.
  out.accessRequests = [];
  out.sessions = {};
  out.removedUsers = {};
  out.dynamicUsers = {};
  out.userOverrides = {};
  if (isObj(state.dynamicUsers) && state.dynamicUsers[email]) out.dynamicUsers[email] = state.dynamicUsers[email];
  if (isObj(state.userOverrides) && state.userOverrides[email]) out.userOverrides[email] = state.userOverrides[email];
  return out;
}

// JSONP callback name, only if it's a plain identifier. Anything else is
// dropped so the response can't be turned into arbitrary script.
function callbackName(e) {
  var cb = e && e.parameter && e.parameter.callback;
  return (cb && /^[A-Za-z_$][\w$]{0,63}$/.test(cb)) ? cb : '';
}

function refuse(e, status, reason) {
  var out = { status: status, reason: reason || '' };
  // Diagnostic for intermittent "missing-token" on requests the dashboard did
  // send a token with: how much of the query string actually arrived. Counts
  // only — no parameter names or values.
  if (reason === 'missing-token') {
    out.paramCount = Object.keys((e && e.parameter) || {}).length;
    out.queryLength = String((e && e.queryString) || '').length;
  }
  var body = JSON.stringify(out);
  var cb = callbackName(e);
  if (cb) return ContentService.createTextOutput(cb + '(' + body + ')')
    .setMimeType(ContentService.MimeType.JAVASCRIPT);
  return ContentService.createTextOutput(body).setMimeType(ContentService.MimeType.JSON);
}

// Returns { email } for a valid dashboard ID token, otherwise { error } with a
// short reason.
function authenticate(e) {
  var token = e && e.parameter && e.parameter.t;
  if (!token) return { error: 'missing-token' };
  var claims = verifyIdToken(token);
  if (!claims) return { error: 'invalid-token' };
  return { email: String(claims.email || '').toLowerCase() };
}

/* ===== Google ID token verification (RS256, local) ===== */
function b64urlToBytes(s) {
  s = String(s).replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4) s += '=';
  return Utilities.base64Decode(s);
}

function b64urlToString(s) {
  return Utilities.newBlob(b64urlToBytes(s)).getDataAsString('UTF-8');
}

function bytesToHex(bytes) {
  var out = '';
  for (var i = 0; i < bytes.length; i++) {
    var b = bytes[i] & 0xff;
    out += (b < 16 ? '0' : '') + b.toString(16);
  }
  return out;
}

function modPow(base, exp, mod) {
  var result = BigInt(1);
  var zero = BigInt(0), one = BigInt(1), two = BigInt(2);
  base = base % mod;
  while (exp > zero) {
    if (exp % two === one) result = (result * base) % mod;
    exp = exp / two;
    base = (base * base) % mod;
  }
  return result;
}

// RSASSA-PKCS1-v1_5 with SHA-256: sig^e mod n must equal
// 00 01 FF..FF 00 || DigestInfo(SHA-256) || SHA-256(signingInput).
function rsaSha256Verify(signingInput, sigBytes, nB64, eB64) {
  var nHex = bytesToHex(b64urlToBytes(nB64)).replace(/^(00)+/, '');
  var k = Math.ceil(nHex.length / 2);
  if (sigBytes.length !== k) return false;
  var n = BigInt('0x' + nHex);
  var exp = BigInt('0x' + bytesToHex(b64urlToBytes(eB64)));
  var s = BigInt('0x' + bytesToHex(sigBytes));
  if (s >= n) return false;
  var em = modPow(s, exp, n).toString(16);
  while (em.length < k * 2) em = '0' + em;
  var hash = bytesToHex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, signingInput, Utilities.Charset.UTF_8));
  var tail = '00' + SHA256_DIGEST_INFO_HEX + hash;
  var padLen = k * 2 - 4 - tail.length;
  if (padLen < 16) return false;
  return em === '0001' + new Array(padLen + 1).join('f') + tail;
}

// Google's signing key for `kid`. Keys are kept in Script Properties (one
// property per kid) so tokens inside the grace window still verify after
// Google rotates them out of the live JWKS. The JWKS is re-fetched only for an
// unknown kid, at most once a minute.
function getGoogleKey(kid) {
  var props = PropertiesService.getScriptProperties();
  var stored = props.getProperty(JWK_PROP_PREFIX + kid);
  if (stored) return JSON.parse(stored);
  var now = Date.now();
  var lastFetch = Number(props.getProperty(JWKS_FETCHED_AT_PROP) || 0);
  if (now - lastFetch < 60 * 1000) return null;
  props.setProperty(JWKS_FETCHED_AT_PROP, String(now));
  var resp = UrlFetchApp.fetch(GOOGLE_JWKS_URL, { muteHttpExceptions: true });
  if (resp.getResponseCode() !== 200) return null;
  var keys = JSON.parse(resp.getContentText()).keys || [];
  var live = {};
  keys.forEach(function (k) {
    if (!k.kid || !k.n || !k.e) return;
    live[JWK_PROP_PREFIX + k.kid] = true;
    props.setProperty(JWK_PROP_PREFIX + k.kid, JSON.stringify({ n: k.n, e: k.e, seen: now }));
  });
  // Prune keys that left the live set longer ago than the grace window.
  var all = props.getProperties();
  Object.keys(all).forEach(function (name) {
    if (name.indexOf(JWK_PROP_PREFIX) !== 0 || live[name]) return;
    try {
      var old = JSON.parse(all[name]);
      if (now - (old.seen || 0) > (TOKEN_GRACE_SECONDS + 86400) * 1000) props.deleteProperty(name);
    } catch (x) { props.deleteProperty(name); }
  });
  stored = props.getProperty(JWK_PROP_PREFIX + kid);
  return stored ? JSON.parse(stored) : null;
}

// Returns the token's claims if it's a genuine Google ID token issued to this
// dashboard for a verified @joinditto.in account, otherwise null.
function verifyIdToken(token) {
  try {
    var parts = String(token).split('.');
    if (parts.length !== 3) return null;
    var header = JSON.parse(b64urlToString(parts[0]));
    var claims = JSON.parse(b64urlToString(parts[1]));
    if (header.alg !== 'RS256' || !header.kid) return null;
    if (claims.aud !== GOOGLE_CLIENT_ID) return null;
    if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') return null;
    if (claims.hd !== HOSTED_DOMAIN) return null;
    if (claims.email_verified !== true && claims.email_verified !== 'true') return null;
    var now = Math.floor(Date.now() / 1000);
    if (!claims.exp || now > claims.exp + TOKEN_GRACE_SECONDS) return null;
    if (claims.iat && claims.iat > now + 300) return null;
    var key = getGoogleKey(header.kid);
    if (!key) return null;
    if (!rsaSha256Verify(parts[0] + '.' + parts[1], b64urlToBytes(parts[2]), key.n, key.e)) return null;
    return claims;
  } catch (err) {
    return null;
  }
}

// Run once from the editor after pasting (select checkSetup → Run). Grants the
// external-request permission and logs whether each dependency works.
function checkSetup() {
  Logger.log('V8 runtime (BigInt): ' + (typeof BigInt === 'function' ? 'OK' : 'MISSING — enable Chrome V8 runtime in Project Settings'));
  Logger.log('Drive folder: ' + getFolder().getName());
  var resp = UrlFetchApp.fetch(GOOGLE_JWKS_URL, { muteHttpExceptions: true });
  Logger.log('Google signing keys fetch: HTTP ' + resp.getResponseCode());
  checkAllowListDrift();
}

// Compares BASE_USERS / TEAM_CHANNELS / BUDGET_ALLOWED_TEAMS with the live
// dashboard's auth-config.js and logs every difference.
function checkAllowListDrift() {
  var resp = UrlFetchApp.fetch(DASHBOARD_URL + '/js/auth-config.js', { muteHttpExceptions: true });
  if (resp.getResponseCode() !== 200) { Logger.log('Allow-list drift check: could not fetch auth-config.js (HTTP ' + resp.getResponseCode() + ')'); return; }
  var src = resp.getContentText();
  var problems = [];

  var usersBlock = (src.match(/const USERS = \{([\s\S]*?)\n\};/) || [])[1] || '';
  var live = {};
  var re = /'([^']+@[^']+)'\s*:\s*\{[^}]*role:\s*'([^']+)'[^}]*team:\s*'([^']+)'/g, m;
  while ((m = re.exec(usersBlock))) live[m[1]] = { role: m[2], team: m[3] };
  if (!Object.keys(live).length) problems.push('could not parse USERS');
  Object.keys(live).forEach(function (k) {
    var b = BASE_USERS[k];
    if (!b) problems.push('USERS has ' + k + ' — missing from BASE_USERS');
    else if (b.role !== live[k].role || b.team !== live[k].team) problems.push(k + ': dashboard ' + live[k].role + '/' + live[k].team + ' vs backend ' + b.role + '/' + b.team);
  });
  Object.keys(BASE_USERS).forEach(function (k) { if (!live[k]) problems.push('BASE_USERS has ' + k + ' — not in dashboard USERS'); });

  var teamsBlock = (src.match(/const TEAM_CHANNELS = \{([\s\S]*?)\n\};/) || [])[1] || '';
  var liveTeams = {};
  var tre = /'([^']+)'\s*:\s*(null|\[[^\]]*\])/g;
  while ((m = tre.exec(teamsBlock))) liveTeams[m[1]] = m[2] === 'null' ? null : (m[2].match(/'[^']*'/g) || []).map(function (q) { return q.slice(1, -1); });
  if (JSON.stringify(liveTeams) !== JSON.stringify(TEAM_CHANNELS)) problems.push('TEAM_CHANNELS differs: dashboard ' + JSON.stringify(liveTeams));

  var budgetTeams = ((src.match(/BUDGET_ALLOWED_TEAMS = new Set\(\[([^\]]*)\]\)/) || [])[1] || '').match(/'[^']*'/g) || [];
  if (JSON.stringify(budgetTeams.map(function (q) { return q.slice(1, -1); })) !== JSON.stringify(BUDGET_ALLOWED_TEAMS)) problems.push('BUDGET_ALLOWED_TEAMS differs');

  Logger.log(problems.length ? 'Allow-list drift — update the backend constants:\n  ' + problems.join('\n  ') : 'Allow-list matches the live dashboard: OK');
}

function doGet(e) {
  var auth = authenticate(e);
  if (auth.error) return refuse(e, 'unauthorized', auth.error);
  var text = readStateText();
  var data;
  if (isBaseOwner(auth.email)) {
    data = text; // baked-in Owners can never be removed or demoted — skip the parse
  } else {
    var state = parseState(text);
    var user = allUsers(state)[auth.email];
    if (!user) return refuse(e, 'forbidden', 'not-on-allow-list');
    data = JSON.stringify(user.role === 'owner' ? state : viewForUser(state, auth.email, user));
  }
  var cb = callbackName(e);
  if (cb) return ContentService.createTextOutput(cb + '(' + data + ')')
    .setMimeType(ContentService.MimeType.JAVASCRIPT);
  return ContentService.createTextOutput(data).setMimeType(ContentService.MimeType.JSON);
}

function doPost(e) {
  // Same token check as doGet — the token's email decides what the caller
  // may write (OWNER_EMAILS for full-state saves and notify emails).
  var auth = authenticate(e);
  if (auth.error) return refuse(e, 'unauthorized', auth.error);
  var email = auth.email;

  // Serialize every branch so concurrent read-merge-write requests can't clobber
  // each other. Wait up to 30 s for the lock; a queue behind that is fine —
  // dropping a write silently is not.
  var lock = LockService.getScriptLock();
  try { lock.waitLock(30000); }
  catch (lockErr) {
    return ContentService.createTextOutput(JSON.stringify({status:'busy'}))
      .setMimeType(ContentService.MimeType.JSON);
  }

  try {
    var folder = getFolder();
    var files  = folder.getFilesByName(FILE_NAME);

    // Session ping — allow-listed users update their own row. The row is keyed
    // by the token's email, never the payload's, so nobody can fake someone
    // else's activity; role comes from the allow-list, not the client.
    if (e.parameter && e.parameter.ping) {
      var ping;
      try { ping = JSON.parse(e.parameter.ping); } catch(x) { ping = null; }
      if (ping) {
        var raw = files.hasNext() ? files.next() : null;
        var state = readStateForMerge(raw);
        if (!state) return unreadable(e); // never write over a file we couldn't read
        var pinger = allUsers(state)[email];
        if (!pinger) return refuse(e, 'forbidden', 'not-on-allow-list');
        if (!state.sessions) state.sessions = {};
        state.sessions[email] = {
          name: ping.name || pinger.name || '', role: pinger.role,
          lastSeen: new Date().toISOString()
        };
        raw.setContent(JSON.stringify(state));
      }
      return ContentService.createTextOutput(
        JSON.stringify({status:'ok'})
      ).setMimeType(ContentService.MimeType.JSON);
    }

    // Access request — anyone in the domain (allow-listed or not) can file one,
    // but only for themselves: the email comes from the token.
    if (e.parameter && e.parameter.accessRequest) {
      var req;
      try { req = JSON.parse(e.parameter.accessRequest); } catch(x) { req = null; }
      var already = false;
      if (req) req.email = email;
      if (req && req.email && req.name) {
        var rawReq = files.hasNext() ? files.next() : null;
        var stateReq = readStateForMerge(rawReq);
        if (!stateReq) return unreadable(e); // never write over a file we couldn't read
        if (!stateReq.accessRequests) stateReq.accessRequests = [];
        for (var i = 0; i < stateReq.accessRequests.length; i++) {
          if (stateReq.accessRequests[i].email === req.email &&
              stateReq.accessRequests[i].status === 'pending') { already = true; break; }
        }
        if (!already) {
          stateReq.accessRequests.push({
            id: 'req_' + Date.now(),
            email: req.email,
            name: req.name,
            requestedTeam: req.requestedTeam || '',
            requestedRole: req.requestedRole || 'member',
            message: req.message || '',
            requestedAt: new Date().toISOString(),
            status: 'pending'
          });
          rawReq.setContent(JSON.stringify(stateReq));
        }
      }
      return ContentService.createTextOutput(
        JSON.stringify({status:'ok', duplicate: already ? true : false})
      ).setMimeType(ContentService.MimeType.JSON);
    }

    // Notify email — Owners only. `to` is hard-limited to @joinditto.in even
    // for Owners so a bug can't accidentally email externals through MailApp.
    if (e.parameter && e.parameter.notify) {
      if (!isOwnerEmail(email)) return refuse(e, 'forbidden', 'owners only');
      var n;
      try { n = JSON.parse(e.parameter.notify); } catch(x) { n = null; }
      if (n && n.to && n.subject && (n.htmlBody || n.body)) {
        if (!/^[^@]+@joinditto\.in$/i.test(n.to)) {
          return ContentService.createTextOutput(
            JSON.stringify({status:'refused', reason:'to must be @joinditto.in'})
          ).setMimeType(ContentService.MimeType.JSON);
        }
        try {
          MailApp.sendEmail({
            to: n.to,
            subject: n.subject,
            htmlBody: n.htmlBody || n.body,
            name: n.fromName || "God's View Dashboard"
          });
        } catch (mailErr) {
          return ContentService.createTextOutput(
            JSON.stringify({status:'error', error: String(mailErr)})
          ).setMimeType(ContentService.MimeType.JSON);
        }
      }
      return ContentService.createTextOutput(
        JSON.stringify({status:'ok'})
      ).setMimeType(ContentService.MimeType.JSON);
    }

    // Full state POST (from Owner) — Owner emails only. `looksLikeState` guards
    // against a stray beacon (accessRequest / ping hitting the wrong branch)
    // wiping the file with garbage.
    if (e.parameter && e.parameter.data) {
      if (!isOwnerEmail(email)) return refuse(e, 'forbidden', 'owners only');
      var body = e.parameter.data;
      var parsed = null;
      try { parsed = JSON.parse(body); } catch(x) { parsed = null; }
      var looksLikeState = parsed && typeof parsed === 'object' &&
        (parsed.targets || parsed.dailyData || parsed.channelMap || parsed.importMeta);
      if (looksLikeState) {
        if (files.hasNext()) {
          files.next().setContent(body);
        } else {
          folder.createFile(FILE_NAME, body, 'application/json');
        }
        return ContentService.createTextOutput(
          JSON.stringify({status:'ok'})
        ).setMimeType(ContentService.MimeType.JSON);
      }
    }
    return ContentService.createTextOutput(
      JSON.stringify({status:'ignored', reason:'unknown or malformed payload'})
    ).setMimeType(ContentService.MimeType.JSON);
  } finally {
    try { lock.releaseLock(); } catch (rx) {}
  }
}
Step 3: In Project Settings (gear icon), make sure "Enable Chrome V8 runtime" is ticked. Back in the editor, pick checkSetup in the function dropdown → Run → authorize when prompted (click Advanced → Go to project → Allow). The log should show every line OK / HTTP 200.
Step 4: Click Deploy → New deployment → gear icon next to "Select type" → choose Web app
Step 5: Set "Execute as" = Me  |  "Who has access" = Anyone within joinditto.in → click Deploy. (Ditto’s Workspace policy doesn’t offer “Anyone”; the Sign-In token check in the code is what decides who gets data.)
Step 6: Copy the Web app URL (it looks like https://script.google.com/a/macros/joinditto.in/s/…/exec) and paste it below → click Connect
Already deployed before? This version authenticates every request with the dashboard’s Google Sign-In token only — no Google cookies — so it works for people signed into several Google accounts or with third-party cookies blocked, and other websites still can’t read or overwrite the data. The token signature is checked locally against Google’s public keys (no tokeninfo fetch) and expired tokens are accepted for 24 hours, so a tab that missed a refresh keeps syncing. It also refuses JSONP callback names that aren’t plain identifiers, pins the Drive folder by ID, wraps each write in a LockService lock, limits full-state writes and notify emails to OWNER_EMAILS, and hard-refuses notify.to outside @joinditto.in. The backend also enforces the allow-list: only users on it get data, and everyone except Owners receives budgets and weekly spends filtered to their team’s channels, with no access requests, session activity or other users’ records. Before pasting: replace FOLDER_ID with your folder’s Drive ID, update OWNER_EMAILS to match the Owner rows in public/js/auth-config.js, keep BASE_USERS / TEAM_CHANNELS / BUDGET_ALLOWED_TEAMS identical to USERS / TEAM_CHANNELS / BUDGET_ALLOWED_TEAMS in public/js/auth-config.js (checkSetup logs any drift against the live dashboard), and keep GOOGLE_CLIENT_ID equal to GOOGLE_OAUTH_CLIENT_ID in public/js/config.js. Run checkSetup once (Step 3) to grant the new external-request permission, then Deploy → Manage deployments → pencil (Edit) → Version: New version → Deploy. Same deployment ID. The deployment must be Execute as: Me and Who has access: Anyone within joinditto.in, and the dashboard must use the /a/macros/joinditto.in/s/…/exec URL — the plain /macros/s/…/exec form returns 404 for a domain-only deployment.

Google Space Alerts

Get notified when channel fulfillment drops below 80% or budget is being overshot. Paste your Google Space webhook URL below.

Download Templates
Use these templates to format your data before importing.