PageSourceSearch

https://tpc.institute/lib/peer-review-api.js?v=20260915

js tpc.institute collected 2026-10-02 01:57:01 UTC 41,176 bytes, 894 lines download raw bytes

1// lib/peer-review-api.js
2//
3// Thin wrapper around Supabase for the peer-review system. Loads supabase-js
4// from a CDN; the rest of the site doesn't pull this script unless it's a
5// peer-review page.
6//
7// Usage on a page that needs it:
8//   <script src="../lib/config.js"></script>
9//   <script type="module" src="../lib/peer-review-api.js"></script>
10//   …then window.PCI.api.{...} is available.
11//
12// If config.js is not filled in (SUPABASE_URL empty), every API call resolves
13// to { configured: false } so the UI can show "Not yet configured" messages.
14
15(function () {
16  const CFG = window.__PCI_CONFIG__ || {};
17  const configured = !!(CFG.SUPABASE_URL && CFG.SUPABASE_ANON_KEY);
18
19  // Load supabase-js once. We append a <script> tag; if it's already loaded,
20  // resolve immediately. We try jsDelivr first (most reliable), with unpkg as
21  // a fallback if the first request fails.
22  function loadSupabaseLib() {
23    return new Promise((resolve, reject) => {
24      if (window.supabase && window.supabase.createClient) return resolve(window.supabase);
25
26      const SOURCES = [
27        "https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2",
28        "https://unpkg.com/@supabase/supabase-js@2/dist/umd/supabase.js",
29        "https://esm.sh/@supabase/supabase-js@2?bundle",
30      ];
31
32      function tryNext(i) {
33        if (i >= SOURCES.length) return reject(new Error("Failed to load supabase-js from any CDN"));
34        const s = document.createElement("script");
35        s.src = SOURCES[i];
36        s.crossOrigin = "anonymous";
37        s.onload  = () => {
38          if (window.supabase && window.supabase.createClient) resolve(window.supabase);
39          else tryNext(i + 1);
40        };
41        s.onerror = () => tryNext(i + 1);
42        document.head.appendChild(s);
43      }
44      tryNext(0);
45    });
46  }
47
48  let _client = null;
49  async function client() {
50    if (!configured) return null;
51    if (_client) return _client;
52    const sb = await loadSupabaseLib();
53    _client = sb.createClient(CFG.SUPABASE_URL, CFG.SUPABASE_ANON_KEY, {
54      auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true },
55    });
56    return _client;
57  }
58
59  // ─────────────────────── auth ───────────────────────
60  async function getSession() {
61    const c = await client(); if (!c) return null;
62    const { data } = await c.auth.getSession();
63    return data.session;
64  }
65  async function getCurrentReviewer() {
66    const c = await client(); if (!c) return null;
67    const session = (await c.auth.getSession()).data.session;
68    if (!session) return null;
69    const { data, error } = await c.from("reviewers").select("*").eq("user_id", session.user.id).maybeSingle();
70    if (error) throw error;
71    return data;
72  }
73  async function signInWithMagicLink(email, captchaToken) {
74    const c = await client(); if (!c) return { configured: false };
75    const redirectTo = CFG.AUTH_REDIRECT_TO ||
76      new URL("pages/peer-review.html", location.origin).toString();
77    const options = { emailRedirectTo: redirectTo };
78    if (captchaToken) options.captchaToken = captchaToken;
79    const { error } = await c.auth.signInWithOtp({
80      email,
81      options,
82    });
83    if (error) throw error;
84    return { configured: true, sent: true };
85  }
86  async function signOut() {
87    const c = await client(); if (!c) return;
88    await c.auth.signOut();
89  }
90
91  // ─────────────────────── public flows ───────────────────────
92
93  // Claim an invitation code; returns assignment + paper id.
94  async function claimInvitation({ code, email }) {
95    const c = await client(); if (!c) return { configured: false };
96    const norm = String(code || "").trim().toUpperCase();
97    const { data, error } = await c.rpc("claim_invitation", { p_code: norm, p_email: email });
98    if (error) throw error;
99    // RPC returns rows; first row is the result. Schema uses out_-prefixed
100    // column names to avoid an ambiguity with invitations.paper_id; map them
101    // back to the friendlier names callers expect.
102    const row = Array.isArray(data) ? data[0] : data;
103    if (!row) return { configured: true };
104    return {
105      configured: true,
106      assignment_id: row.out_assignment_id ?? row.assignment_id,
107      paper_id:      row.out_paper_id      ?? row.paper_id,
108      paper_title:   row.out_paper_title   ?? row.paper_title,
109    };
110  }
111
112  // Public Apply form.
113  async function applyAsReviewer(form) {
114    const c = await client(); if (!c) return { configured: false };
115    const { error } = await c.from("applications").insert({
116      name:          form.name,
117      email:         form.email,
118      affiliation:   form.affiliation,
119      expertise:     form.expertise || [],
120      bio:           form.bio,
121      link:          form.link,
122      agreed_to_coc: !!form.agreed_to_coc,
123      coi:           form.coi || null,
124    });
125    if (error) throw error;
126    return { configured: true, sent: true };
127  }
128
129  // ─────────────────────── reviewer dashboard ───────────────────────
130
131  async function listMyAssignments() {
132    const c = await client();
133    if (!c) return { configured: false };
134    const session = await c.auth.getSession();
135    if (!session.data.session) return { configured: true, signedIn: false };
136    const SELECT_WITH_SEEN = "id, round, status, due_at, invited_at, accepted_at, submitted_at, draft_seen_at, paper:papers(id, title, kind, number, lead_author, current_round)";
137    const SELECT_NO_SEEN    = "id, round, status, due_at, invited_at, accepted_at, submitted_at, paper:papers(id, title, kind, number, lead_author, current_round)";
138    let { data, error } = await c
139      .from("assignments")
140      .select(SELECT_WITH_SEEN)
141      .order("invited_at", { ascending: false });
142    // Graceful fallback if the draft-notifications migration hasn't been run
143    // yet: the draft_seen_at column won't exist. Re-query without it so the
144    // dashboard still loads (the "new draft" pill just won't appear until the
145    // migration is applied).
146    if (error && /draft_seen_at/.test(error.message || "")) {
147      console.warn("[PCI] assignments.draft_seen_at missing — run the draft-notifications migration. Falling back.");
148      ({ data, error } = await c
149        .from("assignments")
150        .select(SELECT_NO_SEEN)
151        .order("invited_at", { ascending: false }));
152    }
153    if (error) throw error;
154    return { configured: true, signedIn: true, assignments: data || [] };
155  }
156
157  // For each paper id, returns metadata for the single newest draft file in
158  // storage (or null if the paper has no drafts). Used by the reviewer
159  // dashboard to flag "a newer draft was uploaded after you last opened one."
160  // Reads are RLS-gated by the storage policies — a reviewer only sees the
161  // drafts folder of a paper they have an assignment on.
162  async function getLatestDraftMetas(paperIds) {
163    const c = await client(); if (!c || !paperIds || !paperIds.length) return {};
164    const out = {};
165    await Promise.all(paperIds.map(async (id) => {
166      try {
167        const { data, error } = await c.storage.from("drafts").list(id, {
168          limit: 100, sortBy: { column: "updated_at", order: "desc" },
169        });
170        if (error || !data || !data.length) { out[id] = null; return; }
171        // storage list returns updated_at and created_at; prefer the most
172        // recent of the two so we don't miss freshly-uploaded files.
173        const sorted = [...data].sort((a, b) => {
174          const at = (b.updated_at || b.created_at || "").localeCompare(a.updated_at || a.created_at || "");
175          return at;
176        });
177        const top = sorted[0];
178        out[id] = { name: top.name, updated_at: top.updated_at || top.created_at || null };
179      } catch (e) { out[id] = null; }
180    }));
181    return out;
182  }
183
184  // Mark the latest draft as seen for this assignment. Called when the
185  // reviewer successfully opens the draft from their dashboard.
186  async function markDraftSeen(assignmentId) {
187    const c = await client(); if (!c) return;
188    const { error } = await c.from("assignments")
189      .update({ draft_seen_at: new Date().toISOString() })
190      .eq("id", assignmentId);
191    if (error) throw error;
192  }
193
194  async function acceptAssignment(assignmentId) {
195    const c = await client(); if (!c) return;
196    const { error } = await c.from("assignments")
197      .update({ status: "accepted", accepted_at: new Date().toISOString() })
198      .eq("id", assignmentId);
199    if (error) throw error;
200  }
201  async function declineAssignment(assignmentId) {
202    const c = await client(); if (!c) return;
203    const { error } = await c.from("assignments")
204      .update({ status: "declined", declined_at: new Date().toISOString() })
205      .eq("id", assignmentId);
206    if (error) throw error;
207  }
208
209  async function getDraftDownloadUrl(paperId, round) {
210    const c = await client(); if (!c) return null;
211    const { data: list, error: lerr } = await c.storage.from("drafts").list(paperId);
212    if (lerr) throw lerr;
213    if (!list || list.length === 0) return null;
214    // Round-aware match: prefer a file whose name includes "round{N}" or
215    // "-r{N}" or "_r{N}"; fall back to the newest file if no match.
216    const wantRound = round != null ? String(round) : null;
217    const matchers = wantRound ? [
218      new RegExp(`(^|[-_])round[-_]?${wantRound}([-_\\.]|$)`, "i"),
219      new RegExp(`(^|[-_])r${wantRound}([-_\\.]|$)`, "i"),
220    ] : [];
221    let picked = null;
222    for (const re of matchers) {
223      picked = list.find(f => re.test(f.name));
224      if (picked) break;
225    }
226    if (!picked) {
227      picked = [...list].sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""))[0];
228    }
229    const path = `${paperId}/${picked.name}`;
230    const { data, error } = await c.storage.from("drafts").createSignedUrl(path, 60 * 60);
231    if (error) throw error;
232    return data.signedUrl;
233  }
234
235  // List all draft files for a paper (admin / assigned-reviewer scoped via RLS).
236  async function listDrafts(paperId) {
237    const c = await client(); if (!c) return [];
238    const { data, error } = await c.storage.from("drafts").list(paperId, {
239      limit: 100, sortBy: { column: "updated_at", order: "desc" },
240    });
241    if (error) throw error;
242    return data || [];
243  }
244
245  // Upload a draft file. Admin only by RLS.
246  async function uploadDraft(paperId, file, round) {
247    const c = await client(); if (!c) return;
248    // Sanitize filename and bake the round into it so future readers can pick
249    // the right version. Format: <slug>-round<N>-<timestamp>.<ext>
250    const safeBase = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
251    const dot = safeBase.lastIndexOf(".");
252    const stem = dot > 0 ? safeBase.slice(0, dot) : safeBase;
253    const ext  = dot > 0 ? safeBase.slice(dot) : "";
254    const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
255    const r = round != null ? `round${round}-` : "";
256    const path = `${paperId}/${stem}-${r}${stamp}${ext}`;
257    const { data, error } = await c.storage.from("drafts").upload(path, file, {
258      cacheControl: "3600", upsert: false,
259      contentType: file.type || "application/octet-stream",
260    });
261    if (error) throw error;
262    return data;
263  }
264
265  // Get a signed URL for a specific draft file (admin or assigned reviewer).
266  async function getSignedUrlForDraft(paperId, filename, expiresInSec = 3600) {
267    const c = await client(); if (!c) return null;
268    const { data, error } = await c.storage.from("drafts")
269      .createSignedUrl(`${paperId}/${filename}`, expiresInSec);
270    if (error) throw error;
271    return data.signedUrl;
272  }
273
274  // Delete a draft (admin only).
275  async function deleteDraft(paperId, name) {
276    const c = await client(); if (!c) return;
277    const { error } = await c.storage.from("drafts").remove([`${paperId}/${name}`]);
278    if (error) throw error;
279  }
280
281  // ─────────────────── supporting materials (published papers) ───────────────────
282  // Public-facing supporting documentation stored in the 'materials' bucket as
283  // materials/<paper_id>/<filename>. RLS makes downloads public only while the
284  // paper is in the 'published' state; admins can always read and write.
285
286  // Storage's list() returns a single phantom placeholder row for an empty
287  // prefix — drop rows that have no real object id so callers see [] not [ghost].
288  function realFiles(rows) {
289    return (rows || []).filter(f => f && f.name && f.id !== null && f.id !== undefined);
290  }
291
292  // List one paper's materials (admin always; public only when published).
293  async function listMaterials(paperId) {
294    const c = await client(); if (!c) return [];
295    const { data, error } = await c.storage.from("materials").list(paperId, {
296      limit: 100, sortBy: { column: "name", order: "asc" },
297    });
298    if (error) throw error;
299    return realFiles(data);
300  }
301
302  // Batch variant for the Research page: returns { paperId: [files…] }.
303  // Non-fatal per paper — a failed lookup yields an empty list, not a throw.
304  async function listMaterialsForPapers(paperIds) {
305    const c = await client(); if (!c || !paperIds || !paperIds.length) return {};
306    const out = {};
307    await Promise.all(paperIds.map(async (id) => {
308      try {
309        const { data, error } = await c.storage.from("materials").list(id, {
310          limit: 100, sortBy: { column: "name", order: "asc" },
311        });
312        out[id] = error ? [] : realFiles(data);
313      } catch (e) { out[id] = []; }
314    }));
315    return out;
316  }
317
318  // Upload a supporting-materials file (admin only by RLS). Preserves the
319  // original (sanitized) filename so it reads as a human label; re-uploading
320  // the same name replaces it in place.
321  async function uploadMaterial(paperId, file) {
322    const c = await client(); if (!c) return;
323    const safeBase = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
324    const path = `${paperId}/${safeBase || "file"}`;
325    const { data, error } = await c.storage.from("materials").upload(path, file, {
326      cacheControl: "3600", upsert: true,
327      contentType: file.type || "application/octet-stream",
328    });
329    if (error) throw error;
330    return data;
331  }
332
333  // Build the canonical public filename for a published manuscript:
334  //   PCI-<WP|Brief>-<NN>-<YYYY>-<title-slug>.<ext>
335  // e.g. PCI-WP-01-2026-malicious-chrome-extensions.pdf
336  // Centralized here so the whole catalog stays consistent and matches the
337  // citation. The editor sees this as an editable default at publish time.
338  function paperPublicFilename(paper, year, ext) {
339    const kind = paper && paper.kind === "brief" ? "Brief" : "WP";
340    const digits = String((paper && paper.number) || "").replace(/[^0-9]/g, "");
341    const num = (digits || "0").padStart(2, "0");
342    const yr = year || new Date().getFullYear();
343    const slug = String((paper && paper.title) || "paper")
344      .toLowerCase()
345      .replace(/[^a-z0-9]+/g, "-")
346      .replace(/^-+|-+$/g, "")
347      .split("-").filter(Boolean).slice(0, 6).join("-") || "paper";
348    const e = String(ext || "pdf").replace(/^\.+/, "").toLowerCase() || "pdf";
349    return `PCI-${kind}-${num}-${yr}-${slug}.${e}`;
350  }
351
352  // Copy a draft file into the public materials bucket under destName. Admin
353  // only (both buckets are admin-write by RLS). upsert overwrites a prior copy
354  // of the same name, so re-publishing is idempotent. Used at publish time to
355  // post the manuscript publicly without the editor re-uploading it.
356  async function copyDraftToMaterials(paperId, draftName, destName) {
357    const c = await client(); if (!c) throw new Error("Not configured.");
358    const { data: blob, error: derr } = await c.storage.from("drafts")
359      .download(`${paperId}/${draftName}`);
360    if (derr) throw derr;
361    const { error: uerr } = await c.storage.from("materials")
362      .upload(`${paperId}/${destName}`, blob, {
363        cacheControl: "3600", upsert: true,
364        contentType: blob.type || "application/pdf",
365      });
366    if (uerr) throw uerr;
367    return destName;
368  }
369
370  // Short-lived signed URL for one materials file. Works for anonymous visitors
371  // when the paper is published (RLS), and always for admins.
372  async function getMaterialUrl(paperId, name, expiresInSec = 3600) {
373    const c = await client(); if (!c) return null;
374    const { data, error } = await c.storage.from("materials")
375      .createSignedUrl(`${paperId}/${name}`, expiresInSec);
376    if (error) throw error;
377    return data.signedUrl;
378  }
379
380  // Delete a materials file (admin only).
381  async function deleteMaterial(paperId, name) {
382    const c = await client(); if (!c) return;
383    const { error } = await c.storage.from("materials").remove([`${paperId}/${name}`]);
384    if (error) throw error;
385  }
386
387  // ─────────────────── rich media (published papers) ───────────────────
388  // Infographics / videos / podcast assets live in the 'paper-media' bucket as
389  // paper-media/<paper_id>/<filename>. The ORDER and metadata live in the
390  // papers.media jsonb manifest (edited via updatePaper). RLS makes downloads
391  // public only while the paper is 'published'; admins can always read + write.
392
393  // Upload a media asset (admin only by RLS). Returns the stored (sanitized)
394  // filename — the caller records it in the media manifest. Re-uploading the
395  // same name replaces it in place.
396  async function uploadPaperMedia(paperId, file, baseNameHint) {
397    const c = await client(); if (!c) throw new Error("Not configured.");
398    const raw = baseNameHint || file.name || "file";
399    const safe = String(raw).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "file";
400    const path = `${paperId}/${safe}`;
401    // Force text/html for .html/.htm so the stored object renders (and the
402    // "Open full size" link works) instead of being served as text/plain.
403    let ctype = file.type || "application/octet-stream";
404    if (/\.html?$/i.test(safe)) ctype = "text/html";
405    const { error } = await c.storage.from("paper-media").upload(path, file, {
406      cacheControl: "3600", upsert: true,
407      contentType: ctype,
408    });
409    if (error) throw error;
410    return safe;
411  }
412
413  // Short-lived signed URL for one media asset. Works for anonymous visitors
414  // when the paper is published (RLS), and always for admins.
415  async function getPaperMediaUrl(paperId, name, expiresInSec = 3600) {
416    const c = await client(); if (!c || !name) return null;
417    const { data, error } = await c.storage.from("paper-media")
418      .createSignedUrl(`${paperId}/${name}`, expiresInSec);
419    if (error) return null;
420    return data.signedUrl;
421  }
422
423  // Delete a media asset (admin only).
424  async function deletePaperMedia(paperId, name) {
425    const c = await client(); if (!c || !name) return;
426    const { error } = await c.storage.from("paper-media").remove([`${paperId}/${name}`]);
427    if (error) throw error;
428  }
429
430  // ─────────────────────── review form ───────────────────────
431
432  async function saveDraft(assignmentId, payload) {
433    const c = await client(); if (!c) return { configured: false };
434    const { data, error } = await c.rpc("save_review_draft", {
435      p_assignment: assignmentId,
436      p_scores:     payload.scores || {},
437      p_notes:      payload.notes  || {},
438      p_rec:        payload.recommendation || null,
439      p_coi:        payload.coi || {},
440    });
441    if (error) throw error;
442    return { configured: true, id: data };
443  }
444  async function submitReview(assignmentId) {
445    const c = await client(); if (!c) return { configured: false };
446    const { error } = await c.rpc("submit_review", { p_assignment: assignmentId });
447    if (error) throw error;
448    return { configured: true, submitted: true };
449  }
450  async function getReviewDraft(assignmentId) {
451    const c = await client(); if (!c) return null;
452    const { data, error } = await c.from("reviews")
453      .select("*").eq("assignment_id", assignmentId).maybeSingle();
454    if (error) throw error;
455    return data;
456  }
457
458  // Admin-only: full review, joined to assignment + paper + reviewer.
459  async function getFullReview(assignmentId) {
460    const c = await client(); if (!c) return null;
461    const { data, error } = await c.from("reviews")
462      .select("*, assignment:assignments(*, paper:papers(id, title, kind, number, current_round), reviewer:reviewers(id, name, email, affiliation))")
463      .eq("assignment_id", assignmentId).maybeSingle();
464    if (error) throw error;
465    return data;
466  }
467
468  // Update a reviewer row (admin only).
469  async function updateReviewer(id, patch) {
470    const c = await client(); if (!c) return;
471    const allowed = ["name", "affiliation", "expertise", "bio", "link", "status", "is_admin", "coi_notes"];
472    const clean = {};
473    for (const k of allowed) if (k in patch) clean[k] = patch[k];
474    const { data, error } = await c.from("reviewers").update(clean).eq("id", id).select("*").single();
475    if (error) throw error;
476    return data;
477  }
478
479  // Delete a reviewer (admin only). Cascades to their assignments and reviews
480  // via the foreign-key on delete cascade in the schema.
481  async function deleteReviewer(id) {
482    const c = await client(); if (!c) return;
483    const { error } = await c.from("reviewers").delete().eq("id", id);
484    if (error) throw error;
485  }
486
487  // ─────────────────────── admin (editor) ───────────────────────
488
489  async function listAllPapers() {
490    const c = await client(); if (!c) return [];
491    const { data, error } = await c.from("papers").select("*").order("created_at", { ascending: false });
492    if (error) throw error;
493    return data || [];
494  }
495
496  // Public-facing list: published + in_review only (RLS enforces).
497  // No auth required. Used by the homepage and the Research page.
498  async function listPublicPapers() {
499    if (!configured) return { configured: false, papers: [] };
500    const c = await client();
501    const { data, error } = await c.from("papers")
502      .select("id, title, kind, number, lead_author, authors, abstract, abstract_public, summary, tags, state, visibility, current_roun
502d, published_at, expected_at, created_at, links")
503      .in("state", ["published", "in_review"])
504      .order("published_at", { ascending: false, nullsFirst: false });
505    if (error) throw error;
506    // Defence in depth: never surface a 'limited' (embargoed) paper in the
507    // public catalog even if an admin happens to be signed in (RLS would let
508    // them read it). The only way to view a limited paper is its access link.
509    const visible = (data || []).filter(p => p.visibility !== "limited")
510      // Respect the abstract visibility toggle: blank the abstract in the public
511      // payload when an editor has hidden it (the text stays in the DB / editor).
512      .map(p => p.abstract_public === false ? { ...p, abstract: "" } : p);
513    // Sort: published first (by published_at desc), then in_review (by created_at desc).
514    const pub = visible.filter(p => p.state === "published")
515      .sort((a, b) => (b.published_at || "").localeCompare(a.published_at || ""));
516    const inrev = visible.filter(p => p.state === "in_review")
517      .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
518    // Drafting papers are served separately via listForthcoming() (a safe-columns
519    // view that includes embargoed drafts with metadata only).
520    return { configured: true, papers: [...pub, ...inrev] };
521  }
522
523  // Public "Forthcoming" list: drafting papers (incl. embargoed) with SAFE
524  // metadata only — title, author, expected date. Reads the forthcoming_papers
525  // view, which never exposes abstract/summary/keys/PDF. No auth required.
526  async function listForthcoming() {
527    if (!configured) return [];
528    const c = await client(); if (!c) return [];
529    const { data, error } = await c.from("forthcoming_papers")
530      .select("id, title, kind, number, lead_author, authors, state, visibility, expected_at, created_at");
531    if (error) throw error;
532    // Soonest expected first; undated last, newest-created as tiebreak.
533    return (data || []).sort((a, b) =>
534      (a.expected_at || "9999").localeCompare(b.expected_at || "9999")
535      || (b.created_at || "").localeCompare(a.created_at || ""));
536  }
537
538  // Fetch ONE public (non-limited) published paper by id, for the paper page's
539  // public path. Returns null if it isn't anon-readable (RLS) — the page then
540  // falls back to the key-gated /api/paper endpoint.
541  async function getPublicPaperById(id) {
542    if (!configured || !id) return null;
543    const c = await client(); if (!c) return null;
544    const { data, error } = await c.from("papers")
545      .select("id, title, kind, number, lead_author, authors, abstract, summary, tags, state, visibility, published_at, created_at, media, links")
546      .eq("id", id).eq("state", "published").maybeSingle();
547    if (error) return null;
548    if (!data || data.visibility === "limited") return null;
549    return data;
550  }
551
552  // Admin/editor preview: fetch a paper by id REGARDLESS of state, so an editor
553  // can preview the public page before publishing. Relies on RLS — succeeds only
554  // for an authenticated admin (anon callers get null). Used by paper.html's
555  // ?preview=1 path.
556  async function getPaperByIdAdmin(id) {
557    if (!configured || !id) return null;
558    const c = await client(); if (!c) return null;
559    const session = (await c.auth.getSession()).data.session;
560    if (!session) return null;
561    const { data, error } = await c.from("papers")
562      .select("id, title, kind, number, lead_author, authors, abstract, summary, tags, state, visibility, published_at, created_at, media, links")
563      .eq("id", id).maybeSingle();
564    if (error) return null;
565    return data || null;
566  }
567
568  // ─────────────────────── AI reviews (admin-only) ───────────────────────
569  // Editor-managed reviews produced by running a model externally, then pasting
570  // the text in. Admin-only via RLS. Hybrid content: freeform body + optional
571  // A1–A4 scores + optional recommendation. Several may exist per paper.
572  async function listAiReviews(paperId) {
573    if (!configured) return [];
574    const c = await client(); if (!c) return [];
575    let q = c.from("ai_reviews").select("*").order("created_at", { ascending: false });
576    if (paperId) q = q.eq("paper_id", paperId);
577    const { data, error } = await q;
578    if (error) throw error;
579    return data || [];
580  }
581  async function createAiReview(paperId, payload) {
582    const c = await client(); if (!c) return { configured: false };
583    const row = {
584      paper_id: paperId,
585      round: payload.round || 1,
586      model_name: (payload.model_name || "").trim() || "AI model",
587      reviewer_label: payload.reviewer_label || null,
588      run_by: payload.run_by || null,
589      prompt: payload.prompt || null,
590      body: payload.body || "",
591      scores: payload.scores || {},
592      recommendation: payload.recommendation || null,
593      include_in_author_export: !!payload.include_in_author_export,
594      visibility: payload.visibility || {},
595    };
596    const { data, error } = await c.from("ai_reviews").insert(row).select().single();
597    if (error) throw error;
598    return { configured: true, review: data };
599  }
600  async function updateAiReview(id, patch) {
601    const c = await client(); if (!c) return { configured: false };
602    const allowed = ["round", "model_name", "reviewer_label", "run_by", "prompt", "body", "scores", "recommendation", "include_in_author_export", "visibility"];
603    const clean = {};
604    for (const k of allowed) if (k in patch) clean[k] = patch[k];
605    const { data, error } = await c.from("ai_reviews").update(clean).eq("id", id).select().single();
606    if (error) throw error;
607    return { configured: true, review: data };
608  }
609  async function deleteAiReview(id) {
610    const c = await client(); if (!c) return { configured: false };
611    const { error } = await c.from("ai_reviews").delete().eq("id", id);
612    if (error) throw error;
613    return { configured: true, deleted: true };
614  }
615
616  // PUBLIC read (no auth): AI reviews an editor has published, via the
617  // public_ai_reviews safe-columns view. Per-part visibility is enforced by the
618  // view (hidden parts come back null/{}), and only published (non-limited)
619  // papers appear. Returns [] if the view isn't deployed yet.
620  async function listPublicAiReviews(paperId) {
621    if (!configured || !paperId) return [];
622    const c = await client(); if (!c) return [];
623    const { data, error } = await c.from("public_ai_reviews")
624      .select("*").eq("paper_id", paperId).order("created_at", { ascending: true });
625    if (error) return [];
626    return data || [];
627  }
628
629  // Admin read of the download audit log. Optionally scoped to one paper.
630  // RLS makes this admins-only; anon/auth callers get [].
631  async function listDownloadEvents(paperId, limit = 500) {
632    if (!configured) return [];
633    const c = await client(); if (!c) return [];
634    let q = c.from("download_events")
635      .select("id, created_at, paper_id, kind, file_name, access_path, recipient, org, asn, country, ua_family, ip, user_agent")
636      .order("created_at", { ascending: false })
637      .limit(limit);
638    if (paperId) q = q.eq("paper_id", paperId);
639    const { data, error } = await q;
640    if (error) return [];
641    return data || [];
642  }
643
644  // ── Editable site content (homepage + News page) ──
645  // Public reads — RLS returns only live + due rows (anon-safe). Each returns
646  // [] / {} when Supabase isn't configured so callers fall back to their seed.
647  async function listSiteNews(category) {
648    if (!configured) return [];
649    const c = await client(); if (!c) return [];
650    let q = c.from("site_news")
651      .select("id, category, date_label, eyebrow, title, blurb, body, url, cta_label, image_url, archive_url, pinned, sort_order, created_at, publish_at, action_id")
652      .order("sort_order", { ascending: true });
653    if (category) q = q.eq("category", category);
654    const { data, error } = await q;
655    if (error) throw error;
656    return data || [];
657  }
658
659  // Signal feed: pinned post first, then newest. RLS returns only live + due
660  // rows. Sorted client-side because "pinned then newest" mixes two keys.
661  async function listSignal() {
662    const rows = await listSiteNews("signal");
663    const when = r => r.publish_at || r.created_at || "";
664    return rows.sort((a, b) => {
665      if (!!b.pinned !== !!a.pinned) return b.pinned ? 1 : -1;
666      return String(when(b)).localeCompare(String(when(a)));
667    });
668  }
669
670  // Take Action — calls to action, stored as ordinary site_news rows with
671  // category "action". Deliberately not a separate table: site_news already
672  // carries status/publish_at and the RLS read policy filters on those alone
673  // (no category enumeration), so actions inherit draft / scheduled / live and
674  // expiry for free. Retiring an action is one edit and it disappears from the
675  // Take Action page AND from every entry cross-linking it, with no orphans.
676  async function listActions() {
677    const rows = await listSiteNews("action");
678    return rows.sort((a, b) => {
679      if (!!b.pinned !== !!a.pinned) return b.pinned ? 1 : -1;
680      return (a.sort_order || 0) - (b.sort_order || 0);
681    });
682  }
683
684  // id -> action, for pages that render a cross-linked action on an entry.
685  // Expired or drafted actions are simply absent from the map, so the calling
686  // template renders no link rather than a dead one.
687  async function actionMap() {
688    try {
689      const rows = await listActions();
690      return new Map(rows.map(r => [r.id, r]));
691    } catch (e) {
692      return new Map();   // an action lookup must never break the page it is on
693    }
694  }
695
696  // Underbelly digest — public reads PUBLISHED editions only (RLS-enforced).
697  // payload is public-safe by construction. Newest first.
698  async function listEditions() {
699    if (!configured) return [];
700    const c = await client(); if (!c) return [];
701    const { data, error } = await c.from("digest_editions")
702      .select("id, number, edition_date, title, intro, payload, signal_id, published_at")
703      .eq("status", "published")
704      .order("published_at", { ascending: false, nullsFirst: false });
705    if (error) throw error;
706    return data || [];
707  }
708
709  async function listSiteEvents() {
710    if (!configured) return [];
711    const c = await client(); if (!c) return [];
712    const { data, error } = await c.from("site_events")
713      .select("id, kind, date_label, title, where_label, spots, sort_order")
714      .order("sort_order", { ascending: true });
715    if (error) throw error;
716    return data || [];
717  }
718
719  // Returns { <key>: value } using the latest DUE live revision per key.
720  async function getSiteBlocks() {
721    if (!configured) return {};
722    const c = await client(); if (!c) return {};
723    const { data, error } = await c.from("site_blocks")
724      .select("key, value, publish_at, created_at")
725      .order("publish_at", { ascending: true, nullsFirst: true })
726      .order("created_at", { ascending: true });
727    if (error) throw error;
728    const out = {};
729    for (const row of (data || [])) out[row.key] = row.value;  // later rows win
730    return out;
731  }
732
733
734  function generateAccessKey(len = 40) {
735    const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
736    const bytes = new Uint8Array(len);
737    (crypto || window.crypto).getRandomValues(bytes);
738    let out = "";
739    for (let i = 0; i < len; i++) out += alphabet[bytes[i] % alphabet.length];
740    return out;
741  }
742
743  // ── Per-recipient embargo links (paper_access_keys) ──
744  // Admin-only (RLS). Each row is a labelled, independently-revocable link with
745  // open counters maintained by the paper-access edge function.
746  async function listAccessKeys(paperId) {
747    const c = await client(); if (!c) return [];
748    const { data, error } = await c.from("paper_access_keys")
749      .select("id, recipient, key, created_at, revoked_at, last_used_at, use_count")
750      .eq("paper_id", paperId)
751      .order("created_at", { ascending: true });
752    if (error) throw error;
753    return data || [];
754  }
755
756  async function createAccessKey(paperId, recipient) {
757    const c = await client(); if (!c) throw new Error("Not configured.");
758    const row = { paper_id: paperId, recipient: (recipient || "").trim() || null, key: generateAccessKey() };
759    const { data, error } = await c.from("paper_access_keys")
760      .insert(row)
761      .select("id, recipient, key, created_at, revoked_at, last_used_at, use_count")
762      .single();
763    if (error) throw error;
764    try {
765      const me = await getCurrentReviewer().catch(() => null);
766      await c.from("audit_log").insert({
767        actor_email: me ? me.email : null,
768        action: "paper.key_created", target_table: "papers", target_id: paperId,
769        payload: { recipient: data.recipient, key_id: data.id },
770      });
771    } catch (e) { /* audit is best-effort */ }
772    return data;
773  }
774
775  async function revokeAccessKey(keyId, paperId) {
776    const c = await client(); if (!c) throw new Error("Not configured.");
777    const { error } = await c.from("paper_access_keys")
778      .update({ revoked_at: new Date().toISOString() })
779      .eq("id", keyId);
780    if (error) throw error;
781    try {
782      const me = await getCurrentReviewer().catch(() => null);
783      await c.from("audit_log").insert({
784        actor_email: me ? me.email : null,
785        action: "paper.key_revoked", target_table: "papers", target_id: paperId || null,
786        payload: { key_id: keyId },
787      });
788    } catch (e) { /* audit is best-effort */ }
789  }
790  async function updatePaper(id, patch) {
791    const c = await client(); if (!c) return;
792    // Only the fields a human would edit. Anything else is rejected by RLS.
793    const allowed = ["title", "kind", "number", "lead_author", "authors", "abstract", "abstract_public", "summary", "tags", "state", "current_roun
793d", "published_at", "expected_at", "visibility", "access_key", "media", "links"];
794    const clean = {};
795    for (const k of allowed) if (k in patch) clean[k] = patch[k];
796    if (clean.current_round != null) clean.current_round = parseInt(clean.current_round, 10) || 1;
797    const { data, error } = await c.from("papers").update(clean).eq("id", id).select("*").single();
798    if (error) throw error;
799    return data;
800  }
801  async function deletePaper(id) {
802    const c = await client(); if (!c) return;
803    // Also nuke any uploaded drafts in storage. Assignments, reviews, and
804    // invitations cascade via the foreign-key on delete cascade in schema.sql.
805    try {
806      const { data: files } = await c.storage.from("drafts").list(id);
807      if (files && files.length) {
808        await c.storage.from("drafts").remove(files.map(f => `${id}/${f.name}`));
809      }
810    } catch (e) { /* non-fatal */ }
811    const { error } = await c.from("papers").delete().eq("id", id);
812    if (error) throw error;
813  }
814  async function listApplications(status = "pending") {
815    const c = await client(); if (!c) return [];
816    const { data, error } = await c.from("applications")
817      .select("*").eq("status", status).order("created_at", { ascending: false });
818    if (error) throw error;
819    return data || [];
820  }
821  async function decideApplication(id, status, notes) {
822    const c = await client(); if (!c) return;
823    const session = (await c.auth.getSession()).data.session;
824    const { data: app, error: aerr } = await c.from("applications")
825      .update({ status, notes, reviewed_at: new Date().toISOString(), reviewed_by: session?.user?.id || null })
826      .eq("id", id).select("*").single();
827    if (aerr) throw aerr;
828    if (status === "approved") {
829      await c.from("reviewers").upsert({
830        email: app.email, name: app.name, affiliation: app.affiliation,
831        expertise: app.expertise, bio: app.bio, link: app.link, status: "active",
832      }, { onConflict: "email" });
833    }
834  }
835  async function createInvitation({ paperId, email, round = 1, dueAt }) {
836    const c = await client(); if (!c) return;
837    const code = "PCI-" + randomCodeSegment(4) + "-" + randomCodeSegment(4);
838    const { data, error } = await c.from("invitations").insert({
839      code, paper_id: paperId, invitee_email: email, round,
840      due_at: dueAt || null,
841    }).select("*").single();
842    if (error) throw error;
843    return data;
844  }
845  async function listInvitations(paperId) {
846    const c = await client(); if (!c) return [];
847    let q = c.from("invitations").select("*").order("created_at", { ascending: false });
848    if (paperId) q = q.eq("paper_id", paperId);
849    const { data, error } = await q;
850    if (error) throw error;
851    return data || [];
852  }
853  async function listAssignmentsForPaper(paperId) {
854    const c = await client(); if (!c) return [];
855    const { data, error } = await c.from("assignments")
856      .select("*, reviewer:reviewers(id, name, email, affiliation), review:reviews(submitted_at, recommendation, draft_saved_at)")
857      .eq("paper_id", paperId).order("invited_at", { ascending: false });
858    if (error) throw error;
859    return data || [];
860  }
861  function randomCodeSegment(n) {
862    const A = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // no easy-to-confuse chars
863    let s = ""; for (let i = 0; i < n; i++) s += A[Math.floor(Math.random() * A.length)];
864    return s;
865  }
866
867  // ─────────────────────── export ───────────────────────
868  window.PCI = window.PCI || {};
869  window.PCI.api = {
870    configured,
871    getSession, getCurrentReviewer, signInWithMagicLink, signOut,
872    claimInvitation, applyAsReviewer,
873    listMyAssignments, acceptAssignment, declineAssignment, getDraftDownloadUrl,
874    getLatestDraftMetas, markDraftSeen,
875    saveDraft, submitReview, getReviewDraft, getFullReview,
876    // drafts
877    listDrafts, uploadDraft, deleteDraft, getSignedUrlForDraft,
878    // supporting materials
879    listMaterials, listMaterialsForPapers, uploadMaterial, getMaterialUrl, deleteMaterial,
880    paperPublicFilename, copyDraftToMaterials,
881    // rich media
882    uploadPaperMedia, getPaperMediaUrl, deletePaperMedia,
883    // admin
884    listAllPapers, listPublicPapers, listForthcoming, getPublicPaperById, getPaperByIdAdmin, listDownloadEvents, generateAccessKey, updatePaper, deletePaper,
885    listAiReviews, createAiReview, updateAiReview, deleteAiReview, listPublicAiReviews,
886    listAccessKeys, createAccessKey, revokeAccessKey,
887    // editable site content (homepage + news page)
888    listSiteNews, listSignal, listActions, actionMap, listEditions, listSiteEvents, getSiteBlocks,
889    updateReviewer, deleteReviewer,
890    listApplications, decideApplication,
891    createInvitation, listInvitations, listAssignmentsForPaper,
892  };
893  window.dispatchEvent(new Event("pci-api-ready"));
894})();

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.