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.