Access is restricted to @joinditto.in Google Workspace accounts on the allow-list.
Not on the list? Contact to request access.
Access denied
Your Google account ()
isn't on the dashboard's access list yet. Request access below — an owner will review your request
and you'll get an email when it's approved.
Fill this out and an owner will review. Google-verified email is used — you don't need to prove identity.
Request submitted
An owner will review your request and you'll get an email at
when it's approved. You can close this tab.
Google Sign-In not yet configured
The Owner needs to complete a one-time setup:
Add the site's origin to Authorized JavaScript origins
Paste the Client ID into index.html at GOOGLE_OAUTH_CLIENT_ID
Commit + push to deploy
Contact for help.
Overview
No data loaded
Checking cloud…
Monthly Trajectory
Daily Leads vs Target
Channel Contribution
No data loaded yet
Import targets and daily lead data to see your marketing performance.
Channel Performance
Track each channel against its monthly target
Channel Trajectories
Channel Trend — All Months
Granularity
from last
No daily lead data imported yet. Upload a Daily Leads CSV (any month) to see this trend.
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.
No data in the selected range
Widen the date range, clear excluded channels, or import more Daily Leads data.
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 PlannerReverse-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.
No term leads data yet
Import Daily Leads for Falcon Term or Bliss Term (Import Data tab), or pick another team or lead type.
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
How this model works — read me first
Click to expand
What am I looking at?
A month-by-month projection of leads per channel, from your last completed month through your target month. This is not a forecast in the statistical sense — it's a plan-backward model. You tell it where you want to be; it works out the growth path required.
Where do the “actuals” come from?
The model uses complete months only — the last day of the month must have passed. Two sources feed the actuals, merged automatically:
Historical Monthly CSV — a one-time wide-format upload for months before you started importing daily leads.
Daily Leads imports — every daily lead you import gets rolled up into that month's total automatically. Import Apr through Aug's daily data and Apr through Aug appear as revised actuals here, no re-upload needed. When both sources cover the same month, daily wins (it's fresher).
The current month is deliberately excluded from actuals — it's incomplete and would drag the model down. The baseline is always the most recent fully-elapsed month.
How is the projection calculated?
For each channel, the model solves for a compound month-over-month growth rate that lands exactly on your goal:
Then applies that rate month-by-month, optionally modulated by a seasonal pattern (see below), and rescales so the target month lands exactly on your goal.
What’s “Seasonal pattern”?
The recurring monthly effect in your business — the fact that some months always run higher and others always lower, regardless of overall growth. Think Q4 gifting bumps or August vacation dips.
For each calendar month across your history, the model measures how that month compared to its trailing 12-month average and averages those ratios across years. Result: a multiplier per calendar month.
August has averaged 15% below the 12-month average → multiplier 0.85. The model projects August 15% lower than a smooth curve would.
December has averaged 20% above → multiplier 1.20. Projected December runs higher.
Requires at least 12 months of history to compute meaningfully. Below that, the model falls back to all-ones (no seasonal adjustment) and the projection is a smooth compound curve.
What’s “Uniform monthly growth”?
The simpler method: same compound growth rate every month, no seasonal wiggles. Use this when you don't have 12+ months of history yet, or when you want to see the flat compounding path clearly for a strategy conversation.
KPI card definitions
Baseline Total — sum of last-actual-month leads across all channels.
Target Total — sum of per-channel goals you set below. The Δ% shows how much bigger (or smaller) that is versus baseline.
Top Growth Channel — whichever channel needs the biggest compound MoM growth rate to hit its goal. Not necessarily the biggest channel in absolute terms.
Months to Target — count of months from baseline to target month, and which method is active.
Set Your Channel Goals
Projected Path
Solid = actual history · dashed = projection through your target
Month-by-Month Projection
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.
Access Requests
Users on @joinditto.in who tried to sign in but weren't on the allow-list.
Review each request, adjust their team/role if needed, then approve or reject.
Approvals write to STATE.dynamicUsers (no code push) and email the requester automatically.
Show past reviewed requests
Access List
Users allowed to sign in via Google SSO. Add, edit, or remove access below — changes take effect on their next sign-in.
Owner accounts are locked to prevent accidental lockout.
Add user
Non-Central-Team users are always members per policy — the role picker is auto-coerced on save.
User
Email
Team
Role
Last active
Actions
User Activity
Track who has accessed the dashboard and when they were last active.
Channel Mapping
Some CSV values arrive as raw strings (e.g. bliss_referral/instagram). Map them to an existing channel or a new canonical name — changes apply immediately across every tab, no re-import needed.
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 expandStep 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://joinditto-marketing-master-dashboard.netlify.app';
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 {}; }
}
/* ===== 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 body = JSON.stringify({ status: status, reason: reason || '' });
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 = {};
if (raw) {
try { state = JSON.parse(raw.getBlob().getDataAsString()); } catch(x) {}
}
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()
};
var updated = JSON.stringify(state);
if (raw) { raw.setContent(updated); }
else { folder.createFile(FILE_NAME, updated, 'application/json'); }
}
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 = {};
if (rawReq) {
try { stateReq = JSON.parse(rawReq.getBlob().getDataAsString()); } catch(x) {}
}
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'
});
var updatedReq = JSON.stringify(stateReq);
if (rawReq) { rawReq.setContent(updatedReq); }
else { folder.createFile(FILE_NAME, updatedReq, 'application/json'); }
}
}
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.
🔗 Hosted Dashboard Link RECOMMENDED
Share a link instead of a file. When the dashboard code is updated (new features, bug
fixes), everyone sees it automatically — no need to reshare the file.
One-time setup (2
minutes) — click to expandStep 1: Click Share (top-right) to download the latest HTML file Step 2: Open app.netlify.com/drop in a new tab Step 3:Drag the HTML file onto the Netlify page Step 4: Wait a few seconds → copy the URL shown (e.g.
https://cosmic-cat-abc.netlify.app) Step 5: Paste that URL below and click Save Link Step 6: Click Copy Link → share via WhatsApp/email instead of the
file
Updating later: When the code changes, click Share to get the new HTML
→ go to app.netlify.com → open your site →
Deploys tab → drag the new file. Same URL, updated code. Everyone at the URL sees
the update on their next refresh.
Already shared files with old code? Those files will keep showing the old version
forever. Ask recipients to use the new hosted link instead — that's the only way they'll get the new
login/session/activity features.
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.