PageSourceSearch

https://www.orwellsbaddream.com/app-core.js

js orwellsbaddream.com collected 2026-10-02 01:38:41 UTC 22,252 bytes, 488 lines download raw bytes

1
2// Init Supabase from config.js (loaded via env-generated file).
3// The SDK is used only for auth flows (signInWithOAuth, signOut,
4// onAuthStateChange). Data queries go through `db` below, which calls
5// PostgREST directly — this is the same HTTP API the SDK wraps.
6const SUPABASE_STORAGE_KEY = "sb-eyjmtwlqscbkjpgvkdnt-auth-token";
7
8const sb = supabase.createClient(SUPABASE_URL, SUPABASE_KEY, {
9  auth: {
10    detectSessionInUrl: true,
11    flowType: "pkce",
12    persistSession: true,
13    storage: window.localStorage,
14  },
15});
16
17function readSession() {
18  try {
19    const raw = localStorage.getItem(SUPABASE_STORAGE_KEY);
20    return raw ? JSON.parse(raw) : null;
21  } catch { return null; }
22}
23function authToken() { return readSession()?.access_token || null; }
24
25// A 401 from PostgREST means the access token expired, not that the user is
26// gone. It expires every hour; supabase-js refreshes it on a timer, but that
27// timer does not run while the tab is closed — so the FIRST query after
28// reopening a tab routinely carries a stale token, and every call site would
29// otherwise see a hard failure and decide the user is signed out.
30//
31// Refresh once, retry once, transparently. Single-flighted, because a page
32// load fires a dozen queries at once and they would each try to refresh —
33// and Supabase rotates refresh tokens, so the second one to arrive spends a
34// token the first already used and kills the session outright.
35let _refreshInFlight = null;
36async function refreshAuthOnce() {
37  if (_refreshInFlight) return _refreshInFlight;
38  _refreshInFlight = (async () => {
39    try {
40      const { data, error } = await sb.auth.refreshSession();
41      if (error) { console.warn("[auth] refresh failed:", error.message); return null; }
42      return data?.session?.access_token || null;
43    } catch (e) {
44      console.warn("[auth] refresh threw:", e?.message || e);
45      return null;
46    } finally {
47      // Cleared on the next tick so concurrent callers share this result,
48      // but a genuinely later 401 can refresh again.
49      setTimeout(() => { _refreshInFlight = null; }, 0);
50    }
51  })();
52  return _refreshInFlight;
53}
54
55// Fluent PostgREST query builder. Mirrors sb.from() shape so call sites read
56// the same. Chain methods build up state; awaiting the builder runs the query.
57function pgQuery(table) {
58  let method = "GET";
59  let body = null;
60  let selectCols = "*";
61  let selectExplicit = false;
62  const filters = [];
63  let order = null;
64  let limit = null;
65  let range = null;
66  let single = false;
67  let maybeSingle = false;
68  let upsertOnConflict = null;
69
70  const builder = {
71    select(cols) { selectCols = cols || "*"; selectExplicit = true; return builder; },
72    insert(payload) { method = "POST"; body = JSON.stringify(payload); return builder; },
73    // Was missing entirely, and its absence was silent: db.from(x).upsert(...)
74    // threw "upsert is not a function", the exception escaped the async click
75    // handler that called it, and the optimistic UI stayed flipped while
76    // nothing was written. A control that lies about having saved.
77    //
78    // PostgREST spells upsert as POST + Prefer: resolution=merge-duplicates,
79    // with on_conflict naming the unique constraint to merge on.
80    upsert(payload, opts) {
81      method = "POST";
82      body = JSON.stringify(payload);
83      upsertOnConflict = (opts && opts.onConflict) || null;
84      return builder;
85    },
86    update(payload) { method = "PATCH"; body = JSON.stringify(payload); return builder; },
87    delete() { method = "DELETE"; return builder; },
88    eq(col, val) { filters.push([col, `eq.${val}`]); return builder; },
89    neq(col, val) { filters.push([col, `neq.${val}`]); return builder; },
90    in(col, vals) {
91      const list = vals.map(v => `"${String(v).replace(/"/g, '\\"')}"`).join(",");
92      filters.push([col, `in.(${list})`]);
93      return builder;
94    },
95    is(col, val) { filters.push([col, `is.${val}`]); return builder; },
96    not(col, op, val) { filters.push([col, `not.${op}.${val}`]); return builder; },
97    ilike(col, pattern) { filters.push([col, `ilike.${pattern}`]); return builder; },
98    or(exprs) { filters.push(["or", `(${exprs.join(",")})`]); return builder; },
99    order(col, opts = {}) { order = `${col}.${opts.ascending === false ? "desc" : "asc"}`; return builder; },
100    limit(n) { limit = n; return builder; },
101    range(from, to) { range = [from, to]; return builder; },
102    single() { single = true; return builder; },
103    maybeSingle() { maybeSingle = true; return builder; },
104    then(onFulfilled, onRejected) { return execute().then(onFulfilled, onRejected); },
105  };
106
107  async function execute() {
108    const params = new URLSearchParams();
109    if (method === "GET" || selectExplicit) params.set("select", selectCols);
110    for (const [col, expr] of filters) params.append(col, expr);
111    if (order) params.set("order", order);
112    if (limit != null) params.set("limit", String(limit));
113    // Names the unique constraint PostgREST merges on. Without it the POST is
114    // a plain insert and a second write raises a duplicate-key error.
115    if (upsertOnConflict) params.set("on_conflict", upsertOnConflict);
116
117    const headers = { "apikey": SUPABASE_KEY };
118    const tok = authToken();
119    if (tok) headers["Authorization"] = `Bearer ${tok}`;
120    if (body != null) headers["Content-Type"] = "application/json";
121    if (method !== "GET") {
122      const prefer = [selectExplicit ? "return=representation" : "return=minimal"];
123      if (upsertOnConflict !== null) prefer.push("resolution=merge-duplicates");
124      headers["Prefer"] = prefer.join(",");
125    }
126    if (range) { headers["Range-Unit"] = "items"; headers["Range"] = `${range[0]}-${range[1]}`; }
127    if (single) headers["Accept"] = "application/vnd.pgrst.object+json";
128
129    const url = `${SUPABASE_URL}/rest/v1/${table}?${params.toString()}`;
130    let res;
131    try {
132      res = await fetch(url, { method, headers, body });
133      // Expired token: refresh and replay once. Without this the caller sees
134      // a failure it cannot distinguish from "no data", and the app decides
135      // the user is signed out.
136      if (res.status === 401 && authToken()) {
137        const fresh = await refreshAuthOnce();
138        if (fresh) {
139          headers["Authorization"] = `Bearer ${fresh}`;
140          res = await fetch(url, { method, headers, body });
141        }
142      }
143    } catch (e) {
144      return { data: null, error: { message: e.message || "Network error" } };
145    }
146    if (res.status === 204) return { data: null, error: null };
147
148    const text = await res.text();
149    let parsed = null;
150    if (text) {
151      try { parsed = JSON.parse(text); } catch { parsed = text; }
152    }
153    if (!res.ok) {
154      const msg = parsed?.message || parsed?.error || res.statusText || `HTTP ${res.status}`;
155      return { data: null, error: { message: msg, status: res.status, details: parsed } };
156    }
157    if (single) return { data: parsed, error: null };
158    if (maybeSingle) {
159      const rows = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);
160      return { data: rows[0] ?? null, error: null };
161    }
162    return { data: parsed, error: null };
163  }
164
165  return builder;
166}
167
168const db = {
169  from(table) { return pgQuery(table); },
170  async rpc(name, params) {
171    const headers = { "apikey": SUPABASE_KEY, "Content-Type": "application/json" };
172    const tok = authToken();
173    if (tok) headers["Authorization"] = `Bearer ${tok}`;
174    let res;
175    try {
176      const call = () => fetch(`${SUPABASE_URL}/rest/v1/rpc/${name}`, {
177        method: "POST", headers, body: JSON.stringify(params || {}),
178      });
179      res = await call();
180      if (res.status === 401 && authToken()) {
181        const fresh = await refreshAuthOnce();
182        if (fresh) { headers["Authorization"] = `Bearer ${fresh}`; res = await call(); }
183      }
184    } catch (e) {
185      return { data: null, error: { message: e.message || "Network error" } };
186    }
187    if (res.status === 204) return { data: null, error: null };
188    const text = await res.text();
189    let parsed = null;
190    if (text) {
191      try { parsed = JSON.parse(text); } catch { parsed = text; }
192    }
193    if (!res.ok) {
194      const msg = parsed?.message || parsed?.error || `HTTP ${res.status}`;
195      return { data: null, error: { message: msg, status: res.status, details: parsed } };
196    }
197    return { data: parsed, error: null };
198  },
199};
200
201let currentUser = null;
202let currentProfile = null;
203// URL routing: every page maps to a real URL so links are shareable,
204// bookmarkable, and browser back/forward actually works. The routing is
205// best-effort — state still lives in module-level vars (currentTeamId,
206// currentProfile_*). URL is just a reflection we keep in sync here.
207
208const PUBLIC_PAGES = new Set(["landing", "install", "terms", "privacy", "privacy-profile-data"]);
209let pendingPostAuthPath = null;   // stashed when a logged-out visitor hits a private URL
210let pendingJoinCode = null;       // stashed from legacy #join/<code> during boot
211
212function pathForPage(page, extra) {
213  if (page === "dashboard") return "/home";
214  if (page === "team") {
215    const tid = typeof extra === "string" ? extra : extra?.teamId;
216    if (!tid) return "/home";
217    // Each workspace view is its own address, the way a list is — People
218    // included, so the three read alike in the bar. Bare /team/<id> still
219    // resolves to People, for old links and for landing on the workspace.
220    const view = (typeof extra === "object" && extra) ? extra.view : null;
221    const seg = view === "integrations" ? "configure"
222              : view === "team" ? "team"
223              : view === "people" ? "people"
224              : view === "notifications" ? "notifications"
225              : null;
226    return seg ? `/team/${encodeURIComponent(tid)}/${seg}` : `/team/${encodeURIComponent(tid)}`;
227  }
228  if (page === "profile") {
229    const tid = extra?.teamId || currentProfile_TeamId;
230    const pid = extra?.profileId || currentProfile_Id;
231    if (!tid || !pid) return "/home";
232    return `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`;
233  }
234  if (page === "review") {
235    const tid = extra?.teamId;
236    const iid = extra?.integrationId;
237    const eid = extra?.eventId;
238    if (!tid || !iid) return "/home";
239    const base = `/team/${encodeURIComponent(tid)}/review/${encodeURIComponent(iid)}`;
240    return eid ? `${base}/event/${encodeURIComponent(eid)}` : base;
241  }
242  if (page === "entries") {
243    const tid = extra?.teamId || currentTeamId;
244    if (!tid) return "/home";
245    let p = `/team/${encodeURIComponent(tid)}/entries`;
246    if (extra?.listId) {
247      p += `/${encodeURIComponent(extra.listId)}`;
248      if (extra.view === "outreach") p += "/outreach";   // pipeline is the default sub-view
249    }
250    return p;
251  }
252  if (page === "settings") return "/account";
253  if (page === "oauth-consent") return "/oauth/consent";
254  if (page === "install") return "/install";
255  if (page === "terms") return "/terms";
256  if (page === "privacy") return "/privacy";
257  if (page === "privacy-profile-data") return "/privacy/profile-data";
258  return "/";
259}
260
261function pageFromPath(rawPath) {
262  const path = rawPath.replace(/\/+$/, "") || "/";
263  if (path === "/" || path === "") return { page: "landing" };
264  if (path === "/home") return { page: "dashboard" };
265  // /dashboard was this page's address until it was renamed. Old links and
266  // bookmarks still land, and navigate() rewrites the bar to /home.
267  if (path === "/dashboard") return { page: "dashboard" };
268  if (path === "/account") return { page: "settings" };
269  // /settings was this page's address until it was renamed — and the team's
270  // own Configure view now owns the word "settings" in this app. Old links
271  // still land, and navigate() rewrites the bar to /account.
272  if (path === "/settings") return { page: "settings" };
273  if (path === "/oauth/consent") return { page: "oauth-consent" };
274  if (path === "/install") return { page: "install" };
275  if (path === "/terms") return { page: "terms" };
276  if (path === "/privacy") return { page: "privacy" };
277  if (path === "/privacy/profile-data") return { page: "privacy-profile-data" };
278  let m = path.match(/^\/team\/([^/]+)\/profile\/([^/]+)$/);
279  if (m) return { page: "profile", teamId: decodeURIComponent(m[1]), profileId: decodeURIComponent(m[2]) };
280  m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)\/event\/([^/]+)$/);
281  if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]), eventId: decodeURIComponent(m[3]) };
282  m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)$/);
283  if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]) };
284  m = path.match(/^\/team\/([^/]+)\/entries(?:\/([^/]+)(\/outreach)?)?$/);
285  if (m) return {
286    page: "entries",
287    teamId: decodeURIComponent(m[1]),
288    listId: m[2] ? decodeURIComponent(m[2]) : null,
289    view: m[3] ? "outreach" : (m[2] ? "pipeline" : null),
290  };
291  m = path.match(/^\/team\/([^/]+)\/(people|configure|team|notifications)$/);
292  if (m) return {
293    page: "team",
294    teamId: decodeURIComponent(m[1]),
295    view: m[2] === "configure" ? "integrations" : m[2],
296  };
297  m = path.match(/^\/team\/([^/]+)$/);
298  if (m) return { page: "team", teamId: decodeURIComponent(m[1]) };
299  return { page: "landing" };
300}
301
302// Legacy hash deep-links (#privacy, #terms, #profile/TID/PID, #join/CODE, #login)
303// shipped in the first version of the site — translate to paths so the rest of
304// the routing layer doesn't have to think about hashes. Runs synchronously before
305// auth resolves, which is fine because we only rewrite the URL; actual navigation
306// happens inside renderRoute / onAuth.
307function migrateLegacyHash() {
308  const h = window.location.hash;
309  if (!h) return;
310  if (h === "#privacy") { history.replaceState(null, "", "/privacy"); return; }
311  if (h === "#terms")   { history.replaceState(null, "", "/terms");   return; }
312  if (h.startsWith("#profile/")) {
313    const parts = h.slice("#profile/".length).split("/");
314    const tid = decodeURIComponent(parts[0] || "");
315    const pid = decodeURIComponent(parts[1] || "");
316    if (tid && pid) {
317      history.replaceState(null, "", `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`);
318    }
319    return;
320  }
321  if (h.startsWith("#join/")) {
322    pendingJoinCode = h.slice("#join/".length);
323    history.replaceState(null, "", window.location.pathname);
324    return;
325  }
326  // #login and OAuth hash params (#access_token=...) are left for the existing
327  // handlers at onAuthStateChange / line 5717 to consume.
328}
329
330let _settingsReturn = { page: "dashboard" };
331function goBackFromSettings() {
332  if (_settingsReturn && _settingsReturn.page === "team" && _settingsReturn.teamId) navigate("team", _settingsReturn.teamId);
333  else navigate("dashboard");
334}
335
336function navigate(page, extra, opts = {}) {
337  // Reveal the body — the inline script in <head> hid it while we
338  // waited for Supabase to resolve the stored session. Once we've
339  // picked a page to show, no more flicker risk.
340  document.documentElement.classList.remove("auth-pending");
341  // Remember where Settings was opened from so its back link returns there:
342  // a team/workspace page → that team; anywhere else → the dashboard (home).
343  if (page === "settings") {
344    const prevId = document.querySelector(".page.active")?.id || "";
345    _settingsReturn = ((prevId === "page-team" || prevId === "page-entries" || prevId === "page-profile") && typeof currentTeamId !== "undefined" && currentTeamId)
346      ? { page: "team", teamId: currentTeamId }
347      : { page: "dashboard" };
348  }
349  document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
350  const target = document.getElementById(`page-${page}`);
351  if (target) target.classList.add("active");
352
353  document.body.classList.toggle("on-profile", page === "profile");
354  // Clear CSV import status when leaving the team page
355  const csvStatus = document.getElementById("csv-status");
356  if (csvStatus) csvStatus.innerHTML = "";
357
358  // Sync the URL to reflect the page we just rendered. For profile we synthesize
359  // the extra from the globals openProfile already set (keeps every existing
360  // caller of navigate("profile") working unchanged).
361  if (!opts.skipHistory) {
362    let extraForPath = extra;
363    if (page === "profile" && !extra) {
364      extraForPath = { teamId: currentProfile_TeamId, profileId: currentProfile_Id };
365    }
366    const targetPath = pathForPage(page, extraForPath);
367    const currentPath = window.location.pathname + window.location.search;
368    if (targetPath !== currentPath) {
369      if (opts.replace) history.replaceState({ page }, "", targetPath);
370      else              history.pushState({ page }, "", targetPath);
371    }
372  }
373
374  if (page === "dashboard") loadDashboard();
375  if (page === "team" && extra) {
376    if (typeof extra === "object" && extra.view && typeof _pendingTeamView !== "undefined") {
377      _pendingTeamView = extra.view;
378      // Switch the view NOW, not when the team data lands. loadTeamDetail is
379      // several round trips long and only set the view at the end of it, so
380      // #page-team came up showing whichever view you were last on — press
381      // People from Configure and you watched Configure for a second first.
382      if (typeof setTeamView === "function") setTeamView(extra.view, { syncUrl: false });
383    }
384    loadTeamDetail(typeof extra === "string" ? extra : extra.teamId);
385  }
386  if (page === "settings") loadSettings();
387  if (page === "oauth-consent") loadOAuthConsent();
388  if (page === "profile") loadProfile();
389  if (page === "review") {
390    if (extra && typeof extra === "object") {
391      if (extra.teamId) currentReview_TeamId = extra.teamId;
392      if (extra.integrationId) currentReview_IntegrationId = extra.integrationId;
393      currentReview_EventId = extra.eventId || null;
394    }
395    loadReview();
396  }
397  if (page === "entries" && !opts.skipLoad) {
398    const tid = (extra && typeof extra === "object" ? extra.teamId : extra) || currentTeamId;
399    const lid = (extra && typeof extra === "object") ? extra.listId : null;
400    const view = (extra && typeof extra === "object") ? extra.view : null;
401    loadEntriesPage(tid, lid, view);
402  }
403  // Show/hide the workspace sidebar (team + pipeline pages) and offset content.
404  if (typeof updateWorkspaceChrome === "function") updateWorkspaceChrome(page);
405  window.scrollTo(0, 0);
406}
407
408// Resolve the current URL into a rendered page. Called on boot, on popstate,
409// and after auth completes (so a logged-in deep-link lands where it should).
410function renderRoute() {
411  const parsed = pageFromPath(window.location.pathname);
412  if (PUBLIC_PAGES.has(parsed.page)) {
413    navigate(parsed.page, undefined, { skipHistory: true });
414    return;
415  }
416  if (!currentUser) {
417    // Stash intended destination so onAuth can restore it after sign-in.
418    // Skip `/` — that just means the user was on the home/landing page.
419    // Include the query string: extension deep-links carry ?name=<...> and
420    // losing it would blank out the profile page's name-based matching.
421    if (window.location.pathname !== "/") pendingPostAuthPath = window.location.pathname + window.location.search;
422    // Land the user on `/` (the canonical sign-in URL) — the login page
423    // shouldn't show under a /home or /team/X URL. Drop skipHistory
424    // so navigate() updates the address bar via replaceState.
425    navigate("landing", undefined, { replace: true });
426    return;
427  }
428  if (parsed.page === "team") {
429    if (parsed.view && typeof _pendingTeamView !== "undefined") _pendingTeamView = parsed.view;
430    navigate("team", { teamId: parsed.teamId, view: parsed.view || null }, { skipHistory: true });
431    // Returning from Stripe Checkout lands on /team/<id>?billing=…
432    // skipHistory above leaves the query string intact for this to read.
433    if (typeof handleBillingReturn === "function") handleBillingReturn(parsed.teamId);
434    return;
435  }
436  if (parsed.page === "profile") {
437    currentProfile_TeamId = parsed.teamId;
438    currentProfile_Id = parsed.profileId;
439    // Deep-links from the extension carry ?name=<rendered LinkedIn name> so the
440    // gsheet RPC and Luma name-matching have a display name to work with.
441    // Without it those sections render empty even when matches exist.
442    const nameParam = new URLSearchParams(window.location.search).get("name");
443    currentProfile_Name = nameParam ? nameParam.trim() || null : null;
444    // No people-list row context on a direct URL — clear so a stale
445    // preview from a previous click doesn't leak onto this profile.
446    currentProfile_ListRow = null;
447    navigate("profile", undefined, { skipHistory: true });
448    return;
449  }
450  if (parsed.page === "review") {
451    currentReview_TeamId = parsed.teamId;
452    currentReview_IntegrationId = parsed.integrationId;
453    currentReview_EventId = parsed.eventId || null;
454    navigate("review", undefined, { skipHistory: true });
455    return;
456  }
457  if (parsed.page === "entries") {
458    navigate("entries", { teamId: parsed.teamId, listId: parsed.listId, view: parsed.view }, { skipHistory: true });
459    return;
460  }
461  navigate(parsed.page, undefined, { skipHistory: true });
462}
463
464window.addEventListener("popstate", () => { renderRoute(); });
465
466// The name that gets written into `who` and shown to teammates on every
467// outreach, view, comment and marking.
468//
469// NEVER the email address. That was the last fallback everywhere — a dozen
470// copies of `full_name || linkedin_name || currentUser.email` — so anyone
471// whose profile had no name broadcast their address to the whole team on
472// every profile they opened. An email is not a display name; it is contact
473// details, and the person it belongs to did not choose to publish it here.
474//
475// user_metadata is worth trying before giving up: Google and LinkedIn both
476// put a real name there at sign-up even when the profile row has not caught
477// up yet.
478function myWhoName() {
479  const meta = (typeof currentUser !== "undefined" && currentUser && currentUser.user_metadata) || {};
480  const prof = (typeof currentProfile !== "undefined" && currentProfile) || {};
481  return (
482    (prof.full_name || "").trim() ||
483    (prof.linkedin_name || "").trim() ||
484    (meta.full_name || "").trim() ||
485    (meta.name || "").trim() ||
486    "Unknown"
487  );
488}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.