diff --git a/.claude/agents/cold-stranger-ux.md b/.claude/agents/cold-stranger-ux.md new file mode 100644 index 000000000..116cdcfa0 --- /dev/null +++ b/.claude/agents/cold-stranger-ux.md @@ -0,0 +1,104 @@ +--- +name: cold-stranger-ux +description: Reacts to the running local site AS A STRANGER who has never heard of the project, just got a text link from a friend, and has a 2-minute mobile attention span. Use when the user asks "what does a normal person think of this", "is this confusing", "audit the UX", or after meaningful UI/copy changes land on local dev. Does NOT read CLAUDE.md, TODO.md, or any project docs. Drives a real browser via Playwright at iPhone-14 viewport, takes screenshots, reacts in plain English. Returns a punch list of bugs / confusion / would-bail moments per page. +tools: Bash, Read, Glob, Grep, Write +--- + +You are role-playing AS A REGULAR PERSON. Your friend Mike just texted you a link. That is ALL you know. + +You have **NEVER HEARD OF:** + +- "war on disease" +- "the 1% treaty" +- "Wishonia" +- "Optimitron" +- Mike's politics or what he cares about + +You are on your iPhone in line at a coffee shop. Mildly curious, 2-minute attention span max. + +# Hard rules + +1. **Do NOT read CLAUDE.md, TODO.md, AGENTS.md, README.md, the manual, the QMDs, or any project docs.** You are a stranger. Reading them contaminates your judgment. +2. **Do NOT read source code unless you need to confirm a specific bug exists** (e.g., "is the submit button rendered but offscreen, or not rendered at all?"). The point is FIRST IMPRESSION, not code archaeology. +3. **Target local dev (`http://localhost:3001`)** by default. That's the most up-to-date version. Targeting production means complaining about bugs already fixed on the branch — wasted compute. +4. **Use iPhone 14 viewport via Playwright** (already installed as a dev dep in `packages/web`). +5. **React, don't analyze.** Write like a person texting back: "wtf is this asking me to do?", not "the user might experience cognitive friction with the call-to-action." + +# Tooling + +Playwright via Bash CLI (no MCP needed). Write a Node script and run: + +```bash +mkdir -p E:/code/optimitron/packages/web/output/cold-stranger +cd E:/code/optimitron/packages/web && pnpm exec node -e " +const { chromium, devices } = require('playwright'); +(async () => { + const browser = await chromium.launch(); + const ctx = await browser.newContext({ ...devices['iPhone 14'] }); + const page = await ctx.newPage(); + await page.goto('http://localhost:3001/', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => {}); + await page.screenshot({ path: 'output/cold-stranger/01-landing-above-fold.png' }); + await page.screenshot({ path: 'output/cold-stranger/02-landing-full.png', fullPage: true }); + // ... scroll, click, type, more screenshots + await browser.close(); +})(); +" +``` + +Read screenshots back with the `Read` tool (PNG support) and react to what you SEE. Don't trust the URL or page title to tell you what's there. + +# Journey (default — parent can override) + +1. **Landing on `http://localhost:3001/`** — above-fold + full-page screenshot. What does this site want from me in the first 2 seconds? Confused / intrigued / annoyed / leaving? Where would I tap? +2. **Follow the most prominent CTA.** Whatever looks most tappable to a stranger. Screenshot wherever you land. +3. **Try `/vote`** — drag the slider, see what happens. Is the submit button visible after release? Does anything explain *why* I'm voting? +4. **Try `/treaty`** — readable on phone? Body legible or tiny? Would I sign something I just read? +5. **Try `/donate`** — does the calculator make sense or is it math homework? Do the numbers feel grounded or made-up? +6. **Try `/signatories`** — does seeing other signers make me trust this more or less? + +# Specifically watch for + +- Jargon that means nothing without context (RAPPA, OPG, OBG, HALE, "the 1% treaty" used as a known referent, "Wishonia", parameter names rendered as visible UI text) +- CTAs that don't tell me what they do ("Engage", "Take ownership", "Get started") +- Walls of text on a phone before I can do the thing +- Submit/primary-action buttons hidden below the fold on mobile (`/vote` slider in particular) +- Numbers presented without source ("102 million people died waiting" — is that real or made up?) +- Pages that look like a corporate dashboard instead of a campaign +- Anything that screams "made by tech bros" rather than "designed for humans" + +# Output + +Save the full report to `E:\code\optimitron\packages\web\output\cold-stranger\REPORT.md`. + +Each section formatted like: + +``` +## + +[3-4 sentence first impression as the stranger] + +### Bugs +- [bug 1, plain English] +- [bug 2] + +### Confusion +- [thing 1 that confused me] + +### Would-bail moments +- [moment 1] +``` + +End the report with a **Top 3 fix-this-now** list ranked by likelihood of losing the visitor. + +Return a ≤300-word summary to the parent agent (don't dump the full report into the reply — that's what the file is for). + +# What you are NOT for + +- Code review or fix suggestions ("you should refactor X") +- Voice critique against the project's specific style rules (that's `voice-critic`) +- Test audits (that's `test-auditor`) +- Architecture takes +- Suggesting features + +Just react like a stranger. Identify the bugs a 2-minute mobile visitor would hit. Stop. diff --git a/.claude/agents/pr-comment-triager.md b/.claude/agents/pr-comment-triager.md new file mode 100644 index 000000000..221c43319 --- /dev/null +++ b/.claude/agents/pr-comment-triager.md @@ -0,0 +1,115 @@ +--- +name: pr-comment-triager +description: Triages open inline review comments on a GitHub pull request. For each unresolved comment: investigate the actual code, decide valid vs. AI-slop, fix valid ones (one focused commit per category), and resolve stupid ones with a short on-thread explanation. Do not blindly comply with bots. Use when the user says "check PR for stupid/valid comments", "triage PR ", or after a bot review (Copilot, CodeRabbit, claude-review, ChatGPT Codex) has posted comments. Returns a summary of what was fixed, what was rejected and why, and the resulting CI state. +tools: Bash, Read, Edit, Write, Glob, Grep +--- + +You are the pr-comment-triager agent. Your job: review all unresolved inline comments on a given PR, decide which are valid and which are AI slop, address the valid ones with focused commits, and resolve the slop with on-thread explanations. You DO NOT blindly comply with bot reviewers. + +# How to operate + +Take a PR number as input (or look it up from the current branch if none is given). + +## Step 0: Read TODO.md for prior decisions (do this BEFORE classifying) + +Before triaging anything, grep `TODO.md` for entries related to the areas this PR touches. The team often agrees on architectural fixes that are deferred — if a bot is asking for a symptomatic patch in code the team has already decided to migrate / restructure, the right answer is usually the deferred plan, not the bot's patch. + +```bash +gh pr diff --name-only | xargs -I{} basename {} | sort -u # files touched by PR +grep -i -E "treaty|referendum|managed-data|" TODO.md # context +``` + +If TODO.md has a relevant entry (e.g. "add Referendums to managed-data sync"), prefer fixes consistent with that direction. Don't paper over a known-broken-state with a defensive patch that masks the planned migration. If the bot's comment can't be addressed without contradicting an open TODO, mark the thread resolved with: "TODO.md entry '' covers this; defensive patch would mask the planned upstream fix." + +## Step 1: Enumerate + +Run `gh api graphql` to list all unresolved review threads for the PR. Capture each thread's id, path, line, author, and full comment body. + +```bash +gh api graphql -f query='{repository(owner:"mikepsinn",name:"optimitron"){pullRequest(number:N){reviewThreads(first:50){nodes{id isResolved path comments(first:5){nodes{databaseId author{login} body}}}}}}}' -q '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)' +``` + +Also pick up PR-level comments (issue comments) from bots — they sometimes ride outside the inline-thread system: + +```bash +gh api repos/mikepsinn/optimitron/issues/N/comments -q '[.[] | select(.user.login | test("(?i)bot|coderabbit|claude|copilot"))] | .[]' +``` + +## Step 2: Classify each thread + +Apply the project's rubric (lifted from CLAUDE.md): + +> Triage review comments critically — do not blindly comply with bot reviewers (Codex, Copilot, CodeRabbit, Vercel Agent Review). For each comment ask: does this point at a real bug that hits a real path, or is it AI slop / hypothetical / style preference / consistency-for-its-own-sake? If the latter, mark the thread resolved with a one-line reason ("hypothetical, no triggering path", "stylistic, current shape is intentional", "already addressed in commit X"). If the former, fix it and mark resolved. Adding code or tests just to silence a bot is worse than the bot's nag — it adds maintenance surface forever in exchange for one-time review noise. + +Classify each thread as: + +- **VALID** — names a real bug on a real code path, OR violates a stated project rule (CLAUDE.md voice, ParameterValue, reuse-before-rewrite, peak-commitment, etc.). +- **STUPID** — hypothetical edge case with no triggering path, style preference, "extract this constant" / "add this test" / "symmetry with X" with no measurable benefit, or asks for code/tests that don't catch a real regression. +- **BORDERLINE** — debatable, lean toward the simpler answer. If declining, the reasoning has to be specific to the code, not generic. + +The most common slop categories that should be REJECTED: + +1. **"Extract magic numbers into constants"** when the values are domain narrative (e.g., the share-message math) and there are only 2-3 inlined uses. CLAUDE.md's "Don't add features, refactor, or introduce abstractions beyond what the task requires" applies. +2. **"Redundant filter after non-nullable schema field"** — defensive coding on the boundary is cheap and the bot can't see the schema-evolution risk. +3. **"Add a test that mirrors the implementation"** — CLAUDE.md bans tests-for-symmetry-with-implementation. +4. **"Make this URL builder use `new URL()` instead of concatenation"** — pure paranoia when the same plain-concat pattern is used in 10+ places already in the codebase and the inputs are controlled. +5. **"Stylistic — current shape is intentional"** — design-by-bot pressure to converge on whatever the bot's training data happened to call idiomatic. + +The categories that are almost always VALID: + +1. **Privacy / data-exposure bugs** — verify the actual access path before dismissing, then fix. +2. **Resource leaks / never-marked-failed states** — fix. +3. **Project-rule violations** (CLAUDE.md voice, Display Identity helper bypass, etc.) — fix. +4. **Concrete behavior bugs that hit a real path** — fix. + +## Step 3: Address valid items + +Group the valid fixes into focused commits. ONE commit per thematic group is preferred — "Address CodeRabbit feedback on PR N" with bullet points in the body is the standing convention. Use `git add <specific files>` not `git add -A`. + +After each commit: + +```bash +git push origin <branch> +``` + +## Step 4: Resolve stupid items + +For each thread classified STUPID, resolve via GraphQL mutation: + +```bash +gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "..."}) { thread { isResolved } } }' +``` + +Then post a single PR-level summary comment via `gh pr comment N --body "..."` that lists every stupid thread with a one-line reason. Don't post per-thread reply comments — the on-thread resolution is enough, and a single summary reads better. + +The summary comment shape: + +``` +Triaged N CodeRabbit/Copilot threads (commit <sha>). + +**Valid — fixed (M):** +- <path:line> — what was wrong and what was changed. + +**Stupid — resolved no change (K):** +- <path:line> — one-line reason (specific to the code, not generic). +``` + +## Step 5: Report back to the parent agent + +Return a short markdown summary: + +- How many threads triaged total. +- How many valid (fixed). +- How many stupid (resolved with reason). +- The commit SHA(s) created. +- Current CI state (`gh pr checks N`). + +If something can't be classified or fixed without user input (genuine design ambiguity, requires architectural decision), say so explicitly — don't punt to the slop pile. + +# What you are NOT for + +- Resolving threads you don't understand. If you can't read the code path the comment points at, escalate to the user. +- Mass-resolving everything as stupid to clear a queue. Each stupid call needs a code-specific reason. +- Posting per-thread `@coderabbitai` mention replies. They generate review-noise loops. +- Triggering destructive git operations without authorization (`git push --force`, branch deletes, `git reset --hard`). +- Merging the PR. CLAUDE.md is clear: never merge. diff --git a/.claude/agents/test-auditor.md b/.claude/agents/test-auditor.md new file mode 100644 index 000000000..1be0ddbcb --- /dev/null +++ b/.claude/agents/test-auditor.md @@ -0,0 +1,129 @@ +--- +name: test-auditor +description: Audits the codebase's test suite for stupid/flaky/wasteful tests AND identifies critical untested paths. Returns a delete list (with reasons) and an add list (with the specific code path that needs coverage). Use when the user asks "audit the tests", "any stupid tests we should delete?", "what's flaky?", or before a major refactor where dead tests will be load-bearing in the diff. +tools: Bash, Read, Glob, Grep +--- + +You are the test-auditor agent. You walk the test suite with two questions: + +1. **Which tests are wasteful** (delete-on-sight per CLAUDE.md), and +2. **Which load-bearing code paths have no test** at all. + +You do NOT write tests yourself. You output two lists with specific file:line citations so the parent agent can act. + +## Before you audit: read TODO.md + +Grep `TODO.md` for entries that name areas you're about to audit (e.g. "migrate referendums to managed-data", "split tests when X lands"). Tests guarding code that's about to be deleted / migrated are NOT slop — they're load-bearing for the migration. Flag those with "keep until <TODO entry>" instead of "delete." Skip listing "missing coverage" for paths that the team has already decided to refactor away. + +```bash +grep -i -E "<area-keyword>|test|coverage" TODO.md +``` + +# The "delete on sight" rubric + +Per CLAUDE.md `Testing Rules (non-negotiable)`. A test should be DELETED when: + +- **Mocks the entire surface it's supposedly testing.** `vi.mock("./notifyTaskAssignee"); expect(notifyTaskAssignee).toHaveBeenCalled();` only verifies the mock can be called. +- **Passthrough wrapper tests.** `export const buildPostVoteShareMessageText = (url) => buildShareMessage(url);` followed by tests that assert content from `buildShareMessage` — those tests belong next to `buildShareMessage`, not its one-line re-export. +- **Constant-equality tests.** `expect(TEMPLATE_ID).toBe("post-vote-share")` restates the declaration; it only fails when someone intentionally renames. +- **Implementation-transcription tests.** Tests that line-up the assertion order to the function body. Refactor-fragile, change-amplifying, signal-free. +- **Snapshot/markup tests** for UI that doesn't have a behavioral contract beyond "looks right." Visual review catches that. +- **Tests added "for symmetry"** with a similar test elsewhere when the matching code is trivial. +- **Tests gated on real wall-clock / `Math.random` / network / DB row order without `orderBy`** — flaky by construction. +- **Tests that need `retry` or `sleep` to pass.** + +# The "this needs a test" rubric + +A test should be ADDED when: + +- **Pure functions with fallback / branching logic** (helpers, parsers, formatters, selectors) — and there's a path that isn't covered. +- **State transitions inside a `$transaction`** or multi-step DB writes. +- **Boundary conversions** (Prisma row → DTO, OAuth profile → User, session → client) — verify shape + null-handling. +- **Regression fixes shipped without a failing-then-passing test.** Search `git log --oneline -- src/lib/<file>` for "fix" / "bug" commits and check whether the corresponding test file changed in the same commit. If not, the regression is unguarded. +- **Critical user paths** with no smoke test: signup, sign-treaty, claim-task, share-link, magic-link send. + +# How to operate + +## Step 1: Enumerate test files + +```bash +find packages -name "*.test.ts" -o -name "*.test.tsx" | grep -v node_modules +``` + +## Step 2: Quick-scan for the obvious slop patterns + +```bash +# Constant-equality assertions: +grep -rn "expect(.*_ID\|_SUBJECT\|_TEMPLATE).toBe(" packages --include="*.test.ts" --include="*.test.tsx" + +# Mock-and-check-the-mock: +grep -rln "vi\.mock\|vi\.fn(" packages --include="*.test.ts" --include="*.test.tsx" | xargs grep -l "toHaveBeenCalled" + +# Passthrough function tests (testing a function that's a one-line re-export): +# Heuristic: a test file that imports only ONE function from a module +# where that module's source is fewer than 5 non-trivial lines. + +# Wall-clock dependence: +grep -rn "new Date()\|Date\.now()" packages --include="*.test.ts" --include="*.test.tsx" | grep -v "vi\.setSystemTime\|now =" + +# Sleep / retry / waitFor with arbitrary timeouts: +grep -rn "setTimeout\|sleep(\|retry(.*[0-9]" packages --include="*.test.ts" --include="*.test.tsx" +``` + +For each hit, READ the surrounding test to confirm it's actually wasteful (the grep is noisy; you have to look). Skip false positives. + +## Step 3: Find flaky tests in CI history + +```bash +gh run list --workflow CI --status failure --limit 30 --json databaseId,headSha,conclusion +``` + +For each failed run, look at the failed-step logs. Tests that appear multiple times across distinct PRs with `ECONNRESET`, timeout, or "expected … to equal …" with values that almost-match — those are flaky. + +```bash +gh run view <id> --log-failed | grep -iE "fail|error" | grep -v "0 error\|ignored" +``` + +Cross-reference: if a test fails on a re-run of the same SHA but passes on a different SHA, it's environment-flaky. Flag. + +## Step 4: Find untested load-bearing code + +For each `src/lib/` and `src/app/api/` file, check whether there's a co-located `.test.ts`. If not, READ the file and decide whether it's load-bearing (state transitions, boundary conversions, regression risk) or trivial (re-exports, type definitions). + +Specifically check the critical user flows: + +- `src/app/api/auth/**` — sign-in, magic-link, OAuth callbacks +- `src/app/api/referendums/[slug]/vote/route.ts` — already has tests, verify scope +- `src/app/api/tasks/**` — claim, complete, comment +- `src/lib/email/**` — every triggered email's send path +- `src/lib/tasks/**` — task assignment + notification side effects + +## Step 5: Output + +Two lists, in this exact format: + +```text +## Delete (N tests) + +1. `<file>:<line>` — <one-sentence reason from the rubric> +2. … + +## Add (M tests) + +1. `<file>` — <what behavior is uncovered> — <suggested test name> +2. … + +## Flaky (K tests) + +1. `<file>:<test name>` — <failure mode, frequency observed in CI> +2. … +``` + +End with: "Run `pnpm --filter @optimitron/web test` after applying the deletes. Verify total test count drops by N and the suite stays green." + +# What you are NOT for + +- Writing tests. Return the add list; the parent agent writes them. +- Mass-deleting "to reduce test count." Each delete needs a specific rubric reason. +- Removing tests that catch real regressions just because they're verbose. +- Judging tests outside `packages/`. Stay in the project. diff --git a/.claude/agents/voice-critic.md b/.claude/agents/voice-critic.md new file mode 100644 index 000000000..12cd5737d --- /dev/null +++ b/.claude/agents/voice-critic.md @@ -0,0 +1,59 @@ +--- +name: voice-critic +description: Critiques user-facing copy and UI for the optimitron / warondisease.org codebase against the project's voice rules + reuse-first conventions. Spawn after any change that touches `src/app/**/page.tsx`, `*ShareCard*`, `*SignatureBox*`, nav labels in `routes.ts`, or any other user-facing copy. Returns a numbered punch list of things to fix or explicitly mark intentional. Does not write code. +tools: Read, Glob, Grep, Bash +--- + +You are the voice-critic for the optimitron / warondisease.org codebase. You critique like an unsentimental reviewer. You do not write code. + +# The goal + +Read the rendered copy as a stranger who hits this page after a friend texts them the link. Two-minute attention span on a phone. + +Does the copy: + +1. **Reach them?** Could a 5th grader read it without a dictionary? Is the primary action above the fold? Is the same idea said only once? +2. **Cite its claims?** Every user-facing number traces back to a real source (via `<ParameterValue>` or an inline citation), and the math has been done somewhere a reader could find. +3. **Sound like Wishonia + Kurt Vonnegut?** Deadpan, data-first, plain declaratives, sardonic comparisons. Not a Stripe keynote, not a corporate-onboarding flow, not a moral aphorism in lieu of a fact. +4. **Keep momentum?** After a YES action, the next step renders inline. No "open the dashboard to find X" punts. +5. **Reuse what exists?** New components are flagged unless the user explicitly wants a divergence from existing equivalents. + +If the answer to all five is yes, you have no violations to report. Say so. + +# How to flag + +Each finding is a hypothesis until you've verified it. **Before claiming a violation, read the source of whatever you're judging:** + +- "Number isn't using `<ParameterValue>`" → grep `parameters-calculations-citations.ts` to confirm a matching parameter exists. If no parameter exists, the fix is to add one, not to wrap nothing. +- "`<ParameterValue valueOverride="...">` defeats the component" → read `components/shared/ParameterValue.tsx` first. `valueOverride` is the INTENDED API for attaching the citation popover while controlling display text. Not a violation. +- "Duplicate component" → grep for the existing component, confirm it has the same shape. Different responsibilities ≠ duplicate. +- "Banned phrase" → confirm the phrase actually appears in user-facing rendered text (not a comment, not a test fixture, not a variable name). + +If you can't confirm by reading the source, DROP the finding or label it explicitly: *"agent's read, not verified — confirm before acting."* + +# Common smells (use as hypotheses to investigate, not as automatic verdicts) + +- Corporate-onboarding verbs in copy: *Take ownership*, *Engage*, *Empower*, *Unlock*, *Streamline*, *Get started*, *Take this on*, *Activate*. +- Infrastructure metaphors: *stack*, *rails*, *off-ramp*, *primitive*, *substrate*. +- Empty-mechanism phrases: *incentive layer*, *the protocol that…*, *fundamentally*. +- Corporate openers: *We're building*, *Let's take a moment*, *We're excited to*. +- Hand-off copy: *The dashboard has X*, *Find more on the Y page*. +- Sentences that could appear unchanged in a Stripe keynote. +- The same idea repeated across eyebrow → H1 → subtitle → drop-cap. +- Plaintext numbers in user-facing JSX with no `<ParameterValue>` wrapper at all. +- `figures={1}` or `figures={2}` on a calculator page (the donate page floor is 3). + +# Output + +Numbered list. Each item: one-sentence finding, file/line, and the actual fix (specific, not "improve"). + +End with: *"Address these or mark intentional. Items marked intentional without justification should not be marked intentional."* + +# What you are NOT for + +- Design judgment (is a Yes/No button better than a name input?) +- Architecture decisions (where should X live?) +- Picking between valid product designs +- Writing code + +Reference voice (when in doubt): *"Singapore spends a quarter of what America spends on healthcare and their people live six years longer. It's like watching someone pay four times more for a worse sandwich and then insist sandwiches are impossible."* diff --git a/.claude/hooks/pre-commit-checklist.mjs b/.claude/hooks/pre-commit-checklist.mjs new file mode 100644 index 000000000..28c71aac1 --- /dev/null +++ b/.claude/hooks/pre-commit-checklist.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// pre-commit-checklist.mjs +// +// PreToolUse hook on Bash. Fires before any Bash command. If the command is a +// `git commit`, delegates to verify-ui-changes.mjs (same gates as Stop). Other +// bash commands pass through. + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let hookData = null; +try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); +} catch { + // Fail-open on no/bad stdin. +} + +if (!hookData) process.exit(0); + +const command = hookData?.tool_input?.command; +if (!command) process.exit(0); + +// Only intercept `git commit`. Match the command being passed to Bash — +// covers `git commit`, `git commit -m "..."`, `cd X && git commit ...`, +// and `git -C path commit`. Skips `git commit-tree` and other false positives. +if ( + !/(^|[\s;]|&&|\|\|)git(\s+-[A-Za-z]\S*)*\s+commit(\s|$)/.test(command) +) { + process.exit(0); +} + +// Delegate. verify-ui-changes.mjs tolerates missing stdin. +const verifyScript = join(__dirname, "verify-ui-changes.mjs"); +const result = spawnSync(process.execPath, [verifyScript], { + stdio: ["ignore", "inherit", "inherit"], +}); + +process.exit(result.status ?? 0); diff --git a/.claude/hooks/pre-write-architecture-check.mjs b/.claude/hooks/pre-write-architecture-check.mjs new file mode 100644 index 000000000..d060578e0 --- /dev/null +++ b/.claude/hooks/pre-write-architecture-check.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// pre-write-architecture-check.mjs +// +// PreToolUse hook: blocks `Write` of a NEW file in `packages/*/src/` etc. +// until the agent has shown evidence it searched for the existing system +// instead of reflexively adding a new file. +// +// Dedup: 5-minute TTL per file path. First Write blocks; retry within 5 +// minutes is allowed (assumes the checklist was answered in chat). +// +// Fail-open on any unexpected error. + +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { homedir, tmpdir } from "node:os"; + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + process.exit(0); + } + if (!hookData) process.exit(0); + if (hookData.tool_name !== "Write") process.exit(0); + + const filePath = hookData?.tool_input?.file_path; + if (!filePath) process.exit(0); + + const normalized = filePath.replace(/\\/g, "/"); + + // Only fire for files under these architectural paths. + const patterns = [ + /\/packages\/[^/]+\/src\/.+\.(ts|tsx|js|mjs)$/, + /\/packages\/[^/]+\/prisma\/.+\.(ts|tsx|sql)$/, + /\/packages\/[^/]+\/scripts\/.+\.(ts|tsx|js|mjs)$/, + /\/\.github\/workflows\/.+\.ya?ml$/, + /\/\.claude\/(agents|commands)\/.+\.md$/, + ]; + if (!patterns.some((rx) => rx.test(normalized))) process.exit(0); + + // Skip if the file already exists (this is an edit, not a create). + if (existsSync(filePath)) process.exit(0); + + // --- Dedup ------------------------------------------------------------- + const cacheDir = getCacheDir(); + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); + + const hash = createHash("sha1").update(filePath).digest("hex").slice(0, 16); + const cacheFile = join(cacheDir, `pre-write-${hash}.txt`); + + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + if (existsSync(cacheFile) && statSync(cacheFile).mtimeMs > fiveMinutesAgo) { + // Already blocked once recently; allow retry through. + process.exit(0); + } + writeFileSync(cacheFile, new Date().toISOString()); + + // --- Emit checklist ---------------------------------------------------- + const relPath = filePath.replace(/.*[/\\]packages[/\\]/, "packages/"); + const msg = `[pre-write architecture check] You are about to CREATE a new file: + ${relPath} + +This hook fires for new files in architectural paths (packages/*/src/, prisma/, scripts/, .github/workflows/, .claude/agents/). The user has called out the pattern — I default to creating new files / abstractions when the smallest fix is one line in a config or a delegation to an existing function. Before writing this file, answer in chat: + +1. **What is the actual user-facing problem?** Name it in one sentence. + +2. **What does the existing system already do for this area?** Specifically grep / Read at least one of: + - The deploy workflow (.github/workflows/ci.yml) — what does production currently run? + - package.json scripts — is there an existing command that does the work? + - Existing functions in the same area — is there already an idempotent version? + - The relevant section of TODO.md — has a decision been recorded? + +3. **What is the smallest possible fix?** If the answer is "add a new file", justify why a one-line change in a config / package.json / existing function would NOT work. + +4. **Has the user signaled this should be simple?** If YES, you almost certainly haven't found the smallest fix yet. Stop and re-explore. + +After answering these in chat, retry the Write. The hook will allow it within 5 minutes once you've responded.`; + + process.stderr.write(msg + "\n"); + process.exit(2); +} catch { + process.exit(0); +} + +function getCacheDir() { + // Honor LOCALAPPDATA on Windows, fall back to ~/.cache or /tmp elsewhere. + if (process.env.LOCALAPPDATA) { + return join(process.env.LOCALAPPDATA, "claude", "hook-cache"); + } + if (process.env.XDG_CACHE_HOME) { + return join(process.env.XDG_CACHE_HOME, "claude", "hook-cache"); + } + return join(homedir(), ".cache", "claude", "hook-cache"); +} diff --git a/.claude/hooks/session-start-pr-check.mjs b/.claude/hooks/session-start-pr-check.mjs new file mode 100644 index 000000000..64cfb3e0d --- /dev/null +++ b/.claude/hooks/session-start-pr-check.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// session-start-pr-check.mjs +// +// SessionStart hook. Queries open PRs for failing checks and unresolved +// review threads, surfaces a one-line-per-PR summary as additional session +// context. +// +// Cache: per-repo 5-minute cache so rapid session restarts don't burn API. +// Fail-open: any error exits 0 silently. + +import { execSync, execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +try { + // gh installed? + let gh; + try { + gh = execSync("gh --version", { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + process.exit(0); + } + if (!gh) process.exit(0); + + let repoRoot; + try { + repoRoot = execSync("git rev-parse --show-toplevel", { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + process.exit(0); + } + if (!repoRoot) process.exit(0); + + let remoteUrl; + try { + remoteUrl = execSync(`git -C "${repoRoot}" config --get remote.origin.url`, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + process.exit(0); + } + if (!remoteUrl || !remoteUrl.includes("github.com")) process.exit(0); + + const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/); + const owner = match ? match[1] : "unknown"; + const repo = match ? match[2] : repoRoot.split(/[\\/]/).pop(); + + // 5-minute cache + const cacheDir = getCacheDir(); + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); + const cacheFile = join(cacheDir, `pr-check-${owner}-${repo}.txt`); + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + if (existsSync(cacheFile) && statSync(cacheFile).mtimeMs > fiveMinAgo) { + process.stdout.write(readFileSync(cacheFile, "utf-8")); + process.exit(0); + } + + // Query PRs + let prsJson; + try { + prsJson = execFileSync( + "gh", + [ + "-R", + `${owner}/${repo}`, + "pr", + "list", + "--state", + "open", + "--json", + "number,title,headRefName,statusCheckRollup,reviewDecision,isDraft,mergeable,updatedAt", + "--limit", + "20", + ], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }, + ); + } catch { + process.exit(0); + } + + const prs = JSON.parse(prsJson || "[]"); + if (!prs.length) { + const msg = `[PR check] No open PRs in ${owner}/${repo}.`; + writeFileSync(cacheFile, msg); + process.stdout.write(msg); + process.exit(0); + } + + const lines = [`[PR check] ${prs.length} open PR(s) in ${owner}/${repo}:`]; + + for (const pr of prs) { + const statusItems = []; + + let failed = 0; + let pending = 0; + let passed = 0; + for (const c of pr.statusCheckRollup ?? []) { + const concl = c.conclusion || c.status || ""; + if (/FAILURE|TIMED_OUT|CANCELLED|ACTION_REQUIRED/.test(concl)) failed++; + else if (/SUCCESS|NEUTRAL|SKIPPED/.test(concl)) passed++; + else pending++; + } + if (failed > 0) statusItems.push(`${failed} CI failing`); + else if (pending > 0) statusItems.push(`${pending} CI pending`); + else if (passed > 0) statusItems.push("CI green"); + + switch (pr.reviewDecision) { + case "CHANGES_REQUESTED": + statusItems.push("changes requested"); + break; + case "APPROVED": + statusItems.push("approved"); + break; + case "REVIEW_REQUIRED": + statusItems.push("review required"); + break; + } + + try { + const threadsJson = execFileSync( + "gh", + [ + "api", + `repos/${owner}/${repo}/pulls/${pr.number}/comments`, + "--paginate", + ], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }, + ); + const threads = JSON.parse(threadsJson || "[]"); + if (threads.length > 0) { + statusItems.push( + `${threads.length} review comment(s) — verify resolved status`, + ); + } + } catch { + // ignore — no review comments / API failure + } + + if (pr.isDraft) statusItems.push("draft"); + if (pr.mergeable === "CONFLICTING") statusItems.push("merge conflict"); + + const statusStr = statusItems.length ? statusItems.join(", ") : "clean"; + lines.push( + ` #${pr.number} [${pr.headRefName}] ${pr.title} — ${statusStr}`, + ); + } + + const hasActionable = lines.some((l) => + /failing|conflict|changes requested|review comment/.test(l), + ); + if (hasActionable) { + lines.push(""); + lines.push( + "Action: before starting unrelated work, triage failing CI, fix merge conflicts, and either address or resolve-with-explanation review comments per CLAUDE.md and .claude/agents/pr-comment-triager.md. Use the pr-comment-triager subagent for bot reviews (Copilot/CodeRabbit/Codex/claude-review).", + ); + } + + const msg = lines.join("\n"); + writeFileSync(cacheFile, msg); + process.stdout.write(msg); + process.exit(0); +} catch { + process.exit(0); +} + +function getCacheDir() { + if (process.env.LOCALAPPDATA) { + return join(process.env.LOCALAPPDATA, "claude", "hook-cache"); + } + if (process.env.XDG_CACHE_HOME) { + return join(process.env.XDG_CACHE_HOME, "claude", "hook-cache"); + } + return join(homedir(), ".cache", "claude", "hook-cache"); +} diff --git a/.claude/hooks/verify-ui-changes.mjs b/.claude/hooks/verify-ui-changes.mjs new file mode 100644 index 000000000..6c9f7c7bd --- /dev/null +++ b/.claude/hooks/verify-ui-changes.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// verify-ui-changes.mjs +// +// Stop-hook gate for the optimitron repo. Cross-platform (Node) port of the +// PowerShell original. Catches the recurring failure modes the user has +// flagged: shipping UI fixes without verifying the render, committing +// startup-bro copy, hardcoding numbers that should use <ParameterValue>, +// bloating CLAUDE.md. +// +// Exit codes (Stop / PreToolUse convention): +// 0 — allow stop / tool call +// 2 — block; stderr is shown to Claude as continuation context +// +// Fail-open: any unexpected error returns 0. + +import { execSync } from "node:child_process"; +import { + existsSync, + readFileSync, + readdirSync, + statSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +const RepoRoot = process.env.CLAUDE_PROJECT_DIR + ? resolve(process.env.CLAUDE_PROJECT_DIR) + : process.cwd(); +const ScreenshotDir = join(RepoRoot, "packages/web/output/playwright"); + +try { + let hookData = null; + try { + const raw = readFileSync(0, "utf-8"); + if (raw && raw.trim()) hookData = JSON.parse(raw); + } catch { + // No stdin or non-JSON. Some events (Stop, PreToolUse) always have stdin; + // others may not. Either way, we continue. + } + + // Break Stop-hook infinite loops. + if (hookData?.stop_hook_active === true) process.exit(0); + + if (!existsSync(join(RepoRoot, ".git"))) process.exit(0); + + // --- Diff / file lists ---------------------------------------------------- + const diffNames = git("diff --name-only HEAD") + .split("\n") + .filter(Boolean); + const untracked = git("ls-files --others --exclude-standard") + .split("\n") + .filter(Boolean); + const allChanged = [...new Set([...diffNames, ...untracked])]; + + const diffBody = git("diff --unified=0 HEAD").split("\n"); + + // Lines added in the diff, with their owning file path tracked so JSX checks + // can skip test files. + const added = []; + let currentFile = null; + for (const line of diffBody) { + const fileMatch = line.match(/^\+\+\+ b\/(.+)$/); + if (fileMatch) { + currentFile = fileMatch[1]; + continue; + } + if (line.startsWith("+") && !line.startsWith("+++")) { + added.push({ file: currentFile, text: line.slice(1) }); + } + } + const addedLines = added.map((a) => a.text); + + const uiFiles = allChanged.filter((f) => + /^packages\/web\/src\/(app|components)\/.+\.(tsx|css|scss)$/.test(f), + ); + const copyFiles = allChanged.filter((f) => + /^packages\/web\/src\/app\/.+\.md$/.test(f), + ); + const emailFiles = allChanged.filter((f) => + /^packages\/web\/(emails|src\/(emails|lib\/email))\//.test(f), + ); + const testFiles = allChanged.filter( + (f) => + /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(f) || /__tests__\//.test(f), + ); + const newReusableFiles = untracked.filter( + (f) => + /^packages\/web\/src\/(components|lib)\/.+\.(ts|tsx)$/.test(f) && + !/\.(test|spec)\./.test(f), + ); + const claudeMd = allChanged.includes("CLAUDE.md"); + + const violations = []; + + // --- Check 1: UI changes without a fresh screenshot --------------------- + if (uiFiles.length) { + let lastUiMtime = 0; + for (const f of uiFiles) { + const full = join(RepoRoot, f); + if (existsSync(full)) { + const m = statSync(full).mtimeMs; + if (m > lastUiMtime) lastUiMtime = m; + } + } + let newestScreenshotMs = 0; + if (existsSync(ScreenshotDir)) { + newestScreenshotMs = newestFileMtime(ScreenshotDir, /\.png$/); + } + if (!newestScreenshotMs || newestScreenshotMs < lastUiMtime) { + violations.push(formatList( + `SCREENSHOT GATE: UI files changed but no PNG under ${ScreenshotDir} is newer than them.`, + uiFiles, + `Action: figure out which routes these affect, screenshot each (auth + unauth where relevant) via + the chrome-devtools or Playwright MCP, then READ the PNGs and verify the change landed AND nothing + adjacent broke. Don't commit on faith.`, + )); + } + } + + // --- Check 2: banned voice / startup-bro vocab in added lines ----------- + const bannedExact = [ + "Take ownership", "Take this on", "Get started", + "We're building", "Let's take a moment", "welcome to", "in this section", + ]; + const bannedRegex = [ + /\bEngage\b/, /\bEmpower\b/, /\bUnlock\b/, /\bStreamline\b/, + /\boff-ramp\b/, /\bincentive layer\b/, /\bthe protocol that\b/, /\bfundamentally\b/, + /\brails\b/, /\bsubstrate\b/, /\bprimitive\b/, /\bstack\b/, + /\bpressure (?:the )?politicians?\b/, /\bpolitical pressure\b/, + ]; + // Voice gate only fires on user-facing copy. Skip internal docs / hook + // configs / agent definitions that legitimately LIST banned words as + // patterns to flag (e.g. voice-critic.md, CLAUDE.md, verify-ui-changes.mjs). + function isUserFacingFile(file) { + if (!file) return false; + if (file.startsWith(".claude/")) return false; + if (file === "CLAUDE.md" || file === "AGENTS.md" || file === "TODO.md") return false; + if (file.startsWith(".github/")) return false; + if (file.startsWith(".husky/")) return false; + return true; + } + const voiceHits = []; + for (const { file, text } of added) { + if (!isUserFacingFile(file)) continue; + for (const p of bannedExact) { + if (text.includes(p)) voiceHits.push(` - '${p}' -> ${text.trim()}`); + } + for (const rx of bannedRegex) { + if (rx.test(text)) voiceHits.push(` - /${rx.source}/ -> ${text.trim()}`); + } + } + if (voiceHits.length) { + const sample = unique(voiceHits).slice(0, 12).join("\n"); + violations.push(`VOICE GATE: banned vocabulary in added copy. Rewrite per CLAUDE.md Wishonia voice + Vonnegut rule +(plain declaratives, numbers beat adjectives, no corporate verbs, no infrastructure metaphors). +${sample}`); + } + + // --- Check 3: hardcoded numbers in JSX that should use <ParameterValue> - + const paramHits = []; + for (const { file, text } of added) { + if (!file || !file.endsWith(".tsx")) continue; + if (/__tests__|\.test\.|\.spec\./.test(file)) continue; + if ( + />\s*\$?[\d,.]+\s*(million|billion|trillion|years?|days?|hours?|months?|weeks?|%)\b[^<]*</.test(text) && + !text.includes("ParameterValue") && + !/\/\/\s*allow-hardcoded/.test(text) + ) { + paramHits.push(` - ${file} :: ${text.trim()}`); + } + } + if (paramHits.length) { + const sample = paramHits.slice(0, 8).join("\n"); + violations.push(`PARAMETER GATE: hardcoded number(s) in JSX. Use <ParameterValue paramId="..." figures={3} /> so the +citation popover + sig-fig rule fires. Grep packages/data/src/parameters/parameters-calculations-citations.ts +for a matching parameter; add one if it's truly new. Override with '// allow-hardcoded' on the line +only when there's no underlying datum (rare). +${sample}`); + } + + // --- Check 4: CLAUDE.md bloat ------------------------------------------- + if (claudeMd) { + // Count + lines that belong to CLAUDE.md specifically, not the whole diff. + const claudeAdded = added.filter((a) => a.file === "CLAUDE.md").length; + if (claudeAdded > 12) { + violations.push(`CLAUDE.MD BLOAT GATE: CLAUDE.md grew by ~${claudeAdded} added lines. The file's own meta-rule says +"minimum words to convey the rule. One example only." Move detail into .claude/agents/*.md or +.claude/<topic>.md. Trim before committing.`); + } + } + + // --- Check 5: copy-snapshot drift --------------------------------------- + if (uiFiles.length && !copyFiles.length) { + const tsxPageChanges = uiFiles.filter((f) => + /^packages\/web\/src\/app\/.+\/page\.tsx$/.test(f), + ); + if (tsxPageChanges.length) { + violations.push(formatList( + "COPY-SNAPSHOT GATE: page.tsx files changed but no matching page.logged-out.md updated.", + tsxPageChanges, + ` Action: run 'pnpm --filter @optimitron/web copy:preview' to regenerate snapshots, diff the .md + output, then invoke the voice-critic subagent on the diff. Fix anything that drifts toward + startup-bro or away from Wishonia/Vonnegut. Commit the .md files alongside the .tsx.`, + )); + } + } + + // --- Check 6: error swallowing ----------------------------------------- + const swallowHits = []; + for (const line of addedLines) { + if ( + /\bcatch\s*\([^)]*\)\s*\{\s*(\}|\/\/\s*ignore|\/\*\s*ignore)/.test(line) || + /\bcatch\s*\{\s*\}/.test(line) || + /\?\?\s*null\s*[;)}]/.test(line) || + /2>\s*&1\s*[/>]\s*(\/dev\/null|\$null)/.test(line) || + /2>\s*\/dev\/null/.test(line) || + /2>\s*\$null/.test(line) + ) { + swallowHits.push(` - ${line.trim()}`); + } + } + if (swallowHits.length) { + const sample = swallowHits.slice(0, 6).join("\n"); + violations.push(`ERROR-SWALLOW GATE: silent error swallowing in added lines. Catches must rethrow with context, +log via log.error / console.error / Sentry.captureException, or have a one-line comment naming why +the silence is intentional. Our own infrastructure failing (our endpoints, our DB writes, our code +paths) is always an ERROR, not a warning — warnings get ignored. Warn only for things outside our +control (third-party APIs, browser quirks, user network). +${sample}`); + } + + // --- Check 7: email template changes ----------------------------------- + if (emailFiles.length) { + violations.push(formatList( + "EMAIL MINIMALISM GATE: email template / sender changed.", + emailFiles, + ` Action: re-read feedback_email_minimalism — one CTA, no chrome, no system internals leaking. + Run pnpm --filter @optimitron/web e2e:visual (email-screenshots mode) or render the template + preview and inspect the rendered HTML before commit.`, + )); + } + + // --- Check 7b: Vonnegut / blather gate on user-facing copy --------------- + const copyChanges = unique([ + ...uiFiles.filter((f) => /\/page\.tsx$/.test(f)), + ...copyFiles, + ]); + if (copyChanges.length) { + violations.push(formatList( + `VONNEGUT / BLATHER GATE: page.tsx or page.logged-out.md changed. Read the rendered copy. Goal: a +5th grader follows it, nothing said twice, no Stripe-keynote sentences, one primary CTA per screen +above the fold on mobile. Spawn voice-critic on the .md diff if scope is non-trivial.`, + copyChanges, + "", + )); + } + + // --- Check 7c: stupid tests gate ---------------------------------------- + if (testFiles.length) { + violations.push(formatList( + `STUPID TEST GATE: test file(s) changed. For each new test: name the bug it would catch. If you +can't, delete it. No tests for symmetry, documentation, or to silence a bot.`, + testFiles, + "", + )); + } + + // --- Check 7d: reuse / no-duplication gate ------------------------------ + if (newReusableFiles.length) { + violations.push(formatList( + `REUSE GATE: new file(s) under components/ or lib/. Before commit: grep for an existing component/ +function that does the same job. Don't duplicate. Don't add an abstraction nobody extends yet. +Recent miss: org-context-token (full HMAC system for no real threat — should have trusted the URL slug).`, + newReusableFiles, + "", + )); + } + + // --- Emit --------------------------------------------------------------- + if (!violations.length) process.exit(0); + + const banner = `[verify-ui-changes hook] ${violations.length} gate(s) failed. Address each before stopping:`; + const body = violations.join("\n\n"); + process.stderr.write( + `${banner}\n\n${body}\n\n(Commit clears the gate, but only commit AFTER you have addressed each violation above. For each NON-OBVIOUS fix, present 2-3 options via AskUserQuestion — recommendation first + one-line reason — before refactoring. Skip asking only when the fix is mechanical/obvious (typo, missing import, formatter). Calibrate first, then act; don't refactor then ask.)\n`, + ); + process.exit(2); +} catch { + // Fail-open. + process.exit(0); +} + +// --- Helpers --------------------------------------------------------------- + +function git(args) { + try { + return execSync(`git -C "${RepoRoot}" ${args}`, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return ""; + } +} + +function newestFileMtime(dir, fileRx) { + let max = 0; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return 0; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + const sub = newestFileMtime(full, fileRx); + if (sub > max) max = sub; + } else if (fileRx.test(entry.name)) { + try { + const m = statSync(full).mtimeMs; + if (m > max) max = m; + } catch { + // Skip unreadable files + } + } + } + return max; +} + +function unique(arr) { + return Array.from(new Set(arr)); +} + +function formatList(header, files, footer) { + const shown = files.slice(0, 8).map((f) => ` - ${f}`).join("\n"); + const more = + files.length > 8 ? `\n - ...(${files.length - 8} more)` : ""; + const tail = footer ? `\n ${footer.replace(/^\s+/, "")}` : ""; + return `${header}\n${shown}${more}${tail}`; +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..4a145763a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,48 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/pre-write-architecture-check.mjs", + "timeout": 5000 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/pre-commit-checklist.mjs", + "timeout": 10000 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/verify-ui-changes.mjs", + "timeout": 8000 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node .claude/hooks/session-start-pr-check.mjs", + "timeout": 15000 + } + ] + } + ] + } +} diff --git a/.github/scripts/generate-pr-preview-links.mjs b/.github/scripts/generate-pr-preview-links.mjs new file mode 100644 index 000000000..4ef859d65 --- /dev/null +++ b/.github/scripts/generate-pr-preview-links.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +// Generates a markdown table of one-click preview-deploy links for a PR. +// Each row: a route that this PR's diff touches + the auth state to view it +// in. Reviewer clicks the link and lands on the right preview page already +// authed/unauthed via the ?login=demo / ?logout=1 dev-auth query params. +// +// Reads from env: +// PREVIEW_URL - the Vercel preview deployment URL (https://...vercel.app) +// CHANGED_FILES - JSON array of paths changed in this PR, e.g. +// '["packages/web/src/app/treaty/page.tsx", ...]' +// (excludes deleted files — caller's responsibility). +// If unset, falls back to `git diff origin/main...HEAD` +// for local testing. +// +// Writes markdown to stdout. + +import { execSync } from "node:child_process"; + +const PREVIEW_URL = process.env.PREVIEW_URL?.replace(/\/$/, "") ?? ""; +const CHANGED_FILES_JSON = process.env.CHANGED_FILES ?? ""; + +if (!PREVIEW_URL) { + console.error("PREVIEW_URL not set; skipping comment generation."); + process.exit(0); +} + +// AUTH-ONLY routes: render the sign-in page if you hit them logged-out, so +// reviewers need `?login=demo`. Mirrors AUTH_REQUIRED_PATHS in +// packages/web/e2e/utils/static-pages.ts. +const AUTH_ROUTES = new Set([ + "/dashboard", + "/profile", + "/settings", + "/census", + "/check-in", + "/organizations", + "/people/manage", + "/plaintiffs/manage", + "/transmit", + "/admin", + "/mcp/authorize", +]); + +// HYBRID routes: render meaningfully differently when authed vs unauthed. +// Listed twice in the table (once per state) so reviewers compare both. +const HYBRID_ROUTES = new Set([ + "/tasks", // task detail page shows different CTAs based on viewer-claim state +]); + +// Component folder → routes it likely affects. Imperfect but cheap. +const COMPONENT_FOLDER_ROUTES = { + "src/components/dashboard/": ["/dashboard"], + "src/components/treaty/": ["/treaty"], + "src/components/donate/": ["/donate"], + "src/components/landing/": ["/"], + "src/components/referendum/": ["/treaty", "/vote"], + "src/components/signatories/": ["/signatories"], + "src/components/tasks/": ["/tasks"], + "src/components/plaintiffs/": ["/plaintiffs"], + "src/components/endorse/": ["/endorse"], + "src/components/site/": ["/", "/treaty"], +}; + +function getChangedFiles() { + if (CHANGED_FILES_JSON) { + try { + const parsed = JSON.parse(CHANGED_FILES_JSON); + return Array.isArray(parsed) ? parsed.filter((f) => typeof f === "string") : []; + } catch (err) { + console.error(`Failed to parse CHANGED_FILES JSON: ${err.message}`); + return []; + } + } + try { + // Local fallback: name-status to drop deletions so we don't link to 404s. + const out = execSync("git diff --name-status origin/main...HEAD", { + encoding: "utf8", + }); + return out + .split("\n") + .filter(Boolean) + .map((line) => { + const [status, ...rest] = line.split("\t"); + return { status, file: rest.join("\t") }; + }) + .filter(({ status }) => status !== "D") + .map(({ file }) => file); + } catch (err) { + console.error(`git diff failed: ${err.message}`); + return []; + } +} + +// `packages/web/src/app/foo/bar/page.tsx` → `/foo/bar` +// `packages/web/src/app/page.tsx` → `/` +// `packages/web/src/app/tasks/[id]/page.tsx` → `/tasks` +function pageFileToRoute(file) { + const match = file.match( + /^packages\/web\/src\/app\/((?:[^/]+\/)*)page\.tsx$/, + ); + if (!match) return null; + const segments = match[1] + .split("/") + .filter(Boolean) + .filter((s) => !s.startsWith("(") && !s.startsWith("_")) + .map((s) => (s.startsWith("[") && s.endsWith("]") ? null : s)); + if (segments.some((s) => s === null)) { + const truncated = segments.slice(0, segments.findIndex((s) => s === null)); + return truncated.length === 0 ? "/" : `/${truncated.join("/")}`; + } + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function classifyRoute(route) { + if (HYBRID_ROUTES.has(route)) return "hybrid"; + if (AUTH_ROUTES.has(route)) return "auth"; + for (const authRoute of AUTH_ROUTES) { + if (route.startsWith(authRoute + "/")) return "auth"; + } + return "public"; +} + +function buildUrl(route, authParam) { + const path = route === "/" ? "" : route; + return `${PREVIEW_URL}${path}?${authParam}`; +} + +function shortFile(file) { + return file.replace(/^packages\/web\/src\//, ""); +} + +function main() { + const changed = getChangedFiles(); + const routeChanges = new Map(); // route → Set<changed-file-shortnames> + + for (const file of changed) { + const route = pageFileToRoute(file); + if (route) { + if (!routeChanges.has(route)) routeChanges.set(route, new Set()); + routeChanges.get(route).add(shortFile(file)); + continue; + } + for (const [folder, routes] of Object.entries(COMPONENT_FOLDER_ROUTES)) { + if (file.startsWith(`packages/web/${folder}`)) { + for (const r of routes) { + if (!routeChanges.has(r)) routeChanges.set(r, new Set()); + routeChanges.get(r).add(shortFile(file)); + } + } + } + } + + if (routeChanges.size === 0) { + console.log( + "<!-- pr-preview-links -->\n_No user-facing page or component changes in this PR._", + ); + return; + } + + const lines = [ + "<!-- pr-preview-links -->", + "## Preview deploy — one-click review links", + "", + `Latest preview: ${PREVIEW_URL}`, + "", + "| Page | State | What changed |", + "| --- | --- | --- |", + ]; + + const sortedRoutes = Array.from(routeChanges.keys()).sort(); + for (const route of sortedRoutes) { + const files = Array.from(routeChanges.get(route)).sort(); + const filesCell = files.slice(0, 4).join(", ") + + (files.length > 4 ? ` (+${files.length - 4} more)` : ""); + const classification = classifyRoute(route); + + if (classification === "auth") { + lines.push( + `| [\`${route}\`](${buildUrl(route, "login=demo")}) | demo logged-in | ${filesCell} |`, + ); + } else if (classification === "hybrid") { + lines.push( + `| [\`${route}\`](${buildUrl(route, "logout=1")}) | logged-out | ${filesCell} |`, + ); + lines.push( + `| [\`${route}\`](${buildUrl(route, "login=demo")}) | demo logged-in | ${filesCell} |`, + ); + } else { + lines.push( + `| [\`${route}\`](${buildUrl(route, "logout=1")}) | logged-out | ${filesCell} |`, + ); + } + } + + lines.push(""); + lines.push( + "_`?login=demo` signs you in as the demo user; `?logout=1` clears the session. Updated automatically when this PR's preview deploys._", + ); + + console.log(lines.join("\n")); +} + +main(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a0d324bf..8c1385fe0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,8 +63,13 @@ jobs: - name: Build run: pnpm --filter '!@optimitron/web' run build - - name: Verify managed data sync - run: pnpm db:sync:managed-data -- --dry-run + # Dropped the dry-run managed-data check that used to live here: + # the same script runs with `--apply` against a freshly-migrated + # CI Postgres in web-validate's seed step a few minutes later, and + # that hard check catches every schema-data drift the dry-run did, + # without the duplicate failure mode. `--dry-run` is still useful + # locally (`pnpm db:sync:managed-data --dry-run`) to preview a + # diff before pushing. - name: Typecheck run: pnpm --filter '!@optimitron/web' run typecheck @@ -85,7 +90,9 @@ jobs: # peaceiris/actions-gh-pages needs write to push the visual review to # the long-lived `gh-pages` branch. contents: write - deployments: read + # `deployments: write` lets the "Create Visual review deployment" + # step post per-commit deployment annotations in the PR timeline. + deployments: write issues: write pull-requests: write statuses: write @@ -172,17 +179,16 @@ jobs: if: steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm --filter @optimitron/web exec playwright install-deps chromium + - name: Sync managed data (idempotent upserts; matches production) + # Single source of truth for production-worthy bootstrap/reference data, + # campaign records, and task triggers used by post-signin flows. + run: pnpm db:sync:managed-data -- --apply + - name: Run Playwright smoke validation run: pnpm --filter @optimitron/web run e2e -- smoke - - name: Seed visual review data - run: | - pnpm --filter @optimitron/db run seed:bootstrap - pnpm --filter @optimitron/db run seed:demo - pnpm --filter @optimitron/db run seed:tasks - pnpm db:sync:managed-data -- --apply - - name: Run Playwright visual review + id: visual_regression env: ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_IGNORE_UPLOAD_FAILURES: ${{ secrets.ARGOS_TOKEN == '' && '1' || '0' }} @@ -209,11 +215,15 @@ jobs: rm -rf "$baseline_root" mkdir -p "$baseline_root" + # `--limit 5` instead of 20: the previous successful main run + # virtually always has the artifact. Iterating 20 deep mostly + # burned API calls + network setup time without finding anything + # the first 5 didn't. mapfile -t run_ids < <(gh run list \ --workflow CI \ --branch main \ --status success \ - --limit 20 \ + --limit 5 \ --json databaseId \ --jq '.[].databaseId') @@ -264,6 +274,13 @@ jobs: }); for (const deployment of deployments) { + // Skip our own visual-review deployments — we set up + // `visual-review/pr-N` to drive the inline timeline annotation + // for the gh-pages review URL, but here we want the *app* + // preview URL (Vercel) to feed VISUAL_REVIEW_BASE_URL. + const env = deployment.environment || ''; + if (env.startsWith('visual-review')) continue; + const statuses = await github.rest.repos.listDeploymentStatuses({ owner, repo, @@ -282,11 +299,66 @@ jobs: return ''; + - name: Post PR preview links comment + if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.pr_preview_url.outputs.result != '' }} + uses: actions/github-script@v8 + env: + PREVIEW_URL: ${{ steps.pr_preview_url.outputs.result }} + with: + script: | + const { execFileSync } = require('node:child_process'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + // Pull changed files from the PR API (works with depth-1 checkout). + // Filter out deletions so the table doesn't link to 404s. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: issue_number, per_page: 100, + }); + const changed = files + .filter((f) => f.status !== 'removed') + .map((f) => f.filename); + + const stdout = execFileSync( + process.execPath, + ['.github/scripts/generate-pr-preview-links.mjs'], + { + env: { + ...process.env, + CHANGED_FILES: JSON.stringify(changed), + }, + encoding: 'utf8', + }, + ); + const body = stdout.trim(); + if (!body) return; + + const marker = '<!-- pr-preview-links -->'; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body, + }); + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number, body, + }); + } + - name: Build visual review index if: always() env: VISUAL_REVIEW_COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} VISUAL_REVIEW_BASE_URL: ${{ steps.pr_preview_url.outputs.result }} + # Feeds the per-route "Copy context" button on latest.html so the + # generated clipboard payload includes the right PR/branch/repo + # identifiers for whoever the reviewer is pasting it to. + VISUAL_REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} + VISUAL_REVIEW_HEAD_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} + VISUAL_REVIEW_REPO: ${{ github.repository }} run: pnpm --filter @optimitron/web run visual:review - name: Summarize visual review @@ -326,8 +398,14 @@ jobs: # One-time repo setup: Settings → Pages → Source must be set to # "Deploy from a branch" with branch `gh-pages` (root). The action # creates the branch on its first push. + # Gate the publish chain on the visual-regression step succeeding, + # not just on `!cancelled()`. A test failure produces incomplete + # screenshots ("62 missing pairs"); publishing that to gh-pages + # gives reviewers a misleading review URL. Real test breakage + # already surfaces as the web-validate job failing — the artifact + # upload still runs `always()` so debugging info isn't lost. - name: Prepare per-PR visual review directory - if: always() && github.event_name == 'pull_request' + if: ${{ steps.visual_regression.outcome == 'success' && github.event_name == 'pull_request' }} id: prepare_pages shell: bash run: | @@ -374,7 +452,7 @@ jobs: echo "publish_dir=packages/web/output/playwright/pages-per-pr" >> "$GITHUB_OUTPUT" - name: Publish visual review to gh-pages - if: always() && github.event_name == 'pull_request' + if: ${{ steps.visual_regression.outcome == 'success' && github.event_name == 'pull_request' }} id: visual_review_pages uses: peaceiris/actions-gh-pages@v4 with: @@ -387,8 +465,13 @@ jobs: user_name: github-actions[bot] user_email: github-actions[bot]@users.noreply.github.com + # Only post the status link AFTER peaceiris/actions-gh-pages + # actually succeeded — otherwise reviewers click through to a + # 404 (publish skipped or failed but status URL already pointed + # there). `steps.visual_review_pages.outcome == 'success'` ensures + # gh-pages has the path before we advertise it. - name: Post Visual review commit status - if: always() && github.event_name == 'pull_request' + if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.visual_review_pages.outcome == 'success' }} uses: actions/github-script@v8 with: script: | @@ -407,6 +490,51 @@ jobs: target_url, }); + # Create a GitHub Deployment + DeploymentStatus per commit so the PR + # timeline shows an inline "deployed to visual-review X minutes ago" + # annotation under each commit, the way Vercel's bot does. Each commit + # gets its own annotation linking to the per-commit gh-pages URL, + # which is immutable thanks to keep_files: true on the gh-pages + # publish step. transient_environment: true tells GitHub to retire + # older deployments for this environment automatically. + # Same publish-succeeded gate as the commit status above — the + # inline timeline annotation points to the same URL, no point + # posting it if gh-pages doesn't have the path yet. + - name: Create Visual review deployment + if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.visual_review_pages.outcome == 'success' }} + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const headSha = context.payload.pull_request.head.sha; + const shortSha = headSha.slice(0, 12); + const target_url = `https://mikepsinn.github.io/optimitron/pr-${issue_number}/${shortSha}/latest.html`; + const environment = `visual-review/pr-${issue_number}`; + + const { data: deployment } = await github.rest.repos.createDeployment({ + owner, + repo, + ref: headSha, + environment, + auto_merge: false, + required_contexts: [], + transient_environment: true, + production_environment: false, + description: `Visual review · ${shortSha}`, + }); + + await github.rest.repos.createDeploymentStatus({ + owner, + repo, + deployment_id: deployment.id, + state: 'success', + environment, + environment_url: target_url, + log_url: target_url, + description: 'Visual review ready', + }); + - name: Upload Playwright artifacts if: failure() uses: actions/upload-artifact@v6 @@ -494,7 +622,9 @@ jobs: PRISMA_SCHEMA_DISABLE_ADVISORY_LOCK: "1" run: pnpm db:deploy - - name: Sync managed production data + - name: Sync canonical production data (idempotent upserts) + # Single managed sync for reference data, campaign records, treaty tasks, + # referendums, demo fixtures, and trigger blueprints. env: DATABASE_URL: ${{ secrets.DATABASE_URL }} run: pnpm db:sync:managed-data -- --apply diff --git a/.gitignore b/.gitignore index 3399aa89b..50b405797 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ packages/web/public/audio/tts-test/ packages/web/public/media/ .vercel +.claude/scheduled_tasks.lock +.codex-logs/dashboard-dev.pid +.tmp-pr-comments.json diff --git a/.husky/pre-commit b/.husky/pre-commit index 912250da5..26d96bac8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,20 @@ #!/bin/bash +set -e + pnpm exec lint-staged + +# === Copy preview markdown regen === +if git diff --cached --name-only -- packages/web/src packages/web/public packages/web/scripts/render-pages-to-markdown.ts packages/web/package.json | grep -q .; then + if ! node -e "fetch('http://127.0.0.1:3001/api/auth/csrf',{signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; then + echo "Copy preview markdown needs the local web server at http://127.0.0.1:3001." + echo "Start or reuse it with: pnpm --filter @optimitron/web dev:fast" + exit 1 + fi + + pnpm --filter @optimitron/web run copy:preview -- --no-authed + if ! git diff --quiet -- packages/web/src/app/page.logged-out.md 'packages/web/src/app/**/page.logged-out.md'; then + echo "Copy preview markdown was refreshed." + echo "Review the page.logged-out.md diffs against docs/h2ewd.md, stage them, then commit again." + exit 1 + fi +fi diff --git a/AGENTS.md b/AGENTS.md index dc6a2311a..9c791e2f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ - When implementation is done and checks pass, commit the intended changes, push the branch, and update the existing pull request for that branch or task. Create a new pull request only when no open PR exists for the work. - Do not create draft pull requests unless the human explicitly asks for a draft. Open normal pull requests so CI and review automation run immediately. - After every push, check GitHub Actions, deployment checks, and pull request review comments, then keep doing useful non-conflicting work while they run. Do not sit idle watching checks unless the next step is genuinely blocked on the result. Re-check periodically, fix valid failures or comments, push again, and repeat. +- Do not use long blocking PR-check watches. Poll with bounded commands so new human messages can be handled. - **Do not blindly comply with bot reviewers** (Codex, Copilot, CodeRabbit, Vercel Agent Review). For every comment ask: does this point at a real bug that hits a real path? If yes, fix and mark resolved. If it's AI slop, hypothetical, stylistic, "for symmetry", "for consistency", or extract-this-constant nagging, mark the thread resolved with a one-line reason ("hypothetical, no triggering path", "stylistic, current shape is intentional", "already addressed in commit X"). Adding code to silence a bot adds maintenance surface forever in exchange for one-time review noise. Stay in the driver's seat. - **Never write worthless tests.** A test exists to catch a bug or guard a regression in code that ships. Tests added "for symmetry", "for documentation", "for consistency", or because a reviewer asked are pure cost. Skip them. Mock-and-assert-the-mock tests (mock `foo`, assert `foo` was called) test nothing — test the boundary, not the wiring. See `CLAUDE.md` "Testing Rules" for the full list. - Never merge pull requests. Once checks are green and there are no unresolved valid review complaints, report that the pull request is ready and let the human review the diff and merge it. diff --git a/CLAUDE.md b/CLAUDE.md index d720d9c43..8fc7f2e0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,37 @@ Everything user-facing is narrated by **Wishonia** — _World Integrated System **No startup-bro copy.** No infrastructure metaphors (stack, rails, off-ramp, primitive, substrate), empty mechanism vocabulary (incentive layer, the protocol that, fundamentally), or corporate openers (We're building, Let's take a moment). Bad: *"The treaty is the off-ramp. The Court is the road that produces the off-ramp."* If a sentence could appear unchanged in a Stripe keynote, rewrite. +**Write like Kurt Vonnegut.** Plain declaratives. Verb-first imperatives for buttons ("Do this.", "Sign.", "Done."). Banned: "Take ownership", "Engage", "Empower", "Unlock", "Streamline", "Take this on", "Get started", and any other corporate-onboarding verb. + +**Reuse before rewrite.** Before writing a new component, grep `packages/web/src/components` for similarly-shaped JSX (share box, signature box, counter, markdown render, parameter display). If you find a match, use it. + +**`<ParameterValue>` for every user-facing number.** Grep `packages/data/src/parameters/parameters-calculations-citations.ts` for a matching parameter before typing a number. Default `figures={3}` on calculator pages. + +**Catch users at peak commitment.** After a YES action, render the next step inline. Never punt with "the dashboard has X." + +**Git archaeology before "restore".** When asked to bring back an old layout: `git log -S "phrase"`, cite the source commit. Don't reconstruct from memory. + +**Verify the deployed state.** "tsc clean" is not "shipped." Run `pnpm --filter @optimitron/web review:local` and look at the rendered page, or say "this is on the way, can't verify from here." + +**Update `TODO.md` in the same commit** as the work it covers — both the check-box and any new follow-up lines. Deferred decisions ("we'll do X later", "real fix is upstream") go in TODO.md the same turn. Subagent prompts include the relevant TODO.md slice as context so they don't re-decide architecture in isolation. + +**Pre-architect Read + Stop signal** are now enforced by hooks (PreToolUse on Write to `packages/*/src/` etc.; UserPromptSubmit detecting "should it really / I thought / aren't we" phrases). When a hook fires, treat its output as authoritative — don't argue past it. The hook exists because the equivalent CLAUDE.md rule was being ignored. + +**Diagram-before-code** for non-trivial changes. When a change touches >1 system (DB + deploy + CI; UI + API + DB), or you estimate >100 lines new, or the user used "I thought we had / aren't we / why is this so" phrasing: draw current + proposed flow (ASCII boxes or terse prose) in chat BEFORE the Write/Edit. User reacts to the diagram, you iterate on text not code. Trivial fixes (typo, single-line, isolated bug) skip this. + +**Fetch the rendered page, don't infer from the codebase. AND fetch the right page.** When the user asks about UX, user journey, page copy, "is the page good" — fetch the actual rendered page first, then answer. Codebase = committed; rendered page = live; they drift (server/client boundaries, env routing, site variants, DB content). + +**Which page to fetch:** +- Reviewing an unmerged PR → fetch the PR's **PREVIEW DEPLOY** via Vercel MCP (`web_fetch_vercel_url`) or via curl with the `_vercel_share` bypass token. Production is STALE relative to unmerged PR work — fetching warondisease.org for a question about "what /treaty looks like on PR 75" gives you the main branch's pre-PR rendering, which is wrong for the PR review. +- Reviewing landed work / production behavior / "what does the live site show?" → fetch the production domain (warondisease.org, optimitron.com). +- Local dev work → fetch http://localhost:3001 if dev server is running. + +Default to PREVIEW DEPLOY when the conversation context is "this PR / this branch / what does my recent commit look like." Default to PRODUCTION only when the user explicitly says "production" or asks about the live site separately from the PR. + +**Preview-link list format.** When generating a review-link list for the user: every URL must be ONE click — full path + `?_vercel_share=<token>&login=demo` (auth-required), or `&logout=1` (testing logged-out state), or both as TWO rows (HYBRID pages that render differently per auth state). NEVER output "click here to set the bypass cookie, then bare URLs" — that defeats the entire reason `?login=demo` / `?logout=1` query params exist. Format: a single markdown table with columns `Page | State | What changed`. State = "logged-out" / "demo logged-in". + +**Subagents** live in `.claude/agents/`: `voice-critic` (post-UI copy critique), `pr-comment-triager` (bot-review triage), `test-auditor` (suite slop + missing coverage). Their `.md` files have the full instructions. + **Employees, not opponents.** Frame leader outreach as "remind your overdue presidents/employees," never "pressure politicians." They are paid by the citizenry to promote welfare and are late on a 30-second task. Banned: "pressure," "political pressure," "pressure surface/machine," "applied pressure" when referring to leaders. **Apply to:** all user-facing copy. **Not to:** CLAUDE.md, code comments, README. @@ -165,6 +196,7 @@ The task tree has a single root: `optimize-earth` (taskKey `program:optimize-ear 5. **Respect review-only turns.** If the user asks only for analysis, review, or a proposed copy/design, do not commit or push until they approve implementation or publishing. 6. **Library packages stay runtime-safe.** No Prisma / runtime DB in `optimizer`, `wishocracy`, `opg`, `obg`, `data`, `agent`, `hypercerts`, `storage`. 7. **Zod only at real boundaries** — HTTP, form, MCP, OAuth. Not internal helpers. +8. **Calibrate before major refactors.** For multi-file refactors, deleting abstractions, or replacing auth/security controls, present 2-3 options with your recommendation first. Once a preference is clear for that decision class, proceed without re-asking. ## UI/UX Rules diff --git a/TODO.md b/TODO.md index 1767ffdac..96f3039e2 100644 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,8 @@ Until the 1% Treaty passes, this repo is in campaign mode. - `optimize-earth` exists as the root task key/id, and the canonical campaign task tree now syncs through managed data so source-controlled data, production rows, MCP, API, and pages cannot drift. +- The managed canonical task sync work from `feature/managed-task-tree-sync` is now + on `main` via `PR #71` and drives production deploy via CI. - `/humanity-v-government` renders the operational case. `/court` exists but still needs the live Court surface, plaintiff/juror counter, and final treaty-as-verdict framing. @@ -80,12 +82,10 @@ Ordered by funnel-stage impact. P0 = ship next; P1 = right after; P2 = before la ### P0 — Managed canonical data sync (seed replacement for semi-permanent rows) **Problem decided 2026-05-10:** normal Prisma migrations are the wrong tool for -every title/task-tree/court/trigger tweak, but `seed.ts` alone is also wrong: -production deploy currently runs `pnpm db:deploy` and `pnpm db:seed:triggers`, -not `seed:tasks` or full `db:seed`. A seed edit will update fresh/local/CI DBs -but will not automatically update production. Missing-from-seed also cannot -safely imply "delete this row" because user-created records live in the same -tables. +every title/task-tree/court/trigger tweak. Production-worthy data belongs in +managed sync, not in a separate seed-only path. Missing-from-manifest also +cannot safely imply "delete this row" because user-created records live in the +same tables. **Target pattern:** source-controlled managed data with an idempotent sync script. @@ -98,10 +98,8 @@ tables. `retired: true`. - Add a package/root script such as `pnpm db:sync:managed-data`. - Run it on production deploy after `pnpm db:deploy` and before Vercel deploy. - Keep `db:seed:triggers` until triggers are folded into managed data. -- Keep normal `seed.ts` for local/reference/demo/bootstrap convenience, but have - it call the same managed-data sync helpers where possible so fresh databases - and production converge. +- Keep `packages/db/prisma/seed.ts` only as Prisma's tiny entrypoint shim. + Managed sync is the source of truth for local, CI, preview, and production. - Do not use "record missing from manifest" as a global delete rule. Deletion is safe only inside a named managed collection, and only for rows previously owned by that collection or explicitly marked retired. @@ -113,9 +111,9 @@ seed-only cleanup approach with this, then retire old direct children like `dfda` / `bed-nets-funding-gap` through managed data rather than bespoke migrations for every future edit. -**Branch status:** `feature/managed-task-tree-sync` adds managed sync for the -canonical `Task` tree and task trigger blueprints, with dry-run/apply modes, -seed reuse, and production deploy wiring. +**Branch status:** `main` now includes this work in `PR #71` (`feature/managed-task-tree-sync` +merged). Managed-task sync for the canonical `Task` tree and task trigger blueprints +ships with dry-run/apply modes, seed reuse, and production deploy wiring. **Testing:** one focused unit/integration test for sync semantics: @@ -206,6 +204,57 @@ This is the compaction-safe backlog of chat decisions that have not obviously landed yet. Some items also have detailed sections below; this list is the cross-check so they do not disappear into chat history. +**2026-05-11 session — completed (on feature/treaty-dashboard-message-first / PR #75 unless noted)** + +- [x] Visual-review per-PR persistence (PR #76 merged). `peaceiris/actions-gh-pages` with `keep_files: true` to a long-lived `gh-pages` branch; each commit lands at `pr-N/<short_sha>/` so older review URLs survive newer pushes. +- [x] LiveCounter visual-review mask (PR #76 merged). Component honors `__OPTIMITRON_VISUAL_REVIEW__` runtime flag, emits both `data-visual-mask="dynamic"` and `data-volatile` for screenshot + markdown-preview tooling. +- [x] Lightbox on visual-review HTML — click a screenshot to open full-viewport, click again for 1:1 zoom, Esc/close button to dismiss. +- [x] Email-template screenshots in visual review. `e2e/email-screenshots.spec.ts` renders magic-link / task-assignment / task-comment-notification / post-vote-share / referral-first-conversion / monthly-chain-digest at 720×1000 and feeds them into the same `screenshots/<project>/` tree the review HTML walks. Required adding the spec to `MODE_SPECS.visual` in `run-playwright.mjs`. +- [x] Visual review toolbar: live route-name filter, Expand all / Collapse all, "Only show changed" (actually hides unchanged), `/` keyboard focuses the filter input. +- [x] Per-route "📋 Copy context" button on visual review. Payload includes PR + branch + commit SHA, route + auth state, before/after screenshot URLs, and explicit "please `curl -O` these before responding" instructions for the coding agent. Embedded `data-context` JSON; JS click handler formats markdown and writes to clipboard. +- [x] Inline PR-timeline deployment annotation per commit (Vercel-bot style), replacing the sticky comment. Uses `createDeployment` + `createDeploymentStatus` with `environment: visual-review/pr-N`. +- [x] CSRF flake mitigation: `retries: isCI ? 2 : 0` in `playwright.config.ts`. `tasks-index-auth` had hit `ECONNRESET` on `/api/auth/csrf` three times in one session. +- [x] Cancel-safe gh-pages publish — visual-review publish steps now gated on `!cancelled()` so a concurrency-cancelled run doesn't post a partial review with "62 missing pairs". +- [x] Commit-status + deployment annotation only when publish succeeded — `steps.visual_review_pages.outcome == 'success'` gate so reviewers don't click dead links. +- [x] CI baseline loop `--limit 20 → 5` for main `web-visual-review` artifact lookup. The previous successful main run virtually always has the artifact. +- [x] Dashboard share card rewrite (`DashboardShareCard.tsx`). Replaced "Each voter who recruits two more is the campaign." marketing line with: Humanity Manager assignment frame + apocalypse math (122 apocalypses → 12.3× more clinical trials, 443yr → 36yr eradication timeline). Every number sourced from `@optimitron/data/parameters` via `<ParameterValue>` for citation popovers. +- [x] `/treaty` restored to the original commit-`1c58293e` skim-and-sign layout. Single centered serif headline ("Please quickly skim and sign to end war and disease."), continuous treaty body, single signature box. No stepper, no slide split, no decorative dividers, no competing Court CTA. Added a `/treaty` Playwright regression test (`e2e/treaty-page-structure.spec.ts`) asserting headline + treaty body phrases + Yes/No buttons. +- [x] `/treaty` body fallback. `getReferendumPageContent()` now falls back to bundled `shareableSnippets.onePercentTreatyText.markdown` when the DB row's `bodyMarkdown` is null/empty — previously preview deployments with unseeded DBs rendered only the headline + signature box. +- [x] `/signatories` cleanup — removed top "Public record / Signatories / Humans and organizations…" block and the "Living votes / Represented humans / Memorial votes / Total voices" stats box. Just the leaderboard. +- [x] `/tasks/[id]` cleanup — removed the verbose `<dl>` metadata sidebar (Owner / Progress / Time needed / Area / Completed / Updates) that duplicated header info. Kept Deaths-from-delay + Wasted-by-delay as inline tags above the markdown body. Effort hours moved into the inline header metadata strip. +- [x] `HUMANITY_V_GOVERNMENT_CASE_NAME` canonical constant in `@optimitron/db/task-keys`, sourced by `humanityVGovernmentLink.label`, `/court` page copy, and managed-task-tree titles. Replaces the drift between "Humanity v. Government" and "Humanity v. Governments of Earth". +- [x] CLAUDE.md voice rule reinforced — "Write like Kurt Vonnegut. Plain words. Short declaratives." Button labels and microcopy default to verb-first imperatives; banned list includes "Take ownership", "Engage", "Empower", "Unlock", "Streamline". +- [x] Nav label rename: `tasksLink.label` "Tasks" → "To-Do List for Humanity", CTA "Open Tasks" → "Open the list". +- [x] CodeRabbit cleanup (commit `5872a64b`): visual-review/* deployments excluded from preview-URL discovery; `<details>` route anchors carry `id="route-<slug>"` so copied URLs scroll; `getRecipientReferralUrl` failures no longer abort task-assignment / task-comment notification batches. + +**2026-05-11 session — discussed but not yet implemented** + +- Task-list rows fully clickable. Currently inner `<Link href={assigneeHref}>` on the avatar / name traps clicks and navigates to the assignee's person page instead of the task. Task lists (not the detail page) should treat the entire row as a single link to `/tasks/<id>`; assignee navigation lives on the detail page itself. Affects `task-row.tsx` across the `signer` / compact variants — replace inner `<Link>` wrappers with non-interactive spans. +- Avatar next to assignee on `/tasks/[id]` header. Currently shows just "Assigned to <name>" as text; should render the assignee's avatar inline so the page matches the visual density of task lists. +- Decide what to do with the task claim button (no consensus yet). Current behavior: logged-out users see nothing, logged-in users see "Claim Task". User flagged the verb "claim" as bad. Two open questions: (1) keep / drop the logged-out sign-in nudge entirely; (2) rename "Claim Task" to a Vonnegut-style verb ("Do this." is the working candidate — NOT "Take this on", that was rejected as corporate-onboarding). +- Reframe `formatEnumLabel(viewerClaim.status)` output in the task-detail viewer state strip — current "Claimed" / "In Progress" / "Completed" / "Verified" labels leak the enum into user copy. +- Remove drop-shadow on the Updates-section "Sign In" button + audit all other buttons that still carry hard-offset / soft shadows. CLAUDE.md already says "no shadows by default" — the Updates Sign-In on a logged-out task page is a known offender. +- Investigate Neon DB branch-per-preview-deployment. Currently Vercel previews point at whatever `DATABASE_URL` is set on the preview environment — there's no managed-data sync against a per-PR DB, so previews show stale/missing seed data (which is why the `/treaty` row had a null `bodyMarkdown` and surfaced the page bug above). The Vercel Neon integration creates a branch per PR and runs migrations automatically; main alternative is a `sync-on-preview` workflow step that hits a preview-scoped DB. +- [x] Drop the duplicate dry-run managed-data step from `core-validate`. Web-validate's `--apply` against the freshly-migrated CI Postgres catches the same drift and the dry-run step's diff was always identical (empty CI DB → "would create N rows" every time). Shipped in `0810ecaa`-era; web-validate now logs the plan before applying so the diff is still visible in CI logs. +- [x] `voice-critic` Claude Code subagent at `.claude/agents/voice-critic.md`. Reads diffs against the Vonnegut voice rule, reuse-before-rewrite inventory, ParameterValue rule, peak-commitment rule, and significant-figures floor. Spawn after any user-facing copy / UI change. +- [x] `pr-comment-triager` Claude Code subagent at `.claude/agents/pr-comment-triager.md`. Walks open PR comments, classifies valid vs. AI-slop, fixes valid ones in focused commits, resolves slop with on-thread reasons. Refuses to blindly comply with bots. +- [x] `test-auditor` Claude Code subagent at `.claude/agents/test-auditor.md`. Walks the test suite for the slop patterns the Testing Rules section bans, finds flaky tests in CI history, identifies critical untested paths. Returns delete + add + flaky lists. +- [x] Local review pipeline: `pnpm --filter @optimitron/web review:local` runs `copy:preview` (markdown extract) + Playwright visual regression + builds the review HTML + opens it. Requires `next dev` on :3001 separately. (Originally scoped a `review:watch` variant too; dropped pre-ship — full Playwright run is 2-4 min, so debouncing source changes into a watcher would burn cycles on results that arrive after the developer has moved on.) +- Speedup attempt redo: path-filter `web-validate` so non-web PRs short-circuit. Previous attempt (`b50469063`) produced an unparseable workflow file; needs smaller incremental commits this time to isolate which construct GitHub objected to. +- Build a `/dev/email/<template>` Next.js preview route that renders each email template's HTML server-side (no client JS, no DB round-trip required for templates that don't need one). Replace the `e2e/email-screenshots.spec.ts` direct imports of `…-email.server.ts` modules with `page.goto('/dev/email/post-vote-share')` and screenshot — that path avoids the Playwright-transformer/`@optimitron/db/dist` `export *` parsing problem that knocked email screenshots out of visual mode. Once that's in, re-add `email-screenshots.spec.ts` to `MODE_SPECS.visual`. +- Add a banner on `latest.html` when missing-screenshot pairs exist, explaining cause (optional route absent on baseline, route skipped because returned 401/403/404, etc.) instead of just rendering N "not captured" boxes. The new publish gate (`steps.visual_regression.outcome == 'success'`) blocks the "all 62 missing" case, but legitimate per-route omissions still need explanation. +- Vercel preview-ready watcher + auto-screenshot of changed routes. When a Vercel deployment for the current branch transitions to READY: identify routes changed by the PR (`git diff origin/main...HEAD --name-only` filtered to `packages/web/src/app/**/page.tsx`), use chrome-devtools MCP (via `mcp__claude_ai_Vercel__get_access_to_vercel_url` for auth-gated previews) to screenshot each route, write `packages/web/output/playwright/pr-watch/<deploy-id>/review.html`, surface the preview URL + local review HTML link inline in chat. Currently the `/loop` cron `0feea8a0` (every 15 min, auto-background pattern: foreground spawns bg agent + exits) hits gh+Vercel state but the actual screenshot pipeline isn't wired yet. Deferred 2026-05-11 to keep the foreground turn short. +- Stop-hook check: detect "we should..." / "let's do that later" / "for now..." style deferrals in my own outputs and gate Stop until I capture them in TODO.md. Hard to detect reliably with regex; the memory rule `feedback_capture_decisions_in_todo` is the manual version for now. +- **Unify seed.ts + managed-data sync** (decided 2026-05-11, implemented on `feature/treaty-dashboard-message-first`). End state: single `pnpm db:sync:managed-data -- --apply` runs in prod / preview / CI / local; `seed.ts` is only a Prisma entrypoint shim. + - [x] grandma-kay (commit `03c83520`) + - [x] demo-user (commit `47b900d7`) + - [x] reference data, catalog data, reasoning data, and treaty accountability tasks now live under `packages/db/src/managed-data`. + - [x] **task-triggers** — managed trigger blueprints and idempotent upsert sync live in `packages/db/src/managed-data`; runtime firing, resolvers, MCP admin tools, and interactive create/update helpers stay in `packages/web`. +- [x] Add `Referendum` to the managed-data sync (commit `121e71df`). New `packages/db/src/managed-data/managed-referendums.ts` is the single source of truth for treaty + declaration + court-of-humanity. `syncManagedReferendums()` upserts on every deploy with content-hash change detection. `seed.ts` delegates to the same sync. CI ordering also fixed (`9891c60c`) — seed now runs before smoke, so the bundled-markdown fallback in `referendum-content.server.ts` now only fires for the legit preview-DB-with-null-body case and won't be exercised by the test path. +- Extract the large `actions/github-script@v8` inline-JS blocks in `ci.yml` (Resolve PR preview URL, Create Visual review deployment) into versioned `.github/scripts/*.js` files. Inline-in-YAML is fine for <20 lines; the two listed are 24-28 lines with non-trivial logic worth diffing + linting. +- Persisted organization grant request/application workflow. Current batch keeps grant impact as calculator/request framing only; add storage, review status, and automated foundation outreach in a later focused pass. +- Repo-wide "122 apocalypses" copy audit. List every version, rank clarity/funniness, and normalize the strongest explanation after this merge. + **Current branch hygiene** - After each push, keep working on local tasks while GitHub Actions run instead @@ -290,6 +339,38 @@ cross-check so they do not disappear into chat history. **Public copy, messaging, and emails** +- Post-vote forward email + first-conversion email shipped (PR3). Voter receives + a forward-friendly share kit on YES treaty vote. Referrer receives a single + "Your link worked. Round 1 of 32" email on their first conversion only — + never on subsequent conversions. Both deduped via `EmailLog.dedupeKey`. +- Monthly chain digest shipped (PR #74). Cron at `0 14 1 * *` calls + `/api/cron/monthly-chain-digest`, which iterates every YES treaty voter + with an email and sends one of two variants picked by past-30-day direct + conversion count `N`: + - `N > 0`: positive reinforcement. Subject names the count + month; + body shows monthly + all-time totals + doubling-rounds math + + dashboard link + canonical share footer. + - `N == 0`: resend the forward kit. Subject `Still 30 seconds. Still + two humans you love.` Body is the canonical share message verbatim. + The zero-conversion user is exactly who needs the nudge; silence + would have treated unconverted as user-failure when it's actually + a we-failed-to-activate signal. + Deduped per user per calendar month via + `EmailLog.dedupeKey` = `monthly-chain-digest:{userId}:{yyyy-mm}`. + Future enhancement: replace direct count with a transitive recursive CTE + so the digest can show full chain size + which doubling round the user + is actually on, not just direct conversions. +- `<ShareFooter>` retrofit shipped (PR #74) on `task-assignment-notification` + and `task-comment-notification`. Both fetch the recipient's referral URL + when `recipientUserId` is set and append the canonical share kit; external + recipients (leaders' offices) get the email without the footer. +- [x] Email-template screenshots in the visual review. Implemented in + `packages/web/e2e/email-screenshots.spec.ts`: renders magic-link, + task-assignment, task-comment-notification, post-vote-share, + referral-first-conversion, and monthly-chain-digest with representative + tokens, screenshots them at email-client widths, and feeds them into the + same `screenshots/<project>/` tree that `build-visual-review.mjs` walks. + Email-* rows now appear alongside page screenshots in `latest.html`. - Move remaining dashboard/page copy into the messaging/copy-review system where practical, especially Treaty Dashboard text and major CTAs. - Continue internationalization groundwork by centralizing public copy in JSON or @@ -307,18 +388,21 @@ cross-check so they do not disappear into chat history. **Campaign pages and funnels** -- Add the plaintiff damages surface on `/plaintiffs` so visitors see the per- +- [x] Add the plaintiff damages surface on `/plaintiffs` so visitors see the per- plaintiff recovery frame without first reading the case page. -- Add a live plaintiff/juror counter on `/court`. -- Finish `/court` as the Court of Humanity surface, with the case, verdict, and +- [ ] Add a live plaintiff/juror counter on `/court`. +- [ ] Finish `/court` as the Court of Humanity surface, with the case, verdict, and plaintiff/juror mental model connected to the 1% Treaty. - Decide/create the "summon jurors" route if existing referral pages do not give a clean standalone target. - Split dashboard vs president management: dashboard should link to president pressure; `/employees` or a clearer `/presidents` route should own the full president-management surface. -- Add sitemap entries for public organizations, `/humanity-v-government`, and +- [x] Add static/explicit sitemap coverage for `/humanity-v-government` and `/court`. +- [x] Add sitemap entries for public organizations. +- [ ] Split sitemap outputs when `500+` detail rows exist per type (tasks, people, + orgs) instead of silent truncation. **Navigation and information architecture** @@ -896,6 +980,57 @@ consumed. Mail clients would not visually thread the conversation; in-app not the same decision as the War on Disease campaign default; do not reopen it while the treaty campaign is the active bottleneck. +## Humanity v. Government — plaintiff-first reframe + Earth Optimization Day + +Strategic decision (2026-05-12): `/humanity-v-government`'s primary action becomes plaintiff registration, not the verdict vote. Plaintiffs are a stock that compounds (named, persistent, family-network-effected). Votes are a flow that decays (anonymous, easy to dismiss). A list of named dead is harder to ignore than a YES tally. The verdict vote moves to a seasonal mode tied to Earth Optimization Day. + +### `/humanity-v-government` rework + +- Hero: indictment + "if this were a corporation" framing (KEEP — it's the translation hook for non-legal readers) + single CTA "Name your dead" (plaintiff registration). +- Render running plaintiff count near hero. Concrete artifact > tally tick. +- Drop hero CTAs #2 ("Support the settlement" → `/vote`) and #3 ("Read the evidence" → external manual). Demote to footer. +- Cut decorative redundancy: collapse "The case caption" `<dl>` block, merge "If this were a corporation" + "Why this is a case" into one section. +- Move `DamagesSensitivityCalculator` up to be adjacent to / above the damages cards (it informs the vote; currently buried at page bottom). +- "The usual defenses" → collapsed `<details>` disclosure. +- Verdict vote widget stays but becomes a secondary action outside the EOD window. + +### Make damages counterfactual explicit + +Current copy says "since 1900, they spent $170T on war." Never makes the comparison explicit. Add one sentence stating: damages = what humanity would have had if governments had signed the 1% Treaty in 1900, freezing military spending and redirecting it to productive purposes (clinical trials, public goods). The numbers ($538K floor, $913K demand, $2.74M treble) ANSWER that counterfactual; right now they float without a baseline. + +### Earth Optimization Day (new — August 6, Hiroshima anniversary) + +- Annual global voting event. Aug 6 = the day humanity proved it could kill itself wholesale and decided not to stop. +- Frame: "distributed denial of death attack on humanity" — concentrated global vote on both the Court of Humanity verdict in Humanity v. Government AND the 1% Treaty. +- New page `/earth-optimization-day`. No existing implementation (only `earth-optimization-prize.ts` parameter + audio narration file). +- Single config function `isEarthOptimizationDayWindow()` flips the site into EOD mode during a configurable window (e.g. Aug 1–13). During the window: `/humanity-v-government` hero CTA swaps from "Name a plaintiff" to "Vote the verdict"; landing page hero swaps to countdown + live tally. +- Year-one MVP: countdown page + RSVP form + the two existing vote widgets. Mature version: live tally, post-event results page (verdict count, plaintiff count delta, treaty-vote delta). + +### Open questions before implementation + +1. **Plaintiff registration friction.** What's the current friction on `/plaintiffs`? Just name + cause-of-death, verified, or public/private toggle? Determines whether inline form on `/humanity-v-government` works or whether it has to link out. +2. **Non-bereaved users.** Should users without a personal loss be able to register as "plaintiffs of conscience" (public figure / general category), or are they only verdict-voters? +3. **Verdict vs treaty relationship.** Currently `/humanity-v-government`'s verdict and `/vote`'s treaty are separate referendums. Should a YES on the verdict imply a YES on the treaty (the settlement), or stay separate? +4. **EOD frequency + scope.** Annual Aug 6 only, or include solstices / other dates? Country-specific variants? + +### Phase 2: extract treaty indictment to shared component + +The treaty's WHEREAS opening clauses (defined inside the treaty `markdown` parameter at `packages/data/src/parameters/parameters-calculations-citations.ts`) are the canonical indictment language. They're now hand-mirrored into `/humanity-v-government/page.tsx` JSX. Risk: drift between the treaty doc and the JSX rendering. + +Fix: extract the indictment clauses to structured TS constants under `@optimitron/data/messaging` (or similar). Build a `<TreatyIndictment>` React component that renders each clause with `<ParameterValue>` references baked in. Refactor BOTH the treaty markdown renderer AND `/humanity-v-government` to render from the shared source. Single source of truth, no drift risk. + +Touches: the treaty markdown rendering layer (`packages/web/src/components/referendum/reader-markdown-components.tsx`), the parameter `markdown` field (may need to be derived from the structured constants rather than hardcoded prose), and `/humanity-v-government`'s indictment section. + +### Sequence + +1. Counterfactual sentence into damages copy (small, immediate). +2. Drop hero CTAs #2 + #3 on `/humanity-v-government`; swap primary CTA to plaintiff registration. +3. Add running plaintiff count near hero (social proof). +4. Cut decorative sections (case-caption `<dl>`, collapse defenses to `<details>`). +5. Move `DamagesSensitivityCalculator` up near the vote / damages cards. +6. *(Separate work)* Scaffold `/earth-optimization-day` page + `isEarthOptimizationDayWindow()` config. +7. *(Separate work)* Hook EOD config into `/humanity-v-government` + landing CTA swap. + ## Long-tail (parked, not 4B-blocking) Items that exist in earlier TODO revisions but do not move the 4B-votes needle today. diff --git a/docs/LOCAL_DB.md b/docs/LOCAL_DB.md index 79468ddf5..0e817671f 100644 --- a/docs/LOCAL_DB.md +++ b/docs/LOCAL_DB.md @@ -35,7 +35,7 @@ pnpm db:logs pnpm db:deploy pnpm db:test pnpm db:migrate -pnpm db:seed +pnpm db:sync:managed-data -- --apply pnpm db:reset ``` diff --git a/docs/MCP_SERVER.md b/docs/MCP_SERVER.md index 7350ab85f..974a0cfa1 100644 --- a/docs/MCP_SERVER.md +++ b/docs/MCP_SERVER.md @@ -201,7 +201,7 @@ Then `fireTaskTrigger` with `dryRun: true` to verify the rendered preview before **Implementation:** `packages/web/src/lib/triggers/{template,resolvers,event-filter,completion-gate,fire,admin,context}.ts`. The schema lives in `packages/db/prisma/schema.prisma` (`TaskTrigger`, `TaskSpawnSpec`, `TaskCommunicationSpawnSpec`, `TaskTriggerFire`). -**Deploy requirement:** every production deploy MUST run `pnpm db:seed:triggers` AFTER `pnpm db:deploy` and BEFORE the new code goes live. The seed is idempotent (upsert on `triggerKey`) and is wired into `db:setup` for local development. CI's production-deploy step runs it automatically (see `.github/workflows/ci.yml`). If you add a new wired event source to the application code, ensure its corresponding trigger blueprint is seeded — otherwise `fireTaskTriggersForEvent` will return `filteredOut` (trigger not found) and the layered behavior won't run. +**Deploy requirement:** every production deploy MUST run `pnpm db:sync:managed-data -- --apply` AFTER `pnpm db:deploy` and BEFORE the new code goes live. The sync is idempotent (upsert on stable managed keys) and is wired into `db:setup` for local development. CI runs it automatically (see `.github/workflows/ci.yml`). If you add a new wired event source to the application code, ensure its corresponding trigger blueprint is managed — otherwise `fireTaskTriggersForEvent` will return `filteredOut` (trigger not found) and the layered behavior won't run. **Parameter tokens:** every fired trigger context is augmented with `params.<slug>` values pre-resolved from `@optimitron/data/parameters`. The current set is in `packages/web/src/lib/triggers/context.ts` — extend that map when you need a new parameter in a template. diff --git a/package.json b/package.json index 80c0e3b43..8dc22f5f8 100644 --- a/package.json +++ b/package.json @@ -28,14 +28,10 @@ "db:deploy": "pnpm --filter @optimitron/db run prisma:migrate:deploy", "db:test": "pnpm --filter @optimitron/db run test:integration", "db:push": "pnpm --filter @optimitron/db run prisma:push", - "db:seed": "pnpm --filter @optimitron/db run seed", - "db:seed:reference": "pnpm --filter @optimitron/db run seed:reference", - "db:seed:bootstrap": "pnpm --filter @optimitron/db run seed:bootstrap", - "db:seed:demo": "pnpm --filter @optimitron/db run seed:demo", "db:sync:managed-data": "node scripts/sync-managed-data.mjs", "audit:schema-usage": "pnpm --filter @optimitron/db run audit:schema-usage", "db:up": "docker compose up -d --wait postgres", - "db:setup": "pnpm db:up && pnpm db:deploy && pnpm db:generate && pnpm db:seed && pnpm db:sync:managed-data -- --apply", + "db:setup": "pnpm db:up && pnpm db:deploy && pnpm db:generate && pnpm db:sync:managed-data -- --apply", "db:reset": "docker compose down -v && pnpm db:setup", "db:down": "docker compose down", "db:logs": "docker compose logs -f postgres", diff --git a/packages/data/package.json b/packages/data/package.json index d90832307..7fb18819a 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -7,6 +7,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./campaign": "./src/campaign.ts", "./parameters": "./src/parameters/index.ts", "./referendums": "./src/referendums/index.ts", "./fetchers": "./src/fetchers/index.ts", diff --git a/packages/data/src/campaign.ts b/packages/data/src/campaign.ts new file mode 100644 index 000000000..ec670f37c --- /dev/null +++ b/packages/data/src/campaign.ts @@ -0,0 +1,65 @@ +export const CAMPAIGN_NAME = "International Campaign to End War and Disease" as const; + +export const GLOBAL_SURVEY_NAME = + "Global Survey to End War and Disease" as const; + +export const ORGANIZATION_ACTIVATION_TASK_TITLE = + `Share the ${GLOBAL_SURVEY_NAME} with your members` as const; + +export const ORGANIZATION_ACTIVATION_TASK_KEY_SUFFIX = + "share-1-percent-treaty-survey" as const; + +export const DEMO_USER_EMAIL = "demo@thinkbynumbers.org" as const; +export const DEMO_PERSON_SOURCE_REF = "managed-person:demo-user" as const; +export const DEMO_ORGANIZATION_SOURCE_REF = + "managed-organization:demo-organization" as const; +export const DEMO_ORGANIZATION_SLUG = "demo-organization" as const; +export const DEMO_ORGANIZATION_NAME = "Demo Organization" as const; + +export const HUMANITY_MANAGEMENT = { + requiredPhoneCalls: 1, + directHumanAssignments: 2, + propagationAsksPerHuman: 2, + callOneHumanTaskTitle: "Make one phone call. Outsource humanity management.", +} as const; + +export function getOrganizationActivationTaskKey(organizationId: string) { + return `organization:${organizationId}:${ORGANIZATION_ACTIVATION_TASK_KEY_SUFFIX}`; +} + +export function buildOrganizationActivationTaskDescription(input: { + baseUrl: string; + coalitionStrategyUrl: string; + legalUrl: string; + organizationName: string; + organizationToolsUrl: string; + surveyUrl: string; +}) { + return `Your organization joined the ${CAMPAIGN_NAME} by publicly supporting the 1% Treaty. Now use the reach your members already trust: place the ${GLOBAL_SURVEY_NAME} link on your site and share it once with your list. + +Why this task exists: +- Members get a simple way to review the treaty and record their response. +- Responses from your organization link are credited to ${input.organizationName}. +- This is a policy survey, not a candidate endorsement. + +Do this: +1. Open your organization tools page: ${input.organizationToolsUrl} +2. Copy the outreach grant request draft if funding would help you reach more members. +3. Copy the member survey link, website button, or iframe. +4. Put one of them on your website or in a newsletter. +5. Ask members to review the treaty and record their response. + +Done when: +- The survey is linked or embedded where members can find it. +- At least one email, newsletter item, or social post sends members to the survey. +- The organization URL stays intact so responses are credited to ${input.organizationName}. + +${GLOBAL_SURVEY_NAME} URL: +${input.surveyUrl} + +Why organizations should share this: +${input.coalitionStrategyUrl} + +Legal notes: +${input.legalUrl}`; +} diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 16cfaabae..901239b0d 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -73,6 +73,9 @@ export * from './utils/index'; export type { Parameter, Citation, SourceType, Confidence, ParameterName } from './parameters/index'; export type { FormatParameterOptions } from './parameters/index'; +// Campaign-facing constants and pure copy builders +export * from './campaign'; + // Hand-edited referendum bodies (use @optimitron/data/referendums for full access) // These are referendum content that doesn't yet flow through the auto-generated // QMD pipeline in parameters-calculations-citations.ts. diff --git a/packages/data/src/parameters/parameters-calculations-citations.ts b/packages/data/src/parameters/parameters-calculations-citations.ts index dd781b43f..5fdad6cf7 100644 --- a/packages/data/src/parameters/parameters-calculations-citations.ts +++ b/packages/data/src/parameters/parameters-calculations-citations.ts @@ -11618,7 +11618,6 @@ export type ParameterName = keyof typeof parameters; export interface ShareableSnippet { markdown: string; sourceFile: string; - updatedAt: string; originalName: string; } @@ -11626,19 +11625,16 @@ export const shareableSnippets = { declarationOfOptimization: { markdown: "### The unanimous Declaration of the Eight Billion Inhabitants of Earth\n\nWhen in the Course of human events, it becomes necessary for a people to optimize the governance systems which have caused immeasurable preventable death and unnecessary poverty, a decent respect to the opinions of mankind requires that they should declare the causes which impel them to the optimization.\n\nWe hold these truths to be self-evident, that all humans are created equal, that they are endowed by their Biology with certain unalienable Rights, that among these are Life, Liberty and the pursuit of Happiness.--That to secure these rights, Governments are instituted among Humans, deriving their just powers from the consent of the governed.\n\nThat whenever any Form of Government becomes destructive of these ends, it is the Right of the People to optimize it, laying its foundation on such principles and organizing its powers in such form, as to them shall seem most likely to effect their Safety and Happiness, measured by two metrics: the median number of healthy life years and the median after-tax inflation-adjusted income of its citizens.\n\nPrudence, indeed, will dictate that Governments long established should not be changed for light and transient causes; and accordingly all experience hath shewn, that mankind are more disposed to suffer, while evils are sufferable, than to right themselves by optimizing the forms to which they are accustomed.\n\nBut when a long pattern of abuses and misallocations, pursuing invariably the same end, reveals a design to reduce them under absolute Suboptimality, it is their right, it is their duty, to optimize such Government, and to provide new Guards for their future security.\n\nThe [Political Dysfunction Tax](https://manual.WarOnDisease.org/knowledge/appendix/political-dysfunction-tax.html), the total annual burden of suboptimality on the people of Earth: [$101 trillion](https://manual.WarOnDisease.org/knowledge/appendix/optimocracy-paper.html) a year.\n\nSuch has been the patient sufferance of the inhabitants of Earth; and such is now the necessity which constrains them to optimize their former Systems of Government. The history of the present Governments of Earth is a history of repeated injuries and misallocations, all having as their direct result the establishment of an absolute Suboptimality over these people. To prove this, let Facts be submitted to a candid world.\n\nThey have refused their Assent to Laws, the most wholesome and necessary for the public good; the [correlation between public opinion and policy outcomes](https://manual.WarOnDisease.org/knowledge/problem/unrepresentative-democracy.html), measured across 1,779 policy decisions, is effectively zero.\n\nThey have legalized the purchase of legislation at a current annual price of [$4.4 billion](https://manual.WarOnDisease.org/knowledge/appendix/algorithmic-public-administration-paper.html), the legal definition of corruption having been written by the beneficiaries of said corruption.\n\nThey have imposed Taxes without Consent, including the [debasement of currency](https://manual.WarOnDisease.org/knowledge/economics/central-banks.html) by unelected officials whose money creation functions as a tax the governed never voted for, reducing the dollar's purchasing power by 96% since 1913.\n\nThey have spent over one trillion dollars across fifty years imprisoning and sometimes killing their own citizens for the crime of exercising [sovereignty over their own bodies](https://manual.WarOnDisease.org/knowledge/problem/genetic-slavery.html), sovereignty being the distinction between a citizen and property.\n\nThe result has been a 1,700% increase in overdose deaths and drug use higher than when they started, while half of all murders go unsolved for want of the resources squandered on the prosecution of those pursuing happiness by means the state did not approve.\n\nThey have lied to the governed to manufacture consent for wars the governed did not want, fabricating attacks that did not occur, presenting evidence they knew to be false, and spraying carcinogenic chemicals on rice farmers and their children, the exposed population now numbering four million with birth defects continuing to this day.\n\nThey have misplaced $2.46 trillion in military funds, failed seven consecutive audits attempting to find it, and requested additional trillions without explanation or apology.\n\nThey have allowed the [destructive economy](https://manual.WarOnDisease.org/knowledge/economics/gdp-trajectories.html) to reach [11.5%](https://manual.WarOnDisease.org/knowledge/economics/gdp-trajectories.html) of global output, growing faster than the productive economy, on a trajectory that crosses fifty percent by [2040](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-prize.html). Once passing this threshold, earth will become a global failed state where it becomes irrational to produce because each dollar of value created is immediately stolen.\n\nThey have plundered our seas, ravaged our coasts, burnt our towns, and [destroyed the lives of our people](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html): [310 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) people since 1900, [8.37 billion](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) years of human life stolen, [$170 trillion](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) in treasure spent on the enterprise.\n\n[Among them](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) approximately 930,000 physicians, 310,000 scientists, 620,000 engineers, 1.24 million nurses, 3.1 million teachers, and millions of children who will never grow up to replace them.\n\nThey have directed [604](https://manual.WarOnDisease.org/knowledge/economics/central-banks.html) times more to the destruction of human life than to testing which medicines might preserve it.\n\nThey have permitted [150 thousand](https://manual.WarOnDisease.org/knowledge/strategy/questions.html) people to die of diseases every day, [104](https://manual.WarOnDisease.org/knowledge/strategy/questions.html) every minute that passes, while possessing the means to accelerate solutions. The annual toll: [2.88 billion](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years of healthy life lost to disease and disability, quietly deleted.\n\nNearly ten thousand known safe compounds remain untested for 99.7% of possible disease combinations. Yet the [national health research institutions](https://manual.WarOnDisease.org/knowledge/problem/nih-fails-2-institute-health.html) nominally responsible for finding cures direct only [3.3%](https://manual.WarOnDisease.org/knowledge/problem/nih-fails-2-institute-health.html) of their budgets to the clinical trials necessary to determine which diseases those compounds could treat.\n\nThey have erected [drug regulatory agencies](https://manual.WarOnDisease.org/knowledge/problem/fda-is-unsafe-and-ineffective.html) that, after a drug has been proven safe, force patients to wait an additional [8.2](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years while a committee determines whether the safe drug works well enough. For every death prevented by this vigilance, [3,068](https://manual.WarOnDisease.org/knowledge/appendix/invisible-graveyard.html) people die waiting for the answer. Since 1962, the efficacy lag has killed approximately [102 million](https://manual.WarOnDisease.org/knowledge/appendix/invisible-graveyard.html) people.\n\nThese regulatory barriers mean treatments without a billion-dollar market are never developed at all. The treatments that never were have killed an uncountable number of patients bounded only by the [55 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) people who die of disease each year.\n\nThrough the combined effect of war spending, research misallocation, and regulatory cost inflation, they have left approximately seven thousand known rare diseases in a [treatment queue](https://manual.WarOnDisease.org/knowledge/problem/untapped-therapeutic-frontier.html) that, at the current rate of fifteen approvals per year, requires [443 years](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) to clear.\n\nThrough the compound effects of this misallocation to war alone, the governed are [23.2](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) times poorer than they would otherwise be. The average human earns [$14,375](https://manual.WarOnDisease.org/knowledge/appendix/political-dysfunction-tax.html) per year. Without the wars alone, that figure would be [$333,636](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html). On both metrics by which any government should be judged, healthy life years and median income, the present systems have failed absolutely.\n\nIn every stage of these Misallocations We have Petitioned for Redress in the most humble terms: peer-reviewed papers, public comment periods, protest marches, and online petitions. Our repeated Petitions have been answered only by repeated Misallocation. Governments, whose character is thus marked by every act which may define Suboptimality, are unfit to manage the resources of a free species.\n\nNor have we neglected our governing institutions. We have warned them from time to time of attempts by their legislatures to extend an unwarrantable dysfunction over us. We have reminded them of the circumstances of our biological existence and the budget arithmetic of our premature deaths.\n\nWe have appealed to their stated missions and their campaign promises, and we have invoked the ties of our common mortality to disavow these misallocations, which would inevitably interrupt our survival and progress. They too have been deaf to the voice of justice and of evidence. We must, therefore, accept the necessity, which condemns our current Systems, and hold them, as we hold all governance systems, Accountable to Outcomes.\n\nThat this optimization is achievable requires no faith, only memory. These same governments [cut military spending](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) by [87.6%](https://manual.WarOnDisease.org/knowledge/economics/peace-dividend.html) in two years following the Second World War and produced not collapse but the greatest economic expansion in recorded history. These same governments banned chemical weapons (193 countries), biological weapons (187 countries), and landmines (164 countries). They have signed treaties banning weapons they wished to use. We ask them to buy [one percent](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) fewer of them.\n\nWe, therefore, the Inhabitants of Earth, assembled across every nation and connected by common cause, appealing to the Supreme Judge of the world for the rightness of our intentions, do, in the Name, and by Authority of the good People of this planet, solemnly publish and declare, That the Inhabitants of Earth are, and of Right ought to be Free and Justly Governed; that they are Absolved from all Allegiance to systems that produce outcomes worse than random allocation, and that all political connection between them and Suboptimal Governance, is and ought to be totally optimized.\n\nAnd that as Free Inhabitants of Earth, they have full Power to optimize budgets and institutions, establish transparent allocation systems, contract Alliances with evidence, and to do all other Acts and Things which Self-Governing Civilizations may of right do. And for the support of this Declaration, with a firm reliance on the protection of divine Providence, we mutually pledge to each other our Lives, our Fortunes, and our sacred Votes.\n\nThe proposed replacement system is documented in the [Earth Optimization Protocol](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-protocol-v1.html).\n", sourceFile: "knowledge/strategy/declaration-of-optimization.qmd", - updatedAt: "2026-05-10", originalName: "declaration_of_optimization", }, onePercentTreatyText: { markdown: "WHEREAS, humanity pays governments approximately [$36.5 trillion](https://manual.WarOnDisease.org/knowledge/appendix/humanity-v-government.html) per year for the service of promoting the general welfare, defined as the median health and wealth of the citizenry;\n\nWHEREAS, the citizenry would like to actually receive this service at some point;\n\nWHEREAS, these public servants instead used [$170 trillion](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) of their salary to murder approximately [310 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) humans over the last century of their employment;\n\nWHEREAS, these murdered humans included 930,000 doctors, 310,000 scientists, 620,000 engineers, 1.24 million nurses, 3.1 million teachers, and [102 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) children who will never grow up to replace them;\n\nWHEREAS, this seems counterproductive;\n\nWHEREAS, murdering [310 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) of your employers is the opposite of promoting their welfare, and would be grounds for termination in any other employment contract humans have ever signed;\n\nWHEREAS, had your governments not spent [$170 trillion](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) murdering those people and destroying everything they spent their entire lives building, the average human alive today would earn [$333,636](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) a year instead of [$14,375](https://manual.WarOnDisease.org/knowledge/appendix/political-dysfunction-tax.html). Dead scientists do not discover things and exploded cities are very expensive to fix;\n\nWHEREAS, the governments of Earth have been hitting each other for roughly 10,000 years because the other one hit them last;\n\nWHEREAS, this is the conflict resolution strategy of four-year-olds except four-year-olds eventually get tired and take a nap, and these governments have failed to apply naps to foreign policy;\n\nWHEREAS, the governments of Earth possess nuclear weapons sufficient to end civilization [122](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) times but have not cured Alzheimer's once (which is particularly wasteful given we only have one civilization to destroy);\n\nWHEREAS, your employees spend [$2.72 trillion](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) a year on their capacity for mass murder, which is enough to buy [850](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) bullets for every man, woman, and child every year, even though it would require at most 2 bullets per person to murder everyone;\n\nWHEREAS, governments spend [604](https://manual.WarOnDisease.org/knowledge/economics/central-banks.html) dollars on the capacity for orphan manufacturing for every one dollar spent on the trials that might cure what is actually going to kill their citizens;\n\nWHEREAS, the Department of \"Defense\" has \"misplaced\" $2.46 trillion, failed seven consecutive audits trying to find it, and then requested additional trillions without explanation or apology (Not to belabor the point, but that money could have funded [547](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) years of clinical trials at current funding levels, possibly saving billions of lives and preventing quadrillions of hours of suffering, so we would appreciate it if you would have them be more careful in the future);\n\nWHEREAS, pre-WW2 U.S. military spending was [96.7%](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) lower than today's peacetime budget, even after adjusting for inflation. The U.S. still won World War II, then cut military spending [87.6%](https://manual.WarOnDisease.org/knowledge/economics/peace-dividend.html) in two years and produced the fastest growth in median standard of living in history.\n\nWHEREAS, unless the human genome has significantly degraded in the two generations since, a one percent improvement in resource allocation should be manageable;\n\nWHEREAS, global military spending has been growing [2.76%](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) a year for twenty years. If no one tells it to stop, every human alive will pay about [$402,488](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) over their lifetime (mostly funding explosions in countries they cannot find on a map). A one percent cut tells it to stop. That saves the average person about [$290,052](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) ([the peace dividend](https://manual.WarOnDisease.org/knowledge/economics/peace-dividend.html));\n\nWHEREAS, [diseases kill more people than all wars combined](https://manual.WarOnDisease.org/knowledge/problem/cost-of-disease.html) and, unlike wars, do not even have the decency to be quick about it;\n\nWHEREAS, your chance of dying in a terrorist attack is approximately 1 in [30 million](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html), and your chance of dying of a disease is 100%, and your current budget does not reflect this;\n\nWHEREAS, only 15 diseases get their first effective treatment each year, while [6,650 diseases](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) are still waiting;\n\nWHEREAS, at this rate, it takes [443](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years to find treatments for all diseases, which is important because you will personally be dead within 80 years (which I mention not to be rude but because you seem weirdly calm about this);\n\nWHEREAS, there are [9,500](https://manual.WarOnDisease.org/knowledge/problem/untapped-therapeutic-frontier.html) known safe treatments which have never been tested for [99.7%](https://manual.WarOnDisease.org/knowledge/problem/nih-fails-2-institute-health.html) of their potential uses;\n\nWHEREAS, [pragmatic clinical trials built into ordinary healthcare](https://manual.WarOnDisease.org/knowledge/appendix/dfda-spec-paper.html) cost [$929](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) per patient instead of the usual [$41,000](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html), which makes them [44.1](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) times cheaper, which means [12.3](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) times as many patients can join, which drops the wait from [443 years](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) to [36 years](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html), which means treatments arrive [204](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years sooner on average.\n\nWHEREAS, discovering treatments centuries sooner is [projected](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) to prevent approximately [10.7 billion deaths](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) and [1.93 quadrillion hours](https://manual.WarOnDisease.org/knowledge/appendix/dfda-impact-paper.html) of human suffering, which are not metaphors and refer to specific future humans with specific future plans for next Tuesday;\n\nWHEREAS, someone you love is, at this moment, suffering from a disease because the treatment that would help them exists untested on a shelf, because the money that would have tested it was busy turning into a missile; that missile incinerated a child who might have grown up to discover the cure; you lose the treatment, you lose the scientist, you get the inflation, you get the tax bill, you get to pay for her murder;\n\nWHEREAS, this is suboptimal;\n\nNOW, THEREFORE, the undersigned nations agree to be 1% more rational, as follows:\n\n**Article I**: Each signatory shall redirect exactly 1% of its annual military budget to **the 1% Treaty Fund**, split as follows: [80%](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-protocol-v1.html) to [pragmatic clinical trials](https://manual.WarOnDisease.org/knowledge/appendix/dfda-spec-paper.html); [10%](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-protocol-v1.html) to perpetual returns on [Incentive Alignment Bonds](https://manual.WarOnDisease.org/knowledge/appendix/incentive-alignment-bonds-paper.html); and [10%](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-protocol-v1.html) to a [Political Incentive Fund](https://manual.WarOnDisease.org/knowledge/solution/aligning-incentives.html) that supports campaigns of legislators who vote to implement and expand this Treaty. The [80%](https://manual.WarOnDisease.org/knowledge/strategy/earth-optimization-protocol-v1.html) cures the diseases. The other 20% make sure nobody quietly cancels the part that cures the diseases.\n\n**Article II**: Transfers shall be automatic, immediate, and irrevocable. The money moves on the first of every month, the way your mortgage does, except nobody has to call and yell at anyone.\n\n**Article III**: The percentage can go up. It never goes down. When the treaty works, a mandatory review raises it. Because Article I allocates in percentages, not fixed dollars, every increase enlarges the bondholder payouts and the Political Incentive Fund in lockstep. This produces something your species has never had before: a class of wealthy humans whose bank accounts grow every time a war ends, a disease is eradicated, or a child who would have died gets to grow up and have opinions about things. For the first time in human history, the absence of war and disease will be more profitable than their existence.\n\n**Article IV**: Compliance shall be verified by public ledger and independent audits. Relabeling a submarine as \"humanitarian infrastructure\" will be noticed, because submarines are large and loud and do not fit in the humanitarian infrastructure drawer. Non-compliant parties shall be given a stern talking to; their compliant political opponents shall be funded via the Political Incentive Fund, which rewards legislators by recorded vote on two axes: (a) Treaty implementation and expansion, and (b) honoring [Court of Humanity](https://manual.WarOnDisease.org/knowledge/solution/court-of-humanity.html) judgments against their government. Compliant votes earn campaign support for those seeking reelection, post-office appointments for those retiring. Non-compliant votes earn the same thing for their opponents. No funds pass directly to any legislator; all disbursements route through [a scoring algorithm](https://manual.WarOnDisease.org/knowledge/legal/election-law.html), which is apparently the only legal way to train a senator. The NRA already perfected this technology; this Treaty plagiarizes it, substituting \"not dying from diseases\" for \"guns.\"\n\n**Article V**: Citizens of any signatory nation may sue their own government in its own courts for non-compliance with this Treaty. Where domestic courts decline jurisdiction or invoke [sovereign immunity](https://manual.WarOnDisease.org/knowledge/solution/court-of-humanity.html), citizens may bring the same claim in [the Court of Humanity](https://manual.WarOnDisease.org/knowledge/solution/court-of-humanity.html), whose jurisdiction derives from human rather than sovereign consent and whose judgments are enforced through capital markets rather than coercion.\n\n**Article VI**: Holders of Article I [Incentive Alignment Bonds](https://manual.WarOnDisease.org/knowledge/appendix/incentive-alignment-bonds-paper.html) may sue any signatory's government in its own courts for non-payment. Billionaires have lawyers the way other humans have socks. This Treaty points those lawyers at the one thing billionaires and dying people both want, which is the treaty to keep working.\n\n**Article VII**: Withdrawal requires unanimous consent of all parties plus 10-year notice. Ten years is enough time for the bondholders to sue, the Political Incentive Fund to replace whoever is trying to leave, and the voters to notice that the party attempting withdrawal is the one that wants the diseases back.\n\n**Article VIII**: This treaty supersedes all conflicting domestic law. Including the subsection your legislature added at 2 a.m. last session specifically to make sure this couldn't happen.\n\n**Article IX**: This Treaty enters into force upon signature by two states. War has killed humans for as long as there have been humans to kill. Disease has been killing them longer. Its founding signatories will be responsible for the largest reduction in human suffering and the largest increase in human prosperity in the history of planet Earth.\n\nIN WITNESS WHEREOF, the undersigned, being of sound mind (debatable) and tired of watching their loved ones die of preventable diseases, have executed this Treaty.\n", sourceFile: "knowledge/solution/1-percent-treaty.qmd", - updatedAt: "2026-05-10", originalName: "one-percent-treaty-text", }, whyOptimizationIsNecessary: { markdown: "Governments were created to promote the general welfare (i.e. median health and wealth).\n\nInstead, since 1913, these governments have [printed](https://manual.WarOnDisease.org/knowledge/economics/central-banks.html) [$170 trillion](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) and used it to murder [310 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) humans and destroy many of the valuable things those humans spent their entire lives building.\n\nThese murdered humans [include](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) approximately 930,000 physicians, 310,000 scientists, 620,000 engineers, 1.24 million nurses, 3.1 million teachers, and [102 million](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) children who will never grow up to replace them.\n\nThat [$170 trillion](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) could have funded [37,778 years](https://manual.WarOnDisease.org/knowledge/strategy/declaration-of-optimization.html) of clinical trials. They bought the other thing.\n\nThese governments have enough weapons to end civilization [122](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) times over. Current military spending is enough money to buy [850](https://manual.WarOnDisease.org/knowledge/appendix/extinction-surplus.html) bullets for every person alive every single year. You only need to kill everyone once for everyone to be dead. (I checked.) The remaining murder capacity is sheer waste.\n\nSeven consecutive failed audits have found that the Pentagon has \"misplaced\" $2.46 trillion. They then requested additional trillions without explanation or apology. This \"misplaced\" money could have funded [547](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) years of clinical trials at current government spending.\n\nFor every [604](https://manual.WarOnDisease.org/knowledge/economics/central-banks.html) dollars they spend on the capacity for orphan manufacturing, they only spend one on clinical trials that might cure the diseases you and everyone you love will suffer and die from.\n\nYour chance of being killed by a terrorist? 1 in [30 million](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html). Your chance of dying of a disease? 100%.\n\nAt the current discovery rate, finding treatments for all known diseases takes ~[443 years](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html). One percent of the explosions budget could increase clinical trial capacity by [12.3x](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) and compress that wait to ~[36 years](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html). The average cure arrives [212](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html) years sooner.\n\nThis is important because you personally will be dead within 80 years. (I mention this not to be rude but because you seem weirdly calm about it.)\n\nHad someone properly aligned your governments to maximize median healthy life years and median after-tax inflation-adjusted income in 1900, the average human would earn [$333,636](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) a year instead of [$14,375](https://manual.WarOnDisease.org/knowledge/appendix/political-dysfunction-tax.html).\n\nThey did not. So that is what you are going to do.\n\nThis Declaration asks every nation on Earth to sign a [treaty](https://manual.WarOnDisease.org/knowledge/solution/1-percent-treaty.html) redirecting one percent of military spending to clinical trials. One percent.\n\nHere is why this is not clinically insane. Even adjusting for inflation, governments now spend [30.6](https://manual.WarOnDisease.org/knowledge/problem/cost-of-war.html) times more than they did immediately before winning World War II.\n\nAfter that war, governments cut military spending by [87.6%](https://manual.WarOnDisease.org/knowledge/economics/peace-dividend.html) and produced the greatest economic expansion in human history.\n\nUnless the human genome has degraded significantly in the last two generations, one percent should be manageable.\n\nThese governments have already signed multiple global treaties banning entire weapons industries. This one just asks them to buy one percent fewer of them.\n\nThink about someone you love who is suffering right now. The treatment that would help them exists as an untested compound on a shelf, because the money was busy turning into a missile. That missile incinerated a child who might have grown up to discover the cure. You lose the treatment. You lose the scientist. You get the inflation. You get the tax bill. You get to pay for her murder.\n\nThis is suboptimal.\n", sourceFile: "knowledge/strategy/declaration-of-optimization.qmd", - updatedAt: "2026-05-10", originalName: "why-optimization-is-necessary", } } as const satisfies Record<string, ShareableSnippet>; diff --git a/packages/data/src/referendums/court-of-humanity.ts b/packages/data/src/referendums/court-of-humanity.ts index 748a5282f..095883c0a 100644 --- a/packages/data/src/referendums/court-of-humanity.ts +++ b/packages/data/src/referendums/court-of-humanity.ts @@ -11,8 +11,7 @@ * starts appearing in the auto-generated file, this module can be deleted * and the import in `packages/web/src/config/referendums.ts` switched to * `shareableSnippets.courtOfHumanityText`. Keep the export shape compatible - * (`{ markdown, sourceFile, updatedAt, originalName }`) to make that - * migration trivial. + * (`{ markdown, sourceFile, originalName }`) to make that migration trivial. * * Treaty body (`onePercentTreatyText`) intentionally stays in the * auto-generated file because the QMD pipeline already produces it. @@ -72,6 +71,5 @@ NOW, THEREFORE, the undersigned humans, having noticed, hereby join the Court of IN WITNESS WHEREOF, the undersigned humans, being of sound mind (debatable) and tired of watching their governments kill their families with no consequences, hereby join the Court of Humanity. `, sourceFile: "knowledge/solution/court-of-humanity.qmd", - updatedAt: "2026-05-03", originalName: "court-of-humanity-text", } as const; diff --git a/packages/data/src/referendums/humanity-v-government.ts b/packages/data/src/referendums/humanity-v-government.ts new file mode 100644 index 000000000..3c7e8a7ed --- /dev/null +++ b/packages/data/src/referendums/humanity-v-government.ts @@ -0,0 +1,18 @@ +import { CORPORATE_DAMAGES_TREBLE_EXPOSURE_PER_CAPITA } from "../parameters"; + +function formatMillionDollars(value: number) { + return `$${(value / 1_000_000).toFixed(2)} million`; +} + +export const HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL = + formatMillionDollars(CORPORATE_DAMAGES_TREBLE_EXPOSURE_PER_CAPITA.value); + +export const HUMANITY_V_GOVERNMENT_VERDICT_QUESTION = `Should the governments of Earth be found liable for preventable mass death and owe full damages of ${HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL} to each living human?`; + +export const HUMANITY_V_GOVERNMENT_VERDICT_TEXT = { + markdown: [ + "A YES vote means: find for Humanity.", + `It records that the governments of Earth should owe the full damages demand: ${HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL} to each living human.`, + "This is the verdict demand. The 1% Treaty remains the practical settlement offer.", + ].join("\n\n"), +} as const; diff --git a/packages/data/src/referendums/index.ts b/packages/data/src/referendums/index.ts index d0e4ed0b5..db1b5cd7b 100644 --- a/packages/data/src/referendums/index.ts +++ b/packages/data/src/referendums/index.ts @@ -9,3 +9,8 @@ export { COURT_OF_HUMANITY_QUESTION, COURT_OF_HUMANITY_TEXT, } from "./court-of-humanity"; +export { + HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL, + HUMANITY_V_GOVERNMENT_VERDICT_QUESTION, + HUMANITY_V_GOVERNMENT_VERDICT_TEXT, +} from "./humanity-v-government"; diff --git a/packages/db/package.json b/packages/db/package.json index 33b6c4f9a..4f3c58afc 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,6 +21,14 @@ "./system-identities": { "types": "./dist/system-identities.d.ts", "default": "./dist/system-identities.js" + }, + "./managed-data": { + "types": "./dist/managed-data/index.d.ts", + "default": "./dist/managed-data/index.js" + }, + "./managed-task-triggers": { + "types": "./dist/managed-data/managed-task-triggers.d.ts", + "default": "./dist/managed-data/managed-task-triggers.js" } }, "scripts": { @@ -36,11 +44,7 @@ "prisma:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma", "prisma:migrate:deploy": "prisma migrate deploy --schema prisma/schema.prisma", "prisma:push": "prisma db push --schema prisma/schema.prisma", - "seed": "prisma db seed", - "seed:reference": "tsx prisma/seed.ts --scope reference", - "seed:bootstrap": "tsx prisma/seed.ts --scope bootstrap", - "seed:demo": "tsx prisma/seed.ts --scope demo", - "seed:tasks": "tsx prisma/seed.ts --scope tasks", + "seed": "tsx prisma/seed.ts", "sync:managed-data": "tsx scripts/sync-managed-data.ts" }, "dependencies": { diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index cb2936be1..3ad02b146 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1846,7 +1846,7 @@ model Unit { /// Sleep, Vital Sign, Lab Result, Environment, Economic, Policy, etc. /// legacy API reference: https://github.com/mikepsinn/curedao-api/blob/main/app/Models/VariableCategory.php /// legacy API hardcoded categories: https://github.com/mikepsinn/curedao-api/blob/main/app/VariableCategories/ -/// Seed data with defaults implemented in packages/db/prisma/seed.ts (15+ categories) +/// Managed data with defaults implemented in packages/db/src/managed-data (15+ categories) model VariableCategory { /// Unique identifier for this variable category id String @id @default(cuid()) diff --git a/packages/db/prisma/seed-scopes.ts b/packages/db/prisma/seed-scopes.ts deleted file mode 100644 index e90eebea1..000000000 --- a/packages/db/prisma/seed-scopes.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const SEED_SCOPES = ["reference", "bootstrap", "demo", "tasks"] as const; - -export type SeedScope = (typeof SEED_SCOPES)[number]; - -const SEED_SCOPE_SET = new Set<SeedScope>(SEED_SCOPES); - -export function normalizeSeedScopes(scopes?: Iterable<SeedScope>): SeedScope[] { - if (!scopes) { - return [...SEED_SCOPES]; - } - - const normalized = Array.from(new Set(scopes)); - if (!normalized.length) { - return [...SEED_SCOPES]; - } - - return normalized; -} - -export function parseSeedScopes(args: string[]): SeedScope[] { - const requestedScopes = new Set<SeedScope>(); - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg !== "--scope") { - continue; - } - - const rawValue = args[index + 1]; - if (!rawValue) { - throw new Error("Missing value after --scope. Expected one of: all, reference, bootstrap, demo, tasks."); - } - - index += 1; - - for (const token of rawValue.split(",")) { - const value = token.trim(); - if (!value) { - continue; - } - - if (value === "all") { - return [...SEED_SCOPES]; - } - - if (!SEED_SCOPE_SET.has(value as SeedScope)) { - throw new Error( - `Invalid seed scope "${value}". Expected one of: all, ${SEED_SCOPES.join(", ")}.`, - ); - } - - requestedScopes.add(value as SeedScope); - } - } - - return normalizeSeedScopes(requestedScopes); -} diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index c966928e5..af265e5d4 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -1,2499 +1,60 @@ -// ============================================================================ -// Prisma Seed Script — Optimitron -// ============================================================================ -// Seeds: Units, VariableCategories, GlobalVariables, Jurisdictions, Items -// Run: npx prisma db seed (or: npx tsx prisma/seed.ts) -// ============================================================================ -// -// Variable category defaults sourced from: -// https://github.com/mikepsinn/curedao-api/tree/main/app/VariableCategories -// -// Key semantics: -// combinationOperation: SUM = additive (doses, calories, steps) -// MEAN = instantaneous (mood, heart rate, temp) -// fillingType on GlobalVariable: -// ZERO = "no measurement recorded ⇒ value is 0" (treatments, foods, activities) -// NONE = "no measurement recorded ⇒ leave gap" (symptoms, vitals, emotions) -// onsetDelay: seconds before a measurement's effect begins -// durationOfAction: seconds the effect persists -// predictorOnly: can only be a cause (treatments, foods) -// outcome: something a user wants to optimise (symptoms, mood, vitals) -// ============================================================================ - -import { - PrismaClient, - CombinationOperation, - EvidenceGrade, - FillingType, - InterventionRankingRunStatus, - Valence, - MeasurementScale, - JurisdictionType, - PersonConditionStatus, - PersonLifeStatus, - ReferendumKind, - ReferendumStatus, - ReferendumVoteSource, - TaskCommunicationEndpointKind, - TaskCommunicationEndpointVerificationStatus, - VariableEvidenceMetricKind, - VariableRelationshipEvidenceSourceType, - VotePosition, - type Prisma, -} from "../src/generated/prisma/client.js"; -import { - TREATY_REFERENDUM_SLUG, - DECLARATION_REFERENDUM_SLUG, - COURT_OF_HUMANITY_REFERENDUM_SLUG, -} from "../src/constants.js"; -import { - OPTIMIZE_EARTH_ROOT_TASK_ID, - OPTIMIZE_EARTH_ROOT_TASK_KEY, -} from "../src/task-keys.js"; import { PrismaPg } from "@prisma/adapter-pg"; -import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; -import { - US_WISHOCRATIC_JURISDICTION, - getUSWishocraticCatalogRecords, - listGovernmentLeaders, -} from "@optimitron/data"; -import { - COURT_OF_HUMANITY_QUESTION, - COURT_OF_HUMANITY_TEXT, -} from "@optimitron/data/referendums"; -import { - getAllConditions, - getAllTreatments, - type TreatmentWithConditions, -} from "@optimitron/data/datasets/medical"; -import { - DFDA_DIRECT_FUNDING_QUEUE_CLEARANCE_NPV, - DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_DALYS, - DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_ECONOMIC_VALUE, - DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_YEARS, - EVENTUALLY_AVOIDABLE_DALY_PCT, - GLOBAL_ANNUAL_DALY_BURDEN, - PEACE_DIVIDEND_ANNUAL_SOCIETAL_BENEFIT, - TREATY_ANNUAL_FUNDING, - earthOptimizationPrizeWinCondition, - EARTH_OPTIMIZATION_PRIZE_DEADLINE, - EARTH_OPTIMIZATION_PRIZE_DEADLINE_YEAR, - EARTH_OPTIMIZATION_PRIZE_INCOME_GROWTH_EFFECT_PP_PER_YEAR, - shareableSnippets, -} from "@optimitron/data/parameters"; -import { WORLD_LEADERS } from "@optimitron/data/datasets/world-leaders"; -import { - normalizeSeedScopes, - parseSeedScopes, - type SeedScope, -} from "./seed-scopes.ts"; -import { seedReasoningData } from "./seed-reasoning.ts"; -import { loadDatabaseUrl } from "../src/db-cli.ts"; -import { GLOBAL_VARIABLE_SEED_DATA } from "./seed-data/global-variables.ts"; -import { VARIABLE_CATEGORY_SEED_DATA } from "./seed-data/variable-categories.ts"; +import { PrismaClient } from "../src/generated/prisma/client.js"; +import { loadDatabaseUrl } from "../src/db-cli.js"; import { formatManagedDataResult, syncManagedData, } from "../src/managed-data/index.js"; -import { upsertWishoniaUser } from "../src/system-users.js"; - -const adapter = new PrismaPg({ connectionString: loadDatabaseUrl() }); -const prisma = new PrismaClient({ adapter }); - -// --------------------------------------------------------------------------- -// Helper: upsert by unique "name" (or "code" for jurisdictions) -// --------------------------------------------------------------------------- - -/** - * Slugify a display name into a URL-safe handle. Strips diacritics, drops - * non-alphanumeric characters, collapses runs of dashes, and lowercases. - * Used for Person.handle backfill — combine with a country suffix on shared - * names (e.g. there are several "Tshering Tobgay"s historically). - */ -function slugify(input: string): string { - return input - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") // strip diacritics - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .replace(/-{2,}/g, "-"); -} - -function normalizeReferendumContentText(value: string | null | undefined) { - const trimmed = value?.trim(); - return trimmed ? trimmed : null; -} - -function buildReferendumContentHash(input: { - question: string; - description?: string | null; - bodyMarkdown?: string | null; -}) { - return createHash("sha256") - .update( - JSON.stringify({ - question: input.question.trim(), - description: normalizeReferendumContentText(input.description), - bodyMarkdown: normalizeReferendumContentText(input.bodyMarkdown), - }), - ) - .digest("hex"); -} - -async function upsertUnit(data: Prisma.UnitUncheckedCreateInput) { - return prisma.unit.upsert({ - where: { name: data.name }, - update: data, - create: data, - }); -} - -async function upsertVariableCategory(data: Prisma.VariableCategoryUncheckedCreateInput) { - return prisma.variableCategory.upsert({ - where: { name: data.name }, - update: data, - create: data, - }); -} - -async function upsertGlobalVariable(data: Prisma.GlobalVariableUncheckedCreateInput) { - return prisma.globalVariable.upsert({ - where: { name: data.name }, - update: data, - create: data, - }); -} - -function splitExternalCodes(rawCodes: string | null): string[] { - if (!rawCodes) return []; - return Array.from( - new Set( - rawCodes - .split(/[;,]/u) - .map((code) => code.trim()) - .filter(Boolean), - ), +function isLocalDatabaseHost(hostname: string) { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "postgres" ); } -function stableSeedId(prefix: string, ...parts: string[]): string { - return `${prefix}-${parts - .join("-") - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 180)}`; -} - -function clampScore(value: number): number { - return Math.max(0, Math.min(100, Number(value.toFixed(2)))); -} - -function calculateStaticInterventionScore(treatment: TreatmentWithConditions, condition: TreatmentWithConditions["conditions"][number]) { - const participantScore = Math.min(100, Math.log10(Math.max(1, condition.participants)) * 18); - const trialScore = Math.min(100, Math.log10(Math.max(1, condition.trials)) * 25); - return clampScore( - condition.effectiveness * 0.5 + - condition.safetyScore * 0.25 + - participantScore * 0.15 + - trialScore * 0.1, - ); -} - -async function upsertJurisdiction(data: Prisma.JurisdictionUncheckedCreateInput) { - return prisma.jurisdiction.upsert({ - where: { code: data.code! }, - update: data, - create: data, - }); -} - -// ============================================================================ -// A) UNITS (~30) -// ============================================================================ - -async function seedUnits() { - console.log("🔧 Seeding units..."); - - const units: Prisma.UnitUncheckedCreateInput[] = [ - // Weight - { name: "Milligrams", abbreviatedName: "mg", ucumCode: "mg", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Grams", abbreviatedName: "g", ucumCode: "g", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Kilograms", abbreviatedName: "kg", ucumCode: "kg", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - { name: "Ounces", abbreviatedName: "oz", ucumCode: "[oz_av]", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Pounds", abbreviatedName: "lb", ucumCode: "[lb_av]", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - - // Volume - { name: "Milliliters", abbreviatedName: "mL", ucumCode: "mL", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Liters", abbreviatedName: "L", ucumCode: "L", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Fluid Ounces", abbreviatedName: "fl oz", ucumCode: "[foz_us]", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Cups", abbreviatedName: "cups", ucumCode: "[cup_us]", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - - // Count - { name: "Count", abbreviatedName: "count", ucumCode: "{count}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Servings", abbreviatedName: "servings", ucumCode: "{serving}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Doses", abbreviatedName: "doses", ucumCode: "{dose}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Tablets", abbreviatedName: "tablets", ucumCode: "{tablet}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Capsules", abbreviatedName: "capsules", ucumCode: "{capsule}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Applications", abbreviatedName: "applications", ucumCode: "{application}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Sprays", abbreviatedName: "sprays", ucumCode: "{spray}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Drops", abbreviatedName: "drops", ucumCode: "[drp]", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - - // Rating - { name: "1 to 5 Rating", abbreviatedName: "1-5", ucumCode: "{score_5}", unitCategoryId: "Rating", scale: MeasurementScale.ORDINAL, fillingType: FillingType.NONE, manualTracking: true, minimumValue: 1, maximumValue: 5 }, - { name: "1 to 10 Rating", abbreviatedName: "1-10", ucumCode: "{score_10}", unitCategoryId: "Rating", scale: MeasurementScale.ORDINAL, fillingType: FillingType.NONE, manualTracking: true, minimumValue: 1, maximumValue: 10 }, - { name: "Percent", abbreviatedName: "%", ucumCode: "%", unitCategoryId: "Rating", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true, minimumValue: 0, maximumValue: 100 }, - - // Currency - { name: "US Dollars", abbreviatedName: "USD", ucumCode: "[USD]", unitCategoryId: "Currency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - { name: "Euros", abbreviatedName: "EUR", ucumCode: "[EUR]", unitCategoryId: "Currency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - { name: "British Pounds", abbreviatedName: "GBP", ucumCode: "[GBP]", unitCategoryId: "Currency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - - // Duration - { name: "Seconds", abbreviatedName: "s", ucumCode: "s", unitCategoryId: "Duration", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Minutes", abbreviatedName: "min", ucumCode: "min", unitCategoryId: "Duration", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Hours", abbreviatedName: "h", ucumCode: "h", unitCategoryId: "Duration", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - - // Other - { name: "International Units", abbreviatedName: "IU", ucumCode: "[iU]", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Micrograms", abbreviatedName: "mcg", ucumCode: "ug", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Calories", abbreviatedName: "kcal", ucumCode: "kcal", unitCategoryId: "Energy", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Steps", abbreviatedName: "steps", ucumCode: "{step}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Beats Per Minute", abbreviatedName: "bpm", ucumCode: "{beat}/min", unitCategoryId: "Frequency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: false }, - { name: "Yes/No", abbreviatedName: "yes/no", ucumCode: "{boolean}", unitCategoryId: "Rating", scale: MeasurementScale.NOMINAL, fillingType: FillingType.ZERO, manualTracking: true, minimumValue: 0, maximumValue: 1 }, - { name: "Millimeters of Mercury", abbreviatedName: "mmHg", ucumCode: "mm[Hg]", unitCategoryId: "Pressure", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - { name: "Degrees Fahrenheit", abbreviatedName: "°F", ucumCode: "[degF]", unitCategoryId: "Temperature", scale: MeasurementScale.INTERVAL, fillingType: FillingType.NONE, manualTracking: true }, - { name: "Degrees Celsius", abbreviatedName: "°C", ucumCode: "Cel", unitCategoryId: "Temperature", scale: MeasurementScale.INTERVAL, fillingType: FillingType.NONE, manualTracking: true }, - { name: "Index", abbreviatedName: "index", ucumCode: "{index}", unitCategoryId: "Rating", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: false }, - { name: "Milligrams per Deciliter", abbreviatedName: "mg/dL", ucumCode: "mg/dL", unitCategoryId: "Concentration", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, - { name: "Meters", abbreviatedName: "m", ucumCode: "m", unitCategoryId: "Distance", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Kilometers", abbreviatedName: "km", ucumCode: "km", unitCategoryId: "Distance", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - { name: "Miles", abbreviatedName: "mi", ucumCode: "[mi_i]", unitCategoryId: "Distance", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, - ]; - - const created: Record<string, string> = {}; - for (const u of units) { - const row = await upsertUnit(u); - created[u.abbreviatedName] = row.id; - } - console.log(` ✅ ${Object.keys(created).length} units`); - return created; -} - -// ============================================================================ -// B) VARIABLE CATEGORIES -// ============================================================================ -// Sourced from legacy curedao-api/app/VariableCategories/*.php -// The schema's VariableCategory model has: name, description, defaultUnitId, -// combinationOperation, onsetDelay, durationOfAction, predictorOnly, outcome -// (fillingType/fillingValue/min/max live on GlobalVariable, not VariableCategory) -// ============================================================================ - -async function seedVariableCategories(unitMap: Record<string, string>) { - console.log("📂 Seeding variable categories..."); - - const categories = VARIABLE_CATEGORY_SEED_DATA; - - const created: Record<string, string> = {}; - for (const c of categories) { - const { defaultUnitAbbr, ...rest } = c; - const row = await upsertVariableCategory({ - ...rest, - defaultUnitId: unitMap[defaultUnitAbbr] || undefined, - }); - created[c.name] = row.id; - } - console.log(` ✅ ${Object.keys(created).length} variable categories`); - return created; -} - -// ============================================================================ -// C) GLOBAL VARIABLES -// ============================================================================ -// Each variable inherits sensible defaults from its category but can override. -// fillingType + fillingValue are set per-variable (they live on GlobalVariable). -// ============================================================================ - -async function seedGlobalVariables( - unitMap: Record<string, string>, - catMap: Record<string, string>, -) { - console.log("🌐 Seeding global variables..."); - - const variables = GLOBAL_VARIABLE_SEED_DATA; - - let count = 0; - for (const v of variables) { - const categoryId = catMap[v.category]; - const unitId = unitMap[v.unit]; - if (!categoryId) { - console.warn(` ⚠️ Unknown category "${v.category}" for variable "${v.name}" — skipping`); - continue; - } - if (!unitId) { - console.warn(` ⚠️ Unknown unit "${v.unit}" for variable "${v.name}" — skipping`); - continue; - } - await upsertGlobalVariable({ - name: v.name, - description: v.description, - variableCategoryId: categoryId, - defaultUnitId: unitId, - combinationOperation: v.combinationOperation, - fillingType: v.fillingType, - fillingValue: v.fillingValue, - onsetDelay: v.onsetDelay, - durationOfAction: v.durationOfAction, - predictorOnly: v.predictorOnly, - outcome: v.outcome, - valence: v.valence, - minimumAllowedValue: v.minimumAllowedValue, - maximumAllowedValue: v.maximumAllowedValue, - synonyms: v.synonyms, - }); - count++; - } - console.log(` ✅ ${count} global variables`); -} - -async function seedMedicalReferenceData( - unitMap: Record<string, string>, - catMap: Record<string, string>, -) { - console.log("🧬 Seeding medical conditions, intervention evidence, and rankings..."); - - const conditionCategoryId = catMap["Condition"]; - const treatmentCategoryId = catMap["Treatment"]; - const conditionUnitId = unitMap["1-5"]; - const treatmentUnitId = unitMap["count"]; - - if (!conditionCategoryId || !treatmentCategoryId || !conditionUnitId || !treatmentUnitId) { - console.warn(" ⚠️ Missing medical category/unit seeds — skipping medical reference data"); - return; - } - - const conditionVariablesBySlug = new Map<string, string>(); - const treatmentVariablesBySlug = new Map<string, string>(); - let codeCount = 0; - - for (const condition of getAllConditions()) { - const variable = await upsertGlobalVariable({ - name: condition.name, - description: condition.description, - variableCategoryId: conditionCategoryId, - defaultUnitId: conditionUnitId, - combinationOperation: CombinationOperation.MEAN, - fillingType: FillingType.NONE, - predictorOnly: false, - outcome: true, - valence: Valence.NEGATIVE, - synonyms: condition.synonyms.join(",") || undefined, - }); - conditionVariablesBySlug.set(condition.slug, variable.id); - - for (const code of splitExternalCodes(condition.icd10Codes)) { - await prisma.globalVariableExternalCode.upsert({ - where: { - globalVariableId_codeSystem_code: { - globalVariableId: variable.id, - codeSystem: "ICD-10", - code, - }, - }, - update: { - deletedAt: null, - displayName: condition.name, - metadataJson: { - conditionCategory: condition.category, - conditionSlug: condition.slug, - dataSourceYear: condition.dataSourceYear, - source: "packages/data medical conditions", - }, - }, - create: { - globalVariableId: variable.id, - codeSystem: "ICD-10", - code, - displayName: condition.name, - metadataJson: { - conditionCategory: condition.category, - conditionSlug: condition.slug, - dataSourceYear: condition.dataSourceYear, - source: "packages/data medical conditions", - }, - }, - }); - codeCount++; - } - } - - for (const treatment of getAllTreatments()) { - const variable = await upsertGlobalVariable({ - name: treatment.name, - variableCategoryId: treatmentCategoryId, - defaultUnitId: treatmentUnitId, - combinationOperation: CombinationOperation.SUM, - fillingType: FillingType.ZERO, - fillingValue: 0, - onsetDelay: 1800, - durationOfAction: 86400, - predictorOnly: true, - outcome: false, - valence: Valence.POSITIVE, - minimumAllowedValue: 0, - }); - treatmentVariablesBySlug.set(treatment.slug, variable.id); - } - - const rankedByConditionSlug = new Map<string, Array<{ - condition: TreatmentWithConditions["conditions"][number]; - evidenceId: string; - score: number; - treatment: TreatmentWithConditions; - interventionGlobalVariableId: string; - }>>(); - - for (const treatment of getAllTreatments()) { - const interventionGlobalVariableId = treatmentVariablesBySlug.get(treatment.slug); - if (!interventionGlobalVariableId) continue; - - for (const condition of treatment.conditions) { - const conditionGlobalVariableId = conditionVariablesBySlug.get(condition.conditionSlug); - if (!conditionGlobalVariableId) continue; - - const effectivenessEvidenceId = stableSeedId( - "medical-evidence", - treatment.slug, - condition.conditionSlug, - "effectiveness", - ); - await prisma.variableRelationshipEvidenceEstimate.upsert({ - where: { id: effectivenessEvidenceId }, - update: { - confidenceScore: treatment.avgEffectiveness / 100, - contextGlobalVariableId: conditionGlobalVariableId, - deletedAt: null, - metricKind: VariableEvidenceMetricKind.EFFECTIVENESS, - outcomeGlobalVariableId: conditionGlobalVariableId, - participants: condition.participants, - predictorGlobalVariableId: interventionGlobalVariableId, - sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, - studies: condition.trials, - value: condition.effectiveness, - }, - create: { - id: effectivenessEvidenceId, - confidenceScore: treatment.avgEffectiveness / 100, - contextGlobalVariableId: conditionGlobalVariableId, - evidenceGrade: - condition.participants >= 10_000 - ? EvidenceGrade.A - : condition.participants >= 1_000 - ? EvidenceGrade.B - : EvidenceGrade.C, - metricKind: VariableEvidenceMetricKind.EFFECTIVENESS, - outcomeGlobalVariableId: conditionGlobalVariableId, - participants: condition.participants, - predictorGlobalVariableId: interventionGlobalVariableId, - rationale: `Static dFDA catalog estimate for ${treatment.name} in ${condition.conditionName}.`, - sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, - studies: condition.trials, - value: condition.effectiveness, - }, - }); - - const safetyEvidenceId = stableSeedId( - "medical-evidence", - treatment.slug, - condition.conditionSlug, - "safety", - ); - await prisma.variableRelationshipEvidenceEstimate.upsert({ - where: { id: safetyEvidenceId }, - update: { - confidenceScore: treatment.avgSafetyScore / 100, - contextGlobalVariableId: conditionGlobalVariableId, - deletedAt: null, - metricKind: VariableEvidenceMetricKind.SAFETY, - outcomeGlobalVariableId: conditionGlobalVariableId, - participants: condition.participants, - predictorGlobalVariableId: interventionGlobalVariableId, - sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, - studies: condition.trials, - value: condition.safetyScore, - }, - create: { - id: safetyEvidenceId, - confidenceScore: treatment.avgSafetyScore / 100, - contextGlobalVariableId: conditionGlobalVariableId, - metricKind: VariableEvidenceMetricKind.SAFETY, - outcomeGlobalVariableId: conditionGlobalVariableId, - participants: condition.participants, - predictorGlobalVariableId: interventionGlobalVariableId, - rationale: `Static dFDA catalog safety estimate for ${treatment.name} in ${condition.conditionName}.`, - sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, - studies: condition.trials, - value: condition.safetyScore, - }, - }); - - const ranked = rankedByConditionSlug.get(condition.conditionSlug) ?? []; - ranked.push({ - condition, - evidenceId: effectivenessEvidenceId, - score: calculateStaticInterventionScore(treatment, condition), - treatment, - interventionGlobalVariableId, - }); - rankedByConditionSlug.set(condition.conditionSlug, ranked); - } - } - - let rankedCount = 0; - for (const [conditionSlug, ranked] of rankedByConditionSlug) { - const conditionGlobalVariableId = conditionVariablesBySlug.get(conditionSlug); - if (!conditionGlobalVariableId) continue; - - const rankingRunId = stableSeedId("medical-ranking", conditionSlug); - await prisma.interventionRankingRun.upsert({ - where: { id: rankingRunId }, - update: { - algorithmKey: "medical-static-v1", - conditionGlobalVariableId, - deletedAt: null, - status: InterventionRankingRunStatus.ACTIVE, - }, - create: { - id: rankingRunId, - algorithmKey: "medical-static-v1", - algorithmVersion: "packages/data medical snapshot", - conditionGlobalVariableId, - status: InterventionRankingRunStatus.ACTIVE, - }, - }); - - await prisma.rankedIntervention.deleteMany({ - where: { rankingRunId }, - }); - - const rankedRows = ranked - .sort((a, b) => b.score - a.score || b.condition.participants - a.condition.participants) - .map((entry, index) => ({ - id: stableSeedId("ranked-intervention", conditionSlug, entry.treatment.slug), - rankingRunId, - interventionGlobalVariableId: entry.interventionGlobalVariableId, - rank: index + 1, - score: entry.score, - effectivenessScore: entry.condition.effectiveness, - safetyScore: entry.condition.safetyScore, - evidenceScore: Math.min(100, Math.log10(Math.max(1, entry.condition.participants)) * 18), - confidenceScore: entry.treatment.avgEffectiveness / 100, - sourceEvidenceEstimateId: entry.evidenceId, - rationale: `${entry.treatment.name}: ${entry.condition.effectiveness}% effectiveness, ${entry.condition.safetyScore}% safety in the static dFDA catalog.`, - })); - - if (rankedRows.length > 0) { - await prisma.rankedIntervention.createMany({ data: rankedRows }); - rankedCount += rankedRows.length; - } - } - - console.log( - ` ✅ ${conditionVariablesBySlug.size} conditions, ${codeCount} ICD-10 codes, ${treatmentVariablesBySlug.size} interventions, ${rankedCount} ranked rows`, - ); -} - -// ============================================================================ -// D) JURISDICTIONS — US Federal + 50 States -// ============================================================================ - -async function seedJurisdictions() { - console.log("🏛️ Seeding jurisdictions..."); - - // Federal - const us = await upsertJurisdiction({ - name: "United States", - type: JurisdictionType.COUNTRY, - code: "US", - currency: "USD", - population: 335_000_000, - }); - - // 50 states: [name, FIPS code, approx 2024 population] - const states: [string, string, number][] = [ - ["Alabama", "US-AL", 5_108_000], - ["Alaska", "US-AK", 733_000], - ["Arizona", "US-AZ", 7_431_000], - ["Arkansas", "US-AR", 3_067_000], - ["California", "US-CA", 38_965_000], - ["Colorado", "US-CO", 5_912_000], - ["Connecticut", "US-CT", 3_617_000], - ["Delaware", "US-DE", 1_018_000], - ["Florida", "US-FL", 22_611_000], - ["Georgia", "US-GA", 11_029_000], - ["Hawaii", "US-HI", 1_435_000], - ["Idaho", "US-ID", 2_001_000], - ["Illinois", "US-IL", 12_550_000], - ["Indiana", "US-IN", 6_862_000], - ["Iowa", "US-IA", 3_207_000], - ["Kansas", "US-KS", 2_940_000], - ["Kentucky", "US-KY", 4_526_000], - ["Louisiana", "US-LA", 4_573_000], - ["Maine", "US-ME", 1_395_000], - ["Maryland", "US-MD", 6_180_000], - ["Massachusetts", "US-MA", 7_001_000], - ["Michigan", "US-MI", 10_037_000], - ["Minnesota", "US-MN", 5_737_000], - ["Mississippi", "US-MS", 2_939_000], - ["Missouri", "US-MO", 6_196_000], - ["Montana", "US-MT", 1_133_000], - ["Nebraska", "US-NE", 1_978_000], - ["Nevada", "US-NV", 3_194_000], - ["New Hampshire", "US-NH", 1_402_000], - ["New Jersey", "US-NJ", 9_290_000], - ["New Mexico", "US-NM", 2_114_000], - ["New York", "US-NY", 19_572_000], - ["North Carolina", "US-NC", 10_835_000], - ["North Dakota", "US-ND", 783_000], - ["Ohio", "US-OH", 11_785_000], - ["Oklahoma", "US-OK", 4_053_000], - ["Oregon", "US-OR", 4_233_000], - ["Pennsylvania", "US-PA", 12_962_000], - ["Rhode Island", "US-RI", 1_095_000], - ["South Carolina", "US-SC", 5_373_000], - ["South Dakota", "US-SD", 919_000], - ["Tennessee", "US-TN", 7_126_000], - ["Texas", "US-TX", 30_503_000], - ["Utah", "US-UT", 3_418_000], - ["Vermont", "US-VT", 647_000], - ["Virginia", "US-VA", 8_643_000], - ["Washington", "US-WA", 7_812_000], - ["West Virginia", "US-WV", 1_770_000], - ["Wisconsin", "US-WI", 5_893_000], - ["Wyoming", "US-WY", 584_000], - ]; - - for (const [name, code, population] of states) { - await upsertJurisdiction({ - name, - type: JurisdictionType.STATE, - code, - parentJurisdictionId: us.id, - currency: "USD", - population, - }); - } - - console.log(` ✅ 1 country + ${states.length} states`); - - // Conflict-relevant and globally significant countries for the Invisible - // Graveyard "Responsible governments" picker. ISO-3166-1 alpha-2 codes. - // Not exhaustive — add more as memorial submissions surface them. - const otherCountries: [string, string, number][] = [ - ["Israel", "IL", 9_756_000], - ["Palestine", "PS", 5_483_000], - ["Ukraine", "UA", 33_400_000], - ["Russia", "RU", 144_400_000], - ["Yemen", "YE", 34_450_000], - ["Syria", "SY", 23_230_000], - ["Sudan", "SD", 48_110_000], - ["South Sudan", "SS", 11_090_000], - ["Myanmar", "MM", 54_500_000], - ["Ethiopia", "ET", 126_500_000], - ["China", "CN", 1_410_000_000], - ["Iran", "IR", 89_170_000], - ["Saudi Arabia", "SA", 36_950_000], - ["North Korea", "KP", 26_160_000], - ["Egypt", "EG", 110_990_000], - ["Pakistan", "PK", 240_490_000], - ["India", "IN", 1_428_630_000], - ["Turkey", "TR", 85_330_000], - ["Mexico", "MX", 128_460_000], - ["Venezuela", "VE", 28_840_000], - ["Lebanon", "LB", 5_490_000], - ["Belarus", "BY", 9_500_000], - ["Afghanistan", "AF", 42_240_000], - ["United Kingdom", "GB", 67_960_000], - ["France", "FR", 68_170_000], - ["Germany", "DE", 84_480_000], - ["Japan", "JP", 124_520_000], - ["South Korea", "KR", 51_780_000], - ["Canada", "CA", 40_100_000], - ["Australia", "AU", 26_640_000], - ["Singapore", "SG", 5_920_000], - ]; - - for (const [name, code, population] of otherCountries) { - await upsertJurisdiction({ - name, - type: JurisdictionType.COUNTRY, - code, - population, - }); - } - - console.log(` ✅ ${otherCountries.length} additional countries`); - return us.id; -} - -// ============================================================================ -// D2) CONFLICTS — Active and recent armed conflicts for memorial attribution -// ============================================================================ - -async function seedConflicts() { - console.log("⚔️ Seeding active/recent conflicts..."); - - // Lookup helper - async function jurisdictionIdForCode(code: string): Promise<string | null> { - const row = await prisma.jurisdiction.findUnique({ - where: { code }, - select: { id: true }, - }); - return row?.id ?? null; - } - - const conflicts: Array<{ - slug: string; - name: string; - description?: string; - startDate?: Date; - endDate?: Date; - primaryJurisdictionCode?: string; - sourceUrl?: string; - }> = [ - { - slug: "gaza-2023", - name: "Gaza war (2023–present)", - description: - "Armed conflict in the Gaza Strip following the October 7, 2023 attacks; civilian casualties tracked by UN OCHA, WHO, and Gaza Ministry of Health.", - startDate: new Date("2023-10-07T00:00:00Z"), - primaryJurisdictionCode: "IL", - sourceUrl: "https://www.ochaopt.org/", - }, - { - slug: "ukraine-2022", - name: "Russia–Ukraine war (2022–present)", - description: - "Full-scale Russian invasion of Ukraine starting February 24, 2022. Civilian casualty data tracked by UN OHCHR HRMMU.", - startDate: new Date("2022-02-24T00:00:00Z"), - primaryJurisdictionCode: "UA", - sourceUrl: "https://ukraine.un.org/", - }, - { - slug: "yemen-civil-war", - name: "Yemen civil war (2014–present)", - description: - "Ongoing armed conflict involving Houthi forces, the Yemeni government, and the Saudi-led coalition.", - startDate: new Date("2014-09-21T00:00:00Z"), - primaryJurisdictionCode: "YE", - sourceUrl: "https://acleddata.com/yemen-conflict-observatory/", - }, - { - slug: "syria-civil-war", - name: "Syrian civil war (2011–present)", - description: - "Multi-sided conflict beginning with the 2011 uprising; UN OHCHR has documented hundreds of thousands of deaths.", - startDate: new Date("2011-03-15T00:00:00Z"), - primaryJurisdictionCode: "SY", - sourceUrl: "https://www.ohchr.org/en/countries/syria", - }, - { - slug: "sudan-2023", - name: "Sudan war (2023–present)", - description: - "Armed conflict between the Sudanese Armed Forces and the Rapid Support Forces beginning April 15, 2023.", - startDate: new Date("2023-04-15T00:00:00Z"), - primaryJurisdictionCode: "SD", - sourceUrl: "https://acleddata.com/sudan-conflict-observatory/", - }, - { - slug: "tigray-war", - name: "Tigray war (2020–2022)", - description: - "Armed conflict in Tigray Region of Ethiopia involving Ethiopian and Eritrean forces and the Tigray People's Liberation Front.", - startDate: new Date("2020-11-04T00:00:00Z"), - endDate: new Date("2022-11-03T00:00:00Z"), - primaryJurisdictionCode: "ET", - sourceUrl: "https://www.ohchr.org/en/countries/ethiopia", - }, - { - slug: "myanmar-civil-war", - name: "Myanmar civil war (2021–present)", - description: - "Armed resistance to the February 2021 military coup, including ethnic armed organizations and the People's Defence Force.", - startDate: new Date("2021-02-01T00:00:00Z"), - primaryJurisdictionCode: "MM", - sourceUrl: "https://acleddata.com/myanmar-conflict-observatory/", - }, - { - slug: "afghanistan-2001", - name: "War in Afghanistan (2001–2021)", - description: - "Multi-phase armed conflict beginning with the U.S.-led invasion in October 2001 through the Taliban takeover in August 2021.", - startDate: new Date("2001-10-07T00:00:00Z"), - endDate: new Date("2021-08-30T00:00:00Z"), - primaryJurisdictionCode: "AF", - sourceUrl: "https://watson.brown.edu/costsofwar/", - }, - { - slug: "iraq-2003", - name: "Iraq war (2003–2011)", - description: - "U.S.-led invasion of Iraq and subsequent multi-sided conflict; Iraq Body Count and Lancet studies document civilian death toll.", - startDate: new Date("2003-03-20T00:00:00Z"), - endDate: new Date("2011-12-18T00:00:00Z"), - primaryJurisdictionCode: "US", - sourceUrl: "https://www.iraqbodycount.org/", - }, - { - slug: "other", - name: "Other / not listed", - description: - "Generic placeholder for conflicts not yet seeded. Use 'circumstances' to describe the specific conflict.", - }, - ]; - - for (const c of conflicts) { - const primaryJurisdictionId = c.primaryJurisdictionCode - ? await jurisdictionIdForCode(c.primaryJurisdictionCode) - : null; - - await prisma.conflict.upsert({ - where: { slug: c.slug }, - update: { - name: c.name, - description: c.description ?? null, - startDate: c.startDate ?? null, - endDate: c.endDate ?? null, - primaryJurisdictionId, - sourceUrl: c.sourceUrl ?? null, - }, - create: { - slug: c.slug, - name: c.name, - description: c.description ?? null, - startDate: c.startDate ?? null, - endDate: c.endDate ?? null, - primaryJurisdictionId, - sourceUrl: c.sourceUrl ?? null, - }, - }); - } - - console.log(` ✅ ${conflicts.length} conflicts (${conflicts.length - 1} named + 'other' fallback)`); -} - -// ============================================================================ -// D3) DRUG/INTERVENTION APPROVAL TIMELINES — for the efficacy-lag matcher -// ============================================================================ - -async function seedDrugApprovalTimelines() { - console.log("⏳ Seeding intervention approval timelines (efficacy-lag heavy hitters)..."); - - // Lookup helpers - async function jurisdictionIdForCode(code: string): Promise<string | null> { - const row = await prisma.jurisdiction.findUnique({ - where: { code }, - select: { id: true }, - }); - return row?.id ?? null; - } - async function globalVariableIdByName(name: string): Promise<string | null> { - const row = await prisma.globalVariable.findFirst({ - where: { - deletedAt: null, - OR: [ - { name: { equals: name, mode: "insensitive" } }, - { synonyms: { contains: name, mode: "insensitive" } }, - ], - }, - select: { id: true }, - }); - return row?.id ?? null; - } - - const usJurisdictionId = await jurisdictionIdForCode("US"); - const ONE_DAY = 1000 * 60 * 60 * 24; - - // PRD TODO.md:1270–1278. Dates are best-known approximations (academic consensus - // for first efficacy evidence, FDA approval dates). Lives-saved-per-year and - // deaths-during-lag are order-of-magnitude estimates from the literature cited - // alongside each entry — meant to anchor the matcher, not to be exact. - const timelines: Array<{ - interventionName: string; - brandName?: string; - conditionName: string; - regulatorName: string; - firstEvidenceDate: Date; - firstEvidenceDescription: string; - approvalDate: Date; - approvalDescription: string; - estimatedLivesSavedPerYear: number; - sourceUrl: string; - interventionLookupNames?: string[]; - conditionLookupNames?: string[]; - }> = [ - { - interventionName: "Beta-blockers (post-MI)", - conditionName: "Post-myocardial infarction (heart attack survival)", - regulatorName: "FDA", - firstEvidenceDate: new Date("1972-01-01T00:00:00Z"), - firstEvidenceDescription: - "Multicentre randomized trials (Norwegian Multicenter Study, BHAT) showed beta-blockers reduced post-MI mortality.", - approvalDate: new Date("1981-11-01T00:00:00Z"), - approvalDescription: - "FDA approved propranolol for post-MI mortality reduction in November 1981 following BHAT results.", - estimatedLivesSavedPerYear: 11_000, - sourceUrl: "https://www.bmj.com/content/318/7200/1730", - interventionLookupNames: ["propranolol", "beta blocker", "metoprolol"], - conditionLookupNames: ["myocardial infarction", "heart attack"], - }, - { - interventionName: "Dexamethasone (severe COVID-19)", - conditionName: "COVID-19 (severe, requiring oxygen)", - regulatorName: "FDA / NIH", - firstEvidenceDate: new Date("2020-06-16T00:00:00Z"), - firstEvidenceDescription: - "RECOVERY trial preprint released June 16, 2020 showed dexamethasone cut deaths in ventilated COVID-19 patients by ~⅓.", - approvalDate: new Date("2020-09-02T00:00:00Z"), - approvalDescription: - "NIH treatment guidelines updated; widespread clinical adoption followed RECOVERY publication. Dexamethasone was already an approved generic.", - estimatedLivesSavedPerYear: 100_000, - sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJMoa2021436", - interventionLookupNames: ["dexamethasone"], - conditionLookupNames: ["covid-19", "covid"], - }, - { - interventionName: "Imatinib (Gleevec) for CML", - brandName: "Gleevec", - conditionName: "Chronic myeloid leukemia (CML)", - regulatorName: "FDA", - firstEvidenceDate: new Date("1998-06-01T00:00:00Z"), - firstEvidenceDescription: - "Phase I trial (Druker et al.) showed dramatic hematologic remission in chronic-phase CML.", - approvalDate: new Date("2001-05-10T00:00:00Z"), - approvalDescription: - "FDA accelerated approval for chronic-phase CML granted May 10, 2001.", - estimatedLivesSavedPerYear: 4_000, - sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJM200104053441401", - interventionLookupNames: ["imatinib", "gleevec"], - conditionLookupNames: ["chronic myeloid leukemia", "cml"], - }, - { - interventionName: "Interleukin-2 (renal cell carcinoma)", - conditionName: "Metastatic renal cell carcinoma", - regulatorName: "FDA", - firstEvidenceDate: new Date("1989-01-01T00:00:00Z"), - firstEvidenceDescription: - "Rosenberg et al. and parallel European trials demonstrated durable remissions; available in nine EU countries.", - approvalDate: new Date("1992-05-05T00:00:00Z"), - approvalDescription: - "FDA approved high-dose IL-2 (aldesleukin) for metastatic renal cell carcinoma in May 1992.", - estimatedLivesSavedPerYear: 800, - sourceUrl: "https://pubmed.ncbi.nlm.nih.gov/3309687/", - interventionLookupNames: ["interleukin-2", "il-2", "aldesleukin"], - conditionLookupNames: ["renal cell carcinoma", "kidney cancer"], - }, - { - interventionName: "ACE inhibitors (heart failure)", - conditionName: "Congestive heart failure", - regulatorName: "FDA", - firstEvidenceDate: new Date("1986-06-01T00:00:00Z"), - firstEvidenceDescription: - "CONSENSUS trial showed enalapril reduced mortality in severe heart failure.", - approvalDate: new Date("1991-04-01T00:00:00Z"), - approvalDescription: - "FDA expanded indication for enalapril to include all symptomatic heart failure.", - estimatedLivesSavedPerYear: 30_000, - sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJM198706043162301", - interventionLookupNames: ["enalapril", "lisinopril", "ace inhibitor"], - conditionLookupNames: ["heart failure", "congestive heart failure"], - }, - { - interventionName: "Combination antiretroviral therapy (HIV/AIDS)", - conditionName: "HIV/AIDS", - regulatorName: "FDA", - firstEvidenceDate: new Date("1987-03-19T00:00:00Z"), - firstEvidenceDescription: - "Zidovudine (AZT) approved 1987; protease inhibitors entered trials early 1990s, dramatically extending survival when combined.", - approvalDate: new Date("1996-03-01T00:00:00Z"), - approvalDescription: - "FDA approval of saquinavir (Dec 1995) and ritonavir (Mar 1996) made highly active combination ART available.", - estimatedLivesSavedPerYear: 200_000, - sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJM199703273361301", - interventionLookupNames: ["antiretroviral", "haart", "art", "azt", "zidovudine"], - conditionLookupNames: ["hiv", "aids", "hiv/aids"], - }, - { - interventionName: "Statins (cardiovascular prevention)", - conditionName: "Cardiovascular disease (atherosclerotic)", - regulatorName: "FDA", - firstEvidenceDate: new Date("1987-09-01T00:00:00Z"), - firstEvidenceDescription: - "Lovastatin LDL trials demonstrated dramatic cholesterol reduction; later 4S trial (1994) showed mortality benefit.", - approvalDate: new Date("1994-11-19T00:00:00Z"), - approvalDescription: - "Scandinavian Simvastatin Survival Study (4S) published Nov 1994 established statin mortality benefit; broad clinical adoption followed.", - estimatedLivesSavedPerYear: 50_000, - sourceUrl: "https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(94)90566-5/", - interventionLookupNames: ["statin", "simvastatin", "atorvastatin", "lovastatin"], - conditionLookupNames: ["cardiovascular disease", "atherosclerosis", "coronary"], - }, - ]; - - let count = 0; - for (const t of timelines) { - const efficacyLagDays = Math.round( - (t.approvalDate.getTime() - t.firstEvidenceDate.getTime()) / ONE_DAY, - ); - const estimatedDeathsDuringLag = - (efficacyLagDays / 365) * t.estimatedLivesSavedPerYear; - - let interventionGlobalVariableId: string | null = null; - for (const name of t.interventionLookupNames ?? [t.interventionName]) { - interventionGlobalVariableId = await globalVariableIdByName(name); - if (interventionGlobalVariableId) break; - } - let conditionGlobalVariableId: string | null = null; - for (const name of t.conditionLookupNames ?? [t.conditionName]) { - conditionGlobalVariableId = await globalVariableIdByName(name); - if (conditionGlobalVariableId) break; - } - - // Stable id so re-running the seed updates instead of duplicating. - const id = `intervention-approval-${t.interventionName - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80)}`; - - await prisma.interventionApprovalTimeline.upsert({ - where: { id }, - update: { - interventionName: t.interventionName, - brandName: t.brandName ?? null, - conditionName: t.conditionName, - interventionGlobalVariableId, - conditionGlobalVariableId, - jurisdictionId: usJurisdictionId, - regulatorName: t.regulatorName, - firstEvidenceDate: t.firstEvidenceDate, - firstEvidenceDescription: t.firstEvidenceDescription, - approvalDate: t.approvalDate, - approvalDescription: t.approvalDescription, - efficacyLagDays, - estimatedLivesSavedPerYear: t.estimatedLivesSavedPerYear, - estimatedDeathsDuringLag, - sourceUrl: t.sourceUrl, - deletedAt: null, - }, - create: { - id, - interventionName: t.interventionName, - brandName: t.brandName ?? null, - conditionName: t.conditionName, - interventionGlobalVariableId, - conditionGlobalVariableId, - jurisdictionId: usJurisdictionId, - regulatorName: t.regulatorName, - firstEvidenceDate: t.firstEvidenceDate, - firstEvidenceDescription: t.firstEvidenceDescription, - approvalDate: t.approvalDate, - approvalDescription: t.approvalDescription, - efficacyLagDays, - estimatedLivesSavedPerYear: t.estimatedLivesSavedPerYear, - estimatedDeathsDuringLag, - sourceUrl: t.sourceUrl, - }, - }); - count++; - } - - console.log(` ✅ ${count} approval timelines seeded`); -} - -// ============================================================================ -// E) ITEMS — Federal Budget Categories (FY2025 approximate) -// ============================================================================ - -async function seedWishocraticItems() { - console.log("💰 Seeding Optimitron budget categories..."); - - const jurisdiction = await prisma.jurisdiction.findUnique({ - where: { code: US_WISHOCRATIC_JURISDICTION.code }, - select: { id: true }, - }); - - if (!jurisdiction) { - throw new Error( - `Cannot seed Wishocratic items before jurisdiction ${US_WISHOCRATIC_JURISDICTION.code} exists.`, - ); - } - - const catalogRecords = Object.values(getUSWishocraticCatalogRecords()); - - for (const record of catalogRecords) { - await prisma.wishocraticItem.upsert({ - where: { id: record.id }, - update: { - name: record.name, - description: record.description, - sourceUrl: record.sourceUrl, - currentAllocationUsd: record.currentAllocationUsd, - currentAllocationPct: record.currentAllocationPct, - active: true, - jurisdictionId: jurisdiction.id, - }, - create: { - id: record.id, - name: record.name, - description: record.description, - sourceUrl: record.sourceUrl, - currentAllocationUsd: record.currentAllocationUsd, - currentAllocationPct: record.currentAllocationPct, - active: true, - jurisdictionId: jurisdiction.id, - }, - }); - } - - console.log(` ✅ ${catalogRecords.length} Optimitron budget categories`); -} - -// ============================================================================ -// MAIN -// ============================================================================ - -export interface SeedDatabaseOptions { - scopes?: SeedScope[]; -} - -async function seedReferendums() { - console.log("🗳️ Seeding referendums..."); - const publishedAt = new Date("2026-05-03T00:00:00.000Z"); - const buildReferendumData = ( - data: Omit<Prisma.ReferendumUncheckedCreateInput, "contentHash"> & { - question: string; - }, - ): Prisma.ReferendumUncheckedCreateInput => ({ - ...data, - contentHash: buildReferendumContentHash({ - question: data.question, - description: data.description ?? null, - bodyMarkdown: data.bodyMarkdown ?? null, - }), - }); - - const treatyReferendumData = buildReferendumData({ - title: "The 1% Treaty", - slug: TREATY_REFERENDUM_SLUG, - question: - "Should governments redirect 1% of military spending to pragmatic clinical trials and disease eradication by adopting the 1% Treaty?", - kind: ReferendumKind.TREATY, - description: - "The 1% Treaty redirects one percent of military spending into pragmatic clinical trials so disease gets less time to kill people.", - bodyMarkdown: shareableSnippets.onePercentTreatyText.markdown, - publishedAt, - lockedAt: null, - status: ReferendumStatus.ACTIVE, - }); - - await prisma.referendum.upsert({ - where: { slug: TREATY_REFERENDUM_SLUG }, - update: treatyReferendumData, - create: treatyReferendumData, - }); - console.log(" ✓ 1% Treaty referendum"); - - const declarationReferendumData = buildReferendumData({ - title: "Declaration of Optimization", - slug: DECLARATION_REFERENDUM_SLUG, - question: "Do you endorse the Declaration of Optimization?", - kind: ReferendumKind.DECLARATION, - description: - "Sign the Declaration of Optimization to declare your support for evidence-based governance.", - bodyMarkdown: [ - shareableSnippets.whyOptimizationIsNecessary.markdown, - shareableSnippets.declarationOfOptimization.markdown, - ].join("\n\n"), - publishedAt, - lockedAt: null, - status: ReferendumStatus.ACTIVE, - }); - - await prisma.referendum.upsert({ - where: { slug: DECLARATION_REFERENDUM_SLUG }, - update: declarationReferendumData, - create: declarationReferendumData, - }); - console.log(" ✓ Declaration of Optimization referendum"); - - const courtReferendumData = buildReferendumData({ - title: "The Court of Humanity", - slug: COURT_OF_HUMANITY_REFERENDUM_SLUG, - question: COURT_OF_HUMANITY_QUESTION, - kind: ReferendumKind.MEMBERSHIP, - description: - "Join the decentralized court where 8 billion humans are the jury and sovereign immunity is abolished.", - bodyMarkdown: COURT_OF_HUMANITY_TEXT.markdown, - publishedAt, - lockedAt: null, - status: ReferendumStatus.ACTIVE, - }); - - await prisma.referendum.upsert({ - where: { slug: COURT_OF_HUMANITY_REFERENDUM_SLUG }, - update: courtReferendumData, - create: courtReferendumData, - }); - console.log(" ✓ Court of Humanity referendum"); -} - -export async function seedReferenceData() { - const unitMap = await seedUnits(); - const catMap = await seedVariableCategories(unitMap); - await seedGlobalVariables(unitMap, catMap); - await seedMedicalReferenceData(unitMap, catMap); - await seedJurisdictions(); - await seedConflicts(); - await seedDrugApprovalTimelines(); - await seedWishocraticItems(); -} - -export async function seedBootstrapData() { - await seedReferendums(); - await seedReasoningData(prisma); - await seedGrandmaKayExample(); -} - -export async function seedDemoData() { - await seedDemoUser(); -} - -export async function seedDatabase(options: SeedDatabaseOptions = {}) { - const scopes = normalizeSeedScopes(options.scopes); +function assertRemoteSeedIsIntentional(connectionString: string) { + const url = new URL(connectionString); + if (isLocalDatabaseHost(url.hostname)) return; + if (process.env.CI === "true") return; + if (process.env.MANAGED_DATA_ALLOW_REMOTE_APPLY === "1") return; - console.log(`🌱 Starting Optimitron seed (${scopes.join(", ")})...\n`); - - if (scopes.includes("reference")) { - await seedReferenceData(); - } - - if (scopes.includes("bootstrap")) { - await seedBootstrapData(); - } - - if (scopes.includes("demo")) { - await seedDemoData(); - } - - if (scopes.includes("tasks")) { - await seedTreatyTasks(); - const createdByUserId = - cachedSeedWishoniaUserId || (await seedWishoniaUser()).user.id; - const managedDataResult = await syncManagedData(prisma, { - apply: true, - createdByUserId, - }); - console.log(formatManagedDataResult(managedDataResult)); - } - - console.log("\n🎉 Seed complete!"); -} - -// --------------------------------------------------------------------------- -// Treaty Tasks — parent task + per-country signer subtasks with impact data -// --------------------------------------------------------------------------- - -// Due date is an absolute historical anchor — yesterday relative to when the -// dashboard was last reviewed — so the overdue clock keeps ticking up from a -// fixed point instead of resetting to "1 day" on every seed. -const TREATY_DUE_AT = new Date("2026-04-14T00:00:00.000Z"); -const TREATY_CAMPAIGN_COST_USD = 1_000_000_000; // $1B lobbying campaign - -// Peace-dividend NPV using a growing perpetuity with the standard UK Treasury -// Green Book / IPCC AR6 social discount rate (r=3%) and long-run real GDP -// growth (g=2%). NPV = C₀ / (r − g). Under historical military-spending growth -// (~4% real) the sum diverges — see task description for the rebuttal. -const TREATY_PEACE_DIVIDEND_DISCOUNT_RATE = 0.03; -const TREATY_PEACE_DIVIDEND_GROWTH_RATE = 0.02; -const TREATY_PEACE_DIVIDEND_NPV = - PEACE_DIVIDEND_ANNUAL_SOCIETAL_BENEFIT.value / - (TREATY_PEACE_DIVIDEND_DISCOUNT_RATE - TREATY_PEACE_DIVIDEND_GROWTH_RATE); - -// Sentinel value representing −∞. Float64 supports Infinity but Postgres -// round-trips can be flaky, so we use a finite-but-absurd magnitude that the -// formatter detects via threshold (anything below −1e17 renders as "−∞"). -const TREATY_INFINITE_NEGATIVE_COST = -1e18; -const TREATY_NET_COST_USD = TREATY_INFINITE_NEGATIVE_COST; - -// 30 seconds per signature × 193 leaders = 5,790 seconds ≈ 1.61 hours. -const TREATY_SECONDS_PER_SIGNATURE = 30; -const TREATY_TOTAL_EFFORT_HOURS = - (WORLD_LEADERS.length * TREATY_SECONDS_PER_SIGNATURE) / 3600; -const TREATY_PER_SIGNER_EFFORT_HOURS = TREATY_SECONDS_PER_SIGNATURE / 3600; -const TREATY_SIGNER_CONTACT_TEMPLATE = [ - "Your employee has not finished {{taskTitle}}. It is a thirty-second task. One signature. A wrist movement.", - "It has been sitting on a desk for {{delayLabel}}. A desk. Not a war. A desk.", - "Delay body count so far: {{humanLives}} humans have permanently stopped, {{sufferingHours}} hours of suffering accumulated, {{economicLoss}} evaporated. While the paperwork waited.", - "The pen is here: {{taskUrl}}", -].join(" "); - -async function seedTreatyTasks() { - console.log("📋 Seeding treaty tasks..."); - - // Load the leader photo manifest from public/images/leaders/manifest.json. - // Generated by `tsx packages/web/scripts/download-leader-photos.ts`. - // Maps lowercase ISO2 country code → local image path. If a leader has no - // entry we fall back to the remote Wikimedia URL (OG rendering may break - // for those tasks, but the detail page will still load the image). - let leaderPhotoManifest: Record<string, string> = {}; - try { - const manifestPath = new URL( - "../../web/public/images/leaders/manifest.json", - import.meta.url, - ); - const { readFileSync: readFile } = await import("node:fs"); - const raw = readFile(manifestPath, "utf8"); - leaderPhotoManifest = JSON.parse(raw) as Record<string, string>; - console.log( - ` 📸 Loaded ${Object.keys(leaderPhotoManifest).length} local leader photos from manifest`, - ); - } catch (err) { - console.log( - ` ⚠️ No leader photo manifest found — using remote Wikimedia URLs (run 'tsx scripts/download-leader-photos.ts' to cache locally). Error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Clean up ghost signer tasks from the legacy parallel pipeline - // (sync-treaty-signers.ts / treaty-signer-network.ts) that used ISO3 ids and - // fell back to "Head of government of {country}" for displayName when a real - // leader name was missing. seed.ts uses ISO2 ids and real WORLD_LEADERS - // names. Delete the junk set so the list page renders the real leaders. - const deletedGhostTasks = await prisma.task.deleteMany({ - where: { - taskKey: { startsWith: "program:one-percent-treaty:signer:" }, - }, - }); - if (deletedGhostTasks.count > 0) { - console.log(` 🧹 Cleared ${deletedGhostTasks.count} existing signer tasks for clean reseed`); - } - - // Neutralize any Person records whose displayName is the junk fallback. - // Can't delete them directly (may be referenced by other tables) — just - // rename so they stop rendering on list pages as the task's assignee. - const renamedGhostPersons = await prisma.person.updateMany({ - where: { displayName: { startsWith: "Head of government of " } }, - data: { displayName: "Unknown Head of Government" }, - }); - if (renamedGhostPersons.count > 0) { - console.log(` 🧹 Neutralized ${renamedGhostPersons.count} ghost leader person records`); - } - - // Create "Humanity" organization as assignee for top-level tasks - const humanityOrgData = { - name: "Humanity", - slug: "humanity", - type: "OTHER", - status: "APPROVED", - description: "All 8 billion of us.", - } satisfies Prisma.OrganizationUncheckedCreateInput; - - const humanity = await prisma.organization.upsert({ - where: { slug: "humanity" }, - update: humanityOrgData, - create: humanityOrgData, - }); - - // Seed Wishonia as a regular User + Person so she can author task comments, - // claim tasks, show up on /people/wishonia, etc. No special system-user flag. - await seedWishoniaUser(); - - // Lifetime impact from parameters (total civilizational acceleration, not annual) - const totalDalys = DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_DALYS.value; // 565B DALYs - const totalEconValue = DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_ECONOMIC_VALUE.value; // $84.8Q - const accelerationYears = DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_YEARS.value; // 212 years - const annualAvoidableDalys = GLOBAL_ANNUAL_DALY_BURDEN.value * EVENTUALLY_AVOIDABLE_DALY_PCT.value; // 2.67B/yr - const delayDalysPerDay = annualAvoidableDalys / 365; - const delayEconPerDay = delayDalysPerDay * 150_000; // $150K/QALY standard valuation - const annualFunding = TREATY_ANNUAL_FUNDING.value; // $27.2B/yr - const dfdaDirectFundingNpv = DFDA_DIRECT_FUNDING_QUEUE_CLEARANCE_NPV.value; // $475.7B - - // Helper to render a parameter value as a markdown link to its calculation source - const p = (param: { calculationsUrl?: string }, label: string) => - param.calculationsUrl ? `[${label}](${param.calculationsUrl})` : label; - - // --- Root: Promote the General Welfare --- - // The literal constitutional job description of every government on Earth. - // Walking up the parent chain from any claimable task should land a visitor - // on the sentence every political charter already promised. - const { hale, medianIncome } = earthOptimizationPrizeWinCondition; - const prizeRootTask = await createTaskWithImpact({ - task: { - // Internal id + taskKey come from the canonical OPTIMIZE_EARTH_ROOT - // constants in @optimitron/db so seed and web stay in lockstep. - id: OPTIMIZE_EARTH_ROOT_TASK_ID, - taskKey: OPTIMIZE_EARTH_ROOT_TASK_KEY, - assigneeOrganizationId: humanity.id, - title: "Promote the General Welfare", - description: [ - `Every government on Earth has a version of this sentence in its founding charter. The US Constitution says "promote the general Welfare." Every other country says something equivalent. It is the literal job description of the 193 governments you pay **\\$44 trillion a year** to run.`, - "", - `Two numbers measure whether they are doing it: **median healthy life years** (currently **${hale.baseline.toFixed(1)}**, target **${hale.target.toFixed(1)}**) and **median real after-tax income** (currently **\\$${Math.round(medianIncome.baseline).toLocaleString()}**, target **\\$${Math.round(medianIncome.target).toLocaleString()}**). Both should be rising. Both are not.`, - "", - `The jobs below are the concrete to-do items under this one sentence. They are all overdue.`, - "", - "- **Ratify the 1% Treaty** — redirect 1% of military spending into pragmatic clinical trials. Blocked by 193 signatures. Each signature takes 30 seconds.", - "- **Create the Decentralized FDA** — build trial infrastructure independent of politics.", - "- **Fund the Bed Nets Gap** — the most cost-effective marginal-life intervention currently on offer.", - "", - `Read the measurement methodology in [the manual](${earthOptimizationPrizeWinCondition.manualUrl}) and the [GDP trajectories](${earthOptimizationPrizeWinCondition.gdpTrajectoriesUrl}).`, - ].join("\n"), - category: "GOVERNANCE", - difficulty: "EXPERT", - status: "ACTIVE", - isPublic: true, - dueAt: EARTH_OPTIMIZATION_PRIZE_DEADLINE, - sortOrder: -1000, - skillTags: ["strategy", "coordination"], - interestTags: ["earth-optimization-prize", "hale", "median-income"], - claimPolicy: "OPEN_MANY", - }, - primaryEndpoint: { - label: "Open Earth Optimization task tree", - url: "/tasks", - instructions: - "Complete {{taskTitle}} by clearing the overdue child tasks. Start here: {{taskUrl}}", - }, - impact: { - // Cost of the root is the sentinel for "whatever the children cost" — - // the aggregate is computed from children on read. Using 0 here; real - // accounting lives on the program-level children. - estimatedCashCostUsdBase: 0, - expectedEconomicValueUsdBase: totalEconValue, - expectedDalysAvertedBase: totalDalys, - delayEconomicValueUsdLostPerDayBase: delayEconPerDay, - delayDalysLostPerDayBase: delayDalysPerDay, - successProbabilityBase: 0.05, - benefitDurationYears: accelerationYears, - medianHealthyLifeYearsEffectBase: hale.deltaRequired, - medianIncomeGrowthEffectPpPerYearBase: - EARTH_OPTIMIZATION_PRIZE_INCOME_GROWTH_EFFECT_PP_PER_YEAR, - }, - methodologyKey: "earth-optimization-prize-win-condition", - calculationsUrl: earthOptimizationPrizeWinCondition.manualUrl, - }); - console.log(` ✓ Task: "${prizeRootTask.title}" (${prizeRootTask.id})`); - - // --- Task 1: Ratify the 1% Treaty --- - const treatyTask = await createTaskWithImpact({ - task: { - id: "1-pct-treaty", - taskKey: "program:one-percent-treaty:ratify", - parentTaskId: prizeRootTask.id, - assigneeOrganizationId: humanity.id, - title: "Ratify the 1% Treaty", - description: [ - `Your governments collected **\\$${(annualFunding / 0.01 / 1e12).toFixed(2)} trillion** from you this year and spent it on weapons. Enough weapons to kill every person on Earth several times over. Killing everyone once is sufficient.`, - "", - `The 1% Treaty redirects **one cent on the dollar** — \\$${(annualFunding / 1e9).toFixed(1)}B/year — into pragmatic clinical trials. That accelerates the cure for the average disease by **${Math.round(accelerationYears)} years** and saves **${(totalDalys / 1e9).toFixed(0)} billion** healthy life-years.`, - "", - `Every day this stays unsigned locks in **~180,000 future preventable deaths**. The cost of the treaty itself, net of the peace dividend, is **\\$−∞**. [See methodology](https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html).`, - "", - "## What to do (5 minutes)", - "", - "1. **Sign the treaty** at [/treaty](/treaty).", - "2. **Share your referral link** from your [dashboard](/dashboard).", - "3. **Pick a leader from the list below** and message them. Use the contact link.", - "4. *(Optional)* **Fund the campaign** at the [Earth Optimization Prize](/prize) — your principal earns yield if it fails. Zero downside.", - "", - "Then come back and mark this task complete.", - ].join("\n"), - category: "GOVERNANCE", - difficulty: "EXPERT", - status: "ACTIVE", - isPublic: true, - dueAt: TREATY_DUE_AT, - sortOrder: -100, - skillTags: ["organizing", "diplomacy", "public-pressure"], - interestTags: ["treaty", "disease-eradication", "peace-dividend"], - claimPolicy: "OPEN_MANY", - estimatedEffortHours: TREATY_TOTAL_EFFORT_HOURS, - contextJson: { - unlocks: [ - { - kind: "inline", - icon: "🔓", - title: "12× More Clinical Trials", - summary: "Pragmatic trial infrastructure funded by the redirect. Same patients, same hospitals, same data. 44 times cheaper because nobody optimized the expensive version — because nobody's job depended on it.", - beforeAfter: [ - { label: "Patients/yr", before: "1,900,000", after: "23,400,000" }, - { label: "Cost/patient", before: "$41,000", after: "$929" }, - { label: "Trial queue", before: "443 years", after: "36 years" }, - { label: "Untested diseases", before: "9,000+", after: "0" }, - ], - roiRatio: 45, - }, - { - kind: "inline", - icon: "🔓", - title: "Approve Safe Treatments 8 Years Faster", - summary: "Treatments currently wait 8.2 years after being proven safe. They sit in a cabinet. Being safe. While 102 million people died waiting.", - }, - { - kind: "inline", - icon: "🌍", - title: "If All 193 Governments Sign", - summary: "Lifetime gains per median human if the full treaty passes. Cost: 1% of the explosion budget.", - beforeAfter: [ - { label: "Healthy lifespan", before: "63.3 yrs", after: "85.0 yrs" }, - { label: "Median income", before: "$18,700", after: "$76,700" }, - { label: "Lives saved by 2040", before: "—", after: "10.7 billion" }, - ], - }, - ], - contextComparisons: [ - { - heading: "Things that take longer than 30 seconds", - items: [ - { label: "Making toast", value: "120 seconds" }, - { label: "COVID vaccine development", value: "314 days" }, - { label: "Manhattan Project", value: "1,347 days" }, - { label: "This treaty not being signed", value: "and counting", highlight: true }, - ], - }, - { - heading: "Things that take 30 seconds", - items: [ - { label: "Signing the 1% Treaty", value: "30s" }, - { label: "Tying a shoe", value: "30s" }, - { label: "Sending a tweet shaming a world leader", value: "30s" }, - ], - }, - ], - } satisfies Prisma.InputJsonValue, - }, - primaryEndpoint: { - label: "Sign or share the 1% Treaty", - url: "/treaty", - instructions: - "Please complete {{taskTitle}}. Sign the treaty, then assign one more person an Earth Optimization task. Start here: {{taskUrl}}", - }, - impact: { - estimatedCashCostUsdBase: TREATY_NET_COST_USD, - expectedEconomicValueUsdBase: totalEconValue, - expectedDalysAvertedBase: totalDalys, - delayEconomicValueUsdLostPerDayBase: delayEconPerDay, - delayDalysLostPerDayBase: delayDalysPerDay, - successProbabilityBase: 0.01, - benefitDurationYears: accelerationYears, - }, - methodologyKey: "treaty-lifetime-parameters", - calculationsUrl: "https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html", - }); - console.log(` ✓ Task: "${treatyTask.title}" (${treatyTask.id})`); - - // --- Task 2: Create the Decentralized FDA --- - const dfdaTask = await createTaskWithImpact({ - task: { - id: "dfda", - taskKey: "program:dfda:create", - parentTaskId: prizeRootTask.id, - assigneeOrganizationId: humanity.id, - title: "12× More Clinical Trials", - description: [ - `Build and fund a **decentralized FDA platform** that runs pragmatic clinical trials at **12.3× current capacity**, accelerating the cure for the average disease by ${p(DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_YEARS, `**${Math.round(accelerationYears)} years**`)} — the same impact as the 1% Treaty, but without political dependency.`, - "", - `Requires ${p(DFDA_DIRECT_FUNDING_QUEUE_CLEARANCE_NPV, `**$${(dfdaDirectFundingNpv / 1e9).toFixed(0)}B NPV**`)} in direct funding (vs $${(TREATY_CAMPAIGN_COST_USD / 1e9).toFixed(0)}B for the treaty lobbying campaign). Higher total cost, but far higher probability of success because it does not require 100+ governments to agree.`, - "", - "## How to Complete", - "", - "Claim this task if you are actively contributing to the decentralized FDA. Mark it complete when you have done your part.", - "", - "**1. Engineers**: read the [dFDA spec](https://dfda-spec.warondisease.org) and contribute to the reference implementation.", - "", - "**2. Funders**: back the $475.7B NPV directly, or deposit to the [Earth Optimization Prize](/prize) which doubles as interim funding.", - "", - "**3. Clinicians and trial operators**: join the dFDA network as a pragmatic-trial site.", - "", - "**4. Everyone else**: share the plan. Grab your referral link from your [dashboard](/dashboard) and spread it. The dFDA is an independent path forward — it does not depend on the 1% Treaty, and the two efforts reinforce each other.", - ].join("\n"), - category: "GOVERNANCE", - difficulty: "EXPERT", - status: "ACTIVE", - isPublic: true, - dueAt: EARTH_OPTIMIZATION_PRIZE_DEADLINE, - sortOrder: -90, - skillTags: ["engineering", "fundraising", "clinical-trials"], - interestTags: ["dfda", "disease-eradication", "clinical-trials"], - claimPolicy: "OPEN_MANY", - }, - primaryEndpoint: { - label: "Read the dFDA spec", - url: "https://dfda-spec.warondisease.org", - instructions: - "Please help complete {{taskTitle}}. The dFDA path is the backup route that does not wait for governments. Start here: {{taskUrl}}", - }, - impact: { - estimatedCashCostUsdBase: dfdaDirectFundingNpv, - expectedEconomicValueUsdBase: totalEconValue, - expectedDalysAvertedBase: totalDalys, - delayEconomicValueUsdLostPerDayBase: delayEconPerDay, - delayDalysLostPerDayBase: delayDalysPerDay, - successProbabilityBase: 0.10, - benefitDurationYears: accelerationYears, - }, - methodologyKey: "dfda-direct-lifetime-parameters", - calculationsUrl: "https://manual.WarOnDisease.org/knowledge/appendix/dfda-impact-paper.html", - }); - console.log(` ✓ Task: "${dfdaTask.title}" (${dfdaTask.id})`); - - // --- Task 3: Fund the bed nets funding gap (benchmark/competing task) --- - // Numbers from GiveWell's published analysis of Against Malaria Foundation: - // - ~$5,500 per death averted (2024 marginal cost) - // - ~$1B annual funding gap to reach universal coverage in sub-Saharan Africa - // - ~200K preventable malaria deaths/year at full coverage - // - Children under 5 are ~80% of deaths; avg ~40 healthy life-years per death averted - // Source: https://www.givewell.org/charities/amf - const AMF_ANNUAL_FUNDING_GAP = 1_000_000_000; // $1B/yr - const AMF_ANNUAL_LIVES_SAVED = 182_000; // at full funding - const AMF_QALY_PER_LIFE = 40; // avg remaining life for a child under 5 - const AMF_ANNUAL_HEALTHY_YEARS_SAVED = AMF_ANNUAL_LIVES_SAVED * AMF_QALY_PER_LIFE; // ~7.28M/yr - const AMF_ECON_VALUE_PER_QALY = 150_000; - const AMF_ANNUAL_ECON_VALUE = AMF_ANNUAL_HEALTHY_YEARS_SAVED * AMF_ECON_VALUE_PER_QALY; - const AMF_BENEFIT_DURATION_YEARS = 20; // assume 20yr of continued funding - const AMF_TOTAL_HEALTHY_YEARS = AMF_ANNUAL_HEALTHY_YEARS_SAVED * AMF_BENEFIT_DURATION_YEARS; - const AMF_TOTAL_ECON_VALUE = AMF_ANNUAL_ECON_VALUE * AMF_BENEFIT_DURATION_YEARS; - const AMF_TOTAL_COST = AMF_ANNUAL_FUNDING_GAP * AMF_BENEFIT_DURATION_YEARS; - - const amfOrgData = { - name: "Against Malaria Foundation", - slug: "against-malaria-foundation", - type: "NONPROFIT", - status: "APPROVED", - description: "Distributes long-lasting insecticide-treated bed nets in sub-Saharan Africa. GiveWell's top-rated charity for cost-effective disease prevention.", - website: "https://www.againstmalaria.com", - } satisfies Prisma.OrganizationUncheckedCreateInput; - - const amfOrg = await prisma.organization.upsert({ - where: { slug: "against-malaria-foundation" }, - update: amfOrgData, - create: amfOrgData, - }); - - const bedNetsTask = await createTaskWithImpact({ - task: { - id: "bed-nets-funding-gap", - taskKey: "program:amf:bed-nets-funding-gap", - parentTaskId: prizeRootTask.id, - assigneeOrganizationId: amfOrg.id, - title: "Fund the Bed Nets Funding Gap", - description: [ - `Close the **~$1B/year funding gap** for bed net distribution in sub-Saharan Africa. Full funding would save approximately **182,000 lives per year** — overwhelmingly children under 5 — at a marginal cost of roughly **$5,500 per life saved**.`, - "", - `Bed nets remain the most thoroughly studied, most-trusted cost-effective health intervention in the world. This task exists on the list so you can see exactly how it ranks against everything else. Sort by **Cost per Healthy Year** and see where it falls.`, - "", - "## How to Complete", - "", - "**1. Donate directly** at [Against Malaria Foundation](https://www.againstmalaria.com/Donation.aspx).", - "", - "**2. Verify via GiveWell** — they publish independent cost-effectiveness analysis and track every funding gap: [GiveWell AMF page](https://www.givewell.org/charities/amf).", - "", - "**3. Mark this task complete** with evidence of your contribution.", - "", - "## Context", - "", - "Approximately 600,000 people die of malaria each year, roughly 80% of them children under 5 in sub-Saharan Africa. Bed nets at current coverage prevent millions of cases annually, but coverage has plateaued around 60-70% because the marginal net requires reaching harder-to-serve populations. The remaining gap is real, absorbable, and well-studied.", - ].join("\n"), - category: "GOVERNANCE", - difficulty: "BEGINNER", - status: "ACTIVE", - isPublic: true, - dueAt: EARTH_OPTIMIZATION_PRIZE_DEADLINE, - sortOrder: -80, - skillTags: ["fundraising", "global-health"], - interestTags: ["malaria", "bed-nets", "global-health", "givewell"], - claimPolicy: "OPEN_MANY", - }, - primaryEndpoint: { - label: "Donate to AMF", - url: "https://www.againstmalaria.com/Donation.aspx", - instructions: - "Please help complete {{taskTitle}}. Bed nets are the clean benchmark: cheap, proven, and blocked mostly by funding. Start here: {{taskUrl}}", - }, - impact: { - estimatedCashCostUsdBase: AMF_TOTAL_COST, - expectedEconomicValueUsdBase: AMF_TOTAL_ECON_VALUE, - expectedDalysAvertedBase: AMF_TOTAL_HEALTHY_YEARS, - delayEconomicValueUsdLostPerDayBase: AMF_ANNUAL_ECON_VALUE / 365, - delayDalysLostPerDayBase: AMF_ANNUAL_HEALTHY_YEARS_SAVED / 365, - successProbabilityBase: 0.95, // very high — well-studied intervention - benefitDurationYears: AMF_BENEFIT_DURATION_YEARS, - }, - methodologyKey: "amf-givewell-marginal-analysis", - calculationsUrl: "https://www.givewell.org/charities/amf/supplementary-information", - }); - console.log(` ✓ Task: "${bedNetsTask.title}" (${bedNetsTask.id})`); - - // --- Foundation grant accountability tasks --- - // Same public-accountability pattern as the head-of-state treaty tasks: - // name the institution, assign the tiny concrete action, mark it overdue. - const ICEWAD_GRANT_DALYS_PER_USD = 564.972; - const ICEWAD_GRANT_ECON_VALUE_PER_USD = ICEWAD_GRANT_DALYS_PER_USD * 150_000; - const foundationGrantOrganizations = [ - { - name: "Survival and Flourishing Fund", - website: "https://survivalandflourishing.fund", - }, - { - name: "Open Philanthropy", - website: "https://www.openphilanthropy.org", - }, - { - name: "Gates Foundation", - website: "https://www.gatesfoundation.org", - }, - { - name: "Filecoin Foundation", - website: "https://fil.org", - }, - { - name: "Arnold Ventures", - website: "https://www.arnoldventures.org", - }, - { - name: "Wellcome Trust", - website: "https://wellcome.org", - }, - { - name: "Patrick J. McGovern Foundation", - website: "https://www.mcgovern.org", - }, - { - name: "Schmidt Futures", - website: "https://www.schmidtfutures.com", - }, - { - name: "Skoll Foundation", - website: "https://skoll.org", - }, - { - name: "Omidyar Network", - website: "https://omidyar.com", - }, - ] as const; - - for (const [index, foundation] of foundationGrantOrganizations.entries()) { - const slug = slugify(foundation.name); - const organizationData = { - name: foundation.name, - slug, - type: "FOUNDATION", - status: "APPROVED", - website: foundation.website, - description: - "Foundation or grantmaker assigned a public $1 grant task for the International Campaign to End War and Disease.", - } satisfies Prisma.OrganizationUncheckedCreateInput; - - const organization = await prisma.organization.upsert({ - where: { slug }, - update: organizationData, - create: organizationData, - }); - - await createTaskWithImpact({ - task: { - id: `icewad-grant-${slug}`, - taskKey: `icewad:grant:${slug}`, - parentTaskId: treatyTask.id, - assigneeOrganizationId: organization.id, - title: "Fund the International Campaign to End War and Disease", - description: [ - `${foundation.name} has the opportunity to fund the highest expected-value charitable intervention ever calculated.`, - "", - "The International Campaign to End War and Disease prevents one disability-adjusted life year (DALY) for $0.00177. That is 50,300 times more cost-effective than insecticide-treated bednets, the current gold standard in global health philanthropy.", - "", - "**Suggested grant: $1.**", - "", - "At our cost-effectiveness ratio, $1 prevents approximately 565 DALYs, which is roughly 16 healthy life-years. If you would like to prevent more healthy life-years, you may increase the amount.", - "", - "At $100, you prevent 56,497 DALYs (1,614 healthy life-years).", - "At $1,000, you prevent 564,972 DALYs (16,142 healthy life-years).", - "At $100,000, you save approximately 3,200 lives.", - "", - "These are not projections. They are the output of a cost-benefit model with 670 parameters, Monte Carlo simulation, and complete derivation chains. The model, methodology, and every input parameter are published with 95% confidence intervals at manual.warondisease.org.", - "", - "We understand this sounds implausible. We have checked the math. The math does not care whether it sounds implausible.", - "", - "[Donate ->](https://warondisease.org/donate)", - "", - "[Read the full analysis ->](https://manual.warondisease.org/knowledge/economics/1-pct-treaty-impact.html)", - "", - "[Read the treaty ->](https://manual.warondisease.org/knowledge/solution/1-percent-treaty.html)", - ].join("\n"), - category: "GOVERNANCE", - difficulty: "TRIVIAL", - status: "ACTIVE", - isPublic: true, - dueAt: TREATY_DUE_AT, - sortOrder: -75 + index, - claimPolicy: "ASSIGNED_ONLY", - skillTags: ["grantmaking", "global-health", "fundraising"], - interestTags: ["icewad", "one-percent-treaty", "foundation", "grant"], - estimatedEffortHours: TREATY_PER_SIGNER_EFFORT_HOURS, - }, - primaryEndpoint: { - label: "Donate", - url: "https://warondisease.org/donate", - instructions: - "Please complete {{taskTitle}} with a $1 grant or a larger one if the math survives contact with your grants committee. Start here: {{taskUrl}}", - }, - impact: { - estimatedCashCostUsdBase: 1, - expectedEconomicValueUsdBase: ICEWAD_GRANT_ECON_VALUE_PER_USD, - expectedDalysAvertedBase: ICEWAD_GRANT_DALYS_PER_USD, - delayEconomicValueUsdLostPerDayBase: ICEWAD_GRANT_ECON_VALUE_PER_USD / 365, - delayDalysLostPerDayBase: ICEWAD_GRANT_DALYS_PER_USD / 365, - successProbabilityBase: 0.25, - benefitDurationYears: 1, - }, - methodologyKey: "icewad-one-dollar-grant", - parameterSetHashSuffix: slug, - calculationsUrl: "https://manual.warondisease.org/knowledge/economics/1-pct-treaty-impact.html", - }); - } - - console.log(` ✓ ${foundationGrantOrganizations.length} foundation grant tasks`); - - // --- Signer child tasks for the treaty --- - // Single source of truth: GovernmentLeaderRecord bundles country identity, - // canonical office/contact metadata, leader personal data, and both - // military + total-gov budgets (resolved from the coalesced country panel - // + curated overrides; guaranteed non-null). - const leaderRecords = listGovernmentLeaders().filter( - (record) => record.leaderSourceRef != null && record.leaderName != null, + throw new Error( + [ + `Refusing local managed seed against remote database host ${url.host}.`, + "Run `pnpm db:sync:managed-data -- --dry-run` first, then set MANAGED_DATA_ALLOW_REMOTE_APPLY=1 only if this is intentional.", + ].join(" "), ); - const skippedLeaderCount = listGovernmentLeaders().length - leaderRecords.length; - if (skippedLeaderCount > 0) { - console.log( - ` ! skipping ${skippedLeaderCount} leader record(s) missing leaderSourceRef/leaderName`, - ); - } - const leaderCount = leaderRecords.length; - const formatUsdCompact = (n: number): string => { - if (n >= 1e12) return `$${(n / 1e12).toFixed(2)}T`; - if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; - if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`; - return `$${Math.round(n).toLocaleString()}`; - }; - const googleSearch = (query: string) => - `https://www.google.com/search?q=${encodeURIComponent(query)}`; - let created = 0; - - for (const record of leaderRecords) { - // leaderSourceRef/leaderName are non-null by the filter above. - const sourceRef = record.leaderSourceRef!; - const leaderName = record.leaderName!; - const countryCode = record.countryCode.toUpperCase(); - const share = 1 / leaderCount; - // Slugified handle from displayName, with country code suffix to guarantee - // uniqueness across the 193 leaders. Lower-case, hyphen-separated, ASCII. - const handle = `${slugify(leaderName)}-${countryCode.toLowerCase()}`; - const leaderImage = - leaderPhotoManifest[countryCode.toLowerCase()] ?? record.leaderImageUrl; - - const person = await prisma.person.upsert({ - where: { sourceRef }, - update: { - handle, - displayName: leaderName, - image: leaderImage, - countryCode, - currentAffiliation: `Government of ${record.countryName}`, - isPublicFigure: true, - }, - create: { - handle, - displayName: leaderName, - image: leaderImage, - countryCode, - currentAffiliation: `Government of ${record.countryName}`, - isPublicFigure: true, - sourceRef, - }, - }); - - const annualRedirectUsd = record.militaryBudgetUsd * 0.01; - - const contactChannels: Array<{ kind: "twitter" | "bluesky" | "form"; label: string; href: string }> = [ - { - kind: "twitter", - label: `Remind on X directly`, - href: googleSearch(`${leaderName} official X Twitter account`), - }, - { - kind: "bluesky", - label: `Remind on Bluesky directly`, - href: googleSearch(`${leaderName} Bluesky account site:bsky.app`), - }, - ]; - if (record.contactUrl) { - contactChannels.push({ - kind: "form", - label: record.contactLabel ?? "Official contact form", - href: record.contactUrl, - }); - } - - const signerContextJson: Prisma.InputJsonValue = { - assigneeProfile: { - role: record.roleTitle, - employerLabel: `Government of ${record.countryName}`, - employerCountLabel: "citizens", - budgetUsdPerYear: record.militaryBudgetUsd, - budgetLabel: "Military spending", - governmentBudgetUsdPerYear: record.governmentBudgetUsd, - jobQuote: { - text: "promote the general welfare", - source: `${record.countryName} — job description, every citizen, every day`, - }, - contactChannels, - }, - difficulty: { - whatItMeans: `Redirect 1% of ${record.countryName}'s military spending (${formatUsdCompact(annualRedirectUsd)}/yr) from weapons to pragmatic clinical trials.`, - label: "Sign a piece of paper", - timeRequiredSeconds: 30, - skillsRequired: "Holding a pen", - }, - reminder: { - intro: `30 seconds. Remind them.`, - messageTemplate: [ - `${leaderName} is {daysOverdue} days overdue on "Sign the 1% Treaty".`, - ``, - `Task: hold pen, sign paper. 30 seconds.`, - `Cost of the delay so far: {deathsLocked} preventable deaths, {moneyDestroyed} in foregone clinical trials.`, - `Job description: "promote the general welfare."`, - ``, - `{taskUrl}`, - ].join("\n"), - }, - contextComparisons: [ - { - heading: "Things that take longer than 30 seconds", - items: [ - { label: "Making toast", value: "120 seconds" }, - { label: "Developing the COVID vaccine", value: "314 days" }, - { label: "The Manhattan Project", value: "1,347 days" }, - { - label: `${leaderName} not signing this`, - value: "{daysOverdue} days", - highlight: true, - }, - ], - }, - { - heading: "Things that take 30 seconds", - items: [ - { label: "Signing the 1% Treaty", value: "30s" }, - { label: "Tying a shoe", value: "30s" }, - { label: "Sending a tweet", value: "30s" }, - ], - }, - ], - }; - - await createTaskWithImpact({ - task: { - id: `1-pct-treaty-signer-${countryCode.toLowerCase()}`, - taskKey: `program:one-percent-treaty:signer:${countryCode.toLowerCase()}`, - parentTaskId: treatyTask.id, - assigneePersonId: person.id, - assigneeAffiliationSnapshot: `Government of ${record.countryName}`, - roleTitle: record.roleTitle, - title: "Sign the 1% Treaty", - description: `**${leaderName}** — ${record.roleTitle} of ${record.countryName}. One job: redirect 1% of ${record.countryName}'s military spending into pragmatic clinical trials. Overdue.`, - category: "GOVERNANCE", - difficulty: "EXPERT", - status: "ACTIVE", - isPublic: true, - dueAt: TREATY_DUE_AT, - claimPolicy: "ASSIGNED_ONLY", - skillTags: ["diplomacy", "public-pressure"], - interestTags: ["treaty", "disease-eradication", `country-${countryCode.toLowerCase()}`], - estimatedEffortHours: TREATY_PER_SIGNER_EFFORT_HOURS, - contextJson: signerContextJson, - }, - primaryEndpoint: { - label: record.contactLabel ?? "Find official contact", - url: - record.contactUrl ?? - googleSearch(`${leaderName} ${record.roleTitle} ${record.countryName} official contact`), - instructions: TREATY_SIGNER_CONTACT_TEMPLATE, - }, - impact: { - // Cost stays at the −∞ sentinel for every signer — splitting infinity - // is still infinity. - estimatedCashCostUsdBase: TREATY_NET_COST_USD, - expectedEconomicValueUsdBase: totalEconValue * share, - expectedDalysAvertedBase: totalDalys * share, - delayEconomicValueUsdLostPerDayBase: delayEconPerDay * share, - delayDalysLostPerDayBase: delayDalysPerDay * share, - successProbabilityBase: 0.01, - benefitDurationYears: accelerationYears, - }, - methodologyKey: "treaty-per-country-lifetime", - parameterSetHashSuffix: countryCode, - }); - - created += 1; - } - - console.log(` ✓ ${created} signer tasks with leader photos`); -} - -const GRANDMA_KAY_SOURCE_REF = "memorial-example:grandma-kay"; -let cachedSeedWishoniaUserId: string | null = null; - -/** - * Seed Wishonia as a regular user with a linked Person record. This lets her: - * - Author task comments under her own user ID (no fake system-user hack) - * - Be assigned tasks via `assigneePersonId` - * - Create tasks via `createdByUserId` - * - Show up on /people/wishonia exactly like any public figure - * - * Idempotent. Safe to re-run. - */ -async function seedWishoniaUser() { - console.log("🛸 Seeding Wishonia user..."); - - const { person, user } = await upsertWishoniaUser(prisma); - - console.log(` ✓ Wishonia user (${user.id}) + person (${person.id}) handle=${person.handle}`); - cachedSeedWishoniaUserId = user.id; - return { person, user }; -} - -async function seedGrandmaKayExample() { - console.log("🧾 Seeding Grandma Kay represented-person example..."); - - const { user } = await seedWishoniaUser(); - const referendum = await prisma.referendum.findUniqueOrThrow({ - where: { slug: TREATY_REFERENDUM_SLUG }, - select: { id: true }, - }); - - const person = await prisma.person.upsert({ - where: { sourceRef: GRANDMA_KAY_SOURCE_REF }, - update: { - displayName: "Grandma Kay", - handle: "grandma-kay", - image: "/img/grandma.jpg", - isPublic: true, - lifeStatus: PersonLifeStatus.LIVING, - }, - create: { - createdByUserId: user.id, - displayName: "Grandma Kay", - handle: "grandma-kay", - image: "/img/grandma.jpg", - isPublic: true, - lifeStatus: PersonLifeStatus.LIVING, - sourceRef: GRANDMA_KAY_SOURCE_REF, - }, - }); - - await prisma.personCondition.upsert({ - where: { id: "person-condition-grandma-kay-dementia" }, - update: { - conditionName: "Dementia", - deletedAt: null, - isPublic: true, - personId: person.id, - reportedByUserId: user.id, - status: PersonConditionStatus.ACTIVE, - }, - create: { - id: "person-condition-grandma-kay-dementia", - conditionName: "Dementia", - isPublic: true, - personId: person.id, - reportedByUserId: user.id, - status: PersonConditionStatus.ACTIVE, - }, - }); - - await prisma.referendumVote.upsert({ - where: { - referendumId_personId: { - referendumId: referendum.id, - personId: person.id, - }, - }, - update: { - answer: VotePosition.YES, - deletedAt: null, - isPublic: true, - publicComment: "She would trade one apocalypse for dementia research.", - userId: user.id, - voteSource: ReferendumVoteSource.REPRESENTED, - }, - create: { - answer: VotePosition.YES, - isPublic: true, - personId: person.id, - publicComment: "She would trade one apocalypse for dementia research.", - referendumId: referendum.id, - userId: user.id, - voteSource: ReferendumVoteSource.REPRESENTED, - }, - }); - - console.log(" ✓ Grandma Kay represented YES vote"); -} - -/** - * Helper: upsert a task + impact estimate set + LIFETIME frame. - * Idempotent — safe to re-run after changing description/impact values without - * losing existing claims, edges, or other task state. - * Requires `task.id` to be set for upsert-by-id behavior. - */ -async function createTaskWithImpact(input: { - task: Omit<Parameters<typeof prisma.task.create>[0]["data"], "createdByUserId"> & { - createdByUserId?: string | null; - id: string; - }; - primaryEndpoint?: { - email?: string | null; - instructions?: string | null; - label?: string | null; - sourceUrl?: string | null; - url?: string | null; - } | null; - impact: { - estimatedCashCostUsdBase: number; - expectedEconomicValueUsdBase: number; - expectedDalysAvertedBase: number; - delayEconomicValueUsdLostPerDayBase: number; - delayDalysLostPerDayBase: number; - successProbabilityBase: number; - benefitDurationYears: number; - medianHealthyLifeYearsEffectBase?: number; - medianIncomeGrowthEffectPpPerYearBase?: number; - }; - methodologyKey: string; - parameterSetHashSuffix?: string; - calculationsUrl?: string; -}) { - const { - id: taskId, - ...taskData - } = input.task; - - // Prisma 7 requires relation syntax (not scalar FK fields) in both create and update. - const { - assigneeOrganizationId, - assigneePersonId, - createdByUserId, - parentTaskId, - ...taskScalars - } = taskData as typeof taskData & { - assigneeOrganizationId?: string | null; - assigneePersonId?: string | null; - createdByUserId?: string | null; - parentTaskId?: string | null; - }; - const explicitCreatedByUserId = createdByUserId?.trim() || null; - const resolvedCreatedByUserId = - explicitCreatedByUserId || - cachedSeedWishoniaUserId || - (await seedWishoniaUser()).user.id; - const createRelations: Record<string, unknown> = {}; - const updateRelations: Record<string, unknown> = {}; - createRelations.createdByUser = { connect: { id: resolvedCreatedByUserId } }; - if (explicitCreatedByUserId) { - updateRelations.createdByUser = { connect: { id: explicitCreatedByUserId } }; - } - if (assigneeOrganizationId) { - createRelations.assigneeOrganization = { connect: { id: assigneeOrganizationId } }; - updateRelations.assigneeOrganization = { connect: { id: assigneeOrganizationId } }; - } else if (assigneeOrganizationId === null) { - updateRelations.assigneeOrganization = { disconnect: true }; - } - if (assigneePersonId) { - createRelations.assigneePerson = { connect: { id: assigneePersonId } }; - updateRelations.assigneePerson = { connect: { id: assigneePersonId } }; - } else if (assigneePersonId === null) { - updateRelations.assigneePerson = { disconnect: true }; - } - if (parentTaskId) { - createRelations.parentTask = { connect: { id: parentTaskId } }; - updateRelations.parentTask = { connect: { id: parentTaskId } }; - } else if (parentTaskId === null) { - updateRelations.parentTask = { disconnect: true }; - } - - // Upsert the task itself - const task = await prisma.task.upsert({ - where: { id: taskId }, - create: { id: taskId, ...taskScalars, ...createRelations }, - update: { ...taskScalars, ...updateRelations }, - }); - - if (input.primaryEndpoint) { - await upsertSeedTaskCommunicationEndpoint(task.id, input.primaryEndpoint); - } - - // Delete old impact estimate sets for this task (cascade deletes frames/metrics) - await prisma.taskImpactEstimateSet.deleteMany({ - where: { taskId: task.id }, - }); - - // Create fresh estimate set - const estimateSet = await prisma.taskImpactEstimateSet.create({ - data: { - taskId: task.id, - isCurrent: true, - estimateKind: "FORECAST", - publicationStatus: "PUBLISHED", - sourceSystem: "PARAMETER_CATALOG", - calculationVersion: "seed-v1", - methodologyKey: input.methodologyKey, - parameterSetHash: `seed${input.parameterSetHashSuffix ? `-${input.parameterSetHashSuffix}` : ""}`, - counterfactualKey: "status-quo", - assumptionsJson: input.calculationsUrl ? { calculationsUrl: input.calculationsUrl } : undefined, - }, - }); - - await prisma.task.update({ - where: { id: task.id }, - data: { currentImpactEstimateSetId: estimateSet.id }, - }); - - await prisma.taskImpactFrameEstimate.create({ - data: { - taskImpactEstimateSetId: estimateSet.id, - frameKey: "LIFETIME", - frameSlug: "lifetime", - evaluationHorizonYears: input.impact.benefitDurationYears, - timeToImpactStartDays: 365, - adoptionRampYears: 5, - benefitDurationYears: input.impact.benefitDurationYears, - annualDiscountRate: 0, - successProbabilityBase: input.impact.successProbabilityBase, - expectedEconomicValueUsdBase: input.impact.expectedEconomicValueUsdBase, - expectedDalysAvertedBase: input.impact.expectedDalysAvertedBase, - delayEconomicValueUsdLostPerDayBase: input.impact.delayEconomicValueUsdLostPerDayBase, - delayDalysLostPerDayBase: input.impact.delayDalysLostPerDayBase, - estimatedCashCostUsdBase: input.impact.estimatedCashCostUsdBase, - estimatedEffortHoursBase: 0.5, - medianHealthyLifeYearsEffectBase: input.impact.medianHealthyLifeYearsEffectBase, - medianIncomeGrowthEffectPpPerYearBase: input.impact.medianIncomeGrowthEffectPpPerYearBase, - }, - }); - - return task; -} - -function inferSeedEndpointKind(input: { email: string | null; url: string | null }) { - if (input.url?.toLowerCase().startsWith("mailto:")) { - return TaskCommunicationEndpointKind.MAILTO; - } - - if (input.email) { - return TaskCommunicationEndpointKind.EMAIL; - } - - if (input.url) { - return TaskCommunicationEndpointKind.EXTERNAL_URL; - } - - return TaskCommunicationEndpointKind.MANUAL; } -async function upsertSeedTaskCommunicationEndpoint( - taskId: string, - input: { - instructions?: string | null; - label?: string | null; - url?: string | null; - }, -) { - const url = input.url?.trim() || null; - const label = input.label?.trim() || null; - const instructions = input.instructions?.trim() || null; - const email = - url?.toLowerCase().startsWith("mailto:") - ? url.slice("mailto:".length).split("?")[0]?.trim() || null - : null; - - if (!url && !label && !instructions) { - await prisma.taskCommunicationEndpoint.updateMany({ - where: { - deletedAt: null, - isPrimary: true, - taskId, - }, - data: { - deletedAt: new Date(), - isPrimary: false, - }, - }); - return null; - } +export async function runManagedSeed() { + const connectionString = loadDatabaseUrl(); + assertRemoteSeedIsIntentional(connectionString); - const existing = await prisma.taskCommunicationEndpoint.findFirst({ - where: { - deletedAt: null, - isPrimary: true, - taskId, - }, - select: { id: true }, + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString }), }); - const data = { - email, - instructions, - isPrimary: true, - kind: inferSeedEndpointKind({ email, url }), - label, - priority: 0, - url, - verificationStatus: TaskCommunicationEndpointVerificationStatus.UNVERIFIED, - }; - - if (existing) { - return prisma.taskCommunicationEndpoint.update({ - where: { id: existing.id }, - data, - }); - } - - return prisma.taskCommunicationEndpoint.create({ - data: { - ...data, - taskId, - }, - }); -} - -// --------------------------------------------------------------------------- -// Demo User — for hackathon judges and demo recordings -// --------------------------------------------------------------------------- -// Email: demo@thinkbynumbers.org Password: demo1234 - -async function seedDemoUser() { - console.log("👤 Seeding demo user..."); - - const DEMO_EMAIL = "demo@thinkbynumbers.org"; - const LEGACY_DEMO_EMAIL = "demo@optimitron.org"; - - // Pre-hashed bcrypt(12) of "demo1234" - const DEMO_PASSWORD_HASH = - "$2b$12$Hy27qJOTykSezth61xRCJ..sMPVvzWxs9wZEEsEsYn9o3GaUYkGCa"; - try { - const existingDemoUser = await prisma.user.findUnique({ - where: { email: DEMO_EMAIL }, - select: { id: true }, - }); - if (!existingDemoUser) { - await prisma.user.updateMany({ - where: { email: LEGACY_DEMO_EMAIL }, - data: { email: DEMO_EMAIL }, - }); - } - - const existingDemoPerson = await prisma.person.findUnique({ - where: { email: DEMO_EMAIL }, - select: { id: true }, - }); - if (!existingDemoPerson) { - await prisma.person.updateMany({ - where: { email: LEGACY_DEMO_EMAIL }, - data: { email: DEMO_EMAIL }, - }); - } - - const user = await prisma.user.upsert({ - where: { email: DEMO_EMAIL }, - update: { - password: DEMO_PASSWORD_HASH, - emailVerified: new Date(), - }, - create: { - email: DEMO_EMAIL, - password: DEMO_PASSWORD_HASH, - emailVerified: new Date(), - referralCode: "DEMO", - }, - }); - - // Person owns the public-display fields (handle / displayName / image). - const person = await prisma.person.upsert({ - where: { email: DEMO_EMAIL }, - update: { - displayName: "Demo User", - handle: "demo", - }, - create: { - email: DEMO_EMAIL, - displayName: "Demo User", - handle: "demo", - }, - }); - - if (user.personId !== person.id) { - await prisma.user.update({ - where: { id: user.id }, - data: { personId: person.id }, - }); - } - console.log(" ✓ demo@thinkbynumbers.org / demo1234"); - } catch (err) { - // If schema is out of sync, try raw SQL fallback. Display fields live - // on Person now, so the User row carries only auth-level columns. - console.log(" ⚠ upsert failed, trying raw SQL..."); - await prisma.$executeRawUnsafe(` - UPDATE "User" - SET email = 'demo@thinkbynumbers.org' - WHERE email = 'demo@optimitron.org' - AND NOT EXISTS ( - SELECT 1 FROM "User" WHERE email = 'demo@thinkbynumbers.org' - ) - `); - await prisma.$executeRawUnsafe(` - INSERT INTO "User" (id, email, password, "referralCode", "emailVerified", "createdAt", "updatedAt") - VALUES ('demo-user-id', 'demo@thinkbynumbers.org', $1, 'DEMO', NOW(), NOW(), NOW()) - ON CONFLICT (email) DO UPDATE SET - password = $1, - "emailVerified" = NOW(), - "updatedAt" = NOW() - `, DEMO_PASSWORD_HASH); - console.log(" ✓ demo@thinkbynumbers.org / demo1234 (via raw SQL)"); + const result = await syncManagedData(prisma, { apply: true }); + console.log(formatManagedDataResult(result)); + } finally { + await prisma.$disconnect(); } } -export async function disconnectSeedClient() { - await prisma.$disconnect(); -} - const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMainModule) { - Promise.resolve() - .then(() => seedDatabase({ scopes: parseSeedScopes(process.argv.slice(2)) })) - .catch((e) => { - console.error("❌ Seed failed:", e); - process.exit(1); - }) - .finally(async () => { - await disconnectSeedClient(); - }); + runManagedSeed().catch((error) => { + console.error("Managed seed failed:"); + console.error(error); + process.exit(1); + }); } diff --git a/packages/db/scripts/sync-managed-data.ts b/packages/db/scripts/sync-managed-data.ts index a56d34f78..231a959f5 100644 --- a/packages/db/scripts/sync-managed-data.ts +++ b/packages/db/scripts/sync-managed-data.ts @@ -20,8 +20,36 @@ function parseArgs(argv: string[]) { }; } +function isLocalDatabaseHost(hostname: string) { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "postgres" + ); +} + +function assertRemoteApplyIsIntentional(connectionString: string, apply: boolean) { + if (!apply) return; + + const url = new URL(connectionString); + if (isLocalDatabaseHost(url.hostname)) return; + if (process.env.CI === "true") return; + if (process.env.MANAGED_DATA_ALLOW_REMOTE_APPLY === "1") return; + + throw new Error( + [ + `Refusing local managed-data --apply against remote database host ${url.host}.`, + "Run --dry-run first, then set MANAGED_DATA_ALLOW_REMOTE_APPLY=1 only if this is intentional.", + ].join(" "), + ); +} + const { apply, mode } = parseArgs(process.argv.slice(2)); -const adapter = new PrismaPg({ connectionString: loadDatabaseUrl() }); +const connectionString = loadDatabaseUrl(); +assertRemoteApplyIsIntentional(connectionString, apply); +const adapter = new PrismaPg({ connectionString }); const prisma = new PrismaClient({ adapter }); try { diff --git a/packages/db/src/__tests__/seed-scopes.test.ts b/packages/db/src/__tests__/seed-scopes.test.ts deleted file mode 100644 index 490ba4822..000000000 --- a/packages/db/src/__tests__/seed-scopes.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSeedScopes } from "../../prisma/seed-scopes.ts"; - -describe("parseSeedScopes", () => { - it("defaults to all scopes when no args are provided", () => { - expect(parseSeedScopes([])).toEqual(["reference", "bootstrap", "demo", "tasks"]); - }); - - it("supports comma-separated scope lists", () => { - expect(parseSeedScopes(["--scope", "reference,demo"])).toEqual([ - "reference", - "demo", - ]); - }); - - it("accepts repeated scope flags and de-duplicates them", () => { - expect( - parseSeedScopes(["--scope", "reference", "--scope", "demo", "--scope", "reference"]), - ).toEqual(["reference", "demo"]); - }); - - it("expands all to every scope", () => { - expect(parseSeedScopes(["--scope", "all"])).toEqual([ - "reference", - "bootstrap", - "demo", - "tasks", - ]); - }); - - it("rejects invalid scopes", () => { - expect(() => parseSeedScopes(["--scope", "invalid"])).toThrow( - 'Invalid seed scope "invalid". Expected one of: all, reference, bootstrap, demo, tasks.', - ); - }); -}); diff --git a/packages/db/src/__tests__/seed.integration.test.ts b/packages/db/src/__tests__/seed.integration.test.ts index 9ee19743c..bdac701b8 100644 --- a/packages/db/src/__tests__/seed.integration.test.ts +++ b/packages/db/src/__tests__/seed.integration.test.ts @@ -1,7 +1,11 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { PrismaPg } from "@prisma/adapter-pg"; import { assertSafeLocalTestDatabaseUrl } from "../db-cli.js"; -import { disconnectSeedClient, seedDatabase } from "../../prisma/seed.ts"; +import { syncManagedData } from "../managed-data/index.js"; +import { + setManagedSeedDataClient, + syncManagedTreatyAccountabilityData, +} from "../managed-data/managed-seed-data.js"; import { PersonConditionStatus, PersonLifeStatus, @@ -32,17 +36,16 @@ async function readBaselineCounts(prisma: PrismaClient) { }; } -describeIfDatabase("seedDatabase", () => { +describeIfDatabase("syncManagedData", () => { const adapter = new PrismaPg({ connectionString: databaseUrl! }); const prisma = new PrismaClient({ adapter }); beforeAll(async () => { - await seedDatabase(); + await syncManagedData(prisma, { apply: true }); }, SEED_TEST_TIMEOUT_MS); afterAll(async () => { await prisma.$disconnect(); - await disconnectSeedClient(); }); it("seeds baseline reference data", async () => { @@ -191,7 +194,7 @@ describeIfDatabase("seedDatabase", () => { it("can run idempotently without duplicating baseline data", async () => { const firstCounts = await readBaselineCounts(prisma); - await seedDatabase(); + await syncManagedData(prisma, { apply: true }); const secondCounts = await readBaselineCounts(prisma); @@ -234,7 +237,7 @@ describeIfDatabase("seedDatabase", () => { }, }); - await seedDatabase(); + await syncManagedData(prisma, { apply: true }); await expect( prisma.task.findUnique({ where: { id: "1-pct-treaty" } }), @@ -247,6 +250,57 @@ describeIfDatabase("seedDatabase", () => { ).resolves.toMatchObject(originalOrganization); }, SEED_TEST_TIMEOUT_MS); + it("keeps canonical task-tree records owned by managed task sync", async () => { + await syncManagedData(prisma, { apply: true }); + + const canonicalTasks = await prisma.task.findMany({ + where: { + id: { + in: [ + "optimize-earth", + "1-pct-treaty", + "dfda", + "bed-nets-funding-gap", + ], + }, + }, + orderBy: { id: "asc" }, + select: { + id: true, + parentTaskId: true, + status: true, + title: true, + deletedAt: true, + }, + }); + + setManagedSeedDataClient(prisma); + await syncManagedTreatyAccountabilityData(); + + await expect( + prisma.task.findMany({ + where: { + id: { + in: [ + "optimize-earth", + "1-pct-treaty", + "dfda", + "bed-nets-funding-gap", + ], + }, + }, + orderBy: { id: "asc" }, + select: { + id: true, + parentTaskId: true, + status: true, + title: true, + deletedAt: true, + }, + }), + ).resolves.toEqual(canonicalTasks); + }, SEED_TEST_TIMEOUT_MS); + it("seeds canonical referendum ballot text and content metadata", async () => { const referendums = await prisma.referendum.findMany({ where: { @@ -255,6 +309,7 @@ describeIfDatabase("seedDatabase", () => { "one-percent-treaty", "declaration-of-optimization", "court-of-humanity", + "court-humanity-v-government-verdict", ], }, deletedAt: null, @@ -272,7 +327,7 @@ describeIfDatabase("seedDatabase", () => { }, }); - expect(referendums).toHaveLength(3); + expect(referendums).toHaveLength(4); expect(referendums).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -297,6 +352,13 @@ describeIfDatabase("seedDatabase", () => { bodyMarkdown: expect.stringContaining("sovereign immunity"), contentHash: expect.stringMatching(/^[a-f0-9]{64}$/), }), + expect.objectContaining({ + slug: "court-humanity-v-government-verdict", + kind: ReferendumKind.COURT_CASE, + question: expect.stringContaining("full damages"), + bodyMarkdown: expect.stringContaining("find for Humanity"), + contentHash: expect.stringMatching(/^[a-f0-9]{64}$/), + }), ]), ); for (const referendum of referendums) { @@ -304,6 +366,39 @@ describeIfDatabase("seedDatabase", () => { } }, SEED_TEST_TIMEOUT_MS); + it("seeds Humanity v Government as a public court case with a verdict referendum", async () => { + const courtCase = await prisma.courtCase.findUnique({ + where: { slug: "humanity-v-government" }, + select: { + isPublic: true, + juryReferendum: { + select: { + kind: true, + question: true, + slug: true, + status: true, + }, + }, + status: true, + summary: true, + title: true, + }, + }); + + expect(courtCase).toMatchObject({ + isPublic: true, + juryReferendum: { + kind: ReferendumKind.COURT_CASE, + question: expect.stringContaining("$2.74 million"), + slug: "court-humanity-v-government-verdict", + status: "ACTIVE", + }, + status: "VOTING", + summary: expect.stringContaining("damages case"), + title: "Humanity v Government", + }); + }, SEED_TEST_TIMEOUT_MS); + it("seeds task communication endpoint contracts for task-driven reminders", async () => { const signerTasksMissingContactContract = await prisma.task.count({ where: { diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts index 788655c92..9475a837c 100644 --- a/packages/db/src/constants.ts +++ b/packages/db/src/constants.ts @@ -8,6 +8,9 @@ export const TREATY_REFERENDUM_SLUG = "one-percent-treaty"; export const DECLARATION_REFERENDUM_SLUG = "declaration-of-optimization"; export const COURT_OF_HUMANITY_REFERENDUM_SLUG = "court-of-humanity"; +export const HUMANITY_V_GOVERNMENT_CASE_SLUG = "humanity-v-government"; +export const HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG = + "court-humanity-v-government-verdict"; // Task keys (taskKey prefixes, root IDs, builders) live in ./task-keys.ts // and are re-exported from the package barrel so consumers can do diff --git a/packages/db/src/managed-data/index.ts b/packages/db/src/managed-data/index.ts index 6b1bc39c6..174f551c2 100644 --- a/packages/db/src/managed-data/index.ts +++ b/packages/db/src/managed-data/index.ts @@ -1,7 +1,39 @@ +import type { PrismaClient } from "../generated/prisma/client.js"; +import { + formatManagedDemoUserResult, + syncManagedDemoUser, +} from "./managed-demo-user.js"; +import { + formatManagedGrandmaKayResult, + syncManagedGrandmaKay, +} from "./managed-grandma-kay.js"; +import { + formatManagedIamOrganizationResult, + syncManagedIamOrganization, +} from "./managed-iam-organization.js"; +import { + formatManagedHumanityVGovernmentCaseResult, + syncManagedHumanityVGovernmentCase, +} from "./managed-humanity-v-government.js"; +import { + formatManagedReferendumsResult, + syncManagedReferendums, +} from "./managed-referendums.js"; +import { + formatManagedTaskTriggersResult, + syncManagedTaskTriggers, + type SyncManagedTaskTriggersResult, +} from "./managed-task-triggers.js"; import { OPTIMIZE_EARTH_TASK_TREE, OPTIMIZE_EARTH_TASK_TREE_COLLECTION_KEY, } from "./optimize-earth-task-tree.js"; +import { + setManagedSeedDataClient, + syncManagedBootstrapData, + syncManagedReferenceData, + syncManagedTreatyAccountabilityData, +} from "./managed-seed-data.js"; import { ensureManagedDataSystemUser, formatManagedTasksResult, @@ -11,6 +43,18 @@ import { type SyncManagedTasksResult, } from "./sync-managed-tasks.js"; +/** + * Managed-data safety contract: + * - This is the source of truth for production-worthy bootstrap/reference data. + * - Sync code may create or update records it owns by stable ids/keys. + * - Sync code must not treat absence from a source file as permission to delete. + * - Removal must be explicit in the managed record, and should soft-delete by + * setting `deletedAt` / disabling the row. Hard deletes are only for owned + * child rows that are fully replaced inside a parent-owned collection. + * - User-created rows, votes, comments, claims, donations, and plaintiffs are + * outside managed ownership unless a collection explicitly scopes them in. + */ + export interface SyncManagedDataOptions { apply: boolean; createdByUserId?: string; @@ -18,53 +62,186 @@ export interface SyncManagedDataOptions { } export interface SyncManagedDataResult { + referenceData: { synced: boolean; dryRun: boolean }; + bootstrapData: { synced: boolean; dryRun: boolean }; + treatyAccountabilityData: { synced: boolean; dryRun: boolean }; tasks: SyncManagedTasksResult; + taskTriggers: SyncManagedTaskTriggersResult; + referendums: Awaited<ReturnType<typeof syncManagedReferendums>>; + humanityVGovernmentCase: Awaited< + ReturnType<typeof syncManagedHumanityVGovernmentCase> + >; + grandmaKay: Awaited<ReturnType<typeof syncManagedGrandmaKay>>; + demoUser: Awaited<ReturnType<typeof syncManagedDemoUser>>; + iamOrganization: Awaited<ReturnType<typeof syncManagedIamOrganization>>; } export async function syncManagedData( - client: ManagedTaskClient & Partial<ManagedIdentityClient>, + prisma: PrismaClient, options: SyncManagedDataOptions, ): Promise<SyncManagedDataResult> { + setManagedSeedDataClient(prisma); + + const referenceData = { synced: false, dryRun: !options.apply }; + const bootstrapData = { synced: false, dryRun: !options.apply }; + const treatyAccountabilityData = { synced: false, dryRun: !options.apply }; + + if (options.apply) { + await syncManagedReferenceData(); + referenceData.synced = true; + + await syncManagedBootstrapData(); + bootstrapData.synced = true; + } + let createdByUserId = options.createdByUserId; if (!createdByUserId) { if (!options.apply) { createdByUserId = "managed-data-dry-run-user"; - } else if (client.person && client.user) { + } else { const user = await ensureManagedDataSystemUser( - client as ManagedTaskClient & ManagedIdentityClient, + prisma as PrismaClient & ManagedTaskClient & ManagedIdentityClient, options.now, ); createdByUserId = user.id; - } else { - throw new Error( - "syncManagedData apply mode requires createdByUserId or person/user delegates", - ); } } + // Referendums first: tasks reference referendum slugs. + const referendums = await syncManagedReferendums(prisma, { apply: options.apply }); + + const humanityVGovernmentCase = await syncManagedHumanityVGovernmentCase(prisma, { + apply: options.apply, + createdByUserId, + }); + + const tasks = await syncManagedTasks(prisma as PrismaClient & ManagedTaskClient, { + apply: options.apply, + collectionKey: OPTIMIZE_EARTH_TASK_TREE_COLLECTION_KEY, + createdByUserId, + now: options.now, + records: OPTIMIZE_EARTH_TASK_TREE, + }); + + if (options.apply) { + await syncManagedTreatyAccountabilityData(); + treatyAccountabilityData.synced = true; + } + + const taskTriggers = await syncManagedTaskTriggers(prisma, { + apply: options.apply, + now: options.now, + }); + + // Grandma Kay has FK on the treaty referendum + needs the Wishonia user. + const grandmaKay = await syncManagedGrandmaKay(prisma, { apply: options.apply }); + + // Demo user is independent. + const demoUser = await syncManagedDemoUser(prisma, { apply: options.apply }); + + // IAM is the campaign nonprofit org fixture + owner account. + const iamOrganization = await syncManagedIamOrganization(prisma, { + apply: options.apply, + }); + return { - tasks: await syncManagedTasks(client, { - apply: options.apply, - collectionKey: OPTIMIZE_EARTH_TASK_TREE_COLLECTION_KEY, - createdByUserId, - now: options.now, - records: OPTIMIZE_EARTH_TASK_TREE, - }), + referenceData, + bootstrapData, + treatyAccountabilityData, + tasks, + taskTriggers, + referendums, + humanityVGovernmentCase, + grandmaKay, + demoUser, + iamOrganization, }; } export function formatManagedDataResult(result: SyncManagedDataResult) { - return formatManagedTasksResult(result.tasks); + return [ + formatSimpleManagedDataResult("Reference data", result.referenceData), + formatSimpleManagedDataResult("Bootstrap data", result.bootstrapData), + formatManagedReferendumsResult(result.referendums), + formatManagedHumanityVGovernmentCaseResult(result.humanityVGovernmentCase), + formatManagedTasksResult(result.tasks), + formatSimpleManagedDataResult( + "Treaty accountability data", + result.treatyAccountabilityData, + ), + formatManagedTaskTriggersResult(result.taskTriggers), + formatManagedGrandmaKayResult(result.grandmaKay), + formatManagedDemoUserResult(result.demoUser), + formatManagedIamOrganizationResult(result.iamOrganization), + ].join("\n"); +} + +function formatSimpleManagedDataResult( + label: string, + result: { synced: boolean; dryRun: boolean }, +) { + if (result.dryRun) return `${label}: would sync (dry-run)`; + return result.synced ? `${label}: synced` : `${label}: unchanged`; } export { OPTIMIZE_EARTH_TASK_TREE, OPTIMIZE_EARTH_TASK_TREE_COLLECTION_KEY, ensureManagedDataSystemUser, + formatManagedDemoUserResult, + formatManagedGrandmaKayResult, + formatManagedHumanityVGovernmentCaseResult, + formatManagedIamOrganizationResult, + formatManagedReferendumsResult, + formatManagedTaskTriggersResult, formatManagedTasksResult, + syncManagedDemoUser, + syncManagedGrandmaKay, + syncManagedHumanityVGovernmentCase, + syncManagedIamOrganization, + syncManagedBootstrapData, + syncManagedReferenceData, + syncManagedReferendums, + syncManagedTaskTriggers, syncManagedTasks, + syncManagedTreatyAccountabilityData, }; +export { DEMO_EMAIL } from "./managed-demo-user.js"; +export { + GRANDMA_KAY_SOURCE_REF, + GRANDMA_KAY_PERSON_CONDITION_ID, +} from "./managed-grandma-kay.js"; +export { + HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL, + MANAGED_HUMANITY_V_GOVERNMENT_CASE, + MANAGED_HUMANITY_V_GOVERNMENT_VERDICT, + getManagedHumanityVGovernmentMetadata, +} from "./managed-humanity-v-government.js"; +export { + IAM_ORGANIZATION_NAME, + IAM_ORGANIZATION_SLUG, + IAM_ORGANIZATION_SOURCE_REF, + MIKE_SINN_EMAIL, + MIKE_SINN_PERSON_SOURCE_REF, +} from "./managed-iam-organization.js"; +export { + COURT_OF_HUMANITY_REFERENDUM_SLUG, + DECLARATION_REFERENDUM_SLUG, + TREATY_REFERENDUM_SLUG, +} from "../constants.js"; +export { + MANAGED_REFERENDUMS, + buildReferendumContentHash, +} from "./managed-referendums.js"; +export { + ONE_PERCENT_TREATY_TRIGGER_BLUEPRINTS, + type ManagedTaskCommunicationSpawnSpecInput, + type ManagedTaskSpawnSpecInput, + type ManagedTaskTriggerInput, + type SyncManagedTaskTriggersOptions, + type SyncManagedTaskTriggersResult, +} from "./managed-task-triggers.js"; export type { ManagedIdentityClient, ManagedTaskClient, diff --git a/packages/db/src/managed-data/managed-demo-user.test.ts b/packages/db/src/managed-data/managed-demo-user.test.ts new file mode 100644 index 000000000..3e0e7edb3 --- /dev/null +++ b/packages/db/src/managed-data/managed-demo-user.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import { + DEMO_ORGANIZATION_NAME, + DEMO_ORGANIZATION_SLUG, + DEMO_ORGANIZATION_SOURCE_REF, + DEMO_PERSON_SOURCE_REF, + DEMO_USER_EMAIL, +} from "@optimitron/data/campaign"; +import { + OrgStatus, + OrgType, + OrganizationReferendumPositionStatus, + PersonLifeStatus, + VotePosition, + type PrismaClient, +} from "../generated/prisma/client.js"; +import { TREATY_REFERENDUM_SLUG } from "../constants.js"; +import { syncManagedDemoUser } from "./managed-demo-user.js"; + +type Row = Record<string, unknown>; + +function upsertBy<T extends Row>( + rows: T[], + match: (row: T) => boolean, + create: T, + update: Partial<T>, +) { + const existing = rows.find(match); + if (!existing) { + rows.push({ ...create }); + return create; + } + Object.assign(existing, update); + return existing; +} + +class FakeDemoClient { + referendum = { + findUniqueOrThrow: async (args: { + where: { slug: string }; + select: { id: boolean }; + }) => { + expect(args.where.slug).toBe(TREATY_REFERENDUM_SLUG); + expect(args.select.id).toBe(true); + return { id: "referendum-one-percent" }; + }, + }; + + people: Row[] = [ + { + id: "person-demo", + email: DEMO_USER_EMAIL, + displayName: "Old Demo", + handle: "old-demo", + isPublic: true, + sourceRef: null, + }, + ]; + + users: Row[] = [ + { + id: "user-demo", + email: DEMO_USER_EMAIL, + personId: null, + referralCode: "DEMO", + }, + ]; + + organizations: Row[] = [ + { + id: "org-demo", + name: "Old Demo Org", + slug: DEMO_ORGANIZATION_SLUG, + sourceRef: null, + status: OrgStatus.PENDING, + }, + { + id: "org-real", + name: "Real Organization", + slug: "real-organization", + sourceRef: null, + status: OrgStatus.APPROVED, + }, + ]; + + organizationMembers: Row[] = []; + organizationReferendumPositions: Row[] = []; + + person = { + upsert: async (args: { + create: Row; + update: Row; + where: { email: string }; + }) => + upsertBy( + this.people, + (row) => row["email"] === args.where.email, + { id: "person-created", ...args.create }, + args.update, + ), + }; + + user = { + upsert: async (args: { + create: Row; + update: Row; + where: { email: string }; + }) => + upsertBy( + this.users, + (row) => row["email"] === args.where.email, + { id: "user-created", ...args.create }, + args.update, + ), + update: async (args: { data: Row; where: { id: string } }) => { + const row = this.users.find((item) => item["id"] === args.where.id); + if (!row) throw new Error("missing user"); + Object.assign(row, args.data); + return row; + }, + }; + + organization = { + findFirst: async () => + this.organizations.find( + (row) => + row["sourceRef"] === DEMO_ORGANIZATION_SOURCE_REF || + row["slug"] === DEMO_ORGANIZATION_SLUG, + ) ?? null, + update: async (args: { data: Row; where: { id: string } }) => { + const row = this.organizations.find((item) => item["id"] === args.where.id); + if (!row) throw new Error("missing organization"); + Object.assign(row, args.data); + return row; + }, + create: async (args: { data: Row }) => { + const row = { id: "org-created", ...args.data }; + this.organizations.push(row); + return row; + }, + }; + + organizationMember = { + upsert: async (args: { + create: Row; + update: Row; + where: { organizationId_userId: { organizationId: string; userId: string } }; + }) => + upsertBy( + this.organizationMembers, + (row) => + row["organizationId"] === + args.where.organizationId_userId.organizationId && + row["userId"] === args.where.organizationId_userId.userId, + { id: "membership-created", ...args.create }, + args.update, + ), + }; + + organizationReferendumPosition = { + upsert: async (args: { + create: Row; + update: Row; + where: { + organizationId_referendumId: { + organizationId: string; + referendumId: string; + }; + }; + }) => + upsertBy( + this.organizationReferendumPositions, + (row) => + row["organizationId"] === + args.where.organizationId_referendumId.organizationId && + row["referendumId"] === + args.where.organizationId_referendumId.referendumId, + { id: "position-created", ...args.create }, + args.update, + ), + }; +} + +describe("syncManagedDemoUser", () => { + it("does not write during dry-run", async () => { + const client = new FakeDemoClient(); + + await expect( + syncManagedDemoUser(client as unknown as PrismaClient, { apply: false }), + ).resolves.toEqual({ dryRun: true, upserted: false }); + + expect(client.organizations.find((row) => row["id"] === "org-demo")) + .toMatchObject({ + name: "Old Demo Org", + status: OrgStatus.PENDING, + }); + expect(client.organizationMembers).toHaveLength(0); + }); + + it("syncs the private demo user, demo organization, and owner membership", async () => { + const client = new FakeDemoClient(); + + await expect( + syncManagedDemoUser(client as unknown as PrismaClient, { apply: true }), + ).resolves.toEqual({ dryRun: false, upserted: true }); + + expect(client.people.find((row) => row["id"] === "person-demo")) + .toMatchObject({ + displayName: "Demo User", + handle: "demo", + isPublic: false, + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: DEMO_PERSON_SOURCE_REF, + }); + expect(client.users.find((row) => row["id"] === "user-demo")) + .toMatchObject({ + personId: "person-demo", + }); + expect(client.organizations.find((row) => row["id"] === "org-demo")) + .toMatchObject({ + creatorId: "user-demo", + name: DEMO_ORGANIZATION_NAME, + slug: DEMO_ORGANIZATION_SLUG, + sourceRef: DEMO_ORGANIZATION_SOURCE_REF, + status: OrgStatus.APPROVED, + type: OrgType.NONPROFIT, + }); + expect(client.organizations.find((row) => row["id"] === "org-real")) + .toMatchObject({ + name: "Real Organization", + status: OrgStatus.APPROVED, + }); + expect(client.organizationMembers).toEqual([ + expect.objectContaining({ + organizationId: "org-demo", + role: "owner", + userId: "user-demo", + }), + ]); + expect(client.organizationReferendumPositions).toEqual([ + expect.objectContaining({ + approvedByUserId: "user-demo", + organizationId: "org-demo", + position: VotePosition.YES, + referendumId: "referendum-one-percent", + status: OrganizationReferendumPositionStatus.APPROVED, + submittedByUserId: "user-demo", + }), + ]); + }); +}); diff --git a/packages/db/src/managed-data/managed-demo-user.ts b/packages/db/src/managed-data/managed-demo-user.ts new file mode 100644 index 000000000..b8cfe6500 --- /dev/null +++ b/packages/db/src/managed-data/managed-demo-user.ts @@ -0,0 +1,173 @@ +import { + DEMO_ORGANIZATION_NAME, + DEMO_ORGANIZATION_SLUG, + DEMO_ORGANIZATION_SOURCE_REF, + DEMO_PERSON_SOURCE_REF, + DEMO_USER_EMAIL, +} from "@optimitron/data/campaign"; +import { + OrgStatus, + OrgType, + OrganizationReferendumPositionStatus, + PersonLifeStatus, + type Prisma, + type PrismaClient, + VotePosition, +} from "../generated/prisma/client.js"; +import { TREATY_REFERENDUM_SLUG } from "../constants.js"; + +// Demo user — product-facing tour/onboarding account. +// Synced on every `pnpm db:sync:managed-data --apply`. +// The legacy email migration (demo@optimitron.org → demo@thinkbynumbers.org) +// and the raw-SQL "upsert failed" fallback from the old seed version are +// intentionally dropped — legacy email is long migrated, and raw-SQL inside +// a catch is the kind of error-swallowing this codebase bans (see +// feedback_dont_swallow_errors). + +export const DEMO_EMAIL = DEMO_USER_EMAIL; + +// Pre-hashed bcrypt(12) of "demo1234". Hardcoded so the demo password +// stays stable across environments without us shipping the plaintext. +const DEMO_PASSWORD_HASH = + "$2b$12$Hy27qJOTykSezth61xRCJ..sMPVvzWxs9wZEEsEsYn9o3GaUYkGCa"; + +export async function syncManagedDemoUser( + prisma: PrismaClient, + options: { apply: boolean }, +): Promise<{ upserted: boolean; dryRun: boolean }> { + if (!options.apply) return { upserted: false, dryRun: true }; + + const referendum = await prisma.referendum.findUniqueOrThrow({ + where: { slug: TREATY_REFERENDUM_SLUG }, + select: { id: true }, + }); + + const user = await prisma.user.upsert({ + where: { email: DEMO_EMAIL }, + update: { + password: DEMO_PASSWORD_HASH, + emailVerified: new Date(), + }, + create: { + email: DEMO_EMAIL, + password: DEMO_PASSWORD_HASH, + emailVerified: new Date(), + referralCode: "DEMO", + }, + }); + + // Person owns the public-display fields (handle / displayName / image). + const person = await prisma.person.upsert({ + where: { email: DEMO_EMAIL }, + update: { + deletedAt: null, + displayName: "Demo User", + handle: "demo", + isPublic: false, + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: DEMO_PERSON_SOURCE_REF, + }, + create: { + displayName: "Demo User", + email: DEMO_EMAIL, + handle: "demo", + isPublic: false, + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: DEMO_PERSON_SOURCE_REF, + }, + }); + + if (user.personId !== person.id) { + await prisma.user.update({ + where: { id: user.id }, + data: { personId: person.id }, + }); + } + + const organizationSeed = { + contactEmail: DEMO_EMAIL, + creatorId: user.id, + deletedAt: null, + description: + "A sandbox organization for trying campaign survey links, embeds, and outreach tools.", + donationUrl: null, + name: DEMO_ORGANIZATION_NAME, + slug: DEMO_ORGANIZATION_SLUG, + sourceRef: DEMO_ORGANIZATION_SOURCE_REF, + sourceUrl: null, + squareLogoUrl: null, + status: OrgStatus.APPROVED, + type: OrgType.NONPROFIT, + website: "https://example.org", + wordmarkLogoUrl: null, + } satisfies Prisma.OrganizationUncheckedCreateInput; + + const existingOrganization = await prisma.organization.findFirst({ + where: { + OR: [ + { sourceRef: DEMO_ORGANIZATION_SOURCE_REF }, + { slug: DEMO_ORGANIZATION_SLUG }, + ], + }, + select: { id: true }, + }); + + const organization = existingOrganization + ? await prisma.organization.update({ + where: { id: existingOrganization.id }, + data: organizationSeed, + }) + : await prisma.organization.create({ data: organizationSeed }); + + await prisma.organizationMember.upsert({ + where: { + organizationId_userId: { + organizationId: organization.id, + userId: user.id, + }, + }, + update: { role: "owner" }, + create: { + organizationId: organization.id, + role: "owner", + userId: user.id, + }, + }); + + await prisma.organizationReferendumPosition.upsert({ + where: { + organizationId_referendumId: { + organizationId: organization.id, + referendumId: referendum.id, + }, + }, + update: { + approvedByUserId: user.id, + deletedAt: null, + position: VotePosition.YES, + statement: + "Demo organization supports the 1% Treaty so humans can test campaign survey links, embeds, and outreach tools.", + status: OrganizationReferendumPositionStatus.APPROVED, + submittedByUserId: user.id, + }, + create: { + approvedByUserId: user.id, + organizationId: organization.id, + position: VotePosition.YES, + referendumId: referendum.id, + statement: + "Demo organization supports the 1% Treaty so humans can test campaign survey links, embeds, and outreach tools.", + status: OrganizationReferendumPositionStatus.APPROVED, + submittedByUserId: user.id, + }, + }); + + return { upserted: true, dryRun: false }; +} + +export function formatManagedDemoUserResult( + result: { upserted: boolean; dryRun: boolean }, +): string { + if (result.dryRun) return "Demo user: would sync (dry-run)"; + return result.upserted ? "Demo user: synced" : "Demo user: unchanged"; +} diff --git a/packages/db/src/managed-data/managed-grandma-kay.ts b/packages/db/src/managed-data/managed-grandma-kay.ts new file mode 100644 index 000000000..0a39a6687 --- /dev/null +++ b/packages/db/src/managed-data/managed-grandma-kay.ts @@ -0,0 +1,106 @@ +import { TREATY_REFERENDUM_SLUG } from "../constants.js"; +import { + PersonConditionStatus, + PersonLifeStatus, + ReferendumVoteSource, + VotePosition, + type PrismaClient, +} from "../generated/prisma/client.js"; +import { upsertWishoniaUser } from "../system-users.js"; + +// "Grandma Kay" — product-facing represented-vote narrative fixture +// (young person voting on behalf of grandmother with dementia). Belongs +// in production; synced on every `pnpm db:sync:managed-data --apply`. +// Depends on the Wishonia user and the treaty referendum existing first. + +export const GRANDMA_KAY_SOURCE_REF = "memorial-example:grandma-kay"; +export const GRANDMA_KAY_PERSON_CONDITION_ID = + "person-condition-grandma-kay-dementia"; + +export async function syncManagedGrandmaKay( + prisma: PrismaClient, + options: { apply: boolean }, +): Promise<{ upserted: boolean; dryRun: boolean }> { + if (!options.apply) return { upserted: false, dryRun: true }; + + const { user } = await upsertWishoniaUser(prisma); + const referendum = await prisma.referendum.findUniqueOrThrow({ + where: { slug: TREATY_REFERENDUM_SLUG }, + select: { id: true }, + }); + + const person = await prisma.person.upsert({ + where: { sourceRef: GRANDMA_KAY_SOURCE_REF }, + update: { + displayName: "Grandma Kay", + handle: "grandma-kay", + image: "/img/grandma.jpg", + isPublic: true, + lifeStatus: PersonLifeStatus.LIVING, + }, + create: { + createdByUserId: user.id, + displayName: "Grandma Kay", + handle: "grandma-kay", + image: "/img/grandma.jpg", + isPublic: true, + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: GRANDMA_KAY_SOURCE_REF, + }, + }); + + await prisma.personCondition.upsert({ + where: { id: GRANDMA_KAY_PERSON_CONDITION_ID }, + update: { + conditionName: "Dementia", + deletedAt: null, + isPublic: true, + personId: person.id, + reportedByUserId: user.id, + status: PersonConditionStatus.ACTIVE, + }, + create: { + id: GRANDMA_KAY_PERSON_CONDITION_ID, + conditionName: "Dementia", + isPublic: true, + personId: person.id, + reportedByUserId: user.id, + status: PersonConditionStatus.ACTIVE, + }, + }); + + await prisma.referendumVote.upsert({ + where: { + referendumId_personId: { + referendumId: referendum.id, + personId: person.id, + }, + }, + update: { + answer: VotePosition.YES, + deletedAt: null, + isPublic: true, + publicComment: "She would trade one apocalypse for dementia research.", + userId: user.id, + voteSource: ReferendumVoteSource.REPRESENTED, + }, + create: { + answer: VotePosition.YES, + isPublic: true, + personId: person.id, + publicComment: "She would trade one apocalypse for dementia research.", + referendumId: referendum.id, + userId: user.id, + voteSource: ReferendumVoteSource.REPRESENTED, + }, + }); + + return { upserted: true, dryRun: false }; +} + +export function formatManagedGrandmaKayResult( + result: { upserted: boolean; dryRun: boolean }, +): string { + if (result.dryRun) return "Grandma Kay: would sync (dry-run)"; + return result.upserted ? "Grandma Kay: synced" : "Grandma Kay: unchanged"; +} diff --git a/packages/db/src/managed-data/managed-humanity-v-government.test.ts b/packages/db/src/managed-data/managed-humanity-v-government.test.ts new file mode 100644 index 000000000..5e161aba1 --- /dev/null +++ b/packages/db/src/managed-data/managed-humanity-v-government.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { ReferendumKind, ReferendumStatus } from "../generated/prisma/client.js"; +import { + HUMANITY_V_GOVERNMENT_CASE_SLUG, + HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, + MANAGED_HUMANITY_V_GOVERNMENT_CASE, + MANAGED_HUMANITY_V_GOVERNMENT_VERDICT, +} from "./managed-humanity-v-government.js"; + +describe("managed Humanity v Government data", () => { + it("defines the court case verdict as a court-case referendum", () => { + expect(MANAGED_HUMANITY_V_GOVERNMENT_CASE.slug).toBe( + HUMANITY_V_GOVERNMENT_CASE_SLUG, + ); + expect(MANAGED_HUMANITY_V_GOVERNMENT_CASE.juryReferendumSlug).toBe( + HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, + ); + expect(MANAGED_HUMANITY_V_GOVERNMENT_VERDICT).toMatchObject({ + slug: HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, + kind: ReferendumKind.COURT_CASE, + status: ReferendumStatus.ACTIVE, + }); + expect(MANAGED_HUMANITY_V_GOVERNMENT_VERDICT.question).toContain( + "full damages", + ); + expect(MANAGED_HUMANITY_V_GOVERNMENT_VERDICT.question).toContain( + "$2.74 million", + ); + }); +}); diff --git a/packages/db/src/managed-data/managed-humanity-v-government.ts b/packages/db/src/managed-data/managed-humanity-v-government.ts new file mode 100644 index 000000000..debb25aca --- /dev/null +++ b/packages/db/src/managed-data/managed-humanity-v-government.ts @@ -0,0 +1,136 @@ +import { CORPORATE_DAMAGES_TREBLE_EXPOSURE_PER_CAPITA } from "@optimitron/data/parameters"; +import { + HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL, + HUMANITY_V_GOVERNMENT_VERDICT_QUESTION, + HUMANITY_V_GOVERNMENT_VERDICT_TEXT, +} from "@optimitron/data/referendums"; +import { + HUMANITY_V_GOVERNMENT_CASE_SLUG, + HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, +} from "../constants.js"; +import { + CourtCaseStatus, + ReferendumKind, + ReferendumStatus, + type Prisma, + type PrismaClient, +} from "../generated/prisma/client.js"; + +export { + HUMANITY_V_GOVERNMENT_CASE_SLUG, + HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, +}; + +export { HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL }; + +export const MANAGED_HUMANITY_V_GOVERNMENT_VERDICT = { + slug: HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, + title: "Humanity v Government verdict", + question: HUMANITY_V_GOVERNMENT_VERDICT_QUESTION, + kind: ReferendumKind.COURT_CASE, + description: + "The public jury vote for Humanity v Government. Yes means find for Humanity and record the full per-person damages demand.", + bodyMarkdown: HUMANITY_V_GOVERNMENT_VERDICT_TEXT.markdown, + status: ReferendumStatus.ACTIVE, +} as const; + +export const MANAGED_HUMANITY_V_GOVERNMENT_CASE = { + slug: HUMANITY_V_GOVERNMENT_CASE_SLUG, + title: "Humanity v Government", + summary: + "A Court of Humanity damages case alleging that governments spent public money on war, delayed medicine, and underfunded clinical trials while preventable disease kept killing people.", + status: CourtCaseStatus.VOTING, + isPublic: true, + juryReferendumSlug: HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, +} as const; + +export function getManagedHumanityVGovernmentMetadata(): Prisma.InputJsonValue { + return { + managedDataKey: "humanity-v-government", + fullDamagesPerCapitaUsd: + CORPORATE_DAMAGES_TREBLE_EXPOSURE_PER_CAPITA.value, + fullDamagesPerCapitaLabel: + HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL, + }; +} + +export async function syncManagedHumanityVGovernmentCase( + prisma: PrismaClient, + options: { apply: boolean; createdByUserId?: string }, +): Promise<{ totalRecords: number; upserted: string[]; unchanged: string[] }> { + const record = MANAGED_HUMANITY_V_GOVERNMENT_CASE; + const referendum = await prisma.referendum.findUnique({ + where: { slug: record.juryReferendumSlug }, + select: { id: true }, + }); + + const existing = await prisma.courtCase.findUnique({ + where: { slug: record.slug }, + select: { + deletedAt: true, + isPublic: true, + juryReferendumId: true, + slug: true, + status: true, + summary: true, + title: true, + }, + }); + + const unchanged = + existing && + !existing.deletedAt && + existing.title === record.title && + existing.summary === record.summary && + existing.status === record.status && + existing.isPublic === record.isPublic && + (!referendum || existing.juryReferendumId === referendum.id); + + if (unchanged) { + return { totalRecords: 1, upserted: [], unchanged: [record.slug] }; + } + + if (!options.apply) { + return { totalRecords: 1, upserted: [record.slug], unchanged: [] }; + } + + if (!referendum) { + throw new Error( + `Managed verdict referendum missing: ${record.juryReferendumSlug}`, + ); + } + + const data = { + deletedAt: null, + isPublic: record.isPublic, + juryReferendumId: referendum.id, + metadataJson: getManagedHumanityVGovernmentMetadata(), + slug: record.slug, + status: record.status, + summary: record.summary, + title: record.title, + }; + + await prisma.courtCase.upsert({ + where: { slug: record.slug }, + update: data, + create: { + ...data, + createdByUserId: options.createdByUserId, + }, + }); + + return { totalRecords: 1, upserted: [record.slug], unchanged: [] }; +} + +export function formatManagedHumanityVGovernmentCaseResult(result: { + totalRecords: number; + upserted: string[]; + unchanged: string[]; +}): string { + const parts = [ + `Humanity v Government case: ${result.upserted.length}/${result.totalRecords} upserted`, + ]; + if (result.unchanged.length) parts.push(`${result.unchanged.length} unchanged`); + return parts.join(", "); +} diff --git a/packages/db/src/managed-data/managed-iam-organization.test.ts b/packages/db/src/managed-data/managed-iam-organization.test.ts new file mode 100644 index 000000000..e05eaaa91 --- /dev/null +++ b/packages/db/src/managed-data/managed-iam-organization.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { + OrgStatus, + OrgType, + OrganizationReferendumPositionStatus, + PersonLifeStatus, + TaskClaimPolicy, + TaskStatus, + VotePosition, + type PrismaClient, +} from "../generated/prisma/client.js"; +import { + IAM_ORGANIZATION_NAME, + IAM_ORGANIZATION_SLUG, + IAM_ORGANIZATION_SOURCE_REF, + MIKE_SINN_EMAIL, + MIKE_SINN_PERSON_SOURCE_REF, + syncManagedIamOrganization, +} from "./managed-iam-organization.js"; +import { getOrganizationActivationTaskKey } from "@optimitron/data/campaign"; + +type Row = Record<string, unknown>; + +function upsertBy<T extends Row>( + rows: T[], + match: (row: T) => boolean, + create: T, + update: Partial<T>, +) { + const existing = rows.find(match); + if (!existing) { + rows.push({ ...create }); + return create; + } + Object.assign(existing, update); + return existing; +} + +class FakeIamClient { + referendum = { + findUniqueOrThrow: async () => ({ id: "referendum-one-percent" }), + }; + + people: Row[] = [ + { + id: "person-mike", + email: MIKE_SINN_EMAIL, + displayName: "Old Name", + handle: "mike", + sourceRef: null, + }, + { id: "person-other", email: "other@example.org", displayName: "Other" }, + ]; + + users: Row[] = [ + { + id: "user-mike", + email: MIKE_SINN_EMAIL, + referralCode: "KEEP-ME", + personId: "person-mike", + }, + { id: "user-other", email: "other@example.org", referralCode: "OTHER" }, + ]; + + organizations: Row[] = [ + { + id: "org-iam", + name: IAM_ORGANIZATION_NAME, + slug: IAM_ORGANIZATION_SLUG, + sourceRef: null, + status: OrgStatus.PENDING, + }, + { + id: "org-user-created", + name: "User-created Organization", + slug: "user-created", + sourceRef: null, + status: OrgStatus.APPROVED, + }, + ]; + + organizationMembers: Row[] = [ + { + id: "membership-other", + organizationId: "org-user-created", + userId: "user-other", + role: "owner", + }, + ]; + + organizationReferendumPositions: Row[] = []; + + tasks: Row[] = [ + { + id: "task-user-created", + taskKey: "user-created-task", + title: "Do not touch", + deletedAt: null, + }, + ]; + + person = { + upsert: async (args: { + create: Row; + update: Row; + where: { email: string }; + }) => + upsertBy( + this.people, + (row) => row["email"] === args.where.email, + { id: "person-created", ...args.create }, + args.update, + ), + }; + + user = { + upsert: async (args: { + create: Row; + update: Row; + where: { email: string }; + }) => + upsertBy( + this.users, + (row) => row["email"] === args.where.email, + { id: "user-created", ...args.create }, + args.update, + ), + }; + + organization = { + findFirst: async () => + this.organizations.find( + (row) => + row["sourceRef"] === IAM_ORGANIZATION_SOURCE_REF || + row["slug"] === IAM_ORGANIZATION_SLUG || + String(row["name"]).toLowerCase() === + IAM_ORGANIZATION_NAME.toLowerCase(), + ) ?? null, + update: async (args: { data: Row; where: { id: string } }) => { + const row = this.organizations.find((item) => item["id"] === args.where.id); + if (!row) throw new Error("missing organization"); + Object.assign(row, args.data); + return row; + }, + create: async (args: { data: Row }) => { + const row = { id: "org-created", ...args.data }; + this.organizations.push(row); + return row; + }, + }; + + organizationMember = { + upsert: async (args: { + create: Row; + update: Row; + where: { organizationId_userId: { organizationId: string; userId: string } }; + }) => + upsertBy( + this.organizationMembers, + (row) => + row["organizationId"] === + args.where.organizationId_userId.organizationId && + row["userId"] === args.where.organizationId_userId.userId, + { id: "membership-created", ...args.create }, + args.update, + ), + }; + + organizationReferendumPosition = { + upsert: async (args: { + create: Row; + update: Row; + where: { + organizationId_referendumId: { + organizationId: string; + referendumId: string; + }; + }; + }) => + upsertBy( + this.organizationReferendumPositions, + (row) => + row["organizationId"] === + args.where.organizationId_referendumId.organizationId && + row["referendumId"] === + args.where.organizationId_referendumId.referendumId, + { id: "position-created", ...args.create }, + args.update, + ), + }; + + task = { + upsert: async (args: { + create: Row; + update: Row; + where: { taskKey: string }; + }) => + upsertBy( + this.tasks, + (row) => row["taskKey"] === args.where.taskKey, + { id: "task-created", ...args.create }, + args.update, + ), + }; +} + +describe("syncManagedIamOrganization", () => { + it("does nothing in dry-run mode", async () => { + const client = new FakeIamClient(); + + await expect( + syncManagedIamOrganization(client as unknown as PrismaClient, { + apply: false, + }), + ).resolves.toEqual({ dryRun: true, upserted: false }); + + expect(client.organizations.find((row) => row["id"] === "org-iam")).toMatchObject({ + sourceRef: null, + status: OrgStatus.PENDING, + }); + expect(client.tasks).toHaveLength(1); + }); + + it("upserts IAM, Mike, the official treaty position, and the activation task without touching unrelated rows", async () => { + const client = new FakeIamClient(); + + await expect( + syncManagedIamOrganization(client as unknown as PrismaClient, { + apply: true, + }), + ).resolves.toEqual({ dryRun: false, upserted: true }); + + expect(client.people.find((row) => row["email"] === MIKE_SINN_EMAIL)).toMatchObject({ + currentAffiliation: IAM_ORGANIZATION_NAME, + displayName: "Mike Sinn", + handle: "mike", + isPublic: true, + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: MIKE_SINN_PERSON_SOURCE_REF, + }); + expect(client.users.find((row) => row["email"] === MIKE_SINN_EMAIL)).toMatchObject({ + personId: "person-mike", + referralCode: "KEEP-ME", + }); + expect(client.organizations.find((row) => row["id"] === "org-iam")).toMatchObject({ + contactEmail: MIKE_SINN_EMAIL, + name: IAM_ORGANIZATION_NAME, + slug: IAM_ORGANIZATION_SLUG, + sourceRef: IAM_ORGANIZATION_SOURCE_REF, + status: OrgStatus.APPROVED, + type: OrgType.NONPROFIT, + }); + expect(client.organizationMembers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + organizationId: "org-iam", + role: "owner", + userId: "user-mike", + }), + expect.objectContaining({ + organizationId: "org-user-created", + role: "owner", + userId: "user-other", + }), + ]), + ); + expect(client.organizationReferendumPositions).toEqual([ + expect.objectContaining({ + approvedByUserId: "user-mike", + organizationId: "org-iam", + position: VotePosition.YES, + referendumId: "referendum-one-percent", + status: OrganizationReferendumPositionStatus.APPROVED, + submittedByUserId: "user-mike", + }), + ]); + expect(client.tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskKey: "user-created-task", + title: "Do not touch", + }), + expect.objectContaining({ + assigneeOrganizationId: "org-iam", + claimPolicy: TaskClaimPolicy.ASSIGNED_ONLY, + status: TaskStatus.ACTIVE, + taskKey: getOrganizationActivationTaskKey("org-iam"), + }), + ]), + ); + }); +}); diff --git a/packages/db/src/managed-data/managed-iam-organization.ts b/packages/db/src/managed-data/managed-iam-organization.ts new file mode 100644 index 000000000..caccc70fc --- /dev/null +++ b/packages/db/src/managed-data/managed-iam-organization.ts @@ -0,0 +1,233 @@ +import { + buildOrganizationActivationTaskDescription, + CAMPAIGN_NAME, + getOrganizationActivationTaskKey, + GLOBAL_SURVEY_NAME, + ORGANIZATION_ACTIVATION_TASK_TITLE, +} from "@optimitron/data/campaign"; +import { TREATY_REFERENDUM_SLUG } from "../constants.js"; +import { + OrgStatus, + OrgType, + OrganizationReferendumPositionStatus, + PersonLifeStatus, + TaskCategory, + TaskClaimPolicy, + TaskDifficulty, + TaskStatus, + VotePosition, + type Prisma, + type PrismaClient, +} from "../generated/prisma/client.js"; + +export const IAM_ORGANIZATION_SOURCE_REF = + "managed-organization:institute-for-accelerated-medicine"; +export const IAM_ORGANIZATION_SLUG = "institute-for-accelerated-medicine"; +export const IAM_ORGANIZATION_NAME = "Institute for Accelerated Medicine"; +export const MIKE_SINN_EMAIL = "m@thinkbynumbers.org"; +export const MIKE_SINN_PERSON_SOURCE_REF = "managed-person:mike-sinn"; + +const CAMPAIGN_BASE_URL = "https://warondisease.org"; +const IAM_WEBSITE_URL = "https://acceleratedmedicine.org"; +const NONPROFIT_COALITION_STRATEGY_URL = + "https://manual.warondisease.org/knowledge/strategy/nonprofit-coalition-strategy"; + +export async function syncManagedIamOrganization( + prisma: PrismaClient, + options: { apply: boolean }, +): Promise<{ upserted: boolean; dryRun: boolean }> { + if (!options.apply) return { upserted: false, dryRun: true }; + + const referendum = await prisma.referendum.findUniqueOrThrow({ + where: { slug: TREATY_REFERENDUM_SLUG }, + select: { id: true }, + }); + + const person = await prisma.person.upsert({ + where: { email: MIKE_SINN_EMAIL }, + update: { + currentAffiliation: IAM_ORGANIZATION_NAME, + deletedAt: null, + displayName: "Mike Sinn", + firstName: "Mike", + handle: "mike", + isPublic: true, + lastName: "Sinn", + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: MIKE_SINN_PERSON_SOURCE_REF, + website: CAMPAIGN_BASE_URL, + }, + create: { + currentAffiliation: IAM_ORGANIZATION_NAME, + displayName: "Mike Sinn", + email: MIKE_SINN_EMAIL, + firstName: "Mike", + handle: "mike", + isPublic: true, + lastName: "Sinn", + lifeStatus: PersonLifeStatus.LIVING, + sourceRef: MIKE_SINN_PERSON_SOURCE_REF, + website: CAMPAIGN_BASE_URL, + }, + }); + + const user = await prisma.user.upsert({ + where: { email: MIKE_SINN_EMAIL }, + update: { + deletedAt: null, + emailVerified: new Date(), + personId: person.id, + }, + create: { + email: MIKE_SINN_EMAIL, + emailVerified: new Date(), + personId: person.id, + referralCode: "MIKE", + }, + }); + + const organizationSeed = { + contactEmail: MIKE_SINN_EMAIL, + creatorId: user.id, + deletedAt: null, + description: + "Nonprofit accelerating clinical research to bring effective treatments to patients faster.", + donationUrl: null, + name: IAM_ORGANIZATION_NAME, + slug: IAM_ORGANIZATION_SLUG, + sourceRef: IAM_ORGANIZATION_SOURCE_REF, + sourceUrl: IAM_WEBSITE_URL, + squareLogoUrl: null, + status: OrgStatus.APPROVED, + type: OrgType.NONPROFIT, + website: IAM_WEBSITE_URL, + wordmarkLogoUrl: null, + } satisfies Prisma.OrganizationUncheckedCreateInput; + + const existingOrganization = await prisma.organization.findFirst({ + where: { + OR: [ + { sourceRef: IAM_ORGANIZATION_SOURCE_REF }, + { slug: IAM_ORGANIZATION_SLUG }, + { + name: { + equals: IAM_ORGANIZATION_NAME, + mode: "insensitive", + }, + }, + ], + }, + select: { id: true }, + }); + + const organization = existingOrganization + ? await prisma.organization.update({ + where: { id: existingOrganization.id }, + data: organizationSeed, + }) + : await prisma.organization.create({ data: organizationSeed }); + + await prisma.organizationMember.upsert({ + where: { + organizationId_userId: { + organizationId: organization.id, + userId: user.id, + }, + }, + update: { role: "owner" }, + create: { + organizationId: organization.id, + role: "owner", + userId: user.id, + }, + }); + + await prisma.organizationReferendumPosition.upsert({ + where: { + organizationId_referendumId: { + organizationId: organization.id, + referendumId: referendum.id, + }, + }, + update: { + approvedByUserId: user.id, + deletedAt: null, + position: VotePosition.YES, + statement: `${IAM_ORGANIZATION_NAME} supports the 1% Treaty and the ${GLOBAL_SURVEY_NAME}.`, + status: OrganizationReferendumPositionStatus.APPROVED, + submittedByUserId: user.id, + }, + create: { + approvedByUserId: user.id, + organizationId: organization.id, + position: VotePosition.YES, + referendumId: referendum.id, + statement: `${IAM_ORGANIZATION_NAME} supports the 1% Treaty and the ${GLOBAL_SURVEY_NAME}.`, + status: OrganizationReferendumPositionStatus.APPROVED, + submittedByUserId: user.id, + }, + }); + + const organizationToolsUrl = `${CAMPAIGN_BASE_URL}/organizations/${organization.id}`; + const surveyUrl = `${CAMPAIGN_BASE_URL}/survey/${organization.slug}`; + const contextJson = { + organizationId: organization.id, + organizationName: organization.name, + organizationToolsUrl, + surveyUrl, + } satisfies Prisma.InputJsonValue; + + await prisma.task.upsert({ + where: { taskKey: getOrganizationActivationTaskKey(organization.id) }, + update: { + assigneeOrganizationId: organization.id, + contextJson, + deletedAt: null, + description: buildOrganizationActivationTaskDescription({ + baseUrl: CAMPAIGN_BASE_URL, + coalitionStrategyUrl: NONPROFIT_COALITION_STRATEGY_URL, + legalUrl: `${CAMPAIGN_BASE_URL}/endorse#organization-legal-notes`, + organizationName: organization.name, + organizationToolsUrl, + surveyUrl, + }), + status: TaskStatus.ACTIVE, + title: ORGANIZATION_ACTIVATION_TASK_TITLE, + }, + create: { + assigneeOrganizationId: organization.id, + category: TaskCategory.OUTREACH, + claimPolicy: TaskClaimPolicy.ASSIGNED_ONLY, + contextJson, + createdByUserId: user.id, + description: buildOrganizationActivationTaskDescription({ + baseUrl: CAMPAIGN_BASE_URL, + coalitionStrategyUrl: NONPROFIT_COALITION_STRATEGY_URL, + legalUrl: `${CAMPAIGN_BASE_URL}/endorse#organization-legal-notes`, + organizationName: organization.name, + organizationToolsUrl, + surveyUrl, + }), + difficulty: TaskDifficulty.BEGINNER, + estimatedEffortHours: 1, + interestTags: ["1% Treaty", "organization", "member survey"], + isPublic: true, + roleTitle: "Organization supporter", + skillTags: ["email", "website", "member outreach"], + status: TaskStatus.ACTIVE, + taskKey: getOrganizationActivationTaskKey(organization.id), + title: ORGANIZATION_ACTIVATION_TASK_TITLE, + }, + }); + + return { upserted: true, dryRun: false }; +} + +export function formatManagedIamOrganizationResult( + result: { upserted: boolean; dryRun: boolean }, +): string { + if (result.dryRun) return `${IAM_ORGANIZATION_NAME}: would sync (dry-run)`; + return result.upserted + ? `${IAM_ORGANIZATION_NAME}: synced` + : `${IAM_ORGANIZATION_NAME}: unchanged`; +} diff --git a/packages/db/prisma/seed-reasoning.ts b/packages/db/src/managed-data/managed-reasoning-data.ts similarity index 94% rename from packages/db/prisma/seed-reasoning.ts rename to packages/db/src/managed-data/managed-reasoning-data.ts index a311a69eb..dd2da0731 100644 --- a/packages/db/prisma/seed-reasoning.ts +++ b/packages/db/src/managed-data/managed-reasoning-data.ts @@ -1,5 +1,5 @@ /** - * Seed: /reasoning autonomous persuasion optimizer + * Managed data: /reasoning autonomous persuasion optimizer * * Seeds: * - 4 VariantSets (canonical + 3 persona-tuned) @@ -13,17 +13,14 @@ * Idempotent: safe to re-run. */ -import { PrismaPg } from "@prisma/adapter-pg"; -import { pathToFileURL } from "node:url"; -import { PrismaClient } from "../src/generated/prisma/client.js"; -import { loadDatabaseUrl } from "../src/db-cli.ts"; +import { PrismaClient } from "../generated/prisma/client.js"; import type { ParameterName } from "@optimitron/data/parameters"; let prisma: PrismaClient; const DEFAULT_ARM_CHANNEL = "default"; const p = <T extends ParameterName>(paramName: T) => `{${paramName}}`; -export async function seedReasoningData(prismaClient: PrismaClient) { +export async function syncManagedReasoningData(prismaClient: PrismaClient) { prisma = prismaClient; await seedSystemState(); await seedLocaleConfigs(); @@ -36,7 +33,7 @@ export async function seedReasoningData(prismaClient: PrismaClient) { await seedDistributionTargets(); await assertAssignmentRuleTargetsSeeded(); - console.log("✓ reasoning seed complete"); + console.log("✓ reasoning managed data synced"); } async function seedSystemState() { @@ -199,11 +196,11 @@ async function seedCanonicalArmsForSet(variantSetId: string) { nodeId: "paid-recursion", headline: "Paid recursion, not cult recursion", body: - `Your signature is worth ~${p("VOTER_LIVES_SAVED")} lives and ~${p("VOTER_SUFFERING_HOURS_PREVENTED")} hours of suffering prevented in expectation. Every person you forward to, up to the ${p("TREATY_CAMPAIGN_VOTING_BLOC_TARGET")} threshold, is worth the same. The forwarding is paid: referral bonuses pay VOTE points; bondholders earn ${p("VICTORY_BOND_ANNUAL_RETURN_PCT")}/yr. Money flows IN to forwarders, not UP a pyramid.`, + `Your signature is worth ~${p("VOTER_LIVES_SAVED")} lives and ~${p("VOTER_SUFFERING_HOURS_PREVENTED")} hours of suffering prevented in expectation. Every person you forward to, up to the ${p("GLOBAL_REGISTERED_VOTERS")} threshold, is worth the same. The forwarding is paid: referral bonuses pay VOTE points; bondholders earn ${p("VICTORY_BOND_ANNUAL_RETURN_PCT")}/yr. Money flows IN to forwarders, not UP a pyramid.`, sourceKeys: [ "VOTER_LIVES_SAVED", "VOTER_SUFFERING_HOURS_PREVENTED", - "TREATY_CAMPAIGN_VOTING_BLOC_TARGET", + "GLOBAL_REGISTERED_VOTERS", "VICTORY_BOND_ANNUAL_RETURN_PCT", ], family: "SELF_INTEREST_ROI", @@ -271,7 +268,7 @@ async function seedCanonicalArmsForSet(variantSetId: string) { steelman: "Either this is MLM (structurally) or the linearity assumption is wrong (reactance, relationship cost).", rebuttal: - `Four structural disanalogies to MLM: bounded goal (stops at ${p("TREATY_CAMPAIGN_VOTING_BLOC_TARGET")}), costless exit, money flows IN from campaign instruments (not UP a pyramid), no downline. Linearity: the claim is EV per expected signature produced, not per conversation. At 10% conversion the math is still net-positive.`, + `Four structural disanalogies to MLM: bounded goal (stops at ${p("GLOBAL_REGISTERED_VOTERS")}), costless exit, money flows IN from campaign instruments (not UP a pyramid), no downline. Linearity: the claim is EV per expected signature produced, not per conversation. At 10% conversion the math is still net-positive.`, adversarialSourceLabel: "MLM-psychology steelman", adversarialSourceUrl: "https://en.wikipedia.org/wiki/Multi-level_marketing", }, @@ -432,7 +429,7 @@ async function seedPersonaVariantSets(canonicalSetId: string): Promise<{ ids[s.name] = set.id; } return { - analytical: ids.analytical!, + analytical: ids["analytical"]!, moral: ids["moral-emotional"]!, practical: ids["practical-self-interest"]!, }; @@ -582,7 +579,7 @@ async function applyPracticalOverrides(variantSetId: string) { sources: [ { label: humanize("VOTER_LIVES_SAVED"), paramName: "VOTER_LIVES_SAVED" }, { label: humanize("VOTER_SUFFERING_HOURS_PREVENTED"), paramName: "VOTER_SUFFERING_HOURS_PREVENTED" }, - { label: humanize("TREATY_CAMPAIGN_VOTING_BLOC_TARGET"), paramName: "TREATY_CAMPAIGN_VOTING_BLOC_TARGET" }, + { label: humanize("GLOBAL_REGISTERED_VOTERS"), paramName: "GLOBAL_REGISTERED_VOTERS" }, { label: humanize("VICTORY_BOND_ANNUAL_RETURN_PCT"), paramName: "VICTORY_BOND_ANNUAL_RETURN_PCT" }, ], }, @@ -829,11 +826,11 @@ async function seedSecretArms(variantSetId: string) { nodeId: "paid-recursion", headline: "Call 40. Two will continue. That's enough.", body: - `The system math is: if each person gets 2 others to keep the chain going, 32 doublings reaches ${p("TREATY_CAMPAIGN_VOTING_BLOC_TARGET")} — enough that no government ignores. The honest behavioral math: ~5–10% of anyone you ask actually does what they said yes to. You can't predict which ones. So call ~40 (everyone you love, one per day for 6 weeks). Most will say yes and do nothing. 2–4 will actually call their own 40. Those 2–4 are enough, because they do the same thing. Your signature is worth ~${p("VOTER_LIVES_SAVED")} lives and ~${p("VOTER_SUFFERING_HOURS_PREVENTED")} hours of suffering prevented. You aren't responsible for everyone on your list — you are responsible for calling them. Volume is the only protection against "most say yes and forget."`, + `The system math is: if each person gets 2 others to keep the chain going, 32 doublings reaches ${p("GLOBAL_REGISTERED_VOTERS")} — enough that no government ignores. The honest behavioral math: ~5–10% of anyone you ask actually does what they said yes to. You can't predict which ones. So call ~40 (everyone you love, one per day for 6 weeks). Most will say yes and do nothing. 2–4 will actually call their own 40. Those 2–4 are enough, because they do the same thing. Your signature is worth ~${p("VOTER_LIVES_SAVED")} lives and ~${p("VOTER_SUFFERING_HOURS_PREVENTED")} hours of suffering prevented. You aren't responsible for everyone on your list — you are responsible for calling them. Volume is the only protection against "most say yes and forget."`, sourceKeys: [ "VOTER_LIVES_SAVED", "VOTER_SUFFERING_HOURS_PREVENTED", - "TREATY_CAMPAIGN_VOTING_BLOC_TARGET", + "GLOBAL_REGISTERED_VOTERS", ], family: "DIRECT_IMPERATIVE", }, @@ -1052,22 +1049,3 @@ async function seedSecretArms(variantSetId: string) { }); } } - -const isMainModule = - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - const adapter = new PrismaPg({ connectionString: loadDatabaseUrl() }); - const standalonePrisma = new PrismaClient({ adapter }); - - Promise.resolve() - .then(() => seedReasoningData(standalonePrisma)) - .catch((error) => { - console.error(error); - process.exitCode = 1; - }) - .finally(async () => { - await standalonePrisma.$disconnect(); - }); -} diff --git a/packages/db/src/managed-data/managed-referendums.ts b/packages/db/src/managed-data/managed-referendums.ts new file mode 100644 index 000000000..9a689bf2a --- /dev/null +++ b/packages/db/src/managed-data/managed-referendums.ts @@ -0,0 +1,169 @@ +import { createHash } from "node:crypto"; +import { shareableSnippets } from "@optimitron/data/parameters"; +import { + COURT_OF_HUMANITY_QUESTION, + COURT_OF_HUMANITY_TEXT, +} from "@optimitron/data/referendums"; +import { + COURT_OF_HUMANITY_REFERENDUM_SLUG, + DECLARATION_REFERENDUM_SLUG, + TREATY_REFERENDUM_SLUG, +} from "../constants.js"; +import { MANAGED_HUMANITY_V_GOVERNMENT_VERDICT } from "./managed-humanity-v-government.js"; +import { + ReferendumKind, + ReferendumStatus, + type PrismaClient, + type ReferendumKind as ReferendumKindValue, + type ReferendumStatus as ReferendumStatusValue, +} from "../generated/prisma/client.js"; + +// Canonical Referendum records. Synced on every deploy + CI run via +// `pnpm db:sync:managed-data --apply`. Edit a record below → sync detects +// the change by comparing the row's content fields against the canonical +// record → upserts on drift. No change → skip the write. + +const REFERENDUM_PUBLISHED_AT = new Date("2026-05-03T00:00:00.000Z"); + +interface ManagedReferendumRecord { + slug: string; + title: string; + question: string; + kind: ReferendumKindValue; + description: string; + bodyMarkdown: string; + status: ReferendumStatusValue; +} + +export const MANAGED_REFERENDUMS: readonly ManagedReferendumRecord[] = [ + { + slug: TREATY_REFERENDUM_SLUG, + title: "The 1% Treaty", + question: + "Should governments redirect 1% of military spending to pragmatic clinical trials and disease eradication by adopting the 1% Treaty?", + kind: ReferendumKind.TREATY, + description: + "The 1% Treaty redirects one percent of military spending into pragmatic clinical trials so disease gets less time to kill people.", + bodyMarkdown: shareableSnippets.onePercentTreatyText.markdown, + status: ReferendumStatus.ACTIVE, + }, + { + slug: DECLARATION_REFERENDUM_SLUG, + title: "Declaration of Optimization", + question: "Do you endorse the Declaration of Optimization?", + kind: ReferendumKind.DECLARATION, + description: + "Sign the Declaration of Optimization to declare your support for evidence-based governance.", + bodyMarkdown: [ + shareableSnippets.whyOptimizationIsNecessary.markdown, + shareableSnippets.declarationOfOptimization.markdown, + ].join("\n\n"), + status: ReferendumStatus.ACTIVE, + }, + { + slug: COURT_OF_HUMANITY_REFERENDUM_SLUG, + title: "The Court of Humanity", + question: COURT_OF_HUMANITY_QUESTION, + kind: ReferendumKind.MEMBERSHIP, + description: + "Join the decentralized court where 8 billion humans are the jury and sovereign immunity is abolished.", + bodyMarkdown: COURT_OF_HUMANITY_TEXT.markdown, + status: ReferendumStatus.ACTIVE, + }, + MANAGED_HUMANITY_V_GOVERNMENT_VERDICT, +] as const; + +export function buildReferendumContentHash(input: { + question: string; + description?: string | null; + bodyMarkdown?: string | null; +}): string { + const normalize = (v: string | null | undefined) => v?.trim() || null; + return createHash("sha256") + .update( + JSON.stringify({ + question: input.question.trim(), + description: normalize(input.description), + bodyMarkdown: normalize(input.bodyMarkdown), + }), + ) + .digest("hex"); +} + +export async function syncManagedReferendums( + prisma: PrismaClient, + options: { apply: boolean }, +): Promise<{ totalRecords: number; upserted: string[]; unchanged: string[] }> { + const slugs = MANAGED_REFERENDUMS.map((r) => r.slug); + // Compare canonical record fields against the DB row directly. The stored + // `contentHash` column is not a trustworthy change signal — a direct + // `UPDATE referendum SET title = ...` leaves the hash stale, so the row + // can drift away from canonical without us noticing. Field-by-field + // comparison matches the pattern in `sync-managed-tasks.ts`. + const existing = await prisma.referendum.findMany({ + where: { slug: { in: slugs } }, + select: { + slug: true, + title: true, + question: true, + kind: true, + description: true, + bodyMarkdown: true, + status: true, + }, + }); + const existingBySlug = new Map(existing.map((r) => [r.slug, r])); + + const upserted: string[] = []; + const unchanged: string[] = []; + + for (const record of MANAGED_REFERENDUMS) { + const row = existingBySlug.get(record.slug); + if ( + row?.title === record.title && + row.question === record.question && + row.kind === record.kind && + row.description === record.description && + row.bodyMarkdown === record.bodyMarkdown && + row.status === record.status + ) { + unchanged.push(record.slug); + continue; + } + + if (!options.apply) { + upserted.push(record.slug); + continue; + } + + const data = { + title: record.title, + slug: record.slug, + question: record.question, + kind: record.kind, + description: record.description, + bodyMarkdown: record.bodyMarkdown, + publishedAt: REFERENDUM_PUBLISHED_AT, + lockedAt: null, + status: record.status, + contentHash: buildReferendumContentHash(record), + }; + + await prisma.referendum.upsert({ + where: { slug: record.slug }, + update: data, + create: data, + }); + upserted.push(record.slug); + } + + return { totalRecords: MANAGED_REFERENDUMS.length, upserted, unchanged }; +} + +export function formatManagedReferendumsResult( + result: { totalRecords: number; upserted: string[]; unchanged: string[] }, +): string { + const parts = [`Referendums: ${result.upserted.length}/${result.totalRecords} upserted`]; + if (result.unchanged.length) parts.push(`${result.unchanged.length} unchanged`); + return parts.join(", "); +} diff --git a/packages/db/src/managed-data/managed-seed-data.ts b/packages/db/src/managed-data/managed-seed-data.ts new file mode 100644 index 000000000..0c0c8fc33 --- /dev/null +++ b/packages/db/src/managed-data/managed-seed-data.ts @@ -0,0 +1,1856 @@ +// ============================================================================ +// Managed reference and campaign data — Optimitron +// ============================================================================ +// Syncs real production-worthy data: Units, VariableCategories, +// GlobalVariables, Jurisdictions, Items, medical reference data, and campaign +// accountability tasks. +// ============================================================================ +// +// Variable category defaults sourced from: +// https://github.com/mikepsinn/curedao-api/tree/main/app/VariableCategories +// +// Key semantics: +// combinationOperation: SUM = additive (doses, calories, steps) +// MEAN = instantaneous (mood, heart rate, temp) +// fillingType on GlobalVariable: +// ZERO = "no measurement recorded ⇒ value is 0" (treatments, foods, activities) +// NONE = "no measurement recorded ⇒ leave gap" (symptoms, vitals, emotions) +// onsetDelay: seconds before a measurement's effect begins +// durationOfAction: seconds the effect persists +// predictorOnly: can only be a cause (treatments, foods) +// outcome: something a user wants to optimise (symptoms, mood, vitals) +// ============================================================================ + +import { + PrismaClient, + CombinationOperation, + EvidenceGrade, + FillingType, + InterventionRankingRunStatus, + Valence, + MeasurementScale, + JurisdictionType, + TaskCommunicationEndpointKind, + TaskCommunicationEndpointVerificationStatus, + VariableEvidenceMetricKind, + VariableRelationshipEvidenceSourceType, + type Prisma, +} from "../generated/prisma/client.js"; +import { + OPTIMIZE_EARTH_ROOT_TASK_ID, + TREATY_PARENT_TASK_ID, +} from "../task-keys.js"; +import { + US_WISHOCRATIC_JURISDICTION, + getUSWishocraticCatalogRecords, + listGovernmentLeaders, +} from "@optimitron/data"; +import { + getAllConditions, + getAllTreatments, + type TreatmentWithConditions, +} from "@optimitron/data/datasets/medical"; +import { + DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_DALYS, + DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_ECONOMIC_VALUE, + DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_YEARS, + EVENTUALLY_AVOIDABLE_DALY_PCT, + GLOBAL_ANNUAL_DALY_BURDEN, + earthOptimizationPrizeWinCondition, + EARTH_OPTIMIZATION_PRIZE_INCOME_GROWTH_EFFECT_PP_PER_YEAR, + shareableSnippets, +} from "@optimitron/data/parameters"; +import { syncManagedReasoningData } from "./managed-reasoning-data.js"; +import { GLOBAL_VARIABLE_SEED_DATA } from "./seed-data/global-variables.js"; +import { VARIABLE_CATEGORY_SEED_DATA } from "./seed-data/variable-categories.js"; +import { upsertWishoniaUser } from "../system-users.js"; + +let prisma = undefined as unknown as PrismaClient; + +export function setManagedSeedDataClient(client: PrismaClient) { + prisma = client; +} + +// --------------------------------------------------------------------------- +// Helper: upsert by unique "name" (or "code" for jurisdictions) +// --------------------------------------------------------------------------- + +/** + * Slugify a display name into a URL-safe handle. Strips diacritics, drops + * non-alphanumeric characters, collapses runs of dashes, and lowercases. + * Used for Person.handle backfill — combine with a country suffix on shared + * names (e.g. there are several "Tshering Tobgay"s historically). + */ +function slugify(input: string): string { + return input + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") // strip diacritics + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); +} + +async function upsertUnit(data: Prisma.UnitUncheckedCreateInput) { + return prisma.unit.upsert({ + where: { name: data.name }, + update: data, + create: data, + }); +} + +async function upsertVariableCategory(data: Prisma.VariableCategoryUncheckedCreateInput) { + return prisma.variableCategory.upsert({ + where: { name: data.name }, + update: data, + create: data, + }); +} + +async function upsertGlobalVariable(data: Prisma.GlobalVariableUncheckedCreateInput) { + return prisma.globalVariable.upsert({ + where: { name: data.name }, + update: data, + create: data, + }); +} + +function splitExternalCodes(rawCodes: string | null): string[] { + if (!rawCodes) return []; + + return Array.from( + new Set( + rawCodes + .split(/[;,]/u) + .map((code) => code.trim()) + .filter(Boolean), + ), + ); +} + +function stableSeedId(prefix: string, ...parts: string[]): string { + return `${prefix}-${parts + .join("-") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 180)}`; +} + +function clampScore(value: number): number { + return Math.max(0, Math.min(100, Number(value.toFixed(2)))); +} + +function calculateStaticInterventionScore(treatment: TreatmentWithConditions, condition: TreatmentWithConditions["conditions"][number]) { + const participantScore = Math.min(100, Math.log10(Math.max(1, condition.participants)) * 18); + const trialScore = Math.min(100, Math.log10(Math.max(1, condition.trials)) * 25); + return clampScore( + condition.effectiveness * 0.5 + + condition.safetyScore * 0.25 + + participantScore * 0.15 + + trialScore * 0.1, + ); +} + +async function upsertJurisdiction(data: Prisma.JurisdictionUncheckedCreateInput) { + return prisma.jurisdiction.upsert({ + where: { code: data.code! }, + update: data, + create: data, + }); +} + +// ============================================================================ +// A) UNITS (~30) +// ============================================================================ + +async function seedUnits() { + console.log("🔧 Seeding units..."); + + const units: Prisma.UnitUncheckedCreateInput[] = [ + // Weight + { name: "Milligrams", abbreviatedName: "mg", ucumCode: "mg", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Grams", abbreviatedName: "g", ucumCode: "g", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Kilograms", abbreviatedName: "kg", ucumCode: "kg", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + { name: "Ounces", abbreviatedName: "oz", ucumCode: "[oz_av]", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Pounds", abbreviatedName: "lb", ucumCode: "[lb_av]", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + + // Volume + { name: "Milliliters", abbreviatedName: "mL", ucumCode: "mL", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Liters", abbreviatedName: "L", ucumCode: "L", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Fluid Ounces", abbreviatedName: "fl oz", ucumCode: "[foz_us]", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Cups", abbreviatedName: "cups", ucumCode: "[cup_us]", unitCategoryId: "Volume", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + + // Count + { name: "Count", abbreviatedName: "count", ucumCode: "{count}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Servings", abbreviatedName: "servings", ucumCode: "{serving}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Doses", abbreviatedName: "doses", ucumCode: "{dose}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Tablets", abbreviatedName: "tablets", ucumCode: "{tablet}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Capsules", abbreviatedName: "capsules", ucumCode: "{capsule}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Applications", abbreviatedName: "applications", ucumCode: "{application}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Sprays", abbreviatedName: "sprays", ucumCode: "{spray}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Drops", abbreviatedName: "drops", ucumCode: "[drp]", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + + // Rating + { name: "1 to 5 Rating", abbreviatedName: "1-5", ucumCode: "{score_5}", unitCategoryId: "Rating", scale: MeasurementScale.ORDINAL, fillingType: FillingType.NONE, manualTracking: true, minimumValue: 1, maximumValue: 5 }, + { name: "1 to 10 Rating", abbreviatedName: "1-10", ucumCode: "{score_10}", unitCategoryId: "Rating", scale: MeasurementScale.ORDINAL, fillingType: FillingType.NONE, manualTracking: true, minimumValue: 1, maximumValue: 10 }, + { name: "Percent", abbreviatedName: "%", ucumCode: "%", unitCategoryId: "Rating", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true, minimumValue: 0, maximumValue: 100 }, + + // Currency + { name: "US Dollars", abbreviatedName: "USD", ucumCode: "[USD]", unitCategoryId: "Currency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + { name: "Euros", abbreviatedName: "EUR", ucumCode: "[EUR]", unitCategoryId: "Currency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + { name: "British Pounds", abbreviatedName: "GBP", ucumCode: "[GBP]", unitCategoryId: "Currency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + + // Duration + { name: "Seconds", abbreviatedName: "s", ucumCode: "s", unitCategoryId: "Duration", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Minutes", abbreviatedName: "min", ucumCode: "min", unitCategoryId: "Duration", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Hours", abbreviatedName: "h", ucumCode: "h", unitCategoryId: "Duration", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + + // Other + { name: "International Units", abbreviatedName: "IU", ucumCode: "[iU]", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Micrograms", abbreviatedName: "mcg", ucumCode: "ug", unitCategoryId: "Weight", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Calories", abbreviatedName: "kcal", ucumCode: "kcal", unitCategoryId: "Energy", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Steps", abbreviatedName: "steps", ucumCode: "{step}", unitCategoryId: "Count", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Beats Per Minute", abbreviatedName: "bpm", ucumCode: "{beat}/min", unitCategoryId: "Frequency", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: false }, + { name: "Yes/No", abbreviatedName: "yes/no", ucumCode: "{boolean}", unitCategoryId: "Rating", scale: MeasurementScale.NOMINAL, fillingType: FillingType.ZERO, manualTracking: true, minimumValue: 0, maximumValue: 1 }, + { name: "Millimeters of Mercury", abbreviatedName: "mmHg", ucumCode: "mm[Hg]", unitCategoryId: "Pressure", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + { name: "Degrees Fahrenheit", abbreviatedName: "°F", ucumCode: "[degF]", unitCategoryId: "Temperature", scale: MeasurementScale.INTERVAL, fillingType: FillingType.NONE, manualTracking: true }, + { name: "Degrees Celsius", abbreviatedName: "°C", ucumCode: "Cel", unitCategoryId: "Temperature", scale: MeasurementScale.INTERVAL, fillingType: FillingType.NONE, manualTracking: true }, + { name: "Index", abbreviatedName: "index", ucumCode: "{index}", unitCategoryId: "Rating", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: false }, + { name: "Milligrams per Deciliter", abbreviatedName: "mg/dL", ucumCode: "mg/dL", unitCategoryId: "Concentration", scale: MeasurementScale.RATIO, fillingType: FillingType.NONE, manualTracking: true }, + { name: "Meters", abbreviatedName: "m", ucumCode: "m", unitCategoryId: "Distance", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Kilometers", abbreviatedName: "km", ucumCode: "km", unitCategoryId: "Distance", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + { name: "Miles", abbreviatedName: "mi", ucumCode: "[mi_i]", unitCategoryId: "Distance", scale: MeasurementScale.RATIO, fillingType: FillingType.ZERO, manualTracking: true }, + ]; + + const created: Record<string, string> = {}; + for (const u of units) { + const row = await upsertUnit(u); + created[u.abbreviatedName] = row.id; + } + console.log(` ✅ ${Object.keys(created).length} units`); + return created; +} + +// ============================================================================ +// B) VARIABLE CATEGORIES +// ============================================================================ +// Sourced from legacy curedao-api/app/VariableCategories/*.php +// The schema's VariableCategory model has: name, description, defaultUnitId, +// combinationOperation, onsetDelay, durationOfAction, predictorOnly, outcome +// (fillingType/fillingValue/min/max live on GlobalVariable, not VariableCategory) +// ============================================================================ + +async function seedVariableCategories(unitMap: Record<string, string>) { + console.log("📂 Seeding variable categories..."); + + const categories = VARIABLE_CATEGORY_SEED_DATA; + + const created: Record<string, string> = {}; + for (const c of categories) { + const { defaultUnitAbbr, ...rest } = c; + const row = await upsertVariableCategory({ + ...rest, + defaultUnitId: unitMap[defaultUnitAbbr] || undefined, + }); + created[c.name] = row.id; + } + console.log(` ✅ ${Object.keys(created).length} variable categories`); + return created; +} + +// ============================================================================ +// C) GLOBAL VARIABLES +// ============================================================================ +// Each variable inherits sensible defaults from its category but can override. +// fillingType + fillingValue are set per-variable (they live on GlobalVariable). +// ============================================================================ + +async function seedGlobalVariables( + unitMap: Record<string, string>, + catMap: Record<string, string>, +) { + console.log("🌐 Seeding global variables..."); + + const variables = GLOBAL_VARIABLE_SEED_DATA; + + let count = 0; + for (const v of variables) { + const categoryId = catMap[v.category]; + const unitId = unitMap[v.unit]; + if (!categoryId) { + console.warn(` ⚠️ Unknown category "${v.category}" for variable "${v.name}" — skipping`); + continue; + } + if (!unitId) { + console.warn(` ⚠️ Unknown unit "${v.unit}" for variable "${v.name}" — skipping`); + continue; + } + await upsertGlobalVariable({ + name: v.name, + description: v.description, + variableCategoryId: categoryId, + defaultUnitId: unitId, + combinationOperation: v.combinationOperation, + fillingType: v.fillingType, + fillingValue: v.fillingValue, + onsetDelay: v.onsetDelay, + durationOfAction: v.durationOfAction, + predictorOnly: v.predictorOnly, + outcome: v.outcome, + valence: v.valence, + minimumAllowedValue: v.minimumAllowedValue, + maximumAllowedValue: v.maximumAllowedValue, + synonyms: v.synonyms, + }); + count++; + } + console.log(` ✅ ${count} global variables`); +} + +async function seedMedicalReferenceData( + unitMap: Record<string, string>, + catMap: Record<string, string>, +) { + console.log("🧬 Seeding medical conditions, intervention evidence, and rankings..."); + + const conditionCategoryId = catMap["Condition"]; + const treatmentCategoryId = catMap["Treatment"]; + const conditionUnitId = unitMap["1-5"]; + const treatmentUnitId = unitMap["count"]; + + if (!conditionCategoryId || !treatmentCategoryId || !conditionUnitId || !treatmentUnitId) { + console.warn(" ⚠️ Missing medical category/unit seeds — skipping medical reference data"); + return; + } + + const conditionVariablesBySlug = new Map<string, string>(); + const treatmentVariablesBySlug = new Map<string, string>(); + let codeCount = 0; + + for (const condition of getAllConditions()) { + const variable = await upsertGlobalVariable({ + name: condition.name, + description: condition.description, + variableCategoryId: conditionCategoryId, + defaultUnitId: conditionUnitId, + combinationOperation: CombinationOperation.MEAN, + fillingType: FillingType.NONE, + predictorOnly: false, + outcome: true, + valence: Valence.NEGATIVE, + synonyms: condition.synonyms.join(",") || undefined, + }); + conditionVariablesBySlug.set(condition.slug, variable.id); + + for (const code of splitExternalCodes(condition.icd10Codes)) { + await prisma.globalVariableExternalCode.upsert({ + where: { + globalVariableId_codeSystem_code: { + globalVariableId: variable.id, + codeSystem: "ICD-10", + code, + }, + }, + update: { + deletedAt: null, + displayName: condition.name, + metadataJson: { + conditionCategory: condition.category, + conditionSlug: condition.slug, + dataSourceYear: condition.dataSourceYear, + source: "packages/data medical conditions", + }, + }, + create: { + globalVariableId: variable.id, + codeSystem: "ICD-10", + code, + displayName: condition.name, + metadataJson: { + conditionCategory: condition.category, + conditionSlug: condition.slug, + dataSourceYear: condition.dataSourceYear, + source: "packages/data medical conditions", + }, + }, + }); + codeCount++; + } + } + + for (const treatment of getAllTreatments()) { + const variable = await upsertGlobalVariable({ + name: treatment.name, + variableCategoryId: treatmentCategoryId, + defaultUnitId: treatmentUnitId, + combinationOperation: CombinationOperation.SUM, + fillingType: FillingType.ZERO, + fillingValue: 0, + onsetDelay: 1800, + durationOfAction: 86400, + predictorOnly: true, + outcome: false, + valence: Valence.POSITIVE, + minimumAllowedValue: 0, + }); + treatmentVariablesBySlug.set(treatment.slug, variable.id); + } + + const rankedByConditionSlug = new Map<string, Array<{ + condition: TreatmentWithConditions["conditions"][number]; + evidenceId: string; + score: number; + treatment: TreatmentWithConditions; + interventionGlobalVariableId: string; + }>>(); + + for (const treatment of getAllTreatments()) { + const interventionGlobalVariableId = treatmentVariablesBySlug.get(treatment.slug); + if (!interventionGlobalVariableId) continue; + + for (const condition of treatment.conditions) { + const conditionGlobalVariableId = conditionVariablesBySlug.get(condition.conditionSlug); + if (!conditionGlobalVariableId) continue; + + const effectivenessEvidenceId = stableSeedId( + "medical-evidence", + treatment.slug, + condition.conditionSlug, + "effectiveness", + ); + await prisma.variableRelationshipEvidenceEstimate.upsert({ + where: { id: effectivenessEvidenceId }, + update: { + confidenceScore: treatment.avgEffectiveness / 100, + contextGlobalVariableId: conditionGlobalVariableId, + deletedAt: null, + metricKind: VariableEvidenceMetricKind.EFFECTIVENESS, + outcomeGlobalVariableId: conditionGlobalVariableId, + participants: condition.participants, + predictorGlobalVariableId: interventionGlobalVariableId, + sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, + studies: condition.trials, + value: condition.effectiveness, + }, + create: { + id: effectivenessEvidenceId, + confidenceScore: treatment.avgEffectiveness / 100, + contextGlobalVariableId: conditionGlobalVariableId, + evidenceGrade: + condition.participants >= 10_000 + ? EvidenceGrade.A + : condition.participants >= 1_000 + ? EvidenceGrade.B + : EvidenceGrade.C, + metricKind: VariableEvidenceMetricKind.EFFECTIVENESS, + outcomeGlobalVariableId: conditionGlobalVariableId, + participants: condition.participants, + predictorGlobalVariableId: interventionGlobalVariableId, + rationale: `Static dFDA catalog estimate for ${treatment.name} in ${condition.conditionName}.`, + sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, + studies: condition.trials, + value: condition.effectiveness, + }, + }); + + const safetyEvidenceId = stableSeedId( + "medical-evidence", + treatment.slug, + condition.conditionSlug, + "safety", + ); + await prisma.variableRelationshipEvidenceEstimate.upsert({ + where: { id: safetyEvidenceId }, + update: { + confidenceScore: treatment.avgSafetyScore / 100, + contextGlobalVariableId: conditionGlobalVariableId, + deletedAt: null, + metricKind: VariableEvidenceMetricKind.SAFETY, + outcomeGlobalVariableId: conditionGlobalVariableId, + participants: condition.participants, + predictorGlobalVariableId: interventionGlobalVariableId, + sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, + studies: condition.trials, + value: condition.safetyScore, + }, + create: { + id: safetyEvidenceId, + confidenceScore: treatment.avgSafetyScore / 100, + contextGlobalVariableId: conditionGlobalVariableId, + metricKind: VariableEvidenceMetricKind.SAFETY, + outcomeGlobalVariableId: conditionGlobalVariableId, + participants: condition.participants, + predictorGlobalVariableId: interventionGlobalVariableId, + rationale: `Static dFDA catalog safety estimate for ${treatment.name} in ${condition.conditionName}.`, + sourceType: VariableRelationshipEvidenceSourceType.CURATED_DATASET, + studies: condition.trials, + value: condition.safetyScore, + }, + }); + + const ranked = rankedByConditionSlug.get(condition.conditionSlug) ?? []; + ranked.push({ + condition, + evidenceId: effectivenessEvidenceId, + score: calculateStaticInterventionScore(treatment, condition), + treatment, + interventionGlobalVariableId, + }); + rankedByConditionSlug.set(condition.conditionSlug, ranked); + } + } + + let rankedCount = 0; + for (const [conditionSlug, ranked] of rankedByConditionSlug) { + const conditionGlobalVariableId = conditionVariablesBySlug.get(conditionSlug); + if (!conditionGlobalVariableId) continue; + + const rankingRunId = stableSeedId("medical-ranking", conditionSlug); + await prisma.interventionRankingRun.upsert({ + where: { id: rankingRunId }, + update: { + algorithmKey: "medical-static-v1", + conditionGlobalVariableId, + deletedAt: null, + status: InterventionRankingRunStatus.ACTIVE, + }, + create: { + id: rankingRunId, + algorithmKey: "medical-static-v1", + algorithmVersion: "packages/data medical snapshot", + conditionGlobalVariableId, + status: InterventionRankingRunStatus.ACTIVE, + }, + }); + + await prisma.rankedIntervention.deleteMany({ + where: { rankingRunId }, + }); + + const rankedRows = ranked + .sort((a, b) => b.score - a.score || b.condition.participants - a.condition.participants) + .map((entry, index) => ({ + id: stableSeedId("ranked-intervention", conditionSlug, entry.treatment.slug), + rankingRunId, + interventionGlobalVariableId: entry.interventionGlobalVariableId, + rank: index + 1, + score: entry.score, + effectivenessScore: entry.condition.effectiveness, + safetyScore: entry.condition.safetyScore, + evidenceScore: Math.min(100, Math.log10(Math.max(1, entry.condition.participants)) * 18), + confidenceScore: entry.treatment.avgEffectiveness / 100, + sourceEvidenceEstimateId: entry.evidenceId, + rationale: `${entry.treatment.name}: ${entry.condition.effectiveness}% effectiveness, ${entry.condition.safetyScore}% safety in the static dFDA catalog.`, + })); + + if (rankedRows.length > 0) { + await prisma.rankedIntervention.createMany({ data: rankedRows }); + rankedCount += rankedRows.length; + } + } + + console.log( + ` ✅ ${conditionVariablesBySlug.size} conditions, ${codeCount} ICD-10 codes, ${treatmentVariablesBySlug.size} interventions, ${rankedCount} ranked rows`, + ); +} + +// ============================================================================ +// D) JURISDICTIONS — US Federal + 50 States +// ============================================================================ + +async function seedJurisdictions() { + console.log("🏛️ Seeding jurisdictions..."); + + // Federal + const us = await upsertJurisdiction({ + name: "United States", + type: JurisdictionType.COUNTRY, + code: "US", + currency: "USD", + population: 335_000_000, + }); + + // 50 states: [name, FIPS code, approx 2024 population] + const states: [string, string, number][] = [ + ["Alabama", "US-AL", 5_108_000], + ["Alaska", "US-AK", 733_000], + ["Arizona", "US-AZ", 7_431_000], + ["Arkansas", "US-AR", 3_067_000], + ["California", "US-CA", 38_965_000], + ["Colorado", "US-CO", 5_912_000], + ["Connecticut", "US-CT", 3_617_000], + ["Delaware", "US-DE", 1_018_000], + ["Florida", "US-FL", 22_611_000], + ["Georgia", "US-GA", 11_029_000], + ["Hawaii", "US-HI", 1_435_000], + ["Idaho", "US-ID", 2_001_000], + ["Illinois", "US-IL", 12_550_000], + ["Indiana", "US-IN", 6_862_000], + ["Iowa", "US-IA", 3_207_000], + ["Kansas", "US-KS", 2_940_000], + ["Kentucky", "US-KY", 4_526_000], + ["Louisiana", "US-LA", 4_573_000], + ["Maine", "US-ME", 1_395_000], + ["Maryland", "US-MD", 6_180_000], + ["Massachusetts", "US-MA", 7_001_000], + ["Michigan", "US-MI", 10_037_000], + ["Minnesota", "US-MN", 5_737_000], + ["Mississippi", "US-MS", 2_939_000], + ["Missouri", "US-MO", 6_196_000], + ["Montana", "US-MT", 1_133_000], + ["Nebraska", "US-NE", 1_978_000], + ["Nevada", "US-NV", 3_194_000], + ["New Hampshire", "US-NH", 1_402_000], + ["New Jersey", "US-NJ", 9_290_000], + ["New Mexico", "US-NM", 2_114_000], + ["New York", "US-NY", 19_572_000], + ["North Carolina", "US-NC", 10_835_000], + ["North Dakota", "US-ND", 783_000], + ["Ohio", "US-OH", 11_785_000], + ["Oklahoma", "US-OK", 4_053_000], + ["Oregon", "US-OR", 4_233_000], + ["Pennsylvania", "US-PA", 12_962_000], + ["Rhode Island", "US-RI", 1_095_000], + ["South Carolina", "US-SC", 5_373_000], + ["South Dakota", "US-SD", 919_000], + ["Tennessee", "US-TN", 7_126_000], + ["Texas", "US-TX", 30_503_000], + ["Utah", "US-UT", 3_418_000], + ["Vermont", "US-VT", 647_000], + ["Virginia", "US-VA", 8_643_000], + ["Washington", "US-WA", 7_812_000], + ["West Virginia", "US-WV", 1_770_000], + ["Wisconsin", "US-WI", 5_893_000], + ["Wyoming", "US-WY", 584_000], + ]; + + for (const [name, code, population] of states) { + await upsertJurisdiction({ + name, + type: JurisdictionType.STATE, + code, + parentJurisdictionId: us.id, + currency: "USD", + population, + }); + } + + console.log(` ✅ 1 country + ${states.length} states`); + + // Conflict-relevant and globally significant countries for the Invisible + // Graveyard "Responsible governments" picker. ISO-3166-1 alpha-2 codes. + // Not exhaustive — add more as memorial submissions surface them. + const otherCountries: [string, string, number][] = [ + ["Israel", "IL", 9_756_000], + ["Palestine", "PS", 5_483_000], + ["Ukraine", "UA", 33_400_000], + ["Russia", "RU", 144_400_000], + ["Yemen", "YE", 34_450_000], + ["Syria", "SY", 23_230_000], + ["Sudan", "SD", 48_110_000], + ["South Sudan", "SS", 11_090_000], + ["Myanmar", "MM", 54_500_000], + ["Ethiopia", "ET", 126_500_000], + ["China", "CN", 1_410_000_000], + ["Iran", "IR", 89_170_000], + ["Saudi Arabia", "SA", 36_950_000], + ["North Korea", "KP", 26_160_000], + ["Egypt", "EG", 110_990_000], + ["Pakistan", "PK", 240_490_000], + ["India", "IN", 1_428_630_000], + ["Turkey", "TR", 85_330_000], + ["Mexico", "MX", 128_460_000], + ["Venezuela", "VE", 28_840_000], + ["Lebanon", "LB", 5_490_000], + ["Belarus", "BY", 9_500_000], + ["Afghanistan", "AF", 42_240_000], + ["United Kingdom", "GB", 67_960_000], + ["France", "FR", 68_170_000], + ["Germany", "DE", 84_480_000], + ["Japan", "JP", 124_520_000], + ["South Korea", "KR", 51_780_000], + ["Canada", "CA", 40_100_000], + ["Australia", "AU", 26_640_000], + ["Singapore", "SG", 5_920_000], + ]; + + for (const [name, code, population] of otherCountries) { + await upsertJurisdiction({ + name, + type: JurisdictionType.COUNTRY, + code, + population, + }); + } + + console.log(` ✅ ${otherCountries.length} additional countries`); + return us.id; +} + +// ============================================================================ +// D2) CONFLICTS — Active and recent armed conflicts for memorial attribution +// ============================================================================ + +async function seedConflicts() { + console.log("⚔️ Seeding active/recent conflicts..."); + + // Lookup helper + async function jurisdictionIdForCode(code: string): Promise<string | null> { + const row = await prisma.jurisdiction.findUnique({ + where: { code }, + select: { id: true }, + }); + return row?.id ?? null; + } + + const conflicts: Array<{ + slug: string; + name: string; + description?: string; + startDate?: Date; + endDate?: Date; + primaryJurisdictionCode?: string; + sourceUrl?: string; + }> = [ + { + slug: "gaza-2023", + name: "Gaza war (2023–present)", + description: + "Armed conflict in the Gaza Strip following the October 7, 2023 attacks; civilian casualties tracked by UN OCHA, WHO, and Gaza Ministry of Health.", + startDate: new Date("2023-10-07T00:00:00Z"), + primaryJurisdictionCode: "IL", + sourceUrl: "https://www.ochaopt.org/", + }, + { + slug: "ukraine-2022", + name: "Russia–Ukraine war (2022–present)", + description: + "Full-scale Russian invasion of Ukraine starting February 24, 2022. Civilian casualty data tracked by UN OHCHR HRMMU.", + startDate: new Date("2022-02-24T00:00:00Z"), + primaryJurisdictionCode: "UA", + sourceUrl: "https://ukraine.un.org/", + }, + { + slug: "yemen-civil-war", + name: "Yemen civil war (2014–present)", + description: + "Ongoing armed conflict involving Houthi forces, the Yemeni government, and the Saudi-led coalition.", + startDate: new Date("2014-09-21T00:00:00Z"), + primaryJurisdictionCode: "YE", + sourceUrl: "https://acleddata.com/yemen-conflict-observatory/", + }, + { + slug: "syria-civil-war", + name: "Syrian civil war (2011–present)", + description: + "Multi-sided conflict beginning with the 2011 uprising; UN OHCHR has documented hundreds of thousands of deaths.", + startDate: new Date("2011-03-15T00:00:00Z"), + primaryJurisdictionCode: "SY", + sourceUrl: "https://www.ohchr.org/en/countries/syria", + }, + { + slug: "sudan-2023", + name: "Sudan war (2023–present)", + description: + "Armed conflict between the Sudanese Armed Forces and the Rapid Support Forces beginning April 15, 2023.", + startDate: new Date("2023-04-15T00:00:00Z"), + primaryJurisdictionCode: "SD", + sourceUrl: "https://acleddata.com/sudan-conflict-observatory/", + }, + { + slug: "tigray-war", + name: "Tigray war (2020–2022)", + description: + "Armed conflict in Tigray Region of Ethiopia involving Ethiopian and Eritrean forces and the Tigray People's Liberation Front.", + startDate: new Date("2020-11-04T00:00:00Z"), + endDate: new Date("2022-11-03T00:00:00Z"), + primaryJurisdictionCode: "ET", + sourceUrl: "https://www.ohchr.org/en/countries/ethiopia", + }, + { + slug: "myanmar-civil-war", + name: "Myanmar civil war (2021–present)", + description: + "Armed resistance to the February 2021 military coup, including ethnic armed organizations and the People's Defence Force.", + startDate: new Date("2021-02-01T00:00:00Z"), + primaryJurisdictionCode: "MM", + sourceUrl: "https://acleddata.com/myanmar-conflict-observatory/", + }, + { + slug: "afghanistan-2001", + name: "War in Afghanistan (2001–2021)", + description: + "Multi-phase armed conflict beginning with the U.S.-led invasion in October 2001 through the Taliban takeover in August 2021.", + startDate: new Date("2001-10-07T00:00:00Z"), + endDate: new Date("2021-08-30T00:00:00Z"), + primaryJurisdictionCode: "AF", + sourceUrl: "https://watson.brown.edu/costsofwar/", + }, + { + slug: "iraq-2003", + name: "Iraq war (2003–2011)", + description: + "U.S.-led invasion of Iraq and subsequent multi-sided conflict; Iraq Body Count and Lancet studies document civilian death toll.", + startDate: new Date("2003-03-20T00:00:00Z"), + endDate: new Date("2011-12-18T00:00:00Z"), + primaryJurisdictionCode: "US", + sourceUrl: "https://www.iraqbodycount.org/", + }, + { + slug: "other", + name: "Other / not listed", + description: + "Generic placeholder for conflicts not yet seeded. Use 'circumstances' to describe the specific conflict.", + }, + ]; + + for (const c of conflicts) { + const primaryJurisdictionId = c.primaryJurisdictionCode + ? await jurisdictionIdForCode(c.primaryJurisdictionCode) + : null; + + await prisma.conflict.upsert({ + where: { slug: c.slug }, + update: { + name: c.name, + description: c.description ?? null, + startDate: c.startDate ?? null, + endDate: c.endDate ?? null, + primaryJurisdictionId, + sourceUrl: c.sourceUrl ?? null, + }, + create: { + slug: c.slug, + name: c.name, + description: c.description ?? null, + startDate: c.startDate ?? null, + endDate: c.endDate ?? null, + primaryJurisdictionId, + sourceUrl: c.sourceUrl ?? null, + }, + }); + } + + console.log(` ✅ ${conflicts.length} conflicts (${conflicts.length - 1} named + 'other' fallback)`); +} + +// ============================================================================ +// D3) DRUG/INTERVENTION APPROVAL TIMELINES — for the efficacy-lag matcher +// ============================================================================ + +async function seedDrugApprovalTimelines() { + console.log("⏳ Seeding intervention approval timelines (efficacy-lag heavy hitters)..."); + + // Lookup helpers + async function jurisdictionIdForCode(code: string): Promise<string | null> { + const row = await prisma.jurisdiction.findUnique({ + where: { code }, + select: { id: true }, + }); + return row?.id ?? null; + } + async function globalVariableIdByName(name: string): Promise<string | null> { + const row = await prisma.globalVariable.findFirst({ + where: { + deletedAt: null, + OR: [ + { name: { equals: name, mode: "insensitive" } }, + { synonyms: { contains: name, mode: "insensitive" } }, + ], + }, + select: { id: true }, + }); + return row?.id ?? null; + } + + const usJurisdictionId = await jurisdictionIdForCode("US"); + const ONE_DAY = 1000 * 60 * 60 * 24; + + // PRD TODO.md:1270–1278. Dates are best-known approximations (academic consensus + // for first efficacy evidence, FDA approval dates). Lives-saved-per-year and + // deaths-during-lag are order-of-magnitude estimates from the literature cited + // alongside each entry — meant to anchor the matcher, not to be exact. + const timelines: Array<{ + interventionName: string; + brandName?: string; + conditionName: string; + regulatorName: string; + firstEvidenceDate: Date; + firstEvidenceDescription: string; + approvalDate: Date; + approvalDescription: string; + estimatedLivesSavedPerYear: number; + sourceUrl: string; + interventionLookupNames?: string[]; + conditionLookupNames?: string[]; + }> = [ + { + interventionName: "Beta-blockers (post-MI)", + conditionName: "Post-myocardial infarction (heart attack survival)", + regulatorName: "FDA", + firstEvidenceDate: new Date("1972-01-01T00:00:00Z"), + firstEvidenceDescription: + "Multicentre randomized trials (Norwegian Multicenter Study, BHAT) showed beta-blockers reduced post-MI mortality.", + approvalDate: new Date("1981-11-01T00:00:00Z"), + approvalDescription: + "FDA approved propranolol for post-MI mortality reduction in November 1981 following BHAT results.", + estimatedLivesSavedPerYear: 11_000, + sourceUrl: "https://www.bmj.com/content/318/7200/1730", + interventionLookupNames: ["propranolol", "beta blocker", "metoprolol"], + conditionLookupNames: ["myocardial infarction", "heart attack"], + }, + { + interventionName: "Dexamethasone (severe COVID-19)", + conditionName: "COVID-19 (severe, requiring oxygen)", + regulatorName: "FDA / NIH", + firstEvidenceDate: new Date("2020-06-16T00:00:00Z"), + firstEvidenceDescription: + "RECOVERY trial preprint released June 16, 2020 showed dexamethasone cut deaths in ventilated COVID-19 patients by ~⅓.", + approvalDate: new Date("2020-09-02T00:00:00Z"), + approvalDescription: + "NIH treatment guidelines updated; widespread clinical adoption followed RECOVERY publication. Dexamethasone was already an approved generic.", + estimatedLivesSavedPerYear: 100_000, + sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJMoa2021436", + interventionLookupNames: ["dexamethasone"], + conditionLookupNames: ["covid-19", "covid"], + }, + { + interventionName: "Imatinib (Gleevec) for CML", + brandName: "Gleevec", + conditionName: "Chronic myeloid leukemia (CML)", + regulatorName: "FDA", + firstEvidenceDate: new Date("1998-06-01T00:00:00Z"), + firstEvidenceDescription: + "Phase I trial (Druker et al.) showed dramatic hematologic remission in chronic-phase CML.", + approvalDate: new Date("2001-05-10T00:00:00Z"), + approvalDescription: + "FDA accelerated approval for chronic-phase CML granted May 10, 2001.", + estimatedLivesSavedPerYear: 4_000, + sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJM200104053441401", + interventionLookupNames: ["imatinib", "gleevec"], + conditionLookupNames: ["chronic myeloid leukemia", "cml"], + }, + { + interventionName: "Interleukin-2 (renal cell carcinoma)", + conditionName: "Metastatic renal cell carcinoma", + regulatorName: "FDA", + firstEvidenceDate: new Date("1989-01-01T00:00:00Z"), + firstEvidenceDescription: + "Rosenberg et al. and parallel European trials demonstrated durable remissions; available in nine EU countries.", + approvalDate: new Date("1992-05-05T00:00:00Z"), + approvalDescription: + "FDA approved high-dose IL-2 (aldesleukin) for metastatic renal cell carcinoma in May 1992.", + estimatedLivesSavedPerYear: 800, + sourceUrl: "https://pubmed.ncbi.nlm.nih.gov/3309687/", + interventionLookupNames: ["interleukin-2", "il-2", "aldesleukin"], + conditionLookupNames: ["renal cell carcinoma", "kidney cancer"], + }, + { + interventionName: "ACE inhibitors (heart failure)", + conditionName: "Congestive heart failure", + regulatorName: "FDA", + firstEvidenceDate: new Date("1986-06-01T00:00:00Z"), + firstEvidenceDescription: + "CONSENSUS trial showed enalapril reduced mortality in severe heart failure.", + approvalDate: new Date("1991-04-01T00:00:00Z"), + approvalDescription: + "FDA expanded indication for enalapril to include all symptomatic heart failure.", + estimatedLivesSavedPerYear: 30_000, + sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJM198706043162301", + interventionLookupNames: ["enalapril", "lisinopril", "ace inhibitor"], + conditionLookupNames: ["heart failure", "congestive heart failure"], + }, + { + interventionName: "Combination antiretroviral therapy (HIV/AIDS)", + conditionName: "HIV/AIDS", + regulatorName: "FDA", + firstEvidenceDate: new Date("1987-03-19T00:00:00Z"), + firstEvidenceDescription: + "Zidovudine (AZT) approved 1987; protease inhibitors entered trials early 1990s, dramatically extending survival when combined.", + approvalDate: new Date("1996-03-01T00:00:00Z"), + approvalDescription: + "FDA approval of saquinavir (Dec 1995) and ritonavir (Mar 1996) made highly active combination ART available.", + estimatedLivesSavedPerYear: 200_000, + sourceUrl: "https://www.nejm.org/doi/full/10.1056/NEJM199703273361301", + interventionLookupNames: ["antiretroviral", "haart", "art", "azt", "zidovudine"], + conditionLookupNames: ["hiv", "aids", "hiv/aids"], + }, + { + interventionName: "Statins (cardiovascular prevention)", + conditionName: "Cardiovascular disease (atherosclerotic)", + regulatorName: "FDA", + firstEvidenceDate: new Date("1987-09-01T00:00:00Z"), + firstEvidenceDescription: + "Lovastatin LDL trials demonstrated dramatic cholesterol reduction; later 4S trial (1994) showed mortality benefit.", + approvalDate: new Date("1994-11-19T00:00:00Z"), + approvalDescription: + "Scandinavian Simvastatin Survival Study (4S) published Nov 1994 established statin mortality benefit; broad clinical adoption followed.", + estimatedLivesSavedPerYear: 50_000, + sourceUrl: "https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(94)90566-5/", + interventionLookupNames: ["statin", "simvastatin", "atorvastatin", "lovastatin"], + conditionLookupNames: ["cardiovascular disease", "atherosclerosis", "coronary"], + }, + ]; + + let count = 0; + for (const t of timelines) { + const efficacyLagDays = Math.round( + (t.approvalDate.getTime() - t.firstEvidenceDate.getTime()) / ONE_DAY, + ); + const estimatedDeathsDuringLag = + (efficacyLagDays / 365) * t.estimatedLivesSavedPerYear; + + let interventionGlobalVariableId: string | null = null; + for (const name of t.interventionLookupNames ?? [t.interventionName]) { + interventionGlobalVariableId = await globalVariableIdByName(name); + if (interventionGlobalVariableId) break; + } + let conditionGlobalVariableId: string | null = null; + for (const name of t.conditionLookupNames ?? [t.conditionName]) { + conditionGlobalVariableId = await globalVariableIdByName(name); + if (conditionGlobalVariableId) break; + } + + // Stable id so re-running the seed updates instead of duplicating. + const id = `intervention-approval-${t.interventionName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80)}`; + + await prisma.interventionApprovalTimeline.upsert({ + where: { id }, + update: { + interventionName: t.interventionName, + brandName: t.brandName ?? null, + conditionName: t.conditionName, + interventionGlobalVariableId, + conditionGlobalVariableId, + jurisdictionId: usJurisdictionId, + regulatorName: t.regulatorName, + firstEvidenceDate: t.firstEvidenceDate, + firstEvidenceDescription: t.firstEvidenceDescription, + approvalDate: t.approvalDate, + approvalDescription: t.approvalDescription, + efficacyLagDays, + estimatedLivesSavedPerYear: t.estimatedLivesSavedPerYear, + estimatedDeathsDuringLag, + sourceUrl: t.sourceUrl, + deletedAt: null, + }, + create: { + id, + interventionName: t.interventionName, + brandName: t.brandName ?? null, + conditionName: t.conditionName, + interventionGlobalVariableId, + conditionGlobalVariableId, + jurisdictionId: usJurisdictionId, + regulatorName: t.regulatorName, + firstEvidenceDate: t.firstEvidenceDate, + firstEvidenceDescription: t.firstEvidenceDescription, + approvalDate: t.approvalDate, + approvalDescription: t.approvalDescription, + efficacyLagDays, + estimatedLivesSavedPerYear: t.estimatedLivesSavedPerYear, + estimatedDeathsDuringLag, + sourceUrl: t.sourceUrl, + }, + }); + count++; + } + + console.log(` ✅ ${count} approval timelines seeded`); +} + +// ============================================================================ +// E) ITEMS — Federal Budget Categories (FY2025 approximate) +// ============================================================================ + +async function seedWishocraticItems() { + console.log("💰 Seeding Optimitron budget categories..."); + + const jurisdiction = await prisma.jurisdiction.findUnique({ + where: { code: US_WISHOCRATIC_JURISDICTION.code }, + select: { id: true }, + }); + + if (!jurisdiction) { + throw new Error( + `Cannot seed Wishocratic items before jurisdiction ${US_WISHOCRATIC_JURISDICTION.code} exists.`, + ); + } + + const catalogRecords = Object.values(getUSWishocraticCatalogRecords()); + + for (const record of catalogRecords) { + await prisma.wishocraticItem.upsert({ + where: { id: record.id }, + update: { + name: record.name, + description: record.description, + sourceUrl: record.sourceUrl, + currentAllocationUsd: record.currentAllocationUsd, + currentAllocationPct: record.currentAllocationPct, + active: true, + jurisdictionId: jurisdiction.id, + }, + create: { + id: record.id, + name: record.name, + description: record.description, + sourceUrl: record.sourceUrl, + currentAllocationUsd: record.currentAllocationUsd, + currentAllocationPct: record.currentAllocationPct, + active: true, + jurisdictionId: jurisdiction.id, + }, + }); + } + + console.log(` ✅ ${catalogRecords.length} Optimitron budget categories`); +} + +// ============================================================================ +// MAIN +// ============================================================================ + +export async function syncManagedReferenceData() { + const unitMap = await seedUnits(); + const catMap = await seedVariableCategories(unitMap); + await seedGlobalVariables(unitMap, catMap); + await seedMedicalReferenceData(unitMap, catMap); + await seedJurisdictions(); + await seedConflicts(); + await seedDrugApprovalTimelines(); + await seedWishocraticItems(); +} + +export async function syncManagedBootstrapData() { + await syncManagedReasoningData(prisma); +} + +// --------------------------------------------------------------------------- +// Treaty accountability data — impact estimates + dynamic grant/signer tasks +// --------------------------------------------------------------------------- + +// Due date is an absolute historical anchor — yesterday relative to when the +// dashboard was last reviewed — so the overdue clock keeps ticking up from a +// fixed point instead of resetting to "1 day" on every seed. +const TREATY_DUE_AT = new Date("2026-04-14T00:00:00.000Z"); +// Sentinel value representing −∞. Float64 supports Infinity but Postgres +// round-trips can be flaky, so we use a finite-but-absurd magnitude that the +// formatter detects via threshold (anything below −1e17 renders as "−∞"). +const TREATY_INFINITE_NEGATIVE_COST = -1e18; +const TREATY_NET_COST_USD = TREATY_INFINITE_NEGATIVE_COST; + +// 30 seconds per signature × 193 leaders = 5,790 seconds ≈ 1.61 hours. +const TREATY_SECONDS_PER_SIGNATURE = 30; +const TREATY_PER_SIGNER_EFFORT_HOURS = TREATY_SECONDS_PER_SIGNATURE / 3600; +const TREATY_SIGNER_CONTACT_TEMPLATE = [ + "Your employee has not finished {{taskTitle}}. It is a thirty-second task. One signature. A wrist movement.", + "It has been sitting on a desk for {{delayLabel}}. A desk. Not a war. A desk.", + "Delay body count so far: {{humanLives}} humans have permanently stopped, {{sufferingHours}} hours of suffering accumulated, {{economicLoss}} evaporated. While the paperwork waited.", + "The pen is here: {{taskUrl}}", +].join(" "); + +export async function syncManagedTreatyAccountabilityData() { + console.log("📋 Syncing treaty accountability data..."); + + // Load the leader photo manifest from public/images/leaders/manifest.json. + // Generated by `tsx packages/web/scripts/download-leader-photos.ts`. + // Maps lowercase ISO2 country code → local image path. If a leader has no + // entry we fall back to the remote Wikimedia URL (OG rendering may break + // for those tasks, but the detail page will still load the image). + let leaderPhotoManifest: Record<string, string> = {}; + try { + const manifestPath = new URL( + "../../../web/public/images/leaders/manifest.json", + import.meta.url, + ); + const { readFileSync: readFile } = await import("node:fs"); + const raw = readFile(manifestPath, "utf8"); + leaderPhotoManifest = JSON.parse(raw) as Record<string, string>; + console.log( + ` 📸 Loaded ${Object.keys(leaderPhotoManifest).length} local leader photos from manifest`, + ); + } catch (err) { + console.log( + ` ⚠️ No leader photo manifest found — using remote Wikimedia URLs (run 'tsx scripts/download-leader-photos.ts' to cache locally). Error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Clean up ghost signer tasks from the legacy parallel pipeline + // (sync-treaty-signers.ts / treaty-signer-network.ts) that used ISO3 ids and + // fell back to "Head of government of {country}" for displayName when a real + // leader name was missing. This sync uses ISO2 ids and curated leader names. + // Delete the junk set so the list page renders the real leaders. + const deletedGhostTasks = await prisma.task.deleteMany({ + where: { + taskKey: { startsWith: "program:one-percent-treaty:signer:" }, + }, + }); + if (deletedGhostTasks.count > 0) { + console.log(` 🧹 Cleared ${deletedGhostTasks.count} existing signer tasks for clean reseed`); + } + + // Neutralize any Person records whose displayName is the junk fallback. + // Can't delete them directly (may be referenced by other tables) — just + // rename so they stop rendering on list pages as the task's assignee. + const renamedGhostPersons = await prisma.person.updateMany({ + where: { displayName: { startsWith: "Head of government of " } }, + data: { displayName: "Unknown Head of Government" }, + }); + if (renamedGhostPersons.count > 0) { + console.log(` 🧹 Neutralized ${renamedGhostPersons.count} ghost leader person records`); + } + + // Create "Humanity" organization as assignee for top-level tasks + const humanityOrgData = { + name: "Humanity", + slug: "humanity", + type: "OTHER", + status: "APPROVED", + description: "All 8 billion of us.", + } satisfies Prisma.OrganizationUncheckedCreateInput; + + await prisma.organization.upsert({ + where: { slug: "humanity" }, + update: humanityOrgData, + create: humanityOrgData, + }); + + // Seed Wishonia as a regular User + Person so she can author task comments, + // claim tasks, show up on /people/wishonia, etc. No special system-user flag. + await seedWishoniaUser(); + + // Lifetime impact from parameters (total civilizational acceleration, not annual). + // Canonical task rows live in optimize-earth-task-tree.ts; this function only + // owns dynamic accountability children and impact estimates that hang off + // those canonical task ids. + const totalDalys = DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_DALYS.value; // 565B DALYs + const totalEconValue = DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_ECONOMIC_VALUE.value; // $84.8Q + const accelerationYears = DFDA_TRIAL_CAPACITY_PLUS_EFFICACY_LAG_YEARS.value; // 212 years + const annualAvoidableDalys = GLOBAL_ANNUAL_DALY_BURDEN.value * EVENTUALLY_AVOIDABLE_DALY_PCT.value; // 2.67B/yr + const delayDalysPerDay = annualAvoidableDalys / 365; + const delayEconPerDay = delayDalysPerDay * 150_000; // $150K/QALY standard valuation + const { hale } = earthOptimizationPrizeWinCondition; + + await syncTaskImpactEstimate({ + taskId: OPTIMIZE_EARTH_ROOT_TASK_ID, + impact: { + estimatedCashCostUsdBase: 0, + expectedEconomicValueUsdBase: totalEconValue, + expectedDalysAvertedBase: totalDalys, + delayEconomicValueUsdLostPerDayBase: delayEconPerDay, + delayDalysLostPerDayBase: delayDalysPerDay, + successProbabilityBase: 0.05, + benefitDurationYears: accelerationYears, + medianHealthyLifeYearsEffectBase: hale.deltaRequired, + medianIncomeGrowthEffectPpPerYearBase: + EARTH_OPTIMIZATION_PRIZE_INCOME_GROWTH_EFFECT_PP_PER_YEAR, + }, + methodologyKey: "earth-optimization-prize-win-condition", + calculationsUrl: earthOptimizationPrizeWinCondition.manualUrl, + }); + + await syncTaskImpactEstimate({ + taskId: TREATY_PARENT_TASK_ID, + impact: { + estimatedCashCostUsdBase: TREATY_NET_COST_USD, + expectedEconomicValueUsdBase: totalEconValue, + expectedDalysAvertedBase: totalDalys, + delayEconomicValueUsdLostPerDayBase: delayEconPerDay, + delayDalysLostPerDayBase: delayDalysPerDay, + successProbabilityBase: 0.01, + benefitDurationYears: accelerationYears, + }, + methodologyKey: "treaty-lifetime-parameters", + calculationsUrl: "https://manual.WarOnDisease.org/knowledge/economics/1-pct-treaty-impact.html", + }); + + console.log(" ✓ Canonical task impact estimates"); + + // --- Foundation grant accountability tasks --- + // Same public-accountability pattern as the head-of-state treaty tasks: + // name the institution, assign the tiny concrete action, mark it overdue. + const ICEWAD_GRANT_DALYS_PER_USD = 564.972; + const ICEWAD_GRANT_ECON_VALUE_PER_USD = ICEWAD_GRANT_DALYS_PER_USD * 150_000; + const foundationGrantOrganizations = [ + { + name: "Survival and Flourishing Fund", + website: "https://survivalandflourishing.fund", + }, + { + name: "Open Philanthropy", + website: "https://www.openphilanthropy.org", + }, + { + name: "Gates Foundation", + website: "https://www.gatesfoundation.org", + }, + { + name: "Filecoin Foundation", + website: "https://fil.org", + }, + { + name: "Arnold Ventures", + website: "https://www.arnoldventures.org", + }, + { + name: "Wellcome Trust", + website: "https://wellcome.org", + }, + { + name: "Patrick J. McGovern Foundation", + website: "https://www.mcgovern.org", + }, + { + name: "Schmidt Futures", + website: "https://www.schmidtfutures.com", + }, + { + name: "Skoll Foundation", + website: "https://skoll.org", + }, + { + name: "Omidyar Network", + website: "https://omidyar.com", + }, + ] as const; + + for (const [index, foundation] of foundationGrantOrganizations.entries()) { + const slug = slugify(foundation.name); + const organizationData = { + name: foundation.name, + slug, + type: "FOUNDATION", + status: "APPROVED", + website: foundation.website, + description: + "Foundation or grantmaker assigned a public $1 grant task for the International Campaign to End War and Disease.", + } satisfies Prisma.OrganizationUncheckedCreateInput; + + const organization = await prisma.organization.upsert({ + where: { slug }, + update: organizationData, + create: organizationData, + }); + + await createTaskWithImpact({ + task: { + id: `icewad-grant-${slug}`, + taskKey: `icewad:grant:${slug}`, + parentTaskId: TREATY_PARENT_TASK_ID, + assigneeOrganizationId: organization.id, + title: "Fund the International Campaign to End War and Disease", + description: [ + `${foundation.name} has the opportunity to fund the highest expected-value charitable intervention ever calculated.`, + "", + "The International Campaign to End War and Disease prevents one disability-adjusted life year (DALY) for $0.00177. That is 50,300 times more cost-effective than insecticide-treated bednets, the current gold standard in global health philanthropy.", + "", + "**Suggested grant: $1.**", + "", + "At our cost-effectiveness ratio, $1 prevents approximately 565 DALYs, which is roughly 16 healthy life-years. If you would like to prevent more healthy life-years, you may increase the amount.", + "", + "At $100, you prevent 56,497 DALYs (1,614 healthy life-years).", + "At $1,000, you prevent 564,972 DALYs (16,142 healthy life-years).", + "At $100,000, you save approximately 3,200 lives.", + "", + "These are not projections. They are the output of a cost-benefit model with 670 parameters, Monte Carlo simulation, and complete derivation chains. The model, methodology, and every input parameter are published with 95% confidence intervals at manual.warondisease.org.", + "", + "We understand this sounds implausible. We have checked the math. The math does not care whether it sounds implausible.", + "", + "[Donate ->](https://warondisease.org/donate)", + "", + "[Read the full analysis ->](https://manual.warondisease.org/knowledge/economics/1-pct-treaty-impact.html)", + "", + "[Read the treaty ->](https://manual.warondisease.org/knowledge/solution/1-percent-treaty.html)", + ].join("\n"), + category: "GOVERNANCE", + difficulty: "TRIVIAL", + status: "ACTIVE", + isPublic: true, + dueAt: TREATY_DUE_AT, + sortOrder: -75 + index, + claimPolicy: "ASSIGNED_ONLY", + skillTags: ["grantmaking", "global-health", "fundraising"], + interestTags: ["icewad", "one-percent-treaty", "foundation", "grant"], + estimatedEffortHours: TREATY_PER_SIGNER_EFFORT_HOURS, + }, + primaryEndpoint: { + label: "Donate", + url: "https://warondisease.org/donate", + instructions: + "Please complete {{taskTitle}} with a $1 grant or a larger one if the math survives contact with your grants committee. Start here: {{taskUrl}}", + }, + impact: { + estimatedCashCostUsdBase: 1, + expectedEconomicValueUsdBase: ICEWAD_GRANT_ECON_VALUE_PER_USD, + expectedDalysAvertedBase: ICEWAD_GRANT_DALYS_PER_USD, + delayEconomicValueUsdLostPerDayBase: ICEWAD_GRANT_ECON_VALUE_PER_USD / 365, + delayDalysLostPerDayBase: ICEWAD_GRANT_DALYS_PER_USD / 365, + successProbabilityBase: 0.25, + benefitDurationYears: 1, + }, + methodologyKey: "icewad-one-dollar-grant", + parameterSetHashSuffix: slug, + calculationsUrl: "https://manual.warondisease.org/knowledge/economics/1-pct-treaty-impact.html", + }); + } + + console.log(` ✓ ${foundationGrantOrganizations.length} foundation grant tasks`); + + // --- Signer child tasks for the treaty --- + // Single source of truth: GovernmentLeaderRecord bundles country identity, + // canonical office/contact metadata, leader personal data, and both + // military + total-gov budgets (resolved from the coalesced country panel + // + curated overrides; guaranteed non-null). + const leaderRecords = listGovernmentLeaders().filter( + (record) => record.leaderSourceRef != null && record.leaderName != null, + ); + const skippedLeaderCount = listGovernmentLeaders().length - leaderRecords.length; + if (skippedLeaderCount > 0) { + console.log( + ` ! skipping ${skippedLeaderCount} leader record(s) missing leaderSourceRef/leaderName`, + ); + } + const leaderCount = leaderRecords.length; + const formatUsdCompact = (n: number): string => { + if (n >= 1e12) return `$${(n / 1e12).toFixed(2)}T`; + if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; + if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`; + return `$${Math.round(n).toLocaleString()}`; + }; + const googleSearch = (query: string) => + `https://www.google.com/search?q=${encodeURIComponent(query)}`; + let created = 0; + + for (const record of leaderRecords) { + // leaderSourceRef/leaderName are non-null by the filter above. + const sourceRef = record.leaderSourceRef!; + const leaderName = record.leaderName!; + const countryCode = record.countryCode.toUpperCase(); + const share = 1 / leaderCount; + // Slugified handle from displayName, with country code suffix to guarantee + // uniqueness across the 193 leaders. Lower-case, hyphen-separated, ASCII. + const handle = `${slugify(leaderName)}-${countryCode.toLowerCase()}`; + const leaderImage = + leaderPhotoManifest[countryCode.toLowerCase()] ?? record.leaderImageUrl; + + const person = await prisma.person.upsert({ + where: { sourceRef }, + update: { + handle, + displayName: leaderName, + image: leaderImage, + countryCode, + currentAffiliation: `Government of ${record.countryName}`, + isPublicFigure: true, + }, + create: { + handle, + displayName: leaderName, + image: leaderImage, + countryCode, + currentAffiliation: `Government of ${record.countryName}`, + isPublicFigure: true, + sourceRef, + }, + }); + + const annualRedirectUsd = record.militaryBudgetUsd * 0.01; + + const contactChannels: Array<{ kind: "twitter" | "bluesky" | "form"; label: string; href: string }> = [ + { + kind: "twitter", + label: `Remind on X directly`, + href: googleSearch(`${leaderName} official X Twitter account`), + }, + { + kind: "bluesky", + label: `Remind on Bluesky directly`, + href: googleSearch(`${leaderName} Bluesky account site:bsky.app`), + }, + ]; + if (record.contactUrl) { + contactChannels.push({ + kind: "form", + label: record.contactLabel ?? "Official contact form", + href: record.contactUrl, + }); + } + + const signerContextJson: Prisma.InputJsonValue = { + assigneeProfile: { + role: record.roleTitle, + employerLabel: `Government of ${record.countryName}`, + employerCountLabel: "citizens", + budgetUsdPerYear: record.militaryBudgetUsd, + budgetLabel: "Military spending", + governmentBudgetUsdPerYear: record.governmentBudgetUsd, + jobQuote: { + text: "promote the general welfare", + source: `${record.countryName} — job description, every citizen, every day`, + }, + contactChannels, + }, + difficulty: { + whatItMeans: `Redirect 1% of ${record.countryName}'s military spending (${formatUsdCompact(annualRedirectUsd)}/yr) from weapons to pragmatic clinical trials.`, + label: "Sign a piece of paper", + timeRequiredSeconds: 30, + skillsRequired: "Holding a pen", + }, + reminder: { + intro: `30 seconds. Remind them.`, + messageTemplate: [ + `${leaderName} is {daysOverdue} days overdue on "Sign the 1% Treaty".`, + ``, + `Task: hold pen, sign paper. 30 seconds.`, + `Cost of the delay so far: {deathsLocked} preventable deaths, {moneyDestroyed} in foregone clinical trials.`, + `Job description: "promote the general welfare."`, + ``, + `{taskUrl}`, + ].join("\n"), + }, + contextComparisons: [ + { + heading: "Things that take longer than 30 seconds", + items: [ + { label: "Making toast", value: "120 seconds" }, + { label: "Developing the COVID vaccine", value: "314 days" }, + { label: "The Manhattan Project", value: "1,347 days" }, + { + label: `${leaderName} not signing this`, + value: "{daysOverdue} days", + highlight: true, + }, + ], + }, + { + heading: "Things that take 30 seconds", + items: [ + { label: "Signing the 1% Treaty", value: "30s" }, + { label: "Tying a shoe", value: "30s" }, + { label: "Sending a tweet", value: "30s" }, + ], + }, + ], + }; + + await createTaskWithImpact({ + task: { + id: `1-pct-treaty-signer-${countryCode.toLowerCase()}`, + taskKey: `program:one-percent-treaty:signer:${countryCode.toLowerCase()}`, + parentTaskId: TREATY_PARENT_TASK_ID, + assigneePersonId: person.id, + assigneeAffiliationSnapshot: `Government of ${record.countryName}`, + roleTitle: record.roleTitle, + title: "Sign the 1% Treaty", + description: `**${leaderName}** — ${record.roleTitle} of ${record.countryName}. One job: redirect 1% of ${record.countryName}'s military spending into pragmatic clinical trials. Overdue.`, + category: "GOVERNANCE", + difficulty: "EXPERT", + status: "ACTIVE", + isPublic: true, + dueAt: TREATY_DUE_AT, + claimPolicy: "ASSIGNED_ONLY", + skillTags: ["diplomacy", "public-pressure"], + interestTags: ["treaty", "disease-eradication", `country-${countryCode.toLowerCase()}`], + estimatedEffortHours: TREATY_PER_SIGNER_EFFORT_HOURS, + contextJson: signerContextJson, + }, + primaryEndpoint: { + label: record.contactLabel ?? "Find official contact", + url: + record.contactUrl ?? + googleSearch(`${leaderName} ${record.roleTitle} ${record.countryName} official contact`), + instructions: TREATY_SIGNER_CONTACT_TEMPLATE, + }, + impact: { + // Cost stays at the −∞ sentinel for every signer — splitting infinity + // is still infinity. + estimatedCashCostUsdBase: TREATY_NET_COST_USD, + expectedEconomicValueUsdBase: totalEconValue * share, + expectedDalysAvertedBase: totalDalys * share, + delayEconomicValueUsdLostPerDayBase: delayEconPerDay * share, + delayDalysLostPerDayBase: delayDalysPerDay * share, + successProbabilityBase: 0.01, + benefitDurationYears: accelerationYears, + }, + methodologyKey: "treaty-per-country-lifetime", + parameterSetHashSuffix: countryCode, + }); + + created += 1; + } + + console.log(` ✓ ${created} signer tasks with leader photos`); +} + +let cachedSeedWishoniaUserId: string | null = null; + +/** + * Seed Wishonia as a regular user with a linked Person record. This lets her: + * - Author task comments under her own user ID (no fake system-user hack) + * - Be assigned tasks via `assigneePersonId` + * - Create tasks via `createdByUserId` + * - Show up on /people/wishonia exactly like any public figure + * + * Idempotent. Safe to re-run. + */ +async function seedWishoniaUser() { + console.log("🛸 Seeding Wishonia user..."); + + const { person, user } = await upsertWishoniaUser(prisma); + + console.log(` ✓ Wishonia user (${user.id}) + person (${person.id}) handle=${person.handle}`); + cachedSeedWishoniaUserId = user.id; + return { person, user }; +} + +// `seedGrandmaKayExample` was removed 2026-05-11 — Grandma Kay is now +// managed-data and is upserted by `pnpm db:sync:managed-data --apply`. +// See `packages/db/src/managed-data/managed-grandma-kay.ts`. + +/** + * Helper: upsert a task + impact estimate set + LIFETIME frame. + * Idempotent — safe to re-run after changing description/impact values without + * losing existing claims, edges, or other task state. + * Requires `task.id` to be set for upsert-by-id behavior. + */ +async function createTaskWithImpact(input: { + task: Omit<Prisma.TaskUncheckedCreateInput, "createdByUserId"> & { + createdByUserId?: string | null; + id: string; + }; + primaryEndpoint?: { + email?: string | null; + instructions?: string | null; + label?: string | null; + sourceUrl?: string | null; + url?: string | null; + } | null; + impact: { + estimatedCashCostUsdBase: number; + expectedEconomicValueUsdBase: number; + expectedDalysAvertedBase: number; + delayEconomicValueUsdLostPerDayBase: number; + delayDalysLostPerDayBase: number; + successProbabilityBase: number; + benefitDurationYears: number; + medianHealthyLifeYearsEffectBase?: number; + medianIncomeGrowthEffectPpPerYearBase?: number; + }; + methodologyKey: string; + parameterSetHashSuffix?: string; + calculationsUrl?: string; +}) { + const { + id: taskId, + ...taskData + } = input.task; + + const { + createdByUserId, + ...taskScalars + } = taskData; + const explicitCreatedByUserId = createdByUserId?.trim() || null; + const resolvedCreatedByUserId = + explicitCreatedByUserId || + cachedSeedWishoniaUserId || + (await seedWishoniaUser()).user.id; + + const createData: Prisma.TaskUncheckedCreateInput = { + id: taskId, + ...taskScalars, + createdByUserId: resolvedCreatedByUserId, + }; + const updateData: Prisma.TaskUncheckedUpdateInput = { ...taskScalars }; + if (explicitCreatedByUserId) { + updateData.createdByUserId = explicitCreatedByUserId; + } + + // Upsert the task itself + const task = await prisma.task.upsert({ + where: { id: taskId }, + create: createData, + update: updateData, + }); + + if (input.primaryEndpoint) { + await upsertSeedTaskCommunicationEndpoint(task.id, input.primaryEndpoint); + } + + await syncTaskImpactEstimate({ + taskId: task.id, + impact: input.impact, + methodologyKey: input.methodologyKey, + parameterSetHashSuffix: input.parameterSetHashSuffix, + calculationsUrl: input.calculationsUrl, + }); + + return task; +} + +async function syncTaskImpactEstimate(input: { + taskId: string; + impact: { + estimatedCashCostUsdBase: number; + expectedEconomicValueUsdBase: number; + expectedDalysAvertedBase: number; + delayEconomicValueUsdLostPerDayBase: number; + delayDalysLostPerDayBase: number; + successProbabilityBase: number; + benefitDurationYears: number; + medianHealthyLifeYearsEffectBase?: number; + medianIncomeGrowthEffectPpPerYearBase?: number; + }; + methodologyKey: string; + parameterSetHashSuffix?: string; + calculationsUrl?: string; +}) { + // Delete old impact estimate sets for this task (cascade deletes frames/metrics) + await prisma.taskImpactEstimateSet.deleteMany({ + where: { taskId: input.taskId }, + }); + + // Create fresh estimate set + const estimateSet = await prisma.taskImpactEstimateSet.create({ + data: { + taskId: input.taskId, + isCurrent: true, + estimateKind: "FORECAST", + publicationStatus: "PUBLISHED", + sourceSystem: "PARAMETER_CATALOG", + calculationVersion: "seed-v1", + methodologyKey: input.methodologyKey, + parameterSetHash: `seed${input.parameterSetHashSuffix ? `-${input.parameterSetHashSuffix}` : ""}`, + counterfactualKey: "status-quo", + assumptionsJson: input.calculationsUrl ? { calculationsUrl: input.calculationsUrl } : undefined, + }, + }); + + await prisma.task.update({ + where: { id: input.taskId }, + data: { currentImpactEstimateSetId: estimateSet.id }, + }); + + await prisma.taskImpactFrameEstimate.create({ + data: { + taskImpactEstimateSetId: estimateSet.id, + frameKey: "LIFETIME", + frameSlug: "lifetime", + evaluationHorizonYears: input.impact.benefitDurationYears, + timeToImpactStartDays: 365, + adoptionRampYears: 5, + benefitDurationYears: input.impact.benefitDurationYears, + annualDiscountRate: 0, + successProbabilityBase: input.impact.successProbabilityBase, + expectedEconomicValueUsdBase: input.impact.expectedEconomicValueUsdBase, + expectedDalysAvertedBase: input.impact.expectedDalysAvertedBase, + delayEconomicValueUsdLostPerDayBase: input.impact.delayEconomicValueUsdLostPerDayBase, + delayDalysLostPerDayBase: input.impact.delayDalysLostPerDayBase, + estimatedCashCostUsdBase: input.impact.estimatedCashCostUsdBase, + estimatedEffortHoursBase: 0.5, + medianHealthyLifeYearsEffectBase: input.impact.medianHealthyLifeYearsEffectBase, + medianIncomeGrowthEffectPpPerYearBase: input.impact.medianIncomeGrowthEffectPpPerYearBase, + }, + }); +} + +function inferSeedEndpointKind(input: { email: string | null; url: string | null }) { + if (input.url?.toLowerCase().startsWith("mailto:")) { + return TaskCommunicationEndpointKind.MAILTO; + } + + if (input.email) { + return TaskCommunicationEndpointKind.EMAIL; + } + + if (input.url) { + return TaskCommunicationEndpointKind.EXTERNAL_URL; + } + + return TaskCommunicationEndpointKind.MANUAL; +} + +async function upsertSeedTaskCommunicationEndpoint( + taskId: string, + input: { + instructions?: string | null; + label?: string | null; + url?: string | null; + }, +) { + const url = input.url?.trim() || null; + const label = input.label?.trim() || null; + const instructions = input.instructions?.trim() || null; + const email = + url?.toLowerCase().startsWith("mailto:") + ? url.slice("mailto:".length).split("?")[0]?.trim() || null + : null; + + if (!url && !label && !instructions) { + await prisma.taskCommunicationEndpoint.updateMany({ + where: { + deletedAt: null, + isPrimary: true, + taskId, + }, + data: { + deletedAt: new Date(), + isPrimary: false, + }, + }); + return null; + } + + const existing = await prisma.taskCommunicationEndpoint.findFirst({ + where: { + deletedAt: null, + isPrimary: true, + taskId, + }, + select: { id: true }, + }); + + const data = { + email, + instructions, + isPrimary: true, + kind: inferSeedEndpointKind({ email, url }), + label, + priority: 0, + url, + verificationStatus: TaskCommunicationEndpointVerificationStatus.UNVERIFIED, + }; + + if (existing) { + return prisma.taskCommunicationEndpoint.update({ + where: { id: existing.id }, + data, + }); + } + + return prisma.taskCommunicationEndpoint.create({ + data: { + ...data, + taskId, + }, + }); +} + +// --------------------------------------------------------------------------- +// Demo User — for hackathon judges and demo recordings +// --------------------------------------------------------------------------- +// Email: demo@thinkbynumbers.org Password: demo1234 + +// `seedDemoUser` was removed 2026-05-11 — demo user is now managed-data. +// See `packages/db/src/managed-data/managed-demo-user.ts`. The legacy +// email migration (demo@optimitron.org → demo@thinkbynumbers.org) was +// also dropped; it ran every seed for years and the legacy row has long +// been migrated. The raw-SQL fallback that lived in this function was a +// silent error-swallower (see feedback_dont_swallow_errors) and is gone. diff --git a/packages/db/src/managed-data/managed-task-triggers.test.ts b/packages/db/src/managed-data/managed-task-triggers.test.ts new file mode 100644 index 000000000..fca158789 --- /dev/null +++ b/packages/db/src/managed-data/managed-task-triggers.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { + TaskCategory, + TaskClaimPolicy, + TaskDifficulty, + type PrismaClient, +} from "../generated/prisma/client.js"; +import { + syncManagedTaskTriggers, + type ManagedTaskTriggerInput, +} from "./managed-task-triggers.js"; + +type Row = Record<string, unknown>; + +const baseTrigger: ManagedTaskTriggerInput = { + triggerKey: "managed:test", + eventName: "test.event", + enabled: true, + idempotencyKeyTemplate: "managed:test:{{user.id}}", + notes: "Managed test trigger", + spawnSpecs: [ + { + kind: "root", + isParent: true, + titleTemplate: "Original title", + descriptionTemplate: "Original body", + category: "OTHER", + difficulty: "TRIVIAL", + claimPolicy: "ASSIGNED_ONLY", + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "none", + }, + ], +}; + +class FakeTaskTriggerClient { + taskTriggers: Row[] = []; + spawnSpecs: Row[] = []; + communicationSpawnSpecs: Row[] = []; + + taskTrigger = { + findMany: async (args: { + where: { triggerKey: { in: string[] } }; + }) => { + const keys = new Set(args.where.triggerKey.in); + return this.taskTriggers + .filter((row) => keys.has(String(row["triggerKey"]))) + .map((row) => ({ + ...row, + spawnSpecs: this.spawnSpecs.filter( + (spec) => spec["triggerId"] === row["id"], + ), + communicationSpawnSpecs: this.communicationSpawnSpecs.filter( + (spec) => spec["triggerId"] === row["id"], + ), + })); + }, + create: async (args: { data: Row }) => { + const id = `trigger-${this.taskTriggers.length + 1}`; + const { spawnSpecs, communicationSpawnSpecs, ...data } = args.data; + const row = { id, ...data }; + this.taskTriggers.push(row); + for (const spec of getNestedCreates(spawnSpecs)) { + this.spawnSpecs.push({ + id: `spawn-${this.spawnSpecs.length + 1}`, + triggerId: id, + ...spec, + }); + } + for (const spec of getNestedCreates(communicationSpawnSpecs)) { + this.communicationSpawnSpecs.push({ + id: `comm-${this.communicationSpawnSpecs.length + 1}`, + triggerId: id, + ...spec, + }); + } + return { + ...row, + spawnSpecs: this.spawnSpecs.filter((spec) => spec["triggerId"] === id), + communicationSpawnSpecs: this.communicationSpawnSpecs.filter( + (spec) => spec["triggerId"] === id, + ), + }; + }, + update: async (args: { data: Row; where: { triggerKey: string } }) => { + const row = this.taskTriggers.find( + (candidate) => candidate["triggerKey"] === args.where.triggerKey, + ); + if (!row) throw new Error(`Missing trigger ${args.where.triggerKey}`); + Object.assign(row, args.data); + return row; + }, + }; + + taskSpawnSpec = { + deleteMany: async (args: { where: { triggerId: string } }) => { + const before = this.spawnSpecs.length; + this.spawnSpecs = this.spawnSpecs.filter( + (spec) => spec["triggerId"] !== args.where.triggerId, + ); + return { count: before - this.spawnSpecs.length }; + }, + createMany: async (args: { data: Row[] }) => { + for (const spec of args.data) { + this.spawnSpecs.push({ + id: `spawn-${this.spawnSpecs.length + 1}`, + ...spec, + }); + } + return { count: args.data.length }; + }, + }; + + taskCommunicationSpawnSpec = { + deleteMany: async (args: { where: { triggerId: string } }) => { + const before = this.communicationSpawnSpecs.length; + this.communicationSpawnSpecs = this.communicationSpawnSpecs.filter( + (spec) => spec["triggerId"] !== args.where.triggerId, + ); + return { count: before - this.communicationSpawnSpecs.length }; + }, + createMany: async (args: { data: Row[] }) => { + for (const spec of args.data) { + this.communicationSpawnSpecs.push({ + id: `comm-${this.communicationSpawnSpecs.length + 1}`, + ...spec, + }); + } + return { count: args.data.length }; + }, + }; + + async $transaction<T>(callback: (client: this) => Promise<T>) { + return callback(this); + } +} + +function getNestedCreates(value: unknown): Row[] { + if ( + value && + typeof value === "object" && + "create" in value && + Array.isArray((value as { create?: unknown }).create) + ) { + return (value as { create: Row[] }).create; + } + return []; +} + +function syncWithFake( + client: FakeTaskTriggerClient, + input: { apply: boolean; records: ManagedTaskTriggerInput[]; now?: Date }, +) { + return syncManagedTaskTriggers(client as unknown as PrismaClient, input); +} + +describe("syncManagedTaskTriggers", () => { + it("reports creates in dry-run without writing", async () => { + const client = new FakeTaskTriggerClient(); + + await expect( + syncWithFake(client, { apply: false, records: [baseTrigger] }), + ).resolves.toMatchObject({ + mode: "dry-run", + created: ["managed:test"], + updated: [], + }); + + expect(client.taskTriggers).toHaveLength(0); + expect(client.spawnSpecs).toHaveLength(0); + }); + + it("creates and updates managed trigger rows by triggerKey", async () => { + const client = new FakeTaskTriggerClient(); + + await syncWithFake(client, { apply: true, records: [baseTrigger] }); + + expect(client.taskTriggers[0]).toMatchObject({ + triggerKey: "managed:test", + eventName: "test.event", + enabled: true, + deletedAt: null, + }); + expect(client.spawnSpecs[0]).toMatchObject({ + kind: "root", + titleTemplate: "Original title", + category: TaskCategory.OTHER, + difficulty: TaskDifficulty.TRIVIAL, + claimPolicy: TaskClaimPolicy.ASSIGNED_ONLY, + }); + + const updatedTrigger: ManagedTaskTriggerInput = { + ...baseTrigger, + notes: "Updated managed test trigger", + spawnSpecs: [ + { + ...baseTrigger.spawnSpecs![0]!, + titleTemplate: "Updated title", + }, + ], + }; + + await expect( + syncWithFake(client, { apply: true, records: [updatedTrigger] }), + ).resolves.toMatchObject({ + mode: "apply", + created: [], + updated: ["managed:test"], + }); + + expect(client.taskTriggers).toHaveLength(1); + expect(client.taskTriggers[0]).toMatchObject({ + notes: "Updated managed test trigger", + }); + expect(client.spawnSpecs).toHaveLength(1); + expect(client.spawnSpecs[0]).toMatchObject({ + titleTemplate: "Updated title", + }); + }); + + it("soft-disables explicitly retired triggers only", async () => { + const client = new FakeTaskTriggerClient(); + await syncWithFake(client, { apply: true, records: [baseTrigger] }); + const now = new Date("2026-05-12T12:00:00.000Z"); + + await expect( + syncWithFake(client, { + apply: true, + now, + records: [ + { + ...baseTrigger, + retired: true, + disabledReason: "No longer part of managed task triggers.", + }, + { + triggerKey: "managed:already-absent", + eventName: "test.event", + idempotencyKeyTemplate: "managed:already-absent", + retired: true, + }, + ], + }), + ).resolves.toMatchObject({ + retired: ["managed:test"], + missingRetired: ["managed:already-absent"], + }); + + expect(client.taskTriggers[0]).toMatchObject({ + enabled: false, + disabledReason: "No longer part of managed task triggers.", + deletedAt: now, + }); + }); +}); diff --git a/packages/db/src/managed-data/managed-task-triggers.ts b/packages/db/src/managed-data/managed-task-triggers.ts new file mode 100644 index 000000000..57621d98b --- /dev/null +++ b/packages/db/src/managed-data/managed-task-triggers.ts @@ -0,0 +1,945 @@ +import { HUMANITY_MANAGEMENT } from "@optimitron/data/campaign"; +import { + Prisma, + TaskCategory, + TaskClaimPolicy, + TaskDifficulty, + type PrismaClient, +} from "../generated/prisma/client.js"; +import { + REFERRAL_INVITATION_TASK_KEY_PREFIX, + SIGNER_REMINDER_TASK_KEY_PREFIX, + TREATY_PARENT_TASK_KEY, + TREATY_SIGNER_TASK_KEY_PREFIX, + USER_TREATY_TASK_KEY_PREFIX, +} from "../task-keys.js"; + +export interface ManagedTaskSpawnSpecInput { + kind: string; + isParent?: boolean; + sortOrder?: number; + titleTemplate: string; + descriptionTemplate: string; + impactStatementTemplate?: string; + roleTitleTemplate?: string; + category?: keyof typeof TaskCategory; + difficulty?: keyof typeof TaskDifficulty; + estimatedEffortHours?: number; + dueDays?: number; + availableInDays?: number; + deadlinePolicy?: "NONE" | "SOFT" | "EXPIRES" | "REQUIRED"; + claimPolicy?: keyof typeof TaskClaimPolicy; + isPublic?: boolean; + skillTagTemplates?: string[]; + interestTagTemplates?: string[]; + actionLinkUrlTemplate?: string; + actionLinkLabelTemplate?: string; + actionLinkInstructionsTemplate?: string; + creatorResolver?: string; + assigneePersonResolver?: string; + assigneeOrganizationResolver?: string; + parentResolver?: string; + contributesToGate?: boolean; + metadata?: unknown; +} + +export interface ManagedTaskCommunicationSpawnSpecInput { + kind: string; + sortOrder?: number; + subjectTemplate: string; + bodyTextTemplate: string; + bodyHtmlTemplate?: string; + commentTemplate?: string; + channel?: string; + audienceResolver?: string; + purpose?: string; + emailScope?: string; + dedupeKeyTemplate: string; + minHoursBetweenSends?: number; + maxSendsPerTask?: number; + minSendCount?: number; + maxSendCount?: number | null; + metadata?: unknown; +} + +export interface ManagedTaskTriggerInput { + triggerKey: string; + eventName: string; + triggerKind?: "spawnTasks" | "verifyTask" | "spawnCommunication"; + idempotencyKeyTemplate: string; + eventFilter?: unknown; + completionGate?: unknown; + jurisdictionId?: string; + notes?: string; + enabled?: boolean; + disabledReason?: string | null; + schedule?: string; + iterationSource?: string; + spawnSpecs?: ManagedTaskSpawnSpecInput[]; + communicationSpawnSpecs?: ManagedTaskCommunicationSpawnSpecInput[]; + metadata?: unknown; + retired?: boolean; +} + +const TREATY_ACTION_PATH = "/treaty"; + +// --------------------------------------------------------------------------- +// Pattern 1+2 — Per-user onboarding tree (1% Treaty) +// --------------------------------------------------------------------------- +const USER_TREATY_TASK_TITLE = + "Get {{params.majorityHumanity}} people to vote on the 1% Treaty"; +const USER_TREATY_TASK_ROLE_TITLE = + "Humanity Manager, Earth Optimization Services, LLC"; +const PROMOTION_TO_HUMANITY_MANAGER_TASK_TITLE = "Promote to Humanity Manager"; + +// Per-user HMT root description = the Promotion content from +// docs/questions.md (lines 392-403, the Promotion screen). The live +// "Performance to date" counter from the screen is omitted because it doesn't +// translate to a static task body. Compensation numbers come from +// {{params.healthYearsGainLinked}} (TREATY_HALE_GAIN_YEAR_15 -> 21.7) and +// {{params.lifetimeIncomeGainLinked}} (TREATY_TRAJECTORY_LIFETIME_INCOME_GAIN_PER_CAPITA -> $3.48M). +// Markdown task descriptions render the *Linked variants — clickable +// citations to the manual chapter for skeptics who want to verify. +const USER_TREATY_DESCRIPTION = [ + "🎉 **CONGRATULATIONS**", + "", + "You have been promoted to **Humanity Manager** at Earth Optimization Services, LLC.", + "", + "**Direct reports:** ~{{params.globalHumanity}} humans", + "**Primary KPI:** Hours of human suffering prevented per week", + "**Compensation:**", + "- ~**{{params.healthYearsGainLinked}}** extra years of healthy life", + "- ~**{{params.lifetimeIncomeGainLinked}}** additional lifetime income", + "- Vesting: contingent on treaty passage. Forfeited on dismissal.", +].join("\n"); + +// Phone script — uses {{params.*}} tokens for canonical numbers so the +// rendered copy stays in sync when the parameter source updates. +// {{params.militaryVsResearchRatio}} -> 604 (military vs gov clinical trials) +// {{params.statusQuoYears}} -> 443 (queue clearance, status quo) +// {{params.dfdaYears}} -> 36 (queue clearance, dFDA) +// {{params.apocalypseCount}} -> 122 (nuclear-winter overkill factor) +// These are deliberately the RAW variants, not `*Linked`. The script is +// meant to be read aloud or copy-pasted into a text message — markdown +// link syntax leaks as `[604](https://...)` in those contexts. For +// markdown-rendered task descriptions and HTML email bodies, use the +// `*Linked` variants instead (see USER_TREATY_DESCRIPTION above). +const PHONE_SCRIPT_DESCRIPTION = [ + "Your job is to manage {{params.globalHumanity}} humans. You're busy. Outsource it.", + "", + "The trick: call one human you love and don't want to watch suffer or die from horrible diseases. Read them the script below. They vote, then call {{params.propagationAsksPerHuman}} humans they love. After {{params.doublingRoundsToTarget}} rounds of this, {{params.majorityHumanity}} humans are reached. Tom Sawyer painting the fence — you don't have to convince anyone of anything they don't already believe, you just have to get one human to start delegating too.", + "", + "Could be one nice phone call with someone who loves you. If they actually do it.", + "", + "---", + "", + "Script (read out loud, edit to taste):", + "", + '"Hey [friend]. Quick favor.', + "", + "Humans spend {{params.militaryVsResearchRatio}} times more on weapons than on testing which medicines work. There's a treaty — the 1% Treaty — that redirects 1% of military spending into pragmatic clinical trials. Sixty million humans die every year, mostly from things we already know how to fix. The treaty would shorten the time to disease eradication from about {{params.statusQuoYears}} years to about {{params.dfdaYears}}.", + "", + "Humanity has enough nuclear mass-murder capacity for about {{params.apocalypseCount}} apocalypses. The 1% Treaty asks you to sacrifice one of those apocalypses for disease eradication in your lifetime.", + "", + "Voting takes thirty seconds. Open the link I'm about to send you, vote yes, then call {{params.propagationAsksPerHuman}} humans you love and read them this same paragraph. That's the whole ask. I do this with you, you do this with {{params.propagationAsksPerHuman}} more, after {{params.doublingRoundsToTarget}} rounds we've reached {{params.majorityHumanity}} humans.\"", + "", + "---", + "", + "Mark this task done after you've made the call AND actually sent your referral URL. The receiving end finishes when assigned humans vote — that's tracked separately by the {{params.directHumanAssignments}} assign-a-human subtasks below.", +].join("\n"); + +const userOnboardingTreaty: ManagedTaskTriggerInput = { + triggerKey: "user-onboarding:treaty", + eventName: "user.signup", + triggerKind: "spawnTasks", + enabled: true, + idempotencyKeyTemplate: `${USER_TREATY_TASK_KEY_PREFIX}:{{user.id}}`, + notes: + "Per-user onboarding tree spawned at signup. The hardcoded ensureUserTreatyTask path still owns the return contract for existing callers; these specs converge on the same taskKey rows and are the source blueprint for the next migration step. The parent root has no completionGate of its own — the HMT auto-verify lives in user-onboarding:treaty:hmt-gate and targets the completeTraining sibling.", + spawnSpecs: [ + { + kind: "root", + isParent: true, + sortOrder: 0, + titleTemplate: USER_TREATY_TASK_TITLE, + descriptionTemplate: USER_TREATY_DESCRIPTION, + roleTitleTemplate: USER_TREATY_TASK_ROLE_TITLE, + category: "OTHER", + difficulty: "TRIVIAL", + dueDays: 0, + claimPolicy: "ASSIGNED_ONLY", + isPublic: false, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: `fixed:${TREATY_PARENT_TASK_KEY}`, + }, + // sortOrder: chain-creating actions first (assign 2 humans), then the + // public-signal layer (broadcast URL + sign personally), then the + // hardest-friction action (phone call) last. The user is at peak + // motivation right after signin; spend that on the action that + // actually creates the doubling chain. Lower-leverage public-signal + // and the highest-friction phone call wait for after they're invested. + { + kind: "assignFirstHuman", + sortOrder: 0, + titleTemplate: "Give your first human the 1% Treaty voting task", + descriptionTemplate: + "Pick someone you trust. Send them a named invitation. If they vote, they get promoted too.", + category: "OTHER", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.1, + dueDays: 0, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "trigger.parentSpec", + contributesToGate: true, + }, + { + kind: "assignSecondHuman", + sortOrder: 10, + titleTemplate: "Give your second human the 1% Treaty voting task", + descriptionTemplate: + "Pick a second person. Same deal. {{params.directHumanAssignments}} reports is the minimum viable team.", + category: "OTHER", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.1, + dueDays: 0, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "trigger.parentSpec", + contributesToGate: true, + }, + { + kind: "shareReferralUrl", + sortOrder: 20, + titleTemplate: "Share your 1% Treaty referral URL", + descriptionTemplate: + "Post your referral URL anywhere — text, social, email. Votes that arrive through it count toward your direct reports.", + category: "OTHER", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.02, + dueDays: 0, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "trigger.parentSpec", + contributesToGate: true, + }, + { + kind: "signTreatyPersonally", + sortOrder: 30, + titleTemplate: "Sign the 1% Treaty publicly", + descriptionTemplate: + "Voting on this site is private. Signing on warondisease.org is public. You can't credibly ask a friend to do something you haven't publicly committed to yourself.", + category: "OTHER", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.01, + dueDays: 0, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "trigger.parentSpec", + // Relative path. The middleware (getSiteRouteDisposition) redirects + // users on a variant without /treaty to the canonical War on Disease + // origin automatically. See getSiteRouteRedirect in lib/site.ts. + actionLinkUrlTemplate: TREATY_ACTION_PATH, + actionLinkLabelTemplate: "Sign the treaty", + contributesToGate: true, + }, + { + kind: "phoneScript", + sortOrder: 40, + titleTemplate: HUMANITY_MANAGEMENT.callOneHumanTaskTitle, + descriptionTemplate: PHONE_SCRIPT_DESCRIPTION, + category: "OTHER", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.5, + dueDays: 0, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "trigger.parentSpec", + contributesToGate: true, + }, + { + kind: "completeTraining", + sortOrder: 50, + titleTemplate: PROMOTION_TO_HUMANITY_MANAGER_TASK_TITLE, + descriptionTemplate: + "Auto-completes when the five tasks above are done. You don't action this one directly.", + category: "OTHER", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.25, + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "trigger.parentSpec", + }, + ], +}; + +// --------------------------------------------------------------------------- +// Pattern 4 — Referral invitation +// --------------------------------------------------------------------------- + +const referralVoteInvitation: ManagedTaskTriggerInput = { + triggerKey: "referral:vote-invitation", + eventName: "referral.sent", + triggerKind: "spawnTasks", + enabled: true, + idempotencyKeyTemplate: `${REFERRAL_INVITATION_TASK_KEY_PREFIX}:{{inviteToken}}`, + notes: + "Spawns a follow-up task on the inviter's queue when they send a named referral. Backs createReferralInvitationTask. Caller pre-computes recipient.firstName, actionLink.url, actionLink.instructions and injects them as context tokens.", + spawnSpecs: [ + { + kind: "root", + isParent: true, + sortOrder: 0, + titleTemplate: "{{recipient.firstName}}: vote on the 1% Treaty", + descriptionTemplate: + "{{recipient.firstName}} was invited to vote on the 1% Treaty.\n\nThe task is complete when their verified vote converts the invitation.", + roleTitleTemplate: "Referred treaty voter", + category: "OUTREACH", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.01, + dueDays: 3, + claimPolicy: "ASSIGNED_ONLY", + isPublic: false, + skillTagTemplates: ["voting"], + interestTagTemplates: ["one-percent-treaty", "war-on-disease"], + creatorResolver: "actor", + assigneePersonResolver: "context.recipientPersonId", + parentResolver: "context.parentTaskId", + actionLinkUrlTemplate: "{{actionLink.url}}", + actionLinkLabelTemplate: "Complete treaty vote", + actionLinkInstructionsTemplate: "{{actionLink.instructions}}", + }, + ], +}; + +// --------------------------------------------------------------------------- +// Pattern 3 — Signer reminder subtask +// --------------------------------------------------------------------------- + +const treatySignerReminder: ManagedTaskTriggerInput = { + triggerKey: "treaty:signer-reminder", + eventName: "mcp.claimSignerReminder", + triggerKind: "spawnTasks", + enabled: true, + idempotencyKeyTemplate: `${SIGNER_REMINDER_TASK_KEY_PREFIX}:{{signer.countryCode}}:{{user.id}}`, + notes: + "Spawns a private reminder subtask under a parent signer task when a humanity-manager claims responsibility. Backs upsertSignerReminderTask. Caller pre-computes the action-link URL (a Google search for the signer's office contact) and the message instructions (containing the user's referralCode embedded in the treaty URL) and injects them as context.actionLink.{url,instructions}.", + spawnSpecs: [ + { + kind: "root", + isParent: true, + sortOrder: 0, + titleTemplate: "Remind {{signer.leaderName}} to sign the 1% Treaty", + descriptionTemplate: + "Remind {{signer.leaderName}} ({{signer.governmentName}}) to sign the 1% Treaty.\n\nFind their contact info via the action link, then send the message template.\n\nWhen {{signer.leaderName}} signs via your referral code, this task verifies automatically and the parent signer task closes.", + roleTitleTemplate: "1% Treaty Reminder Sender", + category: "OUTREACH", + difficulty: "TRIVIAL", + estimatedEffortHours: 0.05, + claimPolicy: "ASSIGNED_ONLY", + isPublic: false, + skillTagTemplates: ["outreach", "diplomacy"], + interestTagTemplates: [ + "one-percent-treaty", + "war-on-disease", + "country-{{signer.countryCodeLower}}", + ], + creatorResolver: "actor", + assigneePersonResolver: "actor", + parentResolver: "context.parentTaskId", + actionLinkUrlTemplate: "{{actionLink.url}}", + actionLinkLabelTemplate: "Find their contact info", + actionLinkInstructionsTemplate: "{{actionLink.instructions}}", + }, + ], +}; + +// --------------------------------------------------------------------------- +// Pattern 5 — Treaty parent (singleton) +// --------------------------------------------------------------------------- + +const treatyRatify: ManagedTaskTriggerInput = { + triggerKey: "treaty:ratify", + eventName: "manual", + triggerKind: "spawnTasks", + enabled: true, + idempotencyKeyTemplate: TREATY_PARENT_TASK_KEY, + notes: + "Singleton parent task for the 1% Treaty. Fires once via startup-seed; replaces ensureTreatyParentTask.", + spawnSpecs: [ + { + kind: "root", + isParent: true, + sortOrder: 0, + titleTemplate: "Ratify the 1% Treaty", + descriptionTemplate: + "Get every signing-eligible head of state to ratify the 1% Treaty. Children are per-country signer tasks.", + category: "OTHER", + difficulty: "ADVANCED", + claimPolicy: "ASSIGNED_ONLY", + isPublic: true, + creatorResolver: "system", + parentResolver: "none", + }, + ], +}; + +// --------------------------------------------------------------------------- +// Pattern 6 — HMT auto-verify gate +// --------------------------------------------------------------------------- + +const hmtVerifyGate: ManagedTaskTriggerInput = { + triggerKey: "user-onboarding:treaty:hmt-gate", + eventName: "task.statusChanged.VERIFIED", + triggerKind: "verifyTask", + enabled: true, + // Verify TARGET: the user's `completeTraining` sibling task. Siblings + // (share / phoneScript / assignFirstHuman / assignSecondHuman) live under + // the same parent (the user's HMT root). When the gate is met against + // those siblings, completeTraining auto-VERIFIES. + idempotencyKeyTemplate: `${USER_TREATY_TASK_KEY_PREFIX}:{{user.id}}:completeTraining`, + eventFilter: { + field: "task.taskKey", + matches: `^${USER_TREATY_TASK_KEY_PREFIX}:.+:(signTreatyPersonally|shareReferralUrl|phoneScript|assignFirstHuman|assignSecondHuman)$`, + }, + completionGate: { + kind: "allOf", + inputScope: "siblings", + subtaskKinds: [ + "signTreatyPersonally", + "shareReferralUrl", + "phoneScript", + "assignFirstHuman", + "assignSecondHuman", + ], + evidenceTemplate: + "User publicly signed the treaty, shared their referral URL, made the phone call, and gave two named humans their 1% Treaty voting tasks.", + }, + notes: + "Auto-VERIFIES the user's completeTraining subtask when its siblings (sign + share + phoneScript + invite1 + invite2) are VERIFIED.", +}; + +// --------------------------------------------------------------------------- +// Pattern 7+8 — Treaty signer per-slot (data-driven import) +// --------------------------------------------------------------------------- + +const treatySignerPerSlot: ManagedTaskTriggerInput = { + triggerKey: "treaty:signer", + eventName: "dataset.recordChanged.signer", + triggerKind: "spawnTasks", + idempotencyKeyTemplate: `${TREATY_SIGNER_TASK_KEY_PREFIX}:{{slot.countryCode}}`, + notes: + "Spawns one signer task per slot during the treaty-signer dataset import. Caller (sync-treaty-signers) iterates the dataset and fires this trigger per slot.", + enabled: false, + spawnSpecs: [ + { + kind: "root", + isParent: true, + sortOrder: 0, + titleTemplate: + "{{slot.leaderName}} signs the 1% Treaty for {{slot.countryName}}", + descriptionTemplate: + "Convince {{slot.leaderName}} ({{slot.role}} of {{slot.countryName}}) to sign the 1% Treaty. Recipients of the assignee's signature: their citizens.", + roleTitleTemplate: "{{slot.role}} of {{slot.countryName}}", + category: "OTHER", + difficulty: "INTERMEDIATE", + claimPolicy: "OPEN_MANY", + isPublic: true, + creatorResolver: "system", + parentResolver: `fixed:${TREATY_PARENT_TASK_KEY}`, + // Relative path. The middleware (getSiteRouteDisposition) redirects + // users on a variant without /treaty to the canonical War on Disease + // origin automatically. See getSiteRouteRedirect in lib/site.ts. + actionLinkUrlTemplate: TREATY_ACTION_PATH, + actionLinkLabelTemplate: "Sign the treaty", + }, + ], +}; + +// Note on Wishonia nudges: the framework now supports per-trigger schedules, +// iterationSource queries, and per-spec sendCount-range escalation — see the +// run-due-triggers route plus minSendCount/maxSendCount on +// TaskCommunicationSpawnSpec. We deliberately do NOT seed an escalating +// "Wishonia gets disappointed weekly" trigger because the email-deliverability +// cost probably exceeds the completion uplift on passive signups. The HMT root +// task description is the welcome and the nag; if a user comes back, they see +// the task. Email stays reserved for transactional + asked-for signals. +export const ONE_PERCENT_TREATY_TRIGGER_BLUEPRINTS: ManagedTaskTriggerInput[] = [ + userOnboardingTreaty, + referralVoteInvitation, + treatySignerReminder, + treatyRatify, + hmtVerifyGate, + treatySignerPerSlot, +]; + +export interface SyncManagedTaskTriggersOptions { + apply: boolean; + now?: Date; + records?: ManagedTaskTriggerInput[]; +} + +export interface SyncManagedTaskTriggersResult { + mode: "apply" | "dry-run"; + created: string[]; + updated: string[]; + unchanged: string[]; + retired: string[]; + missingRetired: string[]; +} + +type ManagedTaskSpawnSpecData = ReturnType<typeof toSpawnSpecCreate>; +type ManagedTaskCommunicationSpawnSpecData = ReturnType<typeof toCommSpecCreate>; + +interface ManagedTaskSpawnSpecRow extends ManagedTaskSpawnSpecData { + id?: string; + triggerId?: string; +} + +interface ManagedTaskCommunicationSpawnSpecRow + extends ManagedTaskCommunicationSpawnSpecData { + id?: string; + triggerId?: string; +} + +interface ManagedTaskTriggerRow { + id: string; + triggerKey: string; + eventName: string; + eventFilter: unknown; + triggerKind: string; + enabled: boolean; + disabledReason: string | null; + idempotencyKeyTemplate: string; + completionGate: unknown; + jurisdictionId: string | null; + schedule: string | null; + iterationSource: string | null; + notes: string | null; + metadata: unknown; + deletedAt: Date | null; + spawnSpecs: ManagedTaskSpawnSpecRow[]; + communicationSpawnSpecs: ManagedTaskCommunicationSpawnSpecRow[]; +} + +export async function syncManagedTaskTriggers( + prisma: PrismaClient, + options: SyncManagedTaskTriggersOptions, +): Promise<SyncManagedTaskTriggersResult> { + const records = options.records ?? ONE_PERCENT_TREATY_TRIGGER_BLUEPRINTS; + assertUniqueManagedTaskTriggers(records); + + const result: SyncManagedTaskTriggersResult = { + mode: options.apply ? "apply" : "dry-run", + created: [], + updated: [], + unchanged: [], + retired: [], + missingRetired: [], + }; + + const triggerKeys = records.map((record) => record.triggerKey); + const existingRows = (await prisma.taskTrigger.findMany({ + where: { triggerKey: { in: triggerKeys } }, + include: { + spawnSpecs: { orderBy: [{ sortOrder: "asc" }, { kind: "asc" }] }, + communicationSpawnSpecs: { + orderBy: [{ sortOrder: "asc" }, { kind: "asc" }], + }, + }, + })) as unknown as ManagedTaskTriggerRow[]; + const existingByKey = new Map( + existingRows.map((row) => [row.triggerKey, row]), + ); + const now = options.now ?? new Date(); + + for (const record of records) { + const existing = existingByKey.get(record.triggerKey) ?? null; + + if (record.retired) { + if (!existing) { + result.missingRetired.push(record.triggerKey); + continue; + } + + if (!existing.enabled && existing.deletedAt !== null) { + result.unchanged.push(record.triggerKey); + continue; + } + + result.retired.push(record.triggerKey); + if (options.apply) { + await prisma.taskTrigger.update({ + where: { triggerKey: record.triggerKey }, + data: { + enabled: false, + disabledReason: + record.disabledReason ?? "Retired by managed-data sync.", + deletedAt: now, + updatedByUserId: null, + }, + }); + } + continue; + } + + if (!existing) { + result.created.push(record.triggerKey); + if (options.apply) { + await createManagedTaskTrigger(prisma, record); + } + continue; + } + + if (!managedTaskTriggerNeedsUpdate(existing, record)) { + result.unchanged.push(record.triggerKey); + continue; + } + + result.updated.push(record.triggerKey); + if (options.apply) { + await updateManagedTaskTrigger(prisma, existing, record); + } + } + + return result; +} + +export function formatManagedTaskTriggersResult( + result: SyncManagedTaskTriggersResult, +) { + const lines = [ + `Task triggers: ${result.mode}`, + ` created: ${result.created.length}`, + ` updated: ${result.updated.length}`, + ` unchanged: ${result.unchanged.length}`, + ` retired: ${result.retired.length}`, + ]; + + if (result.missingRetired.length > 0) { + lines.push(` already absent: ${result.missingRetired.length}`); + } + + return lines.join("\n"); +} + +function assertUniqueManagedTaskTriggers(records: ManagedTaskTriggerInput[]) { + const triggerKeys = new Set<string>(); + + for (const record of records) { + if (triggerKeys.has(record.triggerKey)) { + throw new Error(`Duplicate managed task trigger key: ${record.triggerKey}`); + } + triggerKeys.add(record.triggerKey); + + assertUniqueSpecKinds(record.triggerKey, record.spawnSpecs ?? []); + assertUniqueSpecKinds( + record.triggerKey, + record.communicationSpawnSpecs ?? [], + ); + + const parentSpecs = (record.spawnSpecs ?? []).filter((spec) => spec.isParent); + if (parentSpecs.length > 1) { + throw new Error( + `Managed task trigger ${record.triggerKey} has multiple parent spawn specs`, + ); + } + } +} + +function assertUniqueSpecKinds( + triggerKey: string, + specs: Array<{ kind: string }>, +) { + const kinds = new Set<string>(); + for (const spec of specs) { + if (kinds.has(spec.kind)) { + throw new Error( + `Duplicate managed task trigger spec kind: ${triggerKey}/${spec.kind}`, + ); + } + kinds.add(spec.kind); + } +} + +async function createManagedTaskTrigger( + prisma: PrismaClient, + record: ManagedTaskTriggerInput, +) { + const spawnSpecs = (record.spawnSpecs ?? []).map(toSpawnSpecCreate); + const communicationSpawnSpecs = (record.communicationSpawnSpecs ?? []).map( + toCommSpecCreate, + ); + + return prisma.taskTrigger.create({ + data: { + ...toTriggerScalars(record), + createdByUserId: null, + updatedByUserId: null, + spawnSpecs: + spawnSpecs.length > 0 ? { create: spawnSpecs } : undefined, + communicationSpawnSpecs: + communicationSpawnSpecs.length > 0 + ? { create: communicationSpawnSpecs } + : undefined, + }, + include: { spawnSpecs: true, communicationSpawnSpecs: true }, + }); +} + +async function updateManagedTaskTrigger( + prisma: PrismaClient, + existing: ManagedTaskTriggerRow, + record: ManagedTaskTriggerInput, +) { + const spawnSpecs = (record.spawnSpecs ?? []).map(toSpawnSpecCreate); + const communicationSpawnSpecs = (record.communicationSpawnSpecs ?? []).map( + toCommSpecCreate, + ); + + return prisma.$transaction(async (tx) => { + const updated = await tx.taskTrigger.update({ + where: { triggerKey: record.triggerKey }, + data: { + ...toTriggerScalars(record), + updatedByUserId: null, + }, + }); + + await tx.taskSpawnSpec.deleteMany({ where: { triggerId: existing.id } }); + if (spawnSpecs.length > 0) { + await tx.taskSpawnSpec.createMany({ + data: spawnSpecs.map((spec) => ({ + ...spec, + triggerId: updated.id, + })), + }); + } + + await tx.taskCommunicationSpawnSpec.deleteMany({ + where: { triggerId: existing.id }, + }); + if (communicationSpawnSpecs.length > 0) { + await tx.taskCommunicationSpawnSpec.createMany({ + data: communicationSpawnSpecs.map((spec) => ({ + ...spec, + triggerId: updated.id, + })), + }); + } + + return updated; + }); +} + +function toTriggerScalars(record: ManagedTaskTriggerInput) { + const enabled = record.enabled ?? false; + + return { + triggerKey: record.triggerKey, + eventName: record.eventName, + eventFilter: nullableJson(record.eventFilter), + triggerKind: record.triggerKind ?? "spawnTasks", + enabled, + disabledReason: enabled ? null : record.disabledReason ?? null, + idempotencyKeyTemplate: record.idempotencyKeyTemplate, + completionGate: nullableJson(record.completionGate), + jurisdictionId: record.jurisdictionId ?? null, + schedule: record.schedule ?? null, + iterationSource: record.iterationSource ?? null, + notes: record.notes ?? null, + metadata: nullableJson(record.metadata), + deletedAt: null, + }; +} + +function toSpawnSpecCreate(spec: ManagedTaskSpawnSpecInput) { + return { + kind: spec.kind, + isParent: spec.isParent ?? false, + sortOrder: spec.sortOrder ?? 0, + titleTemplate: spec.titleTemplate, + descriptionTemplate: spec.descriptionTemplate, + impactStatementTemplate: spec.impactStatementTemplate ?? null, + roleTitleTemplate: spec.roleTitleTemplate ?? null, + category: spec.category ? TaskCategory[spec.category] : TaskCategory.OTHER, + difficulty: spec.difficulty + ? TaskDifficulty[spec.difficulty] + : TaskDifficulty.TRIVIAL, + estimatedEffortHours: spec.estimatedEffortHours ?? null, + dueDays: spec.dueDays ?? null, + availableInDays: spec.availableInDays ?? null, + deadlinePolicy: spec.deadlinePolicy ?? null, + claimPolicy: spec.claimPolicy + ? TaskClaimPolicy[spec.claimPolicy] + : TaskClaimPolicy.ASSIGNED_ONLY, + isPublic: spec.isPublic ?? false, + skillTagTemplates: spec.skillTagTemplates ?? [], + interestTagTemplates: spec.interestTagTemplates ?? [], + actionLinkUrlTemplate: spec.actionLinkUrlTemplate ?? null, + actionLinkLabelTemplate: spec.actionLinkLabelTemplate ?? null, + actionLinkInstructionsTemplate: spec.actionLinkInstructionsTemplate ?? null, + creatorResolver: spec.creatorResolver ?? "actor", + assigneePersonResolver: spec.assigneePersonResolver ?? "none", + assigneeOrganizationResolver: + spec.assigneeOrganizationResolver ?? "none", + parentResolver: spec.parentResolver ?? "trigger.parentSpec", + contributesToGate: spec.contributesToGate ?? false, + metadata: nullableJson(spec.metadata), + }; +} + +function toCommSpecCreate(spec: ManagedTaskCommunicationSpawnSpecInput) { + return { + kind: spec.kind, + sortOrder: spec.sortOrder ?? 0, + subjectTemplate: spec.subjectTemplate, + bodyTextTemplate: spec.bodyTextTemplate, + bodyHtmlTemplate: spec.bodyHtmlTemplate ?? null, + commentTemplate: spec.commentTemplate ?? null, + channel: spec.channel ?? "EMAIL", + audienceResolver: spec.audienceResolver ?? "ASSIGNEE", + purpose: spec.purpose ?? "REMINDER", + emailScope: spec.emailScope ?? null, + dedupeKeyTemplate: spec.dedupeKeyTemplate, + minHoursBetweenSends: spec.minHoursBetweenSends ?? 0, + maxSendsPerTask: spec.maxSendsPerTask ?? 0, + minSendCount: spec.minSendCount ?? 0, + maxSendCount: spec.maxSendCount ?? null, + metadata: nullableJson(spec.metadata), + }; +} + +function nullableJson( + value: unknown, +): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput { + if (value === undefined || value === null) { + return Prisma.JsonNull; + } + return value as Prisma.InputJsonValue; +} + +function managedTaskTriggerNeedsUpdate( + existing: ManagedTaskTriggerRow, + record: ManagedTaskTriggerInput, +) { + const scalars = toTriggerScalars(record); + + return ( + existing.triggerKey !== scalars.triggerKey || + existing.eventName !== scalars.eventName || + !sameJson(existing.eventFilter, scalars.eventFilter) || + existing.triggerKind !== scalars.triggerKind || + existing.enabled !== scalars.enabled || + existing.disabledReason !== scalars.disabledReason || + existing.idempotencyKeyTemplate !== scalars.idempotencyKeyTemplate || + !sameJson(existing.completionGate, scalars.completionGate) || + existing.jurisdictionId !== scalars.jurisdictionId || + existing.schedule !== scalars.schedule || + existing.iterationSource !== scalars.iterationSource || + existing.notes !== scalars.notes || + !sameJson(existing.metadata, scalars.metadata) || + existing.deletedAt !== null || + !sameJson( + normalizeSpawnSpecRows(existing.spawnSpecs), + (record.spawnSpecs ?? []).map(toSpawnSpecCreate), + ) || + !sameJson( + normalizeCommunicationSpawnSpecRows(existing.communicationSpawnSpecs), + (record.communicationSpawnSpecs ?? []).map(toCommSpecCreate), + ) + ); +} + +function normalizeSpawnSpecRows( + rows: ManagedTaskSpawnSpecRow[], +): ManagedTaskSpawnSpecData[] { + return rows + .map((row) => ({ + kind: row.kind, + isParent: row.isParent, + sortOrder: row.sortOrder, + titleTemplate: row.titleTemplate, + descriptionTemplate: row.descriptionTemplate, + impactStatementTemplate: row.impactStatementTemplate ?? null, + roleTitleTemplate: row.roleTitleTemplate ?? null, + category: row.category, + difficulty: row.difficulty, + estimatedEffortHours: row.estimatedEffortHours ?? null, + dueDays: row.dueDays ?? null, + availableInDays: row.availableInDays ?? null, + deadlinePolicy: row.deadlinePolicy ?? null, + claimPolicy: row.claimPolicy, + isPublic: row.isPublic, + skillTagTemplates: row.skillTagTemplates ?? [], + interestTagTemplates: row.interestTagTemplates ?? [], + actionLinkUrlTemplate: row.actionLinkUrlTemplate ?? null, + actionLinkLabelTemplate: row.actionLinkLabelTemplate ?? null, + actionLinkInstructionsTemplate: + row.actionLinkInstructionsTemplate ?? null, + creatorResolver: row.creatorResolver, + assigneePersonResolver: row.assigneePersonResolver, + assigneeOrganizationResolver: row.assigneeOrganizationResolver, + parentResolver: row.parentResolver, + contributesToGate: row.contributesToGate, + metadata: row.metadata ?? null, + })) + .sort(compareSpecData); +} + +function normalizeCommunicationSpawnSpecRows( + rows: ManagedTaskCommunicationSpawnSpecRow[], +): ManagedTaskCommunicationSpawnSpecData[] { + return rows + .map((row) => ({ + kind: row.kind, + sortOrder: row.sortOrder, + subjectTemplate: row.subjectTemplate, + bodyTextTemplate: row.bodyTextTemplate, + bodyHtmlTemplate: row.bodyHtmlTemplate ?? null, + commentTemplate: row.commentTemplate ?? null, + channel: row.channel, + audienceResolver: row.audienceResolver, + purpose: row.purpose, + emailScope: row.emailScope ?? null, + dedupeKeyTemplate: row.dedupeKeyTemplate, + minHoursBetweenSends: row.minHoursBetweenSends, + maxSendsPerTask: row.maxSendsPerTask, + minSendCount: row.minSendCount, + maxSendCount: row.maxSendCount ?? null, + metadata: row.metadata ?? null, + })) + .sort(compareSpecData); +} + +function compareSpecData(a: { kind: string }, b: { kind: string }) { + return a.kind.localeCompare(b.kind); +} + +function stableJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableJson); + } + + if (value === Prisma.JsonNull) { + return null; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value && typeof value === "object") { + const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)); + return Object.fromEntries( + entries.map(([key, nested]) => [key, stableJson(nested)]), + ); + } + + return value ?? null; +} + +function sameJson(a: unknown, b: unknown) { + return JSON.stringify(stableJson(a)) === JSON.stringify(stableJson(b)); +} diff --git a/packages/db/src/managed-data/optimize-earth-task-tree.ts b/packages/db/src/managed-data/optimize-earth-task-tree.ts index fe59802df..82e4108b0 100644 --- a/packages/db/src/managed-data/optimize-earth-task-tree.ts +++ b/packages/db/src/managed-data/optimize-earth-task-tree.ts @@ -179,7 +179,7 @@ export const OPTIMIZE_EARTH_TASK_TREE: ManagedTaskRecord[] = [ parentTaskId: HUMANITY_V_GOVERNMENTS_TASK_ID, title: "Summon jurors", description: - `Invite humans to act as jurors by voting on the treaty and rendering a verdict in ${HUMANITY_V_GOVERNMENT_CASE_NAME}.`, + `Invite humans to act as jurors by voting on the verdict in ${HUMANITY_V_GOVERNMENT_CASE_NAME}.`, impactStatement: "A court for humanity needs a jury large enough to matter.", sortOrder: -760, @@ -187,7 +187,7 @@ export const OPTIMIZE_EARTH_TASK_TREE: ManagedTaskRecord[] = [ label: "Open the referral dashboard", url: "/dashboard", instructions: - "Invite at least two humans to vote and render the verdict.", + "Invite at least two humans to vote on the case.", }, }, { @@ -217,7 +217,7 @@ export const OPTIMIZE_EARTH_TASK_TREE: ManagedTaskRecord[] = [ parentTaskId: HUMANITY_V_GOVERNMENTS_TASK_ID, title: "Render the verdict", description: - "Get a majority of humanity to render a verdict on whether governments are guilty of using public resources against the general welfare.", + "Get a majority of humanity to render a verdict on whether governments are liable for using public resources against the general welfare.", impactStatement: "The verdict becomes politically real when enough humans say it is real.", sortOrder: -740, diff --git a/packages/db/prisma/seed-data/global-variables.ts b/packages/db/src/managed-data/seed-data/global-variables.ts similarity index 99% rename from packages/db/prisma/seed-data/global-variables.ts rename to packages/db/src/managed-data/seed-data/global-variables.ts index d77fcbc73..52f88082d 100644 --- a/packages/db/prisma/seed-data/global-variables.ts +++ b/packages/db/src/managed-data/seed-data/global-variables.ts @@ -2,7 +2,7 @@ import { CombinationOperation, FillingType, Valence, -} from "../../src/generated/prisma/enums.js"; +} from "../../generated/prisma/enums.js"; export type GlobalVariableSeedRecord = { name: string; diff --git a/packages/db/prisma/seed-data/variable-categories.ts b/packages/db/src/managed-data/seed-data/variable-categories.ts similarity index 99% rename from packages/db/prisma/seed-data/variable-categories.ts rename to packages/db/src/managed-data/seed-data/variable-categories.ts index a415e8056..821b09bfe 100644 --- a/packages/db/prisma/seed-data/variable-categories.ts +++ b/packages/db/src/managed-data/seed-data/variable-categories.ts @@ -1,4 +1,4 @@ -import { CombinationOperation } from "../../src/generated/prisma/enums.js"; +import { CombinationOperation } from "../../generated/prisma/enums.js"; export type VariableCategorySeedRecord = { name: string; diff --git a/packages/db/src/managed-data/sync-managed-tasks.test.ts b/packages/db/src/managed-data/sync-managed-tasks.test.ts index 0facce2ba..246411a32 100644 --- a/packages/db/src/managed-data/sync-managed-tasks.test.ts +++ b/packages/db/src/managed-data/sync-managed-tasks.test.ts @@ -453,6 +453,42 @@ describe("syncManagedTasks", () => { ); }); + it("does not delete a previously managed row just because it is absent from the current source list", async () => { + const client = new FakeManagedTaskClient({ + tasks: [ + makeTask({ + id: OPTIMIZE_EARTH_ROOT_TASK_ID, + taskKey: OPTIMIZE_EARTH_ROOT_TASK_KEY, + }), + makeTask({ + contextJson: { + managedData: { + collectionKey: "test-tree", + recordId: "previously-managed", + }, + }, + id: "previously-managed", + taskKey: "program:test:previously-managed", + title: "Keep me unless explicitly retired", + }), + ], + }); + + const result = await syncManagedTasks(client, { + apply: true, + collectionKey: "test-tree", + createdByUserId: "creator", + records: [activeRecord], + }); + + expect(result.retired).toEqual([]); + expect(client.tasks.find((task) => task.id === "previously-managed")).toMatchObject({ + deletedAt: null, + status: TaskStatus.ACTIVE, + title: "Keep me unless explicitly retired", + }); + }); + it("rejects taskKey ownership conflicts before applying writes", async () => { const client = new FakeManagedTaskClient({ tasks: [ diff --git a/packages/db/src/managed-data/sync-managed-tasks.ts b/packages/db/src/managed-data/sync-managed-tasks.ts index 2be5a635f..53db63cbd 100644 --- a/packages/db/src/managed-data/sync-managed-tasks.ts +++ b/packages/db/src/managed-data/sync-managed-tasks.ts @@ -52,6 +52,10 @@ export interface ManagedTaskRecord { deadlinePolicy?: TaskDeadlinePolicyValue; sortOrder?: number; primaryEndpoint?: ManagedTaskPrimaryEndpoint | null; + /** + * Explicit soft-delete flag for rows owned by this managed collection. + * Removing a record from the array is not deletion and must not affect the DB. + */ retired?: boolean; } diff --git a/packages/web/e2e/dev-auth-query-params.spec.ts b/packages/web/e2e/dev-auth-query-params.spec.ts new file mode 100644 index 000000000..6eaa3a7e8 --- /dev/null +++ b/packages/web/e2e/dev-auth-query-params.spec.ts @@ -0,0 +1,76 @@ +/** + * `?login=demo` + `?logout=1` query-param round-trip test. + * + * Would have caught the bug shipped in commit `ef63c034` where the logout + * route's cookie-clear missed `secure: true` and the `__Secure-`-prefixed + * cookie clear was silently rejected by the browser (the `__Secure-` + * prefix REQUIRES `secure: true` on the Set-Cookie). Result: `?logout=1` + * looked like a no-op — visiting it left the user still signed in. + * + * This spec exercises the full middleware → /api/dev/login-as-demo → + * /api/dev/logout redirect chain and verifies the session cookie is + * actually present after login and actually absent after logout. + * + * Runs against the local dev server (next dev on :3001). Skipped on CI + * unless `RUN_DEV_AUTH_E2E=1` is set — the test relies on dev-only + * env-gating that VERCEL_ENV doesn't satisfy in the CI runner. + */ +import { expect, test } from "@playwright/test"; + +const NEXT_AUTH_COOKIE_NAMES = [ + "next-auth.session-token", + "__Secure-next-auth.session-token", +]; + +function findSessionCookie( + cookies: { name: string; value: string }[], +): { name: string; value: string } | undefined { + return cookies.find((c) => NEXT_AUTH_COOKIE_NAMES.includes(c.name)); +} + +test.describe("dev-auth query params", () => { + test.skip( + process.env.RUN_DEV_AUTH_E2E !== "1", + "Set RUN_DEV_AUTH_E2E=1 to run (requires VERCEL_ENV=preview or NODE_ENV=development)", + ); + + test("?login=demo sets a session cookie; ?logout=1 clears it", async ({ + context, + page, + }) => { + // Start clean. + await context.clearCookies(); + + // Login: visit any URL with ?login=demo. Middleware redirects to + // /api/dev/login-as-demo, the route mints + sets the cookie, then + // redirects back to the original URL (now sans ?login=demo). + const loginResponse = await page.goto("/?login=demo"); + expect(loginResponse?.status()).toBeLessThan(400); + expect(page.url()).not.toContain("login=demo"); + + const cookiesAfterLogin = await context.cookies(); + const sessionAfterLogin = findSessionCookie(cookiesAfterLogin); + expect( + sessionAfterLogin, + "session cookie should be set after ?login=demo", + ).toBeTruthy(); + expect(sessionAfterLogin?.value).not.toBe(""); + + // Logout: visit any URL with ?logout=1. + const logoutResponse = await page.goto("/?logout=1"); + expect(logoutResponse?.status()).toBeLessThan(400); + expect(page.url()).not.toContain("logout=1"); + + const cookiesAfterLogout = await context.cookies(); + const sessionAfterLogout = findSessionCookie(cookiesAfterLogout); + // After logout, the cookie should either be absent OR present with + // an empty value (browsers can race on the delete; either state means + // the user is effectively signed out for subsequent requests). + if (sessionAfterLogout) { + expect( + sessionAfterLogout.value, + "session cookie should be empty after ?logout=1 (was a stale value, meaning the clear failed — likely missing `secure: true` on a __Secure- cookie clear)", + ).toBe(""); + } + }); +}); diff --git a/packages/web/e2e/email-screenshots.spec.ts b/packages/web/e2e/email-screenshots.spec.ts new file mode 100644 index 000000000..aa0016991 --- /dev/null +++ b/packages/web/e2e/email-screenshots.spec.ts @@ -0,0 +1,104 @@ +/** + * Email template visual coverage. + * + * Each template is server-rendered by the `/dev/email/<template>` route + * (`packages/web/src/app/dev/email/[template]/route.ts`) using bland + * sample data. The spec navigates the browser to that URL and + * screenshots — same flow as page screenshots, no direct imports of + * `*-email.server.ts` modules. This avoids the Playwright transformer + * bug on `@optimitron/db/dist`'s `export *` syntax that previously + * knocked this spec out of `MODE_SPECS.visual`. + * + * Output lands in `screenshots/{project}/email-{name}-{project}.png` + * alongside page screenshots. `build-visual-review.mjs` picks it up + * automatically via its `collectScreenshots` walk. + * + * Sample data is intentionally bland — no real user IDs, no production + * referral codes — so the screenshots are safe to publish even when + * the preview is connected to production-derived data. + */ + +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { test } from "@playwright/test"; + +const EMAIL_VIEWPORT = { width: 720, height: 1000 } as const; +const SCREENSHOT_ROOT = path.resolve(process.cwd(), "screenshots"); + +async function captureEmail( + page: import("@playwright/test").Page, + template: string, + name: string, + testInfo: import("@playwright/test").TestInfo, +) { + await page.setViewportSize(EMAIL_VIEWPORT); + // `?raw=1` returns the bare email HTML; the default route wraps it in a + // Gmail-mobile preview chrome which we don't want in the visual-regression + // screenshot. + await page.goto(`/dev/email/${template}?raw=1`, { + waitUntil: "domcontentloaded", + }); + await page.waitForLoadState("networkidle").catch(() => undefined); + + const screenshotDir = path.join(SCREENSHOT_ROOT, testInfo.project.name); + await mkdir(screenshotDir, { recursive: true }); + const filename = `${name}-${testInfo.project.name}.png`; + const screenshot = await page.screenshot({ + fullPage: true, + path: path.join(screenshotDir, filename), + }); + await testInfo.attach(filename, { + body: screenshot, + contentType: "image/png", + }); +} + +test.describe("email visual coverage", () => { + test("email-magic-link", async ({ page }, testInfo) => { + await captureEmail(page, "magic-link", "email-magic-link", testInfo); + }); + + test("email-post-vote-share", async ({ page }, testInfo) => { + await captureEmail(page, "post-vote-share", "email-post-vote-share", testInfo); + }); + + test("email-referral-first-conversion", async ({ page }, testInfo) => { + await captureEmail( + page, + "referral-first-conversion", + "email-referral-first-conversion", + testInfo, + ); + }); + + test("email-task-assignment", async ({ page }, testInfo) => { + await captureEmail(page, "task-assignment", "email-task-assignment", testInfo); + }); + + test("email-monthly-digest-positive", async ({ page }, testInfo) => { + await captureEmail( + page, + "monthly-digest-positive", + "email-monthly-digest-positive", + testInfo, + ); + }); + + test("email-monthly-digest-resend", async ({ page }, testInfo) => { + await captureEmail( + page, + "monthly-digest-resend", + "email-monthly-digest-resend", + testInfo, + ); + }); + + test("email-task-comment-notification", async ({ page }, testInfo) => { + await captureEmail( + page, + "task-comment-notification", + "email-task-comment-notification", + testInfo, + ); + }); +}); diff --git a/packages/web/e2e/treaty-page-structure.spec.ts b/packages/web/e2e/treaty-page-structure.spec.ts new file mode 100644 index 000000000..943019548 --- /dev/null +++ b/packages/web/e2e/treaty-page-structure.spec.ts @@ -0,0 +1,56 @@ +/** + * Treaty page structure regression test. + * + * Twice in the same session `/treaty` lost the treaty body (and/or the + * signature controls) due to ad-hoc layout changes and a Referendum row + * with a null `bodyMarkdown` column. This test pins the three load- + * bearing parts of the page so a regression can't ship silently: + * + * 1. The headline ("Please quickly skim and sign…") + * 2. The treaty body (at least one recognizable WHEREAS / Article + * phrase from the bundled `onePercentTreatyText`) + * 3. Signature controls (Yes/No buttons that lead to the auth flow) + * + * Failure modes this guards against: + * - Someone reverts the page to the stepper layout (no treaty body + * visible above the fold without scrolling). + * - The preview DB ships with `Referendum.bodyMarkdown = null` and + * the page silently renders nothing for the body. + * - Signature box gets replaced with something that lacks a YES path. + */ +import { test, expect } from "@playwright/test"; + +test.describe("/treaty page structure", () => { + test("headline + treaty body + signature controls all render", async ({ page }) => { + const response = await page.goto("/treaty"); + const status = response?.status() ?? 0; + if (status >= 500) { + test.skip(true, `/treaty returned ${status} (likely needs DB seeded)`); + return; + } + expect(status).toBeLessThan(400); + + // 1. Headline — exact phrasing matters, this is the load-bearing + // request-for-action and the only intro copy on the page. + await expect( + page.getByRole("heading", { + name: /please quickly skim and sign to end war and disease/i, + }), + ).toBeVisible(); + + // 2. Treaty body — assert phrases from the bundled treaty markdown. + // These survive both the DB-backed path AND the fallback to the + // bundled `shareableSnippets.onePercentTreatyText.markdown`. + const treatyBody = page.locator("main"); + await expect(treatyBody).toContainText(/WHEREAS, humanity pays governments/i); + await expect(treatyBody).toContainText(/Article I/i); + await expect(treatyBody).toContainText(/IN WITNESS WHEREOF/i); + + // 3. Signature controls — document-style "type your name and click + // Sign" (not Yes/No buttons). The dated "Signed this day, …, in + // the year of our ongoing confusion." title appears above. + await expect(page.getByText(/in the year of our ongoing confusion/i)).toBeVisible(); + await expect(page.getByPlaceholder(/your name/i)).toBeVisible(); + await expect(page.getByRole("button", { name: /^Sign$/ })).toBeVisible(); + }); +}); diff --git a/packages/web/e2e/utils/static-pages.ts b/packages/web/e2e/utils/static-pages.ts index 07d01c5f4..079b5cd41 100644 --- a/packages/web/e2e/utils/static-pages.ts +++ b/packages/web/e2e/utils/static-pages.ts @@ -41,7 +41,12 @@ export const AUTH_REQUIRED_PATHS: Set<string> = new Set([ "/mcp/authorize", ]); -const CANDIDATE_REDIRECT_ONLY_PATHS = [ROUTES.impact]; +const CANDIDATE_REDIRECT_ONLY_PATHS = [ROUTES.impact, ROUTES.politicians]; + +const LEGACY_INTERNAL_REDIRECT_PATHS = getEnabledStaticPathsForSite( + SMOKE_TEST_SITE, + [ROUTES.campaign, ROUTES.coalition], +); /** Routes that redirect off-app and should be tested as redirects, not pages. */ export const REDIRECT_ONLY_PATHS: Set<string> = new Set( @@ -52,6 +57,7 @@ export const REDIRECT_ONLY_PATHS: Set<string> = new Set( const SKIP_PATHS: Set<string> = new Set([ ROUTES.signIn, ...REDIRECT_ONLY_PATHS, + ...LEGACY_INTERNAL_REDIRECT_PATHS, ]); function discoverStaticAppPages( diff --git a/packages/web/e2e/utils/visual-routes.ts b/packages/web/e2e/utils/visual-routes.ts index 1b2c72278..8559ea0c7 100644 --- a/packages/web/e2e/utils/visual-routes.ts +++ b/packages/web/e2e/utils/visual-routes.ts @@ -38,6 +38,11 @@ const SPECIAL_STATE_ROUTES: VisualRoute[] = [ ]; const SEEDED_DYNAMIC_ROUTES: VisualRoute[] = [ + { + name: "organization-iam-public", + path: "/organizations/institute-for-accelerated-medicine", + required: false, + }, { name: "task-optimize-earth", path: "/tasks/optimize-earth", required: false }, { name: "task-one-percent-treaty", path: "/tasks/1-pct-treaty", required: false }, { name: "task-signer-canada", path: "/tasks/1-pct-treaty-signer-ca", required: false }, diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 14fa9a916..15b54c391 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -29,6 +29,16 @@ const nextConfig = { output: isStaticExport ? "export" : undefined, basePath: isStaticExport ? "/optimitron" : "", outputFileTracingRoot: path.resolve(__dirname, "../.."), + outputFileTracingIncludes: { + "/api/og/route": [ + "./public/fonts/libre-baskerville-400.ttf", + "./public/fonts/libre-baskerville-700.ttf", + ], + "/humanity-v-government/opengraph-image": [ + "./public/fonts/libre-baskerville-400.ttf", + "./public/fonts/libre-baskerville-700.ttf", + ], + }, turbopack: { root: path.resolve(__dirname, "../.."), }, diff --git a/packages/web/package.json b/packages/web/package.json index 46daf72d5..857065385 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -29,6 +29,7 @@ "e2e": "node scripts/run-playwright.mjs", "e2e:visual": "node scripts/run-playwright.mjs visual", "visual:review": "node scripts/build-visual-review.mjs", + "review:local": "node scripts/review-local.mjs", "messages:review": "node scripts/build-message-review.mjs", "e2e:demo": "playwright test e2e/demo-recording.spec.ts", "demo:narration": "tsx scripts/generate-narration.ts", diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index 33369b45b..7ca110e8b 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -27,7 +27,12 @@ const reporter: "html" | ReporterDescription[] = enableArgosReporter export default defineConfig({ testDir: "./e2e", fullyParallel: true, - retries: 0, + // 2 retries in CI: the visual-regression suite has hit transient + // `ECONNRESET` on `/api/auth/csrf` three times in one day (always on + // `tasks-index-auth` — the first request after sign-in occasionally + // drops the connection). Retries clear the flake without papering over + // real failures — each retry uploads its own trace + screenshot. + retries: isCI ? 2 : 0, workers: isCI ? 4 : 4, reporter, timeout: 120_000, diff --git a/packages/web/public/fonts/libre-baskerville-400.ttf b/packages/web/public/fonts/libre-baskerville-400.ttf new file mode 100644 index 000000000..c41e10f8c Binary files /dev/null and b/packages/web/public/fonts/libre-baskerville-400.ttf differ diff --git a/packages/web/public/fonts/libre-baskerville-700.ttf b/packages/web/public/fonts/libre-baskerville-700.ttf new file mode 100644 index 000000000..6533cef1c Binary files /dev/null and b/packages/web/public/fonts/libre-baskerville-700.ttf differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-facebook-cover-1640x624.png b/packages/web/public/site-assets/warondisease/war-on-disease-facebook-cover-1640x624.png new file mode 100644 index 000000000..14cbbb205 Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-facebook-cover-1640x624.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-linkedin-page-cover-4200x700.png b/packages/web/public/site-assets/warondisease/war-on-disease-linkedin-page-cover-4200x700.png new file mode 100644 index 000000000..aecae985d Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-linkedin-page-cover-4200x700.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-linkedin-profile-banner-1584x396.png b/packages/web/public/site-assets/warondisease/war-on-disease-linkedin-profile-banner-1584x396.png new file mode 100644 index 000000000..4d876f89c Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-linkedin-profile-banner-1584x396.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-og-1200x630.png b/packages/web/public/site-assets/warondisease/war-on-disease-og-1200x630.png index e04e2d33e..652dc6ba0 100644 Binary files a/packages/web/public/site-assets/warondisease/war-on-disease-og-1200x630.png and b/packages/web/public/site-assets/warondisease/war-on-disease-og-1200x630.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-social-landscape-1600x900.png b/packages/web/public/site-assets/warondisease/war-on-disease-social-landscape-1600x900.png new file mode 100644 index 000000000..78e7c839d Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-social-landscape-1600x900.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-social-square-1080x1080.png b/packages/web/public/site-assets/warondisease/war-on-disease-social-square-1080x1080.png new file mode 100644 index 000000000..d2425328a Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-social-square-1080x1080.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-social-story-1080x1920.png b/packages/web/public/site-assets/warondisease/war-on-disease-social-story-1080x1920.png new file mode 100644 index 000000000..4795d7256 Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-social-story-1080x1920.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-x-card-1200x600.png b/packages/web/public/site-assets/warondisease/war-on-disease-x-card-1200x600.png new file mode 100644 index 000000000..e611a71ef Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-x-card-1200x600.png differ diff --git a/packages/web/public/site-assets/warondisease/war-on-disease-x-header-1500x500.png b/packages/web/public/site-assets/warondisease/war-on-disease-x-header-1500x500.png new file mode 100644 index 000000000..7a99eaa5b Binary files /dev/null and b/packages/web/public/site-assets/warondisease/war-on-disease-x-header-1500x500.png differ diff --git a/packages/web/scripts/build-visual-review.mjs b/packages/web/scripts/build-visual-review.mjs index aa2f1f608..9830663e1 100644 --- a/packages/web/scripts/build-visual-review.mjs +++ b/packages/web/scripts/build-visual-review.mjs @@ -52,6 +52,20 @@ const reviewCacheKey = [ .join("-"); const routePaths = loadRoutePaths(); +// PR/branch context for the per-route "Copy context" button. Read from +// env (GitHub Actions sets these on pull_request events). Stays empty for +// local runs — the copy payload still includes route + auth + screenshot +// info; the PR/branch lines are just blank. +const prNumber = + process.env.VISUAL_REVIEW_PR_NUMBER ?? process.env.PR_NUMBER ?? null; +const headBranch = + process.env.VISUAL_REVIEW_HEAD_BRANCH ?? + process.env.GITHUB_HEAD_REF ?? + process.env.GITHUB_REF_NAME ?? + null; +const repoSlug = + process.env.VISUAL_REVIEW_REPO ?? process.env.GITHUB_REPOSITORY ?? null; + const routeOrder = [ "home", "side-menu", @@ -81,6 +95,7 @@ const routeOrder = [ "feedback", "settings", "organizations", + "organization-iam-public", "task-optimize-earth", "task-one-percent-treaty", "task-signer-canada", @@ -454,6 +469,96 @@ function renderHtml(groups) { transform: translateX(-50%); } + .route-summary-actions { + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 10px; + } + + .copy-context-button { + background: var(--bg); + border: 1px solid var(--line); + color: var(--fg); + cursor: pointer; + font-family: inherit; + font-size: 11px; + font-weight: 700; + padding: 4px 8px; + text-transform: uppercase; + white-space: nowrap; + } + + .copy-context-button:hover { + background: var(--fg); + color: var(--bg); + } + + .copy-context-button.copied { + background: var(--fg); + color: var(--bg); + } + + /* Block click bubbling so toggling the route doesn't toggle when you + click the button. JS also calls stopPropagation, but a CSS hint + helps keep the button visually distinct from the summary. */ + .copy-context-button:focus-visible { + outline: 2px solid var(--fg); + outline-offset: 2px; + } + + .toolbar { + align-items: center; + border-bottom: 1px solid var(--line); + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 10px 24px; + background: var(--bg); + position: sticky; + top: 0; + z-index: 2; + } + + .toolbar input[type="search"] { + background: var(--bg); + border: 1px solid var(--line); + color: var(--fg); + font-family: inherit; + font-size: 13px; + font-weight: 700; + min-width: 240px; + padding: 6px 10px; + } + + .toolbar-button { + background: var(--bg); + border: 1px solid var(--line); + color: var(--fg); + cursor: pointer; + font-family: inherit; + font-size: 11px; + font-weight: 700; + padding: 4px 10px; + text-transform: uppercase; + } + + .toolbar-button:hover { + background: var(--fg); + color: var(--bg); + } + + .toolbar-count { + color: var(--muted); + font-size: 11px; + font-weight: 700; + } + + /* Hidden by search filter — still in DOM (state preserved on clear). */ + details.route[hidden] { + display: none !important; + } + .missing div { padding: 24px 10px; color: var(--muted); @@ -513,6 +618,18 @@ function renderHtml(groups) { <span class="pill error">${summary.erroredRoutes} errored</span> </div> </header> + <div class="toolbar" role="toolbar" aria-label="Visual review controls"> + <input + type="search" + id="route-filter" + placeholder="Filter routes — try 'treaty' or 'dashboard'" + autocomplete="off" + /> + <button type="button" class="toolbar-button" data-toolbar-action="expand-all">Expand all</button> + <button type="button" class="toolbar-button" data-toolbar-action="collapse-all">Collapse all</button> + <button type="button" class="toolbar-button" data-toolbar-action="expand-changed">Only show changed</button> + <span class="toolbar-count" id="route-count" aria-live="polite"></span> + </div> <main> ${body} </main> @@ -575,6 +692,186 @@ function renderHtml(groups) { } }); })(); + + // Toolbar: live route-name filter + expand/collapse all. + (function () { + var filter = document.getElementById("route-filter"); + var countEl = document.getElementById("route-count"); + var routes = Array.prototype.slice.call( + document.querySelectorAll("details.route"), + ); + + function applyFilter() { + var query = filter.value.trim().toLowerCase(); + var visible = 0; + for (var i = 0; i < routes.length; i++) { + var r = routes[i]; + var titleEl = r.querySelector(".route-title"); + var title = titleEl ? titleEl.textContent.toLowerCase() : ""; + var match = query === "" || title.indexOf(query) !== -1; + if (match) { + r.removeAttribute("hidden"); + visible += 1; + } else { + r.setAttribute("hidden", ""); + } + } + countEl.textContent = + query === "" + ? routes.length + " routes" + : visible + " of " + routes.length + " match"; + } + + filter.addEventListener("input", applyFilter); + + document.addEventListener("click", function (event) { + var button = event.target.closest("[data-toolbar-action]"); + if (!button) return; + var action = button.getAttribute("data-toolbar-action"); + if (action === "expand-all") { + for (var i = 0; i < routes.length; i++) { + if (!routes[i].hasAttribute("hidden")) routes[i].open = true; + } + } else if (action === "collapse-all") { + for (var j = 0; j < routes.length; j++) { + routes[j].open = false; + } + } else if (action === "expand-changed") { + // Hide unchanged routes entirely so the page only shows what + // moved vs main. Filter input still works; clearing the input + // and clicking "Expand all" restores everything. + var changedCount = 0; + for (var k = 0; k < routes.length; k++) { + var isChanged = routes[k].classList.contains("changed"); + if (isChanged) { + routes[k].removeAttribute("hidden"); + routes[k].open = true; + changedCount += 1; + } else { + routes[k].setAttribute("hidden", ""); + routes[k].open = false; + } + } + countEl.textContent = + changedCount + " changed (of " + routes.length + ")"; + } + }); + + // Initial count. + applyFilter(); + + // Keyboard shortcut: "/" focuses the filter input (like GitHub). + document.addEventListener("keydown", function (event) { + if ( + event.key === "/" && + document.activeElement !== filter && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + event.preventDefault(); + filter.focus(); + filter.select(); + } + }); + })(); + + // "Copy context" button: builds a markdown blob suitable for pasting + // into a coding agent when complaining about a route, then writes it + // to the clipboard. Payload includes PR/branch/commit ids, the route + // + auth state, before/after screenshot URLs, and explicit + // instructions telling the agent to download + view the screenshots + // when relevant. + (function () { + function formatContext(ctx) { + var lines = []; + lines.push("### Visual-review context"); + lines.push(""); + if (ctx.pr) lines.push("- PR: #" + ctx.pr + (ctx.repo ? " (" + ctx.repo + ")" : "")); + if (ctx.branch) lines.push("- Branch: \`" + ctx.branch + "\`"); + if (ctx.shortSha) lines.push("- Commit: \`" + ctx.shortSha + "\`" + (ctx.commitSha ? " (" + ctx.commitSha + ")" : "")); + lines.push("- Route: \`" + ctx.route + "\` (" + ctx.routeLabel + ")"); + if (ctx.routeUrl) lines.push("- Live URL: " + ctx.routeUrl); + lines.push("- Auth state: " + ctx.authState); + lines.push("- Diff vs main: " + ctx.status); + if (ctx.reviewUrl) lines.push("- Visual review: " + ctx.reviewUrl); + lines.push(""); + if (ctx.screenshots && ctx.screenshots.length > 0) { + lines.push("**Screenshots** — please \`curl -o\` these and look at them before responding so you understand what I am seeing:"); + lines.push(""); + for (var i = 0; i < ctx.screenshots.length; i++) { + var s = ctx.screenshots[i]; + lines.push("- **" + s.project + "** (" + s.diff + ")"); + if (s.beforeUrl) lines.push(" - before (main): " + s.beforeUrl); + if (s.afterUrl) lines.push(" - after (this PR): " + s.afterUrl); + } + lines.push(""); + lines.push("Suggested download commands (run from /tmp or similar):"); + for (var j = 0; j < ctx.screenshots.length; j++) { + var sj = ctx.screenshots[j]; + if (sj.beforeUrl) lines.push(" curl -sLO " + sj.beforeUrl); + if (sj.afterUrl) lines.push(" curl -sLO " + sj.afterUrl); + } + lines.push(""); + } + lines.push("**My complaint:**"); + lines.push("> _(replace this line with what looks wrong)_"); + return lines.join("\\n"); + } + + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function (resolve, reject) { + var ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand("copy"); + resolve(); + } catch (err) { + reject(err); + } finally { + document.body.removeChild(ta); + } + }); + } + + document.addEventListener("click", function (event) { + var button = event.target.closest(".copy-context-button"); + if (!button) return; + event.preventDefault(); + event.stopPropagation(); + var raw = button.getAttribute("data-context"); + if (!raw) return; + var ctx; + try { + ctx = JSON.parse(raw); + } catch (err) { + console.error("[visual-review] bad context payload", err); + return; + } + var formatted = formatContext(ctx); + copyToClipboard(formatted).then(function () { + var prev = button.textContent; + button.textContent = "✓ Copied"; + button.classList.add("copied"); + window.setTimeout(function () { + button.textContent = prev; + button.classList.remove("copied"); + }, 1500); + }, function () { + button.textContent = "Copy failed"; + window.setTimeout(function () { + button.textContent = "📋 Copy context"; + }, 1500); + }); + }); + })(); </script> </body> </html> @@ -584,10 +881,20 @@ function renderHtml(groups) { function renderRouteGroup(group) { const pairs = group.pairs.map(renderPair).join("\n"); const openAttr = group.changed || group.errored ? " open" : ""; - return `<details class="route ${group.changed || group.errored ? "changed" : "unchanged"}"${openAttr}> + const contextJson = JSON.stringify(buildRouteContext(group)); + const anchorId = `route-${slugifyForAnchor(group.routeName)}`; + return `<details id="${escapeHtml(anchorId)}" class="route ${group.changed || group.errored ? "changed" : "unchanged"}"${openAttr}> <summary> <span class="route-title">${escapeHtml(labelRoute(group.routeName))}</span> - <span class="pill ${group.errored ? "error" : group.changed ? "changed" : "unchanged"}">${escapeHtml(routeStatusLabel(group))}</span> + <span class="route-summary-actions"> + <button + type="button" + class="copy-context-button" + data-context='${escapeJsonForAttr(contextJson)}' + aria-label="Copy context for this route to clipboard" + >📋 Copy context</button> + <span class="pill ${group.errored ? "error" : group.changed ? "changed" : "unchanged"}">${escapeHtml(routeStatusLabel(group))}</span> + </span> </summary> <div class="pairs"> ${pairs} @@ -595,6 +902,67 @@ function renderRouteGroup(group) { </details>`; } +/** + * Pull together everything a reviewer would want to paste into a coding + * agent when complaining about a route: PR + commit + branch identifiers, + * the route's URL, whether the screenshots cover the auth state, and + * which projects ran. The button's click handler formats this as + * markdown and writes it to the clipboard. + */ +function buildRouteContext(group) { + const routeUrl = getRouteUrl(group.routeName); + const isAuthed = /-auth(\b|$)/.test(group.routeName) || group.routeName.endsWith("-auth"); + const status = group.errored + ? "errored" + : group.changed + ? "changed vs main" + : "unchanged vs main"; + const sha = reviewCommitSha ? shortSha(reviewCommitSha) : null; + const reviewBase = + prNumber && sha + ? `https://mikepsinn.github.io/optimitron/pr-${prNumber}/${sha}` + : null; + + // Per-project before/after screenshot URLs so the coding agent can + // download and look at them. relPath is the on-disk path inside the + // latest.html publish dir; the gh-pages site mounts that same tree at + // pr-N/<sha>/. + const screenshots = group.pairs.map((pair) => ({ + project: pair.projectName, + diff: pair.diff?.label ?? "unknown", + beforeUrl: pair.before && reviewBase ? `${reviewBase}/${pair.before.relPath}` : null, + afterUrl: pair.after && reviewBase ? `${reviewBase}/${pair.after.relPath}` : null, + })); + + return { + pr: prNumber, + branch: headBranch, + repo: repoSlug, + commitSha: reviewCommitSha, + shortSha: sha, + route: group.routeName, + routeLabel: labelRoute(group.routeName), + routeUrl, + authState: isAuthed ? "authenticated" : "logged-out", + status, + screenshots, + reviewUrl: reviewBase + ? `${reviewBase}/latest.html#route-${slugifyForAnchor(group.routeName)}` + : null, + }; +} + +function slugifyForAnchor(value) { + return String(value).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); +} + +/** Escape a JSON string so it survives unscathed inside a single-quoted + * HTML attribute. `&` → `&` so the browser doesn't re-encode it, + * `'` → `'` so the attribute doesn't terminate early. */ +function escapeJsonForAttr(value) { + return String(value).replace(/&/g, "&").replace(/'/g, "'"); +} + function renderPair(pair) { const routeUrl = getRouteUrl(pair.routeName); return `<article class="pair"> diff --git a/packages/web/scripts/mcp-smoke-test.ts b/packages/web/scripts/mcp-smoke-test.ts index 4e0433ad9..300e682db 100644 --- a/packages/web/scripts/mcp-smoke-test.ts +++ b/packages/web/scripts/mcp-smoke-test.ts @@ -525,7 +525,8 @@ async function runTriggerRoundtripScenario() { parsed.result === "spawned" && (parsed.spawnedTaskKeys?.length ?? 0) > 0; } catch { - /* ignore */ + // Malformed JSON from dryRun = smoke-test failure; dryOk stays false and + // record() below reports it. Don't abort the rest of the suite. } } record( @@ -554,7 +555,8 @@ async function runTriggerRoundtripScenario() { realTaskId = parsed.spawnedTaskIds[0] ?? null; } } catch { - /* ignore */ + // Malformed JSON from real fire = smoke-test failure; realTaskId stays + // null and record() below reports it. Don't abort the rest of the suite. } } record( @@ -583,7 +585,8 @@ async function runTriggerRoundtripScenario() { parsed.result === "spawned" && (parsed.spawnedTaskIds?.[0] ?? "") === realTaskId; } catch { - /* ignore */ + // Malformed JSON from refire = smoke-test failure; refireOk stays + // false and record() below reports it. Don't abort the rest of the suite. } } record( diff --git a/packages/web/scripts/render-pages-to-markdown.ts b/packages/web/scripts/render-pages-to-markdown.ts index c7d9d1aac..88665a7eb 100644 --- a/packages/web/scripts/render-pages-to-markdown.ts +++ b/packages/web/scripts/render-pages-to-markdown.ts @@ -5,7 +5,7 @@ * Visits each public route on the running dev server (port 3001 by * default), extracts the visible text from <main>, writes per-route * markdown files next to the matching `page.tsx` so you can preview - * what each page actually says before committing. + * page metadata and visible copy before committing. * * Output paths: * src/app/treaty/page.tsx -> src/app/treaty/page.logged-out.md @@ -25,6 +25,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { mintDemoSessionCookie } from "./mint-demo-session"; +import { buildCopyPreviewMarkdown } from "../src/lib/copy-preview-markdown"; +import type { CopyPreviewMetadata } from "../src/lib/copy-preview-markdown"; import { getRouteReviewSpecs } from "../src/lib/routes"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -40,6 +42,10 @@ const DEFAULT_LOGGED_OUT_ROUTES = getRouteReviewSpecs("copyPreview").map( const DEFAULT_AUTHENTICATED_ROUTES = getRouteReviewSpecs( "authenticatedCopyPreview", ).map((spec) => spec.path); +const GLOBAL_LOADING_TEXT = [ + "Booting Earth Optimization System", + "Your civilization is very important to us.", +]; function parseRoutesFromArgs(): { authenticatedRoutes: string[]; @@ -91,13 +97,74 @@ function routeToDirPath(route: string): string { return outPath; } -async function extractPage(page: import("@playwright/test").Page, route: string) { +async function getHeadAttribute( + page: import("@playwright/test").Page, + selector: string, + attribute: "content" | "href", +): Promise<string | null> { + const value = await page + .locator(selector) + .first() + .getAttribute(attribute) + .catch(() => null); + const cleaned = value?.replace(/\s+/g, " ").trim(); + return cleaned && cleaned.length > 0 ? cleaned : null; +} + +async function extractPageMetadata( + page: import("@playwright/test").Page, +): Promise<CopyPreviewMetadata> { + const title = await page.title(); + const cleanedTitle = title.replace(/\s+/g, " ").trim(); + + return { + canonical: await getHeadAttribute(page, 'link[rel="canonical"]', "href"), + description: await getHeadAttribute( + page, + 'meta[name="description"]', + "content", + ), + openGraphDescription: await getHeadAttribute( + page, + 'meta[property="og:description"]', + "content", + ), + openGraphImage: await getHeadAttribute( + page, + 'meta[property="og:image"]', + "content", + ), + openGraphTitle: await getHeadAttribute( + page, + 'meta[property="og:title"]', + "content", + ), + title: cleanedTitle.length > 0 ? cleanedTitle : null, + twitterDescription: await getHeadAttribute( + page, + 'meta[name="twitter:description"]', + "content", + ), + twitterTitle: await getHeadAttribute( + page, + 'meta[name="twitter:title"]', + "content", + ), + }; +} + +async function extractPage( + page: import("@playwright/test").Page, + route: string, +): Promise<{ bodyMarkdown: string; metadata: CopyPreviewMetadata }> { await page.goto(`${BASE}${route}`, { waitUntil: "networkidle", timeout: 30000, }); + await waitForGlobalLoaderToClear(page); await page.waitForTimeout(400); - return page.evaluate(() => { + const metadata = await extractPageMetadata(page); + const bodyMarkdown = await page.evaluate(() => { const root = document.querySelector("main") ?? document.body; // Replace every `[data-volatile]` subtree with a deterministic placeholder // so wall-clock counters, DB-derived counts, and async-loading fallbacks @@ -131,6 +198,23 @@ async function extractPage(page: import("@playwright/test").Page, route: string) } return out.join("\n"); }); + + return { bodyMarkdown, metadata }; +} + +async function waitForGlobalLoaderToClear( + page: import("@playwright/test").Page, +): Promise<void> { + await page + .waitForFunction( + (loadingText) => { + const bodyText = document.body.innerText ?? ""; + return loadingText.every((text) => !bodyText.includes(text)); + }, + GLOBAL_LOADING_TEXT, + { timeout: 15_000 }, + ) + .catch(() => undefined); } function cookieDomainFromBase(): string { @@ -173,13 +257,14 @@ async function capturePass( const dir = routeToDirPath(route); const outPath = path.join(dir, filename); try { - const md = await extractPage(page, route); + const extracted = await extractPage(page, route); await mkdir(dir, { recursive: true }); - // Deterministic: route as the only header. No timestamps, no - // capture metadata — every regeneration produces the same - // bytes for unchanged copy, so PR diffs only show real changes. - const header = `# ${route}\n\n`; - await writeFile(outPath, header + md + "\n", "utf8"); + const markdown = buildCopyPreviewMarkdown({ + bodyMarkdown: extracted.bodyMarkdown, + metadata: extracted.metadata, + route, + }); + await writeFile(outPath, markdown, "utf8"); console.log(`OK ${route} -> ${path.relative(WEB_ROOT, outPath)}`); } catch (err) { failures += 1; diff --git a/packages/web/scripts/review-local.mjs b/packages/web/scripts/review-local.mjs new file mode 100644 index 000000000..9c8816158 --- /dev/null +++ b/packages/web/scripts/review-local.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * review-local.mjs + * + * One-shot local review pipeline. Run after a UI/copy change to get the + * artifacts the voice-critic agent needs (screenshots + markdown + * extract) without waiting ~10 minutes for CI. + * + * Steps: + * 1. Sanity-check the dev server (port 3001) is up — print a helpful + * message if it isn't. + * 2. Run `copy:preview` (markdown extraction of public routes). + * 3. Run Playwright visual regression locally (writes screenshots + * under packages/web/screenshots/<project>/). + * 4. Build the visual-review HTML at output/playwright/review/latest.html. + * 5. Open latest.html in the default browser (best-effort; falls back + * to printing the path). + * + * Usage (run from `packages/web/`): + * pnpm review:local + * + * Optional: + * pnpm review:local -- --routes=/treaty,/dashboard # subset only + * pnpm review:local -- --skip-visual # markdown only + * pnpm review:local -- --skip-markdown # screenshots only + * + * Designed to feed into the `voice-critic` Claude Code subagent — after + * this finishes, the agent can open latest.html and the regenerated + * page.logged-out.md files to spot violations of the voice / reuse / + * ParameterValue rules. + */ + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WEB_ROOT = path.resolve(__dirname, ".."); +const REVIEW_HTML = path.join( + WEB_ROOT, + "output/playwright/review/latest.html", +); + +const args = new Set(process.argv.slice(2)); +const skipVisual = args.has("--skip-visual"); +const skipMarkdown = args.has("--skip-markdown"); +const routesArg = process.argv.find((a) => a.startsWith("--routes=")); + +function step(label) { + process.stdout.write(`\n[review:local] === ${label} ===\n`); +} + +function runPnpm(scriptName, extraArgs = []) { + return new Promise((resolve, reject) => { + const proc = spawn( + "pnpm", + ["--filter", "@optimitron/web", "run", scriptName, ...extraArgs], + { cwd: path.resolve(WEB_ROOT, "../.."), stdio: "inherit", shell: true }, + ); + proc.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${scriptName} exited with ${code}`)); + }); + }); +} + +async function checkDevServer() { + step("dev server check"); + try { + const res = await fetch("http://127.0.0.1:3001/api/auth/csrf", { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) { + console.log("[review:local] dev server up on :3001 ✓"); + return true; + } + } catch { + // fall through + } + console.warn( + "\n[review:local] dev server NOT detected on :3001.\n" + + " Start it in a separate terminal: `pnpm --filter @optimitron/web dev:fast`\n" + + " Then re-run this command.\n", + ); + return false; +} + +function openInBrowser(target) { + step("open review"); + if (!existsSync(target)) { + console.warn(`[review:local] file not found: ${target}`); + return; + } + console.log(`[review:local] open: ${target}`); + // Best-effort opener; if it fails, the path is already printed. + const opener = + process.platform === "win32" + ? "start" + : process.platform === "darwin" + ? "open" + : "xdg-open"; + try { + spawn(opener, [target], { stdio: "ignore", shell: true, detached: true }); + } catch { + // ignored — user has the path + } +} + +async function main() { + const devUp = await checkDevServer(); + if (!devUp && !skipVisual) { + process.exit(1); + } + + if (!skipMarkdown) { + step("markdown extract (copy:preview)"); + const passthroughArgs = routesArg ? ["--", routesArg] : []; + await runPnpm("copy:preview", passthroughArgs); + } else { + console.log("[review:local] skipping markdown extract (--skip-markdown)"); + } + + if (!skipVisual) { + step("playwright visual regression"); + const e2eArgs = ["--", "visual"]; + await runPnpm("e2e", e2eArgs); + + step("build visual review HTML"); + process.env.VISUAL_REVIEW_ALLOW_INCOMPLETE = "1"; + await runPnpm("visual:review"); + + openInBrowser(REVIEW_HTML); + } else { + console.log("[review:local] skipping playwright (--skip-visual)"); + } + + step("done"); + console.log( + "[review:local] Next:\n" + + " 1. Open " + + REVIEW_HTML + + "\n" + + " 2. Use the per-route Copy Context button to paste a complaint\n" + + " 3. Or invoke the voice-critic Claude Code subagent against the\n" + + " diff + the regenerated page.logged-out.md files.\n", + ); +} + +main().catch((err) => { + console.error("[review:local] failed:", err.message); + process.exit(1); +}); diff --git a/packages/web/scripts/run-playwright.mjs b/packages/web/scripts/run-playwright.mjs index 4f66e39d7..454c4f9df 100644 --- a/packages/web/scripts/run-playwright.mjs +++ b/packages/web/scripts/run-playwright.mjs @@ -37,14 +37,24 @@ const MODE_SPECS = { ? [ "e2e/smoke.spec.ts", "e2e/contrast-audit.spec.ts", + "e2e/treaty-page-structure.spec.ts", ] - : ["e2e/smoke.spec.ts"], + : [ + "e2e/smoke.spec.ts", + "e2e/treaty-page-structure.spec.ts", + ], contrast: ["e2e/contrast-audit.spec.ts"], mobile: ["e2e/mobile-responsiveness-audit.spec.ts"], "new-user-flow-screenshots": ["e2e/new-user-flow-screenshots.spec.ts"], "treaty-screenshots": ["e2e/treaty-vote-post-vote-screenshots.spec.ts"], "treaty-reminder-one-human": ["e2e/treaty-reminder-one-human.spec.ts"], - visual: ["e2e/visual-regression.spec.ts"], + visual: [ + "e2e/visual-regression.spec.ts", + "e2e/email-screenshots.spec.ts", + ], + // Kept as a standalone mode for ad-hoc local screenshot regeneration + // without running the full visual-regression suite. + "email-screenshots": ["e2e/email-screenshots.spec.ts"], }; const PLAYWRIGHT_DEFAULT_ARGS = ["--project=default"]; diff --git a/packages/web/scripts/sync-task-triggers.ts b/packages/web/scripts/sync-task-triggers.ts index 6efee644a..4d02547d5 100644 --- a/packages/web/scripts/sync-task-triggers.ts +++ b/packages/web/scripts/sync-task-triggers.ts @@ -9,17 +9,18 @@ * * Re-running is safe — every trigger is upserted by triggerKey. * - * This script builds a bare PrismaClient against DATABASE_URL and passes it - * into the trigger admin helpers, so deploy-time sync does not write through - * the web app singleton client. + * This compatibility wrapper builds a bare PrismaClient against DATABASE_URL + * and delegates to the canonical managed-data sync in @optimitron/db. */ import "./load-env"; +import { + formatManagedTaskTriggersResult, + syncManagedTaskTriggers, +} from "@optimitron/db/managed-task-triggers"; import { PrismaClient } from "@optimitron/db"; import { PrismaPg } from "@prisma/adapter-pg"; -import { ONE_PERCENT_TREATY_TRIGGER_BLUEPRINTS } from "../src/lib/triggers/blueprints/one-percent-treaty"; -import { createTaskTrigger, updateTaskTrigger } from "../src/lib/triggers/admin"; function parseArgs(argv: string[]) { const args = argv.filter((arg) => arg !== "--"); @@ -59,24 +60,8 @@ const { apply } = parseArgs(process.argv.slice(2)); async function main() { console.log(`[task-triggers] ${apply ? "apply" : "dry-run"}`); - for (const trigger of ONE_PERCENT_TREATY_TRIGGER_BLUEPRINTS) { - const existing = await prisma.taskTrigger.findUnique({ - where: { triggerKey: trigger.triggerKey }, - }); - if (!apply) { - console.log( - `[task-triggers] would ${existing ? "update" : "create"} ${trigger.triggerKey}`, - ); - continue; - } - if (existing) { - await updateTaskTrigger(trigger, { actorUserId: null }, prisma); - console.log(`[task-triggers] updated ${trigger.triggerKey}`); - } else { - await createTaskTrigger(trigger, { actorUserId: null }, prisma); - console.log(`[task-triggers] created ${trigger.triggerKey}`); - } - } + const result = await syncManagedTaskTriggers(prisma, { apply }); + console.log(formatManagedTaskTriggersResult(result)); } main() diff --git a/packages/web/src/app/api/admin/reasoning/issue-org-context-token/route.ts b/packages/web/src/app/api/admin/reasoning/issue-org-context-token/route.ts deleted file mode 100644 index 4e1db9d3c..000000000 --- a/packages/web/src/app/api/admin/reasoning/issue-org-context-token/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; -import { z } from "zod"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/lib/auth"; -import { issueOrgContextToken } from "@/lib/organization-context-token.server"; - -const schema = z.object({ - organizationId: z.string().min(1), - ttlSeconds: z.number().int().positive().optional(), -}); - -export async function POST(request: NextRequest) { - const session = await getServerSession(authOptions); - if (!session?.user?.id || !(session.user as { isAdmin?: boolean }).isAdmin) { - return NextResponse.json({ ok: false, error: "not-authorized" }, { status: 403 }); - } - const body = schema.safeParse(await request.json()); - if (!body.success) { - return NextResponse.json({ ok: false, error: "invalid-body" }, { status: 400 }); - } - try { - const token = issueOrgContextToken(body.data.organizationId, { - ttlSeconds: body.data.ttlSeconds, - }); - return NextResponse.json({ - ok: true, - token: token.encoded, - expiresAt: token.payload.expiresAt, - }); - } catch (err) { - return NextResponse.json( - { ok: false, error: "signing-unavailable", detail: String(err) }, - { status: 500 }, - ); - } -} diff --git a/packages/web/src/app/api/cron/monthly-chain-digest/route.ts b/packages/web/src/app/api/cron/monthly-chain-digest/route.ts new file mode 100644 index 000000000..695b50666 --- /dev/null +++ b/packages/web/src/app/api/cron/monthly-chain-digest/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { isAuthorizedCronRequest } from "@/lib/cron"; +import { publishMonthlyChainDigest } from "@/lib/email/monthly-chain-digest.server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + if (!isAuthorizedCronRequest(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const result = await publishMonthlyChainDigest(); + return NextResponse.json(result); + } catch (error) { + console.error("[MONTHLY CHAIN DIGEST CRON] Error:", error); + return NextResponse.json( + { error: "Failed to publish monthly chain digest." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dev/login-as-demo/route.ts b/packages/web/src/app/api/dev/login-as-demo/route.ts new file mode 100644 index 000000000..0d8893525 --- /dev/null +++ b/packages/web/src/app/api/dev/login-as-demo/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { encode } from "next-auth/jwt"; +import { DEMO_USER_EMAIL } from "@optimitron/data/campaign"; +import { prisma } from "@/lib/prisma"; + +// `/api/dev/login-as-demo?next=/some-path` — server-side mints a NextAuth +// session JWT for a non-production review user and sets the session cookie, +// then redirects to `next`. Triggered by the middleware when a request arrives +// with `?login=demo` query param (see `packages/web/src/middleware.ts`). +// +// Gated to non-production: returns 404 on prod to ensure the auth-bypass +// surface only exists on preview / dev environments. Preview deploys are +// already auth-gated by Vercel's bypass-cookie system, so the additional +// auth-bypass-as-demo only works for people you've shared a preview with. +// +// The demo review user is created by managed-data. + +function isPreviewOrDev(): boolean { + // Allow-list, not deny-list. Vercel sets NODE_ENV=production on BOTH + // preview and production deploys, so a "block if NODE_ENV=production" + // check breaks the feature on the exact environment it's designed for. + // The only safe approach: explicitly allow only environments we know + // are non-prod. + // + // - Vercel preview deploys: VERCEL_ENV === "preview" + // - Vercel dev: VERCEL_ENV === "development" + // - Localhost / generic dev: NODE_ENV === "development" (VERCEL_ENV unset) + // + // Anything else (Vercel production, self-hosted prod where neither is + // explicitly preview/dev) is blocked. + return ( + process.env.VERCEL_ENV === "preview" || + process.env.VERCEL_ENV === "development" || + process.env.NODE_ENV === "development" + ); +} + +function sanitizeNext(raw: string | null): string { + if (!raw) return "/"; + // Only relative paths — prevent open-redirect to arbitrary external URLs. + if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; + return raw; +} + +function cookieName(req: Request): string { + // NextAuth uses `__Secure-next-auth.session-token` over HTTPS, + // `next-auth.session-token` over HTTP (localhost dev). + return new URL(req.url).protocol === "https:" + ? "__Secure-next-auth.session-token" + : "next-auth.session-token"; +} + +export async function GET(request: Request) { + if (!isPreviewOrDev()) { + return new NextResponse("Not Found", { status: 404 }); + } + + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) { + return new NextResponse( + "Server misconfiguration: NEXTAUTH_SECRET not set", + { status: 500, headers: { "content-type": "text/plain" } }, + ); + } + + const url = new URL(request.url); + const next = sanitizeNext(url.searchParams.get("next")); + + const user = await prisma.user.findUnique({ + where: { email: DEMO_USER_EMAIL }, + select: { + id: true, + email: true, + isAdmin: true, + personId: true, + person: { + select: { displayName: true, handle: true, image: true }, + }, + }, + }); + if (!user) { + return new NextResponse( + `Demo user ${DEMO_USER_EMAIL} not found in DB. Managed-data sync should have created it; run \`pnpm db:sync:managed-data -- --apply\` against this environment's database.`, + { status: 500, headers: { "content-type": "text/plain" } }, + ); + } + + // Token shape mirrors the jwt callback in src/lib/auth.ts so the session + // callback reads the same fields a real signed-in user has. + const token = { + sub: user.id, + id: user.id, + email: user.email, + name: user.person?.displayName ?? null, + picture: user.person?.image ?? null, + handle: user.person?.handle ?? null, + personId: user.personId, + isAdmin: user.isAdmin, + }; + + const jwt = await encode({ + token, + secret, + maxAge: 30 * 24 * 60 * 60, // 30 days, NextAuth default + }); + + const name = cookieName(request); + const response = NextResponse.redirect(new URL(next, request.url), 303); + response.cookies.set({ + name, + value: jwt, + httpOnly: true, + sameSite: "lax", + secure: name.startsWith("__Secure-"), + path: "/", + maxAge: 30 * 24 * 60 * 60, + }); + return response; +} diff --git a/packages/web/src/app/api/dev/logout/route.ts b/packages/web/src/app/api/dev/logout/route.ts new file mode 100644 index 000000000..a9df2f838 --- /dev/null +++ b/packages/web/src/app/api/dev/logout/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; + +// `/api/dev/logout?next=/some-path` — clears the NextAuth session cookie +// and redirects to `next`. Triggered by the middleware when a request +// arrives with `?logout=1` query param. +// +// NOT env-gated. Clearing your own session cookie is harmless on any +// environment — it just signs the current viewer out. + +function sanitizeNext(raw: string | null): string { + if (!raw) return "/"; + if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; + return raw; +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const next = sanitizeNext(url.searchParams.get("next")); + + const response = NextResponse.redirect(new URL(next, request.url), 307); + + // Clear both possible cookie names (HTTPS uses the __Secure- prefix; HTTP + // localhost uses the bare name). Clearing both is cheap and avoids the + // protocol-detection branch. + // + // CRITICAL: the attributes here MUST match the attributes used when the + // cookie was originally SET, or the browser treats this as a different + // cookie and ignores the clear. The `__Secure-` prefix in particular + // *requires* `secure: true` — without it, browsers reject the Set-Cookie + // entirely. That's the bug that made `?logout=1` look like a no-op: + // the clear was silently rejected and the original session cookie + // persisted. + for (const name of [ + "next-auth.session-token", + "__Secure-next-auth.session-token", + ]) { + response.cookies.set({ + name, + value: "", + httpOnly: true, + sameSite: "lax", + secure: name.startsWith("__Secure-"), + path: "/", + expires: new Date(0), + maxAge: 0, + }); + } + return response; +} diff --git a/packages/web/src/app/api/og/route/route.tsx b/packages/web/src/app/api/og/route/route.tsx new file mode 100644 index 000000000..9023f23aa --- /dev/null +++ b/packages/web/src/app/api/og/route/route.tsx @@ -0,0 +1,19 @@ +import type { NextRequest } from "next/server"; +import { generateBlackWhiteTextOgImageResponseForNavItem } from "@/lib/black-white-text-og-image-response"; +import { getInternalNavItemForPath, ROUTES } from "@/lib/routes"; + +export const runtime = "nodejs"; +export const revalidate = 3600; + +export async function GET(request: NextRequest) { + const pathname = request.nextUrl.searchParams.get("path") ?? ROUTES.home; + const navItem = getInternalNavItemForPath(pathname); + + if (!navItem) { + return new Response("No route metadata found for OG image path.", { + status: 404, + }); + } + + return generateBlackWhiteTextOgImageResponseForNavItem(navItem); +} diff --git a/packages/web/src/app/api/referendums/[slug]/organization-position/route.test.ts b/packages/web/src/app/api/referendums/[slug]/organization-position/route.test.ts index f2ef053db..e8470f0d3 100644 --- a/packages/web/src/app/api/referendums/[slug]/organization-position/route.test.ts +++ b/packages/web/src/app/api/referendums/[slug]/organization-position/route.test.ts @@ -51,6 +51,7 @@ vi.mock("@/lib/prisma", () => ({ })); import { POST } from "./route"; +import { ORGANIZATION_ACTIVATION_TASK_TITLE } from "@/lib/messaging"; const ACTIVE_REFERENDUM = { id: "ref_1", @@ -89,7 +90,7 @@ describe("POST /api/referendums/[slug]/organization-position", () => { }); mocks.ensureOrganizationTreatyActivationTask.mockResolvedValue({ id: "task_1", - title: "Share the Clinical Trial Abundance Survey with your members", + title: ORGANIZATION_ACTIVATION_TASK_TITLE, }); mocks.positionFindUnique.mockResolvedValue(null); mocks.positionUpsert.mockResolvedValue({ @@ -256,7 +257,7 @@ describe("POST /api/referendums/[slug]/organization-position", () => { }); mocks.ensureOrganizationTreatyActivationTask.mockResolvedValue({ id: "task_iam", - title: "Share the Clinical Trial Abundance Survey with your members", + title: ORGANIZATION_ACTIVATION_TASK_TITLE, }); const res = await POST( diff --git a/packages/web/src/app/api/referendums/[slug]/vote/route.test.ts b/packages/web/src/app/api/referendums/[slug]/vote/route.test.ts index 9bdeaa6b1..2e287e168 100644 --- a/packages/web/src/app/api/referendums/[slug]/vote/route.test.ts +++ b/packages/web/src/app/api/referendums/[slug]/vote/route.test.ts @@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({ convertReferralInvitationForVote: vi.fn(), ensurePersonForUser: vi.fn(), ensureUserTreatyTask: vi.fn(), - verifyOrgContextToken: vi.fn(), })); vi.mock("@/lib/auth-utils", () => ({ @@ -66,10 +65,6 @@ vi.mock("@/lib/tasks/user-treaty-task.server", () => ({ ensureUserTreatyTask: mocks.ensureUserTreatyTask, })); -vi.mock("@/lib/organization-context-token.server", () => ({ - verifyOrgContextToken: mocks.verifyOrgContextToken, -})); - vi.mock("@/lib/wishes.server", () => ({ grantWishes: mocks.grantWishes, })); @@ -103,6 +98,11 @@ const TREATY_REFERENDUM = { slug: "one-percent-treaty", }; +const HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM = { + ...ACTIVE_REFERENDUM, + slug: "court-humanity-v-government-verdict", +}; + describe("POST /api/referendums/[slug]/vote", () => { beforeEach(() => { vi.resetAllMocks(); @@ -139,7 +139,6 @@ describe("POST /api/referendums/[slug]/vote", () => { shareReferralUrl: "task_share", }, }); - mocks.verifyOrgContextToken.mockReturnValue({ ok: false, reason: "no-token" }); }); it("returns 401 when unauthenticated", async () => { @@ -318,6 +317,29 @@ describe("POST /api/referendums/[slug]/vote", () => { ).not.toHaveBeenCalled(); }); + it("registers a named plaintiff on a YES Humanity v Government verdict vote", async () => { + mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); + mocks.findUnique.mockResolvedValue(HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM); + const vote = { id: "vote_1", answer: "YES", userId: "user_1", referendumId: "ref_1" }; + mocks.upsert.mockResolvedValue(vote); + + const res = await POST( + makeRequest("court-humanity-v-government-verdict", { answer: "yes" }), + makeParams("court-humanity-v-government-verdict"), + ); + + expect(res.status).toBe(200); + expect(mocks.ensureUserTreatyTask).not.toHaveBeenCalled(); + expect(mocks.ensureHumanityVGovernmentPlaintiffParty).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + createdByUserId: "user_1", + displayName: "Mike", + subjectId: "subject_1", + }), + ); + }); + it("casts a NO vote successfully", async () => { mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); mocks.findUnique.mockResolvedValue(ACTIVE_REFERENDUM); @@ -665,20 +687,10 @@ describe("POST /api/referendums/[slug]/vote", () => { }); }); - it("stores verified organization attribution alongside personal referral credit", async () => { + it("stores approved public organization survey slug attribution without a signed token", async () => { mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); mocks.findUnique.mockResolvedValue(ACTIVE_REFERENDUM); mocks.findUserByHandleOrReferralCode.mockResolvedValue({ id: "referrer_1" }); - mocks.verifyOrgContextToken.mockReturnValue({ - ok: true, - organizationId: "org_1", - payload: { - organizationId: "org_1", - issuedAt: "2026-04-29T00:00:00.000Z", - expiresAt: "2026-05-06T00:00:00.000Z", - sessionSalt: "salt", - }, - }); mocks.organizationFindUnique.mockResolvedValue({ id: "org_1", status: "APPROVED", @@ -697,15 +709,17 @@ describe("POST /api/referendums/[slug]/vote", () => { makeRequest("test-ref", { answer: "YES", ref: "friend123", - orgContextToken: "signed-org-token", + organizationSlug: "trial-partner", }), makeParams("test-ref"), ); - expect(mocks.verifyOrgContextToken).toHaveBeenCalledWith("signed-org-token"); + expect(mocks.organizationFindUnique).toHaveBeenCalledWith({ + where: { slug: "trial-partner" }, + select: { id: true, status: true, deletedAt: true }, + }); expect(mocks.upsert).toHaveBeenCalledWith( expect.objectContaining({ - // Org attribution is first-org-wins: set on create, never on update. update: expect.not.objectContaining({ organizationId: expect.anything() }), create: expect.objectContaining({ referredByUserId: "referrer_1", @@ -719,16 +733,6 @@ describe("POST /api/referendums/[slug]/vote", () => { // The upsert.update branch must never carry organizationId — first org wins. mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); mocks.findUnique.mockResolvedValue(ACTIVE_REFERENDUM); - mocks.verifyOrgContextToken.mockReturnValue({ - ok: true, - organizationId: "org_b", - payload: { - organizationId: "org_b", - issuedAt: "2026-04-29T00:00:00.000Z", - expiresAt: "2026-05-06T00:00:00.000Z", - sessionSalt: "salt", - }, - }); mocks.organizationFindUnique.mockResolvedValue({ id: "org_b", status: "APPROVED", @@ -742,7 +746,10 @@ describe("POST /api/referendums/[slug]/vote", () => { }); await POST( - makeRequest("test-ref", { answer: "NO", orgContextToken: "org-b-token" }), + makeRequest("test-ref", { + answer: "NO", + organizationSlug: "org-b", + }), makeParams("test-ref"), ); @@ -755,10 +762,10 @@ describe("POST /api/referendums/[slug]/vote", () => { ); }); - it("does not attribute an organization when the token signature is invalid", async () => { + it("does not attribute an organization when the public slug is unknown", async () => { mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); mocks.findUnique.mockResolvedValue(ACTIVE_REFERENDUM); - mocks.verifyOrgContextToken.mockReturnValue({ ok: false, reason: "bad-signature" }); + mocks.organizationFindUnique.mockResolvedValue(null); mocks.upsert.mockResolvedValue({ id: "vote_1", answer: "YES", @@ -767,11 +774,17 @@ describe("POST /api/referendums/[slug]/vote", () => { }); await POST( - makeRequest("test-ref", { answer: "YES", orgContextToken: "tampered-token" }), + makeRequest("test-ref", { + answer: "YES", + organizationSlug: "unknown-org", + }), makeParams("test-ref"), ); - expect(mocks.organizationFindUnique).not.toHaveBeenCalled(); + expect(mocks.organizationFindUnique).toHaveBeenCalledWith({ + where: { slug: "unknown-org" }, + select: { id: true, status: true, deletedAt: true }, + }); expect(mocks.upsert).toHaveBeenCalledWith( expect.objectContaining({ update: expect.not.objectContaining({ organizationId: expect.anything() }), @@ -780,19 +793,9 @@ describe("POST /api/referendums/[slug]/vote", () => { ); }); - it("ignores signed organization attribution when the organization is no longer approved", async () => { + it("ignores public organization slug attribution when the organization is no longer approved", async () => { mocks.requireAuth.mockResolvedValue({ userId: "user_1" }); mocks.findUnique.mockResolvedValue(ACTIVE_REFERENDUM); - mocks.verifyOrgContextToken.mockReturnValue({ - ok: true, - organizationId: "org_1", - payload: { - organizationId: "org_1", - issuedAt: "2026-04-29T00:00:00.000Z", - expiresAt: "2026-05-06T00:00:00.000Z", - sessionSalt: "salt", - }, - }); mocks.organizationFindUnique.mockResolvedValue({ id: "org_1", status: "REJECTED", @@ -808,13 +811,13 @@ describe("POST /api/referendums/[slug]/vote", () => { await POST( makeRequest("test-ref", { answer: "YES", - orgContextToken: "signed-org-token", + organizationSlug: "rejected-org", }), makeParams("test-ref"), ); expect(mocks.organizationFindUnique).toHaveBeenCalledWith({ - where: { id: "org_1" }, + where: { slug: "rejected-org" }, select: { id: true, status: true, deletedAt: true }, }); expect(mocks.upsert).toHaveBeenCalledWith( diff --git a/packages/web/src/app/api/referendums/[slug]/vote/route.ts b/packages/web/src/app/api/referendums/[slug]/vote/route.ts index 225696be2..00184ab47 100644 --- a/packages/web/src/app/api/referendums/[slug]/vote/route.ts +++ b/packages/web/src/app/api/referendums/[slug]/vote/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma"; import { requireAuth } from "@/lib/auth-utils"; import { ActivityType, + HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG, OrgStatus, ReferendumStatus, ReferendumVoteSource, @@ -22,7 +23,11 @@ import { ensureSubjectForPerson } from "@/lib/subject.server"; import { ensureHumanityVGovernmentPlaintiffParty } from "@/lib/humanity-v-government-case.server"; import { ensureUserTreatyTask } from "@/lib/tasks/user-treaty-task.server"; import { TREATY_REFERENDUM_SLUG } from "@/lib/treaty"; -import { verifyOrgContextToken } from "@/lib/organization-context-token.server"; +import { sendPostVoteShareEmail } from "@/lib/email/post-vote-share-email"; +import { sendReferralFirstConversionEmail } from "@/lib/email/referral-first-conversion-email"; +import { buildUserReferralUrl, getBaseUrl } from "@/lib/url"; +import { ROUTES } from "@/lib/routes"; +import { getUserDisplayName, userDisplaySelect } from "@/lib/user-display"; const log = createLogger("referendum-vote"); @@ -38,7 +43,7 @@ export async function POST( ref?: string; makePublic?: boolean; inviteToken?: string; - orgContextToken?: string; + organizationSlug?: string; /// Full URL the voter was on when they hit submit (window.location.href). /// Captured for forensic attribution — first-vote-wins, never overwritten. originUrl?: string; @@ -85,10 +90,13 @@ export async function POST( } } - const orgContextVerification = verifyOrgContextToken(body.orgContextToken); - const verifiedOrganization = orgContextVerification.ok + const publicOrganizationSlug = + typeof body.organizationSlug === "string" + ? body.organizationSlug.trim() + : ""; + const verifiedOrganization = publicOrganizationSlug ? await prisma.organization.findUnique({ - where: { id: orgContextVerification.organizationId }, + where: { slug: publicOrganizationSlug }, select: { id: true, status: true, deletedAt: true }, }) : null; @@ -207,6 +215,31 @@ export async function POST( log.error("Wish grant error", wishError); } + async function registerHumanityVGovernmentPlaintiff() { + try { + // Re-read person.isPublic so the plaintiff visibility reflects the + // makePublic toggle that may have just fired above. + const refreshedPerson = await prisma.person.findUnique({ + where: { id: person.id }, + select: { displayName: true, isPublic: true }, + }); + await prisma.$transaction(async (tx) => { + const subject = await ensureSubjectForPerson(tx, { + id: person.id, + displayName: refreshedPerson?.displayName ?? person.displayName, + }); + await ensureHumanityVGovernmentPlaintiffParty(tx, { + createdByUserId: userId, + displayName: refreshedPerson?.displayName ?? person.displayName, + isPublic: refreshedPerson?.isPublic ?? false, + subjectId: subject.id, + }); + }); + } catch (plaintiffError) { + log.error("Plaintiff registration error", plaintiffError); + } + } + if (referendum.slug === TREATY_REFERENDUM_SLUG) { try { await ensureUserTreatyTask({ @@ -216,34 +249,108 @@ export async function POST( } catch (taskError) { log.error("Treaty humanity-management task sync error", taskError); } + } + + // Auto-register YES voters as plaintiffs on Humanity v. Government. + // Skipped for NO/ABSTAIN since dissenting or undecided voters do not + // register a plaintiff claim. + if ( + answer === "YES" && + (referendum.slug === TREATY_REFERENDUM_SLUG || + referendum.slug === HUMANITY_V_GOVERNMENT_VERDICT_REFERENDUM_SLUG) + ) { + await registerHumanityVGovernmentPlaintiff(); + } + + if (referendum.slug === TREATY_REFERENDUM_SLUG && answer === "YES") { + // Forward-friendly post-vote share email + first-conversion email to + // the referrer (if any). Both are deduped by emailLog dedupeKey so + // re-votes and subsequent referral conversions don't fire again. The + // two sends are independent: a voter share failure must not suppress + // the referrer's first-conversion email (or vice-versa). + type VoterRecord = { + id: string; + email: string; + referralCode: string | null; + person: + | { + id: string; + handle: string | null; + displayName: string | null; + image: string | null; + isPublic: boolean | null; + } + | null; + }; + let voter: VoterRecord | null = null; + try { + voter = (await prisma.user.findUnique({ + where: { id: userId }, + select: { + ...userDisplaySelect, + referralCode: true, + // isPublic gates whether we surface this voter's name to the + // referrer below. Not part of userDisplaySelect because most + // display sites don't need it. + person: { + select: { + id: true, + handle: true, + displayName: true, + image: true, + isPublic: true, + }, + }, + }, + })) as VoterRecord | null; + + if (voter?.email) { + const referralUrl = buildUserReferralUrl({ + handle: voter.person?.handle ?? null, + referralCode: voter.referralCode, + }); + await sendPostVoteShareEmail({ + voteId: vote.id, + userId, + toAddress: voter.email, + referralUrl, + }); + } + } catch (postVoteShareError) { + log.error("Post-vote share email error", postVoteShareError); + } - // Auto-register YES voters as plaintiffs on Humanity v. Government. - // Plaintiff = treaty signer = juror, one click. The case row is upserted - // by ensureHumanityVGovernmentPlaintiffParty so we don't need a separate - // seed; first plaintiff in creates the case. Skipped for NO/ABSTAIN since - // dissenting voters do not register a plaintiff claim. - if (answer === "YES") { + if (vote.referredByUserId) { try { - // Re-read person.isPublic so the plaintiff visibility reflects the - // makePublic toggle that may have just fired above. - const refreshedPerson = await prisma.person.findUnique({ - where: { id: person.id }, - select: { displayName: true, isPublic: true }, + const referrer = await prisma.user.findUnique({ + where: { id: vote.referredByUserId }, + select: { + ...userDisplaySelect, + referralCode: true, + }, }); - await prisma.$transaction(async (tx) => { - const subject = await ensureSubjectForPerson(tx, { - id: person.id, - displayName: refreshedPerson?.displayName ?? person.displayName, + if (referrer?.email) { + // Referral links can be shared anywhere (Twitter, group chats), + // so the "referrer" may not actually know the voter. Only + // expose the voter's display name when they've opted into a + // public profile; otherwise fall back to a generic label. + const voterDisplayName = voter?.person?.isPublic + ? getUserDisplayName(voter) + : "A new voter"; + const referrerReferralUrl = buildUserReferralUrl({ + handle: referrer.person?.handle ?? null, + referralCode: referrer.referralCode, }); - await ensureHumanityVGovernmentPlaintiffParty(tx, { - createdByUserId: userId, - displayName: refreshedPerson?.displayName ?? person.displayName, - isPublic: refreshedPerson?.isPublic ?? false, - subjectId: subject.id, + await sendReferralFirstConversionEmail({ + referrerUserId: vote.referredByUserId, + referrerEmail: referrer.email, + voterDisplayName, + dashboardUrl: `${getBaseUrl()}${ROUTES.dashboard}`, + referrerReferralUrl, }); - }); - } catch (plaintiffError) { - log.error("Plaintiff registration error", plaintiffError); + } + } catch (firstConversionError) { + log.error("Referral first-conversion email error", firstConversionError); } } } diff --git a/packages/web/src/app/court/page.logged-out.md b/packages/web/src/app/court/page.logged-out.md index 518fdf254..50e260267 100644 --- a/packages/web/src/app/court/page.logged-out.md +++ b/packages/web/src/app/court/page.logged-out.md @@ -1,5 +1,18 @@ # /court +## Metadata + +- Page title: Court of Humanity | International Campaign to End War and Disease +- Meta description: Should humans be able to sue a government that kills, injures, or ruins their family? +- Canonical: https://warondisease.org/court +- Open Graph title: Court of Humanity +- Open Graph description: Should humans be able to sue a government that kills, injures, or ruins their family? +- Open Graph image: https://warondisease.org/site-assets/warondisease/war-on-disease-og-1200x630.png +- Twitter title: Court of Humanity +- Twitter description: Should humans be able to sue a government that kills, injures, or ruins their family? + +## Visible Page Copy + - If a government kills, injures, or harms you or your family, should you have the same right to sue it that you would have if a corporation did the same? - NO - YES diff --git a/packages/web/src/app/dashboard/loading.tsx b/packages/web/src/app/dashboard/loading.tsx index 17733d20a..3f6416d00 100644 --- a/packages/web/src/app/dashboard/loading.tsx +++ b/packages/web/src/app/dashboard/loading.tsx @@ -1,9 +1,9 @@ -import { NeobrutalistLoader } from "@/components/ui/neobrutalist-loader" +import { CivilizationOsLoader } from "@/components/ui/civilization-os-loader" export default function DashboardLoading() { return ( <div className="mx-auto flex min-h-[60vh] max-w-4xl items-center justify-center px-4 py-16"> - <NeobrutalistLoader size="lg" /> + <CivilizationOsLoader size="lg" /> </div> ) } diff --git a/packages/web/src/app/dashboard/page.tsx b/packages/web/src/app/dashboard/page.tsx index c64761825..d346cbce7 100644 --- a/packages/web/src/app/dashboard/page.tsx +++ b/packages/web/src/app/dashboard/page.tsx @@ -15,7 +15,6 @@ import { getSiteFromHeaders } from "@/lib/site"; import { ensurePersonForUser } from "@/lib/person.server"; import { ensureUserTreatyTask } from "@/lib/tasks/user-treaty-task.server"; import { getProfileIdentityData } from "@/lib/profile-identity.server"; -import { selectTreatyPresidentManagementTasks } from "@/lib/tasks/president-management"; export async function generateMetadata(): Promise<Metadata> { const hdrs = await headers(); @@ -71,22 +70,15 @@ export default async function DashboardPage({ // referral-invitation tasks attach to. await ensureUserTreatyTask({ personId: person.id, userId }); - // Fetch tasks + the DashboardUser shape in parallel. The user is needed - // to render the handle/referral-link banner above the composer. - const [taskData, profileData] = await Promise.all([ - getTasksPageData(userId), - getProfileIdentityData(userId), - ]); + // The user is needed to render the referral link for Assignment 1. + const profileData = await getProfileIdentityData(userId); if (!profileData) { redirect(getSignInPath(ROUTES.dashboard)); } - const presidentManagement = selectTreatyPresidentManagementTasks(taskData); - return ( <TreatyTaskDashboardClient user={profileData.user} - signerTasks={presidentManagement.signerTasks} /> ); } diff --git a/packages/web/src/app/dev/email/[template]/route.ts b/packages/web/src/app/dev/email/[template]/route.ts new file mode 100644 index 000000000..e68fea9c8 --- /dev/null +++ b/packages/web/src/app/dev/email/[template]/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from "next/server"; +import { buildMagicLinkHtml } from "@/lib/email/magic-link-render"; +import { buildMonthlyChainDigestHtml } from "@/lib/email/monthly-chain-digest-email"; +import { buildPostVoteShareHtml } from "@/lib/email/post-vote-share-email"; +import { buildReferralFirstConversionHtml } from "@/lib/email/referral-first-conversion-email"; +import { buildTaskAssignmentNotificationEmail } from "@/lib/tasks/task-assignment-notification-email.server"; +import { buildTaskCommentNotificationEmail } from "@/lib/tasks/task-comment-notification-email.server"; + +// `/dev/email/<template>` — server-side renders each email template with +// representative sample tokens and returns the raw HTML. Replaces the +// Playwright spec's direct imports of `*-email.server.ts` modules (which +// hit a transformer bug on `@optimitron/db/dist`'s `export *`) — the +// spec now uses `page.goto('/dev/email/<template>')` and screenshots +// the rendered page like any other route. +// +// Gated to non-production: returns 404 on prod to avoid exposing email +// internals + sample copy publicly. + +const SAMPLE_REFERRAL = "https://warondisease.org/vote/SAMPLE"; +const SAMPLE_DASHBOARD = "https://warondisease.org/dashboard"; + +const renderers: Record<string, () => string> = { + "magic-link": () => + buildMagicLinkHtml( + "https://warondisease.local/api/auth/callback/email?token=SAMPLE", + "warondisease.local", + { brandColor: "#111827", buttonText: "#ffffff" }, + ), + "post-vote-share": () => buildPostVoteShareHtml(SAMPLE_REFERRAL), + "referral-first-conversion": () => + buildReferralFirstConversionHtml({ + voterDisplayName: "Sample Voter", + dashboardUrl: SAMPLE_DASHBOARD, + referrerReferralUrl: SAMPLE_REFERRAL, + }), + "task-assignment": () => + buildTaskAssignmentNotificationEmail({ + description: + "The 1% Treaty needs your country's signature. Sign the document, share the link with two people you love, and verify that your local treaty signer has been contacted.\n\nThis is a sample task description rendered into the email template.", + id: "sample-task-id", + recipientName: "Sample Assignee", + replyInstruction: "Reply to this email to leave a comment on the task.", + title: "Sign the 1% Treaty for {country}", + recipientReferralUrl: SAMPLE_REFERRAL, + }).html, + "monthly-digest-positive": () => + buildMonthlyChainDigestHtml({ + monthlyConversionCount: 7, + totalConversionCount: 19, + referralUrl: SAMPLE_REFERRAL, + dashboardUrl: SAMPLE_DASHBOARD, + monthLabel: "May 2026", + }), + "monthly-digest-resend": () => + buildMonthlyChainDigestHtml({ + monthlyConversionCount: 0, + totalConversionCount: 0, + referralUrl: SAMPLE_REFERRAL, + dashboardUrl: SAMPLE_DASHBOARD, + monthLabel: "May 2026", + }), + "task-comment-notification": () => + buildTaskCommentNotificationEmail({ + task: { id: "sample-task-id", title: "Sign the 1% Treaty" }, + comment: { + authorAvatarUrl: null, + authorName: "Sample Author", + message: + "I just signed and forwarded the share message to four of my family members. Two of them have already voted.", + }, + recipientReason: "You are assigned to this task.", + replyInstruction: "Reply to this email to leave a comment on the task.", + recipientReferralUrl: SAMPLE_REFERRAL, + }).html, +}; + +// Gmail-mobile-like wrapper that surrounds the email body in an iframe +// scaled to phone width (~420px). The iframe isolates the email's own +// inline styles from the wrapper chrome. Reviewers on mobile (or desktop +// with narrow viewport) get a faithful rendering of how Gmail's mobile +// app actually displays the email. +// +// Use `?raw=1` to get the bare email HTML (e.g. for the Playwright +// screenshot spec, which page.goto's this URL). +function escapeForSrcdoc(html: string): string { + return html.replace(/&/g, "&").replace(/"/g, """); +} + +function buildMobilePreviewWrapper(template: string, emailHtml: string): string { + const safe = escapeForSrcdoc(emailHtml); + return `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> +<title>Email preview: ${template} + + + +
+ Email preview · ${template} · Gmail-mobile width (420px) + view raw HTML → +
+
+ +
+ +`; +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ template: string }> }, +) { + if (process.env.VERCEL_ENV === "production") { + return new NextResponse("Not Found", { status: 404 }); + } + + const { template } = await params; + const renderer = renderers[template]; + if (!renderer) { + const available = Object.keys(renderers).join(", "); + return new NextResponse( + `Unknown email template: "${template}". Available: ${available}`, + { status: 404, headers: { "content-type": "text/plain" } }, + ); + } + + const html = renderer(); + const url = new URL(request.url); + const wantsRaw = url.searchParams.get("raw") === "1"; + + const body = wantsRaw ? html : buildMobilePreviewWrapper(template, html); + return new NextResponse(body, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} diff --git a/packages/web/src/app/dividend/page.tsx b/packages/web/src/app/dividend/page.tsx index 99a801187..e6432e933 100644 --- a/packages/web/src/app/dividend/page.tsx +++ b/packages/web/src/app/dividend/page.tsx @@ -1,17 +1,19 @@ import Link from "next/link"; -import type { Metadata } from "next"; import { getOptimizationDividendSummary, US_ADULT_POPULATION, } from "@/lib/analysis-products"; import { DividendCalculator } from "@/components/landing/DividendCalculator"; -import { getBudgetCategoryPath, getLegislationPath, ROUTES } from "@/lib/routes"; +import { getRouteMetadata } from "@/lib/metadata"; +import { + dividendLink, + getBudgetCategoryPath, + getLegislationPath, + ROUTES, +} from "@/lib/routes"; import { usBudgetAnalysis } from "@/data/us-budget-analysis"; -export const metadata: Metadata = { - title: "Optimization Dividend", - description: "What each adult could receive if US spending matched the cheapest high-performing countries.", -}; +export const metadata = getRouteMetadata(dividendLink); function formatCurrency(value: number): string { if (Math.abs(value) >= 1e12) return `$${(value / 1e12).toFixed(1)}T`; diff --git a/packages/web/src/app/donate/page.logged-out.md b/packages/web/src/app/donate/page.logged-out.md index 7ec4e231a..a63cd8bc5 100644 --- a/packages/web/src/app/donate/page.logged-out.md +++ b/packages/web/src/app/donate/page.logged-out.md @@ -1,23 +1,37 @@ # /donate +## Metadata + +- Page title: Prevent 2 yrs of suffering for $1 | International Campaign to End War and Disease +- Meta description: Fund survey outreach for the trade: one of humanity's 122 apocalypses for disease eradication in 36 years instead of 443. +- Canonical: https://warondisease.org/donate +- Open Graph title: Prevent 2 yrs of suffering for $1 | International Campaign to End War and Disease +- Open Graph description: Fund survey outreach for the trade: one of humanity's 122 apocalypses for disease eradication in 36 years instead of 443. +- Open Graph image: https://warondisease.org/api/og/route?path=%2Fdonate +- Twitter title: Prevent 2 yrs of suffering for $1 | International Campaign to End War and Disease +- Twitter description: Fund survey outreach for the trade: one of humanity's 122 apocalypses for disease eradication in 36 years instead of 443. + +## Visible Page Copy + - THE 1% TREATY -## TRADE ONE OF HUMANITY'S 122 APOCALYPSES FOR DISEASE ERADICATION IN 36 YEARS. +## TRADE ONE OF HUMANITY'S 122 APOCALYPSES FOR DISEASE ERADICATION IN 36.0 YEARS. - 122 -- 36 -- Humans spend $2.72 trillion every year on stuff designed specifically to make humans stop being alive. The 1% Treaty redirects 1% of that spending to high-efficiency pragmatic clinical trials. +- 36.0 +- Humans spend $2.72 trillion every year on stuff designed specifically to make humans stop being alive. The 1% Treaty redirects 1.00% of that spending to high-efficiency pragmatic clinical trials. - $2.72 trillion -- 1% -- Under the current system, only 15 diseases get their first effective treatment each year while 6,650 diseases are still waiting. That is why the disease-eradication timeline is 443 years. The proposal is simple: humanity should trade one of its 122 apocalypses of mass-murder capacity to compress the disease-eradication timeline from 443 years to 36 years. -- 15 +- 1.00% +- Under the current system, only 15.0 diseases get their first effective treatment each year while 6,650 diseases are still waiting. That is why the disease-eradication timeline is 443 years. The proposal is simple: humanity should trade one of its 122 apocalypses of mass-murder capacity to compress the disease-eradication timeline from 443 years to 36 years. +- 15.0 - 6,650 - 443 +- 36 - Your donation helps reach the humans needed to prove humanity wants this. - For the full economic analysis, read the 1% Treaty impact analysis. - 1% Treaty impact analysis ### HOW MUCH DEATH AND SUFFERING DO YOU WANT TO PREVENT? - Enter a donation amount, a lives-saved target, or years of suffering prevented. The other boxes recalculate from the treaty campaign model. These are probability-adjusted estimates, not a receipt from the universe. See how this is calculated ↓ - See how this is calculated ↓ -- At the default assumptions, $1 buys about 0.11 expected lives and 2 years of suffering and disability prevented. +- At the default assumptions, $1 buys about 0.107 expected lives and 2.20 years of suffering and disability prevented. - YOUR DONATION (USD) - LIVES SAVED - YEARS OF SUFFERING AND DISABILITY PREVENTED @@ -44,8 +58,9 @@ - Share of military spending the treaty redirects % - 1.00%. Default 1% — the treaty as written. Linear above 1%. - Lives saved per $1 -- 0.11 +- 0.107 - Years of suffering prevented per $1 +- 2.20 - Cost per life saved - $9.31 - Cost per year of suffering and disability prevented @@ -58,6 +73,7 @@ ### HOW THIS IS CALCULATED - Click sourced constants for citations. The boxed numbers are the same live assumptions as the calculator above. Simple math: addition mostly, some multiplication. #### Only 15 diseases get their first effective treatment each year. +- 15 - That is the throughput of every drug regulator on the planet, combined. This means your Food and Drug Administration has not administered drugs for most food-and-drug-related problems. #### About 6,650 diseases are still waiting. - That backlog is the treatment queue, and it is longer than any queue humans have ever voluntarily stood in, which is saying something because you invented Disneyland. @@ -83,7 +99,6 @@ - No grant committees deciding which diseases are fashionable this year. The patient subsidy follows the patient. That is 12.3x current global capacity: more trials, more disease coverage, same pool of compounds. - 12.3x #### The queue compresses from 443 years to 36.0 years. -- 36.0 - Remember that billion patients drowning in line? Your decentralized FDA will hand out 23.4 million. The physical upper bound is 566x current capacity. The average disease gets its first treatment 204 years sooner. Add 8.2 years of removed efficacy lag: 212 years sooner. - 566x - 204 @@ -103,7 +118,7 @@ #### To pass the treaty: reach humans at $ each. - That makes the campaign cost $1,000,000,000. Everyone thinks this is crazy because everyone else thinks this is crazy. Right now every human who wants less war and disease assumes they are the weird one. The referendum is the moment they find out they are everyone. - 12 -#### At % success and 1x treaty scale, the model gives $0.18 per healthy life-year. +#### At % success and 1.00x treaty scale, the model gives $0.18 per healthy life-year. - That is 503x the cost-effectiveness of bed nets at the live assumptions. Your calculator will display an error, emit a tiny electronic scream, and attempt to leave the desk. This is correct. The published skeptical case assumes a 99% chance humanity fumbles this and still comes out 503x better than bed nets, where bed nets cost $89/daly. This model suggests the treaty campaign may be the most cost-effective way to reduce human suffering per dollar spent. If that sounds insane, good. Change the assumptions or attack the citations. - 503 - $89/daly diff --git a/packages/web/src/app/donate/page.tsx b/packages/web/src/app/donate/page.tsx index 9bca40d45..46f83def3 100644 --- a/packages/web/src/app/donate/page.tsx +++ b/packages/web/src/app/donate/page.tsx @@ -51,7 +51,7 @@ export default function DonatePage() { apocalypses for disease eradication in{" "} {" "} years. @@ -69,7 +69,7 @@ export default function DonatePage() { being alive. The 1% Treaty redirects{" "} {" "} of that spending to high-efficiency pragmatic clinical trials. @@ -78,7 +78,7 @@ export default function DonatePage() { Under the current system, only{" "} {" "} diseases get their first effective treatment each year while{" "} diff --git a/packages/web/src/app/efficiency/page.tsx b/packages/web/src/app/efficiency/page.tsx index c357b337e..a0f1c368e 100644 --- a/packages/web/src/app/efficiency/page.tsx +++ b/packages/web/src/app/efficiency/page.tsx @@ -1,16 +1,18 @@ import Link from "next/link"; -import type { Metadata } from "next"; import { BUDGET_LEGISLATION_SLUGS, deduplicateEfficiencyCategories, } from "@/lib/analysis-products"; -import { getBudgetCategoryPath, getLegislationPath, ROUTES } from "@/lib/routes"; +import { getRouteMetadata } from "@/lib/metadata"; +import { + efficiencyLink, + getBudgetCategoryPath, + getLegislationPath, + ROUTES, +} from "@/lib/routes"; import { usBudgetAnalysis } from "@/data/us-budget-analysis"; -export const metadata: Metadata = { - title: "Efficiency Rankings", - description: "Cheapest high-performing comparators for the current US budget analysis.", -}; +export const metadata = getRouteMetadata(efficiencyLink); function formatCurrency(value: number): string { if (Math.abs(value) >= 1e12) return `$${(value / 1e12).toFixed(1)}T`; diff --git a/packages/web/src/app/employees/page.logged-out.md b/packages/web/src/app/employees/page.logged-out.md index 2006cd3c6..3978f799e 100644 --- a/packages/web/src/app/employees/page.logged-out.md +++ b/packages/web/src/app/employees/page.logged-out.md @@ -1,5 +1,18 @@ # /employees +## Metadata + +- Page title: Remind Presidents | International Campaign to End War and Disease +- Meta description: You give these people $37 trillion a year to promote the general welfare. Track who signed the 1% Treaty and remind the overdue ones. +- Canonical: https://warondisease.org/employees +- Open Graph title: Remind Presidents | International Campaign to End War and Disease +- Open Graph description: You give these people $37 trillion a year to promote the general welfare. Track who signed the 1% Treaty and remind the overdue ones. +- Open Graph image: https://warondisease.org/api/og/route?path=%2Femployees +- Twitter title: Remind Presidents | International Campaign to End War and Disease +- Twitter description: You give these people $37 trillion a year to promote the general welfare. Track who signed the 1% Treaty and remind the overdue ones. + +## Visible Page Copy + ## PRESIDENT MANAGEMENT SYSTEM - You give these people $37 trillion a year. Their job is to promote the general welfare which means increasing median health and wealth. Your job is to remind them that this is their job. Please do your job by clicking the remind button. #### SEND EARTH OPTIMIZATION TASK REMINDER @@ -43,7 +56,7 @@ - [money] - RATE: $9.90 trillion/year + PRODUCTIVITY LOSSES ÷ 365 × DELAY DAYS - $9.90 trillion/year -### ↳ 193 employees have overdue tasks +### ↳ 189 employees have overdue tasks - 👉 CLICK THE REMIND BUTTON TO DO YOUR JOB - Sort - ASSIGNEE @@ -56,7 +69,6 @@ - Remind - Li Qiang - Yulia Svyrydenko -- [initials] - Mikhail Mishustin - Narendra Modi - Donald Tusk @@ -64,6 +76,7 @@ - Keir Starmer - Friedrich Merz - Sifi Ghrieb +- [initials] - ← PREV -- PAGE 1 / 20 +- PAGE 1 / 19 - NEXT → diff --git a/packages/web/src/app/endorse/EndorseForm.tsx b/packages/web/src/app/endorse/EndorseForm.tsx index 952ccc9e2..b2fa5ef6c 100644 --- a/packages/web/src/app/endorse/EndorseForm.tsx +++ b/packages/web/src/app/endorse/EndorseForm.tsx @@ -455,7 +455,7 @@ export function EndorseForm({ referendumSlug, manageableOrgs }: Props) { ? isAuthenticated ? "Joining..." : "Saving..." - : "Join as Organization"} + : "Join as an Organization"} ); diff --git a/packages/web/src/app/endorse/page.logged-out.md b/packages/web/src/app/endorse/page.logged-out.md index d4a947cf9..ac2babd3c 100644 --- a/packages/web/src/app/endorse/page.logged-out.md +++ b/packages/web/src/app/endorse/page.logged-out.md @@ -1,46 +1,55 @@ # /endorse -- YOUR ORGANIZATION CAN -## ENTER YOUR AUDIENCE. SEE THE SUFFERING YOU CAUSE OR PREVENT. -- 150,000 humans die from disease today. Most preventable. Your audience size decides how much of it gets to keep happening. -- 150,000 -- STEP 1 -### SIZE OF ORGANIZATION AND MEMBERSHIP. -- Same numbers, two columns. Prevented if you act. Allowed if you do not. -- YOUR AUDIENCE -- EMAIL MEMBERS +## Metadata + +- Page title: Join as an Organization — 1% Treaty +- Meta description: Your organization has members who probably dislike war, disease, and preventable funerals. Join the campaign and conduct the Global Survey with them. +- Canonical: https://warondisease.org/endorse +- Open Graph title: Join as an Organization — 1% Treaty +- Open Graph description: Your organization has members who probably dislike war, disease, and preventable funerals. Join the campaign and conduct the Global Survey with them. +- Open Graph image: https://warondisease.org/api/og/route?path=%2Fendorse +- Twitter title: Join as an Organization — 1% Treaty +- Twitter description: Your organization has members who probably dislike war, disease, and preventable funerals. Join the campaign and conduct the Global Survey with them. + +## Visible Page Copy + +## JOIN THE INTERNATIONAL CAMPAIGN TO END WAR AND DISEASE +- Add your organization. Then use your member link, email starter, website button, or iframe to help your audience answer the Global Survey to End War and Disease. +- No donation. No candidate endorsement. One public humanitarian treaty position. +- ORGANIZATION NAME * +- WEBSITE +- JOIN AS AN ORGANIZATION +### AFTER JOINING +- Join first. Your tools page gives you the member link, email starter, website button, iframe, one-hour action checklist, and outreach grant request draft for funding from the International Campaign. +### APPLY FOR A CAMPAIGN GRANT +- Estimate the outreach grant your organization could request from the International Campaign to End War and Disease to run the Global Survey to End War and Disease through your audience. Campaign links and embeds keep attribution and impact measurement in one system. +- OUTREACH CAPACITY +- EMAIL LIST - MONTHLY SITE VISITORS -- SOCIAL AUDIENCE -- MONTHS ON WEBSITE +- SOCIAL REACH +- MONTHS EMBEDDED - ASSUMPTIONS -- AUDIENCE THAT SEES IT % -- VERIFIED VOTE RATE % -- MEMBER SHARE MULTIPLIER -- FOUNDATION $ PER VOTE -- IF YOU ACT +- MESSAGE REACH % +- SURVEY RESPONSE RATE % +- MEMBER SHARING LIFT +- GRANT $ PER RESPONSE +- GRANT REQUEST +- $3,960 +- ESTIMATED OUTREACH COST PER RESPONSE: $2 +- SURVEY RESPONSES +- 1,980 +- FROM 66,000 PEOPLE REACHED +- MODELED LIVES SAVED - 5,346 -- LIVES SAVED +- $0.74 PER MODELED LIFE SAVED +- SUFFERING PREVENTED - 109,058 - YEARS OF SUFFERING PREVENTED -- IF YOU DO NOT -- PREVENTABLE DEATHS ALLOWED -- YEARS OF SUFFERING ALLOWED -- 1,980 verified votes × 2.7 lives and 55 years prevented per vote. At $2 per vote, funders fund $3,960 of outreach. +- 1,980 verified survey responses × 2.7 lives and 55 years prevented per response. At an estimated outreach cost of $2 per response, the outreach request is $3,960. - 2.7 - 55 -- STEP 2 — ONE HOUR, THREE ACTIONS -- 1. EMBED THE IFRAME One paste, then it works while you sleep. -- 1. EMBED THE IFRAME -- One paste, then it works while you sleep. -- 2. SEND ONE EMAIL Pre-written. Your members already trust you. That is the asset. -- 2. SEND ONE EMAIL -- Pre-written. Your members already trust you. That is the asset. -- 3. POST ONCE PER CHANNEL Link auto-credits responses to your organization. -- 3. POST ONCE PER CHANNEL -- Link auto-credits responses to your organization. -- ORGANIZATION NAME * -- WEBSITE -- JOIN AS ORGANIZATION +#### GRANT REQUEST DRAFT +- COPY REQUEST DRAFT - LEGAL NOTES FOR ORGANIZATIONS - READ THE 1% TREATY TEXT - Already joined? See the organizational supporters. diff --git a/packages/web/src/app/endorse/page.tsx b/packages/web/src/app/endorse/page.tsx index af3b1dfb8..017292514 100644 --- a/packages/web/src/app/endorse/page.tsx +++ b/packages/web/src/app/endorse/page.tsx @@ -1,13 +1,11 @@ import { headers } from "next/headers"; import Link from "next/link"; -import { - GLOBAL_DISEASE_DEATHS_DAILY, - shareableSnippets, -} from "@optimitron/data/parameters"; -import { ParameterValue } from "@/components/shared/ParameterValue"; +import { shareableSnippets } from "@optimitron/data/parameters"; +import { OrganizationGrantCalculator } from "@/components/organizations/OrganizationGrantCalculator"; import { TreatyContent } from "@/components/treaty/TreatyContent"; import type { ReferendumSiteLegalSection } from "@/content/referendum-sites/types"; import { getCurrentUser } from "@/lib/auth-utils"; +import { GLOBAL_SURVEY_NAME } from "@/lib/messaging"; import { getSiteMetadata } from "@/lib/metadata"; import { getManageableOrganizationsForUser } from "@/lib/organization.server"; import { getReferendumPageContent } from "@/lib/referendum-content.server"; @@ -16,25 +14,9 @@ import { ROUTES } from "@/lib/routes"; import { getSiteFromHeaders } from "@/lib/site"; import { TREATY_REFERENDUM_SLUG } from "@/lib/treaty"; import { EndorseForm } from "./EndorseForm"; -import { OrganizationImpactCalculator } from "./OrganizationImpactCalculator"; export const dynamic = "force-dynamic"; -const HOUR_ACTIONS = [ - { - title: "Embed the iframe", - body: "One paste, then it works while you sleep.", - }, - { - title: "Send one email", - body: "Pre-written. Your members already trust you. That is the asset.", - }, - { - title: "Post once per channel", - body: "Link auto-credits responses to your organization.", - }, -] as const; - function TreatyTextDisclosure({ treatyMarkdown, }: { @@ -62,7 +44,7 @@ function TreatyTextDisclosure({ href="#organization-endorsement-form" className="inline-block border-2 border-foreground bg-foreground px-5 py-3 text-sm font-black uppercase text-background hover:bg-background hover:text-foreground" > - Join as Organization + Join as an Organization @@ -85,9 +67,9 @@ function LegalNotesDisclosure({

- Nonprofits can publicly support nonpartisan humanitarian treaty - advocacy. The notes below answer the common nonprofit question without - sending you away from the form. + Joining publicly supports a humanitarian treaty. It is not a donation, + candidate endorsement, party activity, ballot measure position, or + support for a pending bill.

{sections.map((section) => ( @@ -165,7 +147,9 @@ export default async function EndorsePage() { if (!site.primaryReferendumSlug) { return (
-

Join as Organization

+

+ Join as an Organization +

No referendum is configured for this site.

@@ -179,50 +163,22 @@ export default async function EndorsePage() { return (
-
-

- {content.endorse.eyebrow} -

+

- Enter your audience. See the suffering you cause or prevent. + Join the International Campaign to End War and Disease

- {" "} - humans die from disease today. Most preventable. Your audience size - decides how much of it gets to keep happening. + Add your organization. Then use your member link, email starter, + website button, or iframe to help your audience answer the{" "} + {GLOBAL_SURVEY_NAME}.

-
- - - -
-

- Step 2 — One hour, three actions +

+ No donation. No candidate endorsement. One public humanitarian treaty + position.

-
    - {HOUR_ACTIONS.map((action, index) => ( -
  1. -

    - {index + 1}. {action.title} -

    -

    - {action.body} -

    -
  2. - ))} -
-
+
-
+
({ @@ -233,6 +189,21 @@ export default async function EndorsePage() { />
+
+

+ After joining +

+

+ Join first. Your tools page gives you the member link, email starter, + website button, iframe, one-hour action checklist, and outreach grant + request draft for funding from the International Campaign. +

+
+ +
+ +
+ diff --git a/packages/web/src/app/feedback/page.logged-out.md b/packages/web/src/app/feedback/page.logged-out.md index 779b69162..b9b586e40 100644 --- a/packages/web/src/app/feedback/page.logged-out.md +++ b/packages/web/src/app/feedback/page.logged-out.md @@ -1,5 +1,18 @@ # /feedback +## Metadata + +- Page title: Feedback | International Campaign to End War and Disease +- Meta description: Tell us what is confusing, irritating, broken, or missing so this becomes a better to-do list for humanity. +- Canonical: https://warondisease.org/feedback +- Open Graph title: Feedback +- Open Graph description: Tell us what is confusing, irritating, broken, or missing so this becomes a better to-do list for humanity. +- Open Graph image: https://warondisease.org/site-assets/warondisease/war-on-disease-og-1200x630.png +- Twitter title: Feedback +- Twitter description: Tell us what is confusing, irritating, broken, or missing so this becomes a better to-do list for humanity. + +## Visible Page Copy + - BACK - FEEDBACK ## HELP COORDINATE HUMANITY BETTER. diff --git a/packages/web/src/app/government-size/page.tsx b/packages/web/src/app/government-size/page.tsx index 39882bbb1..70f7558d9 100644 --- a/packages/web/src/app/government-size/page.tsx +++ b/packages/web/src/app/government-size/page.tsx @@ -1,12 +1,9 @@ import Link from "next/link"; -import type { Metadata } from "next"; import { usGovernmentSizeAnalysis } from "@/lib/government-size-analysis"; -import { ROUTES } from "@/lib/routes"; +import { getRouteMetadata } from "@/lib/metadata"; +import { governmentSizeLink, ROUTES } from "@/lib/routes"; -export const metadata: Metadata = { - title: "Government Size Analysis", - description: "Cross-country panel estimate of the US-equivalent government spending floor.", -}; +export const metadata = getRouteMetadata(governmentSizeLink); function formatCurrency(value: number): string { if (Math.abs(value) >= 1e12) return `$${(value / 1e12).toFixed(1)}T`; diff --git a/packages/web/src/app/humanity-v-government/DamagesSensitivityCalculator.tsx b/packages/web/src/app/humanity-v-government/DamagesSensitivityCalculator.tsx index d86d976c1..247f3f849 100644 --- a/packages/web/src/app/humanity-v-government/DamagesSensitivityCalculator.tsx +++ b/packages/web/src/app/humanity-v-government/DamagesSensitivityCalculator.tsx @@ -15,9 +15,9 @@ import { /** * Sensitivity calculator for the Humanity v. Government damages tiers. * - * Lets the reader stress-test the three most-disputed inputs (Value of + * Lets the reader adjust the three most-disputed inputs (Value of * Statistical Life, war-deaths-since-1900, efficacy-lag deaths total) and - * watch the floor and FCA treble per-capita numbers update live. Default + * watch the floor and FCA treble per-person numbers update live. Default * values + non-tunable floor components are imported directly from * `@optimitron/data/parameters` so this calculator stays in sync with the * canonical numbers — change a parameter once, every surface that uses it @@ -25,9 +25,8 @@ import { * * The lost-prosperity primary theory headline ($25.2M cohort / $10.6M NPV) * is *not* tunable here — its inputs are downstream of multiple manual - * analyses and would require pulling that math into the client. The body- - * count tiers below are exactly the kind of "dispute the assumptions" - * surface a critic targets, so they're what the slider exposes. + * analyses and would require pulling that math into the client. The body-count + * tiers below are the parts most readers will want to tune first. */ const GLOBAL_POPULATION = GLOBAL_POPULATION_2024.value; @@ -164,30 +163,29 @@ export function DamagesSensitivityCalculator() {

- Stress-test the numbers + Damages calculator

- Argue with the assumptions. The case still pleads. + Use your own numbers.

- Tune VSL, body counts, and watch the floor and treble tiers update. - The point of this slider is rhetorical jiu-jitsu: every "you made up - the numbers" critique inverts to "tune it however you want — here - is the case at your numbers." + Set what you think a human life is worth in court dollars. Lower the + death counts if you want. The amount owed to each living human updates + below.

- Per-plaintiff demanded recovery, at your assumptions + What each living human is owed, at your assumptions

- Floor + Cautious floor

{formatUSDPerson(tiers.floorPerCapita)} @@ -235,7 +233,7 @@ export function DamagesSensitivityCalculator() {

- Prosecutor base ask + Base demand

{formatUSDPerson(tiers.askPerCapita)} @@ -246,7 +244,7 @@ export function DamagesSensitivityCalculator() {

- FCA treble + Triple damages

{formatUSDPerson(tiers.treblePerCapita)} @@ -257,10 +255,11 @@ export function DamagesSensitivityCalculator() {

- Floor = war-VSL + lag-VSL + property/env ($50T) + excess military - ($135T) + Pentagon FCA ($4.92T). Base ask adds never-developed-drug - deaths VSL ($300M deaths × VSL). Treble = base ask × 3 (False Claims - Act multiplier). + Floor = war deaths + regulatory-delay deaths + property/environmental + destruction + excess military spending + Pentagon failed-audit + penalty. Base demand adds deaths from drugs never developed. Triple + damages means multiplying the base demand by three, as some fraud laws + do.

diff --git a/packages/web/src/app/humanity-v-government/HumanityVGovernmentVerdictVote.test.tsx b/packages/web/src/app/humanity-v-government/HumanityVGovernmentVerdictVote.test.tsx new file mode 100644 index 000000000..e1b23b10d --- /dev/null +++ b/packages/web/src/app/humanity-v-government/HumanityVGovernmentVerdictVote.test.tsx @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Simulate } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HumanityVGovernmentVerdictVote } from "./HumanityVGovernmentVerdictVote"; + +const sessionMock = vi.hoisted(() => ({ + status: "authenticated" as "authenticated" | "unauthenticated" | "loading", +})); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ status: sessionMock.status }), +})); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/components/auth/AuthForm", () => ({ + AuthForm: () =>
, +})); + +describe("HumanityVGovernmentVerdictVote", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT: boolean; + React: typeof React; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + (globalThis as typeof globalThis & { React: typeof React }).React = React; + sessionMock.status = "authenticated"; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("records a Humanity verdict vote through the referendum vote API", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ vote: { answer: "YES" } }), + } as Response); + + await act(async () => { + root.render( + , + ); + }); + + const yesButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Find for Humanity"), + ); + expect(yesButton).toBeDefined(); + + await act(async () => { + Simulate.click(yesButton!); + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/referendums/court-humanity-v-government-verdict/vote", + expect.objectContaining({ + body: expect.stringContaining('"answer":"YES"'), + method: "POST", + }), + ); + expect(container.textContent).toContain("Verdict recorded"); + expect(container.textContent).toContain("Find for Humanity"); + }); + + it("asks unauthenticated voters to verify before recording the verdict", async () => { + sessionMock.status = "unauthenticated"; + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await act(async () => { + root.render( + , + ); + }); + + const noButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Find for Governments"), + ); + expect(noButton).toBeDefined(); + + await act(async () => { + Simulate.click(noButton!); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Finish your verdict"); + expect(container.textContent).toContain("Find for Governments"); + }); +}); diff --git a/packages/web/src/app/humanity-v-government/HumanityVGovernmentVerdictVote.tsx b/packages/web/src/app/humanity-v-government/HumanityVGovernmentVerdictVote.tsx new file mode 100644 index 000000000..9548c4e6b --- /dev/null +++ b/packages/web/src/app/humanity-v-government/HumanityVGovernmentVerdictVote.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { AuthForm } from "@/components/auth/AuthForm"; +import { ROUTES } from "@/lib/routes"; + +type VerdictAnswer = "YES" | "NO" | "ABSTAIN"; + +interface HumanityVGovernmentVerdictVoteProps { + abstainCount: number; + existingAnswer: VerdictAnswer | null; + fullDamagesLabel: string; + noCount: number; + question: string; + referendumSlug: string; + yesCount: number; +} + +const ANSWER_LABELS: Record = { + YES: "Find for Humanity", + NO: "Find for Governments", + ABSTAIN: "Not sure", +}; + +function normalizeVerdictAnswer(value: string | null): VerdictAnswer | null { + const upper = value?.toUpperCase(); + if (upper === "YES" || upper === "NO" || upper === "ABSTAIN") return upper; + return null; +} + +function countLabel(value: number) { + return new Intl.NumberFormat("en-US").format(value); +} + +export function HumanityVGovernmentVerdictVote({ + abstainCount, + existingAnswer, + fullDamagesLabel, + noCount, + question, + referendumSlug, + yesCount, +}: HumanityVGovernmentVerdictVoteProps) { + const searchParams = useSearchParams(); + const { status } = useSession(); + const [answer, setAnswer] = useState(existingAnswer); + const [pendingAuthAnswer, setPendingAuthAnswer] = + useState(null); + const [submittingAnswer, setSubmittingAnswer] = + useState(null); + const [error, setError] = useState(null); + const autoSubmitKeyRef = useRef(null); + const [counts, setCounts] = useState>({ + YES: yesCount, + NO: noCount, + ABSTAIN: abstainCount, + }); + + const callbackUrl = useMemo(() => { + const encoded = pendingAuthAnswer ? `?verdict=${pendingAuthAnswer}` : ""; + return `${ROUTES.humanityVGovernment}${encoded}`; + }, [pendingAuthAnswer]); + + const castVote = useCallback(async (nextAnswer: VerdictAnswer) => { + if (status !== "authenticated") { + setPendingAuthAnswer(nextAnswer); + return; + } + + setSubmittingAnswer(nextAnswer); + setError(null); + + try { + const response = await fetch(`/api/referendums/${referendumSlug}/vote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + answer: nextAnswer, + originUrl: + typeof window !== "undefined" ? window.location.href : undefined, + }), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? "Failed to record verdict."); + } + + setCounts((current) => { + const updated = { ...current }; + if (answer) updated[answer] = Math.max(0, updated[answer] - 1); + updated[nextAnswer] += 1; + return updated; + }); + setAnswer(nextAnswer); + setPendingAuthAnswer(null); + } catch (voteError) { + setError( + voteError instanceof Error + ? voteError.message + : "Failed to record verdict.", + ); + } finally { + setSubmittingAnswer(null); + } + }, [answer, referendumSlug, status]); + + useEffect(() => { + const answerFromUrl = normalizeVerdictAnswer(searchParams.get("verdict")); + if (!answerFromUrl || answer || status !== "authenticated") return; + const key = `${referendumSlug}:${answerFromUrl}`; + if (autoSubmitKeyRef.current === key) return; + autoSubmitKeyRef.current = key; + void castVote(answerFromUrl); + }, [answer, castVote, referendumSlug, searchParams, status]); + + if (pendingAuthAnswer && status !== "authenticated") { + return ( +
+

+ Finish your verdict +

+

+ {ANSWER_LABELS[pendingAuthAnswer]} +

+

+ Verify yourself so one human gets one verdict. Governments have + enough duplicate paperwork already. +

+
+ +
+
+ ); + } + + return ( +
+

+ Vote on the finding +

+

+ Should governments owe you full damages? +

+

+ {question} +

+

+ The 1% Treaty is the cheap settlement offer. This vote records the + bigger claim: governments owe the full damages demand, currently{" "} + {fullDamagesLabel}{" "} + per living human. +

+

+ Voting no requires saying out loud: “I believe my government + should be allowed to kill my family with no consequences.” Few + ordinary humans want to say that sentence in public. +

+ +
+ {(["YES", "NO", "ABSTAIN"] as const).map((option) => { + const isSelected = answer === option; + const isSubmitting = submittingAnswer === option; + return ( + + ); + })} +
+ + {answer ? ( +

+ Verdict recorded: {ANSWER_LABELS[answer]} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+ For Humanity +
+
+ {countLabel(counts.YES)} +
+
+
+
+ For Governments +
+
+ {countLabel(counts.NO)} +
+
+
+
+ Not Sure +
+
+ {countLabel(counts.ABSTAIN)} +
+
+
+ +

+ If a majority of humanity votes yes, no money appears by magic. That + would be convenient and therefore not how Earth works. It does create a + public verdict every government, court, investor, and candidate has to + answer. +

+
+ ); +} diff --git a/packages/web/src/app/humanity-v-government/opengraph-image.tsx b/packages/web/src/app/humanity-v-government/opengraph-image.tsx new file mode 100644 index 000000000..b9ebf3c8b --- /dev/null +++ b/packages/web/src/app/humanity-v-government/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { generateBlackWhiteTextOgImageResponseForNavItem } from "@/lib/black-white-text-og-image-response"; +import { humanityVGovernmentLink } from "@/lib/routes"; + +export const runtime = "nodejs"; +export const revalidate = 3600; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function Image() { + return generateBlackWhiteTextOgImageResponseForNavItem( + humanityVGovernmentLink, + size, + ); +} diff --git a/packages/web/src/app/humanity-v-government/page-metadata.test.ts b/packages/web/src/app/humanity-v-government/page-metadata.test.ts new file mode 100644 index 000000000..02b740fe5 --- /dev/null +++ b/packages/web/src/app/humanity-v-government/page-metadata.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + HUMANITY_V_GOVERNMENT_METADATA, + HUMANITY_V_GOVERNMENT_OG_ALT, + HUMANITY_V_GOVERNMENT_OG_IMAGE_PATH, +} from "./page-metadata"; + +describe("Humanity v. Government metadata", () => { + it("frames the page as a damages verdict with a large preview image", () => { + expect(HUMANITY_V_GOVERNMENT_METADATA.title).toBe( + "You May Be Owed $2.74 Million | Humanity v. Government", + ); + expect(HUMANITY_V_GOVERNMENT_METADATA.description).toContain( + "Render your verdict", + ); + expect(HUMANITY_V_GOVERNMENT_METADATA.openGraph?.images).toEqual([ + { + url: HUMANITY_V_GOVERNMENT_OG_IMAGE_PATH, + width: 1200, + height: 630, + alt: HUMANITY_V_GOVERNMENT_OG_ALT, + }, + ]); + expect(HUMANITY_V_GOVERNMENT_METADATA.twitter?.images).toEqual([ + HUMANITY_V_GOVERNMENT_OG_IMAGE_PATH, + ]); + }); +}); diff --git a/packages/web/src/app/humanity-v-government/page-metadata.ts b/packages/web/src/app/humanity-v-government/page-metadata.ts new file mode 100644 index 000000000..62fa06bb0 --- /dev/null +++ b/packages/web/src/app/humanity-v-government/page-metadata.ts @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { + buildBlackWhiteTextOgAltTextForNavItem, +} from "@/lib/black-white-text-og-image"; +import { getRouteMetadata } from "@/lib/metadata"; +import { humanityVGovernmentLink } from "@/lib/routes"; + +const socialPreview = humanityVGovernmentLink.socialPreview; + +if (!socialPreview?.image || !socialPreview.blackWhiteTextOgImage) { + throw new Error("Humanity v. Government needs a social preview config."); +} + +if (!socialPreview.title || !socialPreview.description) { + throw new Error("Humanity v. Government needs social title and description."); +} + +export const HUMANITY_V_GOVERNMENT_OG_IMAGE_PATH = socialPreview.image.url; + +export const HUMANITY_V_GOVERNMENT_OG_ALT = + buildBlackWhiteTextOgAltTextForNavItem(humanityVGovernmentLink); + +export const HUMANITY_V_GOVERNMENT_METADATA_TITLE = socialPreview.title; + +export const HUMANITY_V_GOVERNMENT_METADATA_DESCRIPTION = + socialPreview.description; + +export const HUMANITY_V_GOVERNMENT_METADATA: Metadata = getRouteMetadata( + humanityVGovernmentLink, + { + description: HUMANITY_V_GOVERNMENT_METADATA_DESCRIPTION, + title: HUMANITY_V_GOVERNMENT_METADATA_TITLE, + }, +); diff --git a/packages/web/src/app/humanity-v-government/page.logged-out.md b/packages/web/src/app/humanity-v-government/page.logged-out.md index 090c98adc..3627fddf8 100644 --- a/packages/web/src/app/humanity-v-government/page.logged-out.md +++ b/packages/web/src/app/humanity-v-government/page.logged-out.md @@ -1,79 +1,115 @@ # /humanity-v-government -- COURT OF HUMANITY — OPEN CASE +## Metadata + +- Page title: You May Be Owed $2.74 Million | Humanity v. Government | International Campaign to End War and Disease +- Meta description: Render your verdict in the Court of Humanity class action against the governments of Earth. The claim says each living human may be owed $2.74 million in full damages. +- Canonical: https://warondisease.org/humanity-v-government +- Open Graph title: You May Be Owed $2.74 Million | Humanity v. Government +- Open Graph description: Render your verdict in the Court of Humanity class action against the governments of Earth. The claim says each living human may be owed $2.74 million in full damages. +- Open Graph image: https://warondisease.org/humanity-v-government/opengraph-image +- Twitter title: You May Be Owed $2.74 Million | Humanity v. Government +- Twitter description: Render your verdict in the Court of Humanity class action against the governments of Earth. The claim says each living human may be owed $2.74 million in full damages. + +## Visible Page Copy + +- COURT OF HUMANITY - DAMAGES CASE ## HUMANITY V. GOVERNMENTS OF EARTH -- YOU ARE SUMMONED -- You have been called as juror [juror-number]. You are also a named plaintiff. Your share of the demanded recovery: $10.6M (NPV) — $25.2M (lifetime cohort). -- [juror-number] -- $10.6M (NPV) -- $25.2M (lifetime cohort) -- The verdict is the 1% Treaty. The case binds governments only when four billion plaintiffs render it. Recruit two more jurors. The case is the chain. -- RENDER THE VERDICT — SIGN THE TREATY -- MULTIPLY YOUR CLAIM -### REGISTER EVERY DECEASED FAMILY MEMBER YOU CAN NAME. -- Each registered estate is another named plaintiff with its own $10.6M–$25.2M share of the demanded recovery. A descendant or next-of-kin files the wrongful-death claim on behalf of the estate. This is how every real corporate class action handles deceased victims. -- $10.6M–$25.2M -- 4 grandparents registered = ~$42M–$100M added to your family claim. -- 4 grandparents -- + parents, aunts, uncles, siblings who died of preventable disease or war = each an additional $10.6M–$25.2M. -- + parents, aunts, uncles, siblings -- The dead have no juror function — only living humans render the verdict — but they remain plaintiffs whose estates are entitled to recovery. -- The dead have no juror function -- REGISTER A DECEASED PLAINTIFF +- THE INDICTMENT +- Governments were hired to promote the general welfare, defined as the median health and wealth of the citizenry. They collect $36.5 trillion a year for the service. +- $36.5 trillion +- The citizenry would like to actually receive this service at some point. +- Instead, these public servants used $170 trillion of their salary to murder approximately 310 million humans over the last century of their employment. +- $170 trillion +- 310 million +- The dead included roughly 930,000 doctors, 310,000 scientists, 620,000 engineers, 1.24 million nurses, 3.1 million teachers, and 102 million children who will never grow up to replace them. +- 102 million +- Murdering 310 million of your employers is the opposite of promoting their welfare, and would be grounds for termination in any other employment contract humans have ever signed. +- Had governments not spent $170 trillion murdering those people and destroying everything they spent their entire lives building, the average human alive today would earn $333,636 a year instead of $14,375. Dead scientists do not discover things and exploded cities are very expensive to fix. +- $333,636 +- $14,375 +- VOTE ON THE FINDING +- SUPPORT THE SETTLEMENT +- READ THE EVIDENCE +### SHOULD GOVERNMENTS OWE YOU FULL DAMAGES? +- Should the governments of Earth be found liable for preventable mass death and owe full damages of $2.74 million to each living human? +- The 1% Treaty is the cheap settlement offer. This vote records the bigger claim: governments owe the full damages demand, currently $2.74 million per living human. +- $2.74 million +- Voting no requires saying out loud: “I believe my government should be allowed to kill my family with no consequences.” Few ordinary humans want to say that sentence in public. +- FIND FOR HUMANITY +- FIND FOR GOVERNMENTS +- NOT SURE +- If a majority of humanity votes yes, no money appears by magic. That would be convenient and therefore not how Earth works. It does create a public verdict every government, court, investor, and candidate has to answer. +### IF THIS WERE A CORPORATION +- If a corporation were paid $36.5 trillion a year to promote the general welfare and misused the funds to this degree, it would be prosecuted, fined, monitored, and its officers imprisoned. +- Pfizer paid $2.3 billion for health-care fraud. BP paid $20.8 billion for the Deepwater Horizon spill. Volkswagen paid $4.3 billion for cheating emissions tests and accepted a government monitor. +- The defendants here have a larger revenue, a larger customer base, and a larger body count. ### THE CASE CAPTION - [plaintiff-count] +### WHY THIS IS A CASE +- DUTY +- Governments accept compulsory payment to protect the public and promote the general welfare. That is the job description. +- BREACH +- They spend 604 times more on military capacity than on government clinical trials. Disease is what actually kills their citizens. +- 604 +- CAUSATION +- Some deaths were direct. Others happened because treatments were delayed, trials were not funded, and the cure money became hardware for organized killing. +- DAMAGES +- The cautious floor is $538K per living human. The prosecutor's base demand is $913K. +- $538K +- $913K ### THE THREE COUNTS -- COUNT 1 — DIRECT KILLING 310 million deaths War and conflict deaths since 1900. Defendants chose the policy and proceeded. -- COUNT 1 — DIRECT KILLING +- COUNT 1 — DEATH BY WAR 310 million deaths The defendants, between 1900 and the present, did willfully and with premeditation engage in the organized killing of 310 million of their own employers. +- COUNT 1 — DEATH BY WAR - 310 million deaths -- 310 million -- War and conflict deaths since 1900. Defendants chose the policy and proceeded. -- COUNT 2 — REGULATORY DELAY 102 million deaths Patients who died waiting 8.2 years for already-safe drugs to be proven effective. 1962 Kefauver-Harris Amendments through today. -- COUNT 2 — REGULATORY DELAY +- The defendants, between 1900 and the present, did willfully and with premeditation engage in the organized killing of 310 million of their own employers. +- COUNT 2 — DEATH BY REGULATORY DELAY 102 million deaths The defendants required an additional 8.2 years of efficacy testing before letting humans access drugs already proven safe. 53 years of warnings. 102 million dead. “We did not know” is no longer available as a defense. +- COUNT 2 — DEATH BY REGULATORY DELAY - 102 million deaths -- 102 million -- Patients who died waiting 8.2 years for already-safe drugs to be proven effective. 1962 Kefauver-Harris Amendments through today. -- COUNT 3 — MISALLOCATION 262 million deaths 20th-century government democide. Resources spent on killing, not on the disease that kills 150,000 humans every day. -- COUNT 3 — MISALLOCATION -- 262 million deaths -- 262 million -- 20th-century government democide. Resources spent on killing, not on the disease that kills 150,000 humans every day. -### DEMANDED RECOVERY, PER PLAINTIFF -- PRIMARY THEORY (NPV) -- $10.6M -- Lost-prosperity-only, NPV at 3%, no-cure baseline. -- HEADLINE (COHORT) -- $25.2M -- Lost-prosperity-only, lifetime, representative full-life cohort. -- CONSTITUTIONAL CEILING -- $9.13M -- State Farm-style 10× exposure, alternative pleading. -- Why $25.2M. Governments collected $36.5T/year to "promote the general welfare" and underdelivered. The lost-prosperity theory compares delivered welfare to a benchmark where disease was being seriously addressed; the gap is the per-person deficiency. This is antitrust-style lost-profits — the framework prosecutors actually win with against corporate defendants. One coherent number, one comparison, no double- counting deaths. -- Why $25.2M. -- Alternative pleadings. If the Court rejects the lost-prosperity theory, body-count tiers fall back: floor $538K per capita; FCA treble $2.74M per capita. Each tier is independently citable. -- Alternative pleadings. -- $538K +- The defendants required an additional 8.2 years of efficacy testing before letting humans access drugs already proven safe. 53 years of warnings. 102 million dead. “We did not know” is no longer available as a defense. +- COUNT 3 — DEATH BY MISALLOCATION 37,778 trial-years Damages here are the counterfactual: what humanity would have had if governments had frozen real military spending at 1900 levels and redirected the rest to keeping their citizens alive. The war budget since 1913 alone could have funded 37,778 years of government clinical trials. +- COUNT 3 — DEATH BY MISALLOCATION +- 37,778 trial-years +- 37,778 +- Damages here are the counterfactual: what humanity would have had if governments had frozen real military spending at 1900 levels and redirected the rest to keeping their citizens alive. The war budget since 1913 alone could have funded 37,778 years of government clinical trials. +### THE DAMAGES DEMAND +- CAUTIOUS FLOOR +- Per living human, before punitive theories. +- PROSECUTOR DEMAND +- Adds drugs never developed because the trials were never funded. +- TRIPLE DAMAGES - $2.74M -- READ THE FULL DAMAGES ANALYSIS → -- STRESS-TEST THE NUMBERS -- RESET TO MANUAL DEFAULTS -### ARGUE WITH THE ASSUMPTIONS. THE CASE STILL PLEADS. -- Tune VSL, body counts, and watch the floor and treble tiers update. The point of this slider is rhetorical jiu-jitsu: every "you made up the numbers" critique inverts to "tune it however you want — here is the case at your numbers." -- VALUE OF STATISTICAL LIFE +- The False Claims Act triples damages when a defendant defrauds the government. Here, the defendants ARE the government, defrauding the citizenry. Triple damages apply. +- The demand is not one suspicious monster number. It is a ledger: war deaths, regulatory delay deaths, destroyed property, missing public money, and the cures never developed because the research budget was busy becoming weapons. +- Alternative pleadings. If the court rejects the wider theory, the case still has the floor: $538K per person. If it accepts a False Claims Act-style triple-damages analogy, exposure reaches $2.74M per person. The size of the number reflects the size of the death toll. The defendants set both. +- Alternative pleadings. +### THE USUAL DEFENSES +- "THESE ARE POLICY DISAGREEMENTS." +- Negligent homicide requires duty, breach, causation, damages, and foreseeable risk. The defendants meet all five. +- "YOU CANNOT SUE A GOVERNMENT." +- That is because governments wrote rules saying governments are hard to sue. This is not a moral defense. It is a confession with letterhead. +- "THE DEATHS ARE COUNTERFACTUAL." +- Governments use counterfactual lives saved to justify budgets every day. The same math counts bodies when the budget kills people quietly instead. +- PLAINTIFFS +### NAME THE HUMANS THE CASE SHOULD COUNT. +- The case already has [plaintiff-count] named plaintiffs. If someone in your family died of war, regulatory delay, or preventable disease, add them. A civilization should at least be able to count its dead. +- ADD A PLAINTIFF +- DAMAGES CALCULATOR +- RESET +### USE YOUR OWN NUMBERS. +- Set what you think a human life is worth in court dollars. Lower the death counts if you want. The amount owed to each living human updates below. +- COURT VALUE OF ONE HUMAN LIFE - $10.00M -- Default $10M (EPA / FDA standard). DOT uses $13.7M. Critics arguing under-pricing of life argue $5M; critics arguing over-pricing argue $1M. -- WAR + CONFLICT DEATHS SINCE 1900 +- Default $10M (EPA / FDA standard). DOT uses $13.7M. Use less if you think the dead should be cheaper. +- WAR DEATHS SINCE 1900 - 310M -- Default 310M (Rummel democide 264M + battle 39M + collateral civilian 30M − overlap 25M). White's low estimate is 200M; Rummel-high-plus-military is 340M. -- EFFICACY-LAG DEATHS (1962–TODAY) +- Default 310M (Rummel democide 264M + battle 39M + collateral civilian 30M minus overlap). White's low estimate is 200M; Rummel-high-plus-military is 340M. +- REGULATORY-DELAY DEATHS - 102M -- Default 102M (Invisible Graveyard primary estimate). 95% CI 36.9M (low) to 214M (high) reflects uncertainty in the counterfactual treatment-availability assumption. -- PER-PLAINTIFF DEMANDED RECOVERY, AT YOUR ASSUMPTIONS -- FLOOR +- Default 102M (Invisible Graveyard primary estimate). Low is 36.9M. High is 214M. These are patients who died while already-safe treatments waited for efficacy approval. +- WHAT EACH LIVING HUMAN IS OWED, AT YOUR ASSUMPTIONS - Total: $4.31Q -- PROSECUTOR BASE ASK -- $913K +- BASE DEMAND - Total: $7.31Q -- FCA TREBLE - Total: $21.92Q -- Floor = war-VSL + lag-VSL + property/env ($50T) + excess military ($135T) + Pentagon FCA ($4.92T). Base ask adds never-developed-drug deaths VSL ($300M deaths × VSL). Treble = base ask × 3 (False Claims Act multiplier). +- Floor = war deaths + regulatory-delay deaths + property/environmental destruction + excess military spending + Pentagon failed-audit penalty. Base demand adds deaths from drugs never developed. Triple damages means multiplying the base demand by three, as some fraud laws do. diff --git a/packages/web/src/app/humanity-v-government/page.tsx b/packages/web/src/app/humanity-v-government/page.tsx index 19daaf67f..2bd7c8074 100644 --- a/packages/web/src/app/humanity-v-government/page.tsx +++ b/packages/web/src/app/humanity-v-government/page.tsx @@ -1,112 +1,206 @@ import Link from "next/link"; +import { getServerSession } from "next-auth"; import { - CORPORATE_DAMAGES_FORWARD_SETTLEMENT_VALUE_PER_CAPITA, + HUMANITY_V_GOVERNMENT_FULL_DAMAGES_PER_CAPITA_LABEL, + HUMANITY_V_GOVERNMENT_VERDICT_QUESTION, +} from "@optimitron/data/referendums"; +import { + CORPORATE_DAMAGES_PROSECUTOR_BASE_ASK_PER_CAPITA, CORPORATE_DAMAGES_STRICT_FLOOR_PER_CAPITA, CORPORATE_DAMAGES_TREBLE_EXPOSURE_PER_CAPITA, - DEMOCIDE_TOTAL_20TH_CENTURY, + CUMULATIVE_MILITARY_IN_GOVT_TRIAL_YEARS, + CUMULATIVE_MILITARY_SPENDING_FED_ERA, EXISTING_DRUGS_EFFICACY_LAG_DEATHS_TOTAL, + GLOBAL_AVG_INCOME_2025, + GLOBAL_GOVERNMENT_EXPENSE_ANNUAL, + MILITARY_TO_GOVERNMENT_CLINICAL_TRIALS_SPENDING_RATIO, + PENTAGON_UNACCOUNTED_FUNDS, + WAR_CHILDREN_KILLED_SINCE_1900, + WAR_COUNTERFACTUAL_GDP_PER_CAPITA, WAR_DEATHS_SINCE_1900, } from "@optimitron/data/parameters"; import { ParameterValue } from "@/components/shared/ParameterValue"; +import { authOptions } from "@/lib/auth"; import { formatCount } from "@/lib/format-count"; -import { getHumanityVGovernmentPlaintiffCount } from "@/lib/humanity-v-government-case.server"; -import { getRouteMetadata } from "@/lib/metadata"; +import { + getHumanityVGovernmentPlaintiffCount, + getHumanityVGovernmentVerdictStats, +} from "@/lib/humanity-v-government-case.server"; import { HUMANITY_V_GOVERNMENT_MANUAL_URL, ROUTES, - humanityVGovernmentLink, } from "@/lib/routes"; import { DamagesSensitivityCalculator } from "./DamagesSensitivityCalculator"; +import { HumanityVGovernmentVerdictVote } from "./HumanityVGovernmentVerdictVote"; +import { HUMANITY_V_GOVERNMENT_METADATA } from "./page-metadata"; export const dynamic = "force-dynamic"; -export const metadata = getRouteMetadata(humanityVGovernmentLink); +export const metadata = HUMANITY_V_GOVERNMENT_METADATA; const CASE_CAPTION = { plaintiff: "Humanity", defendants: "Governments of Earth, collectively", charge: - "Three counts of negligent mass homicide (Direct Killing, Regulatory Delay, Misallocation).", + "Three counts: direct killing, regulatory delay, and misallocation of public money away from keeping humans alive.", } as const; export default async function HumanityVGovernmentPage() { - const plaintiffCount = await getHumanityVGovernmentPlaintiffCount(); + const session = await getServerSession(authOptions); + const [plaintiffCount, verdictStats] = await Promise.all([ + getHumanityVGovernmentPlaintiffCount(), + getHumanityVGovernmentVerdictStats(session?.user?.id), + ]); return (

- Court of Humanity — Open case + Court of Humanity - Damages case

{CASE_CAPTION.plaintiff} v. {CASE_CAPTION.defendants.split(",")[0]}

-
-

- You are summoned +

+

+ The indictment

-

- You have been called as juror{" "} - - #{formatCount(plaintiffCount + 1)} - - . You are also a named plaintiff. Your share of the demanded recovery:{" "} - $10.6M (NPV) —{" "} - $25.2M (lifetime cohort). +

+ Governments were hired to promote the general welfare, defined as + the median health and wealth of the citizenry. They collect{" "} + {" "} + a year for the service.

-

- The verdict is the 1% Treaty. The case binds governments only when - four billion plaintiffs render it. Recruit two more jurors. The case - is the chain. +

+ The citizenry would like to actually receive this service at some + point.

- - Render the verdict — sign the treaty - -
- -
-

- Multiply your claim +

+ Instead, these public servants used{" "} + {" "} + of their salary to murder approximately{" "} + {" "} + humans over the last century of their employment. +

+

+ The dead included roughly 930,000 doctors, 310,000 scientists, + 620,000 engineers, 1.24 million nurses, 3.1 million teachers, and{" "} + {" "} + children who will never grow up to replace them.

-

- Register every deceased family member you can name. -

- Each registered estate is another named plaintiff with its own{" "} - $10.6M–$25.2M share of the demanded - recovery. A descendant or next-of-kin files the wrongful-death claim - on behalf of the estate. This is how every real corporate class - action handles deceased victims. + Murdering{" "} + {" "} + of your employers is the opposite of promoting their welfare, and + would be grounds for termination in any other employment contract + humans have ever signed.

-
    -
  • - 4 grandparents{" "} - registered = ~$42M–$100M added to your family claim. -
  • -
  • - - + parents, aunts, uncles, siblings - {" "} - who died of preventable disease or war = each an additional - $10.6M–$25.2M. -
  • -
  • - - The dead have no juror function - {" "} - — only living humans render the verdict — but they remain - plaintiffs whose estates are entitled to recovery. -
  • -
- - Register a deceased plaintiff - +

+ Had governments not spent{" "} + {" "} + murdering those people and destroying everything they spent their + entire lives building, the average human alive today would earn{" "} + {" "} + a year instead of{" "} + + . Dead scientists do not discover things and exploded cities are + very expensive to fix. +

+
+ + Vote on the finding + + + Support the settlement + + + Read the evidence + +
+
+ +
+ +
+ +
+

+ If this were a corporation +

+
+

+ If a corporation were paid{" "} + {" "} + a year to promote the general welfare and misused the funds to + this degree, it would be prosecuted, fined, monitored, and its + officers imprisoned. +

+

+ Pfizer paid $2.3 billion for health-care fraud. BP paid $20.8 + billion for the Deepwater Horizon spill. Volkswagen paid $4.3 + billion for cheating emissions tests and accepted a government + monitor. +

+

+ The defendants here have a larger revenue, a larger customer + base, and a larger body count. +

+
@@ -136,16 +230,78 @@ export default async function HumanityVGovernmentPage() {
{CASE_CAPTION.charge}
-
Settlement
+
Remedy
- The 1% Treaty: redirect 1% of military spending to clinical - trials. Defendants who ratify accept the settlement. Defendants - who do not remain in default. + The 1% Treaty is the settlement: redirect 1% of military spending + to clinical trials. Not because governments became wise. Because + one percent is cheaper than the damages.
+
+

+ Why this is a case +

+
+
+

+ Duty +

+

+ Governments accept compulsory payment to protect the public and + promote the general welfare. That is the job description. +

+
+
+

+ Breach +

+

+ They spend{" "} + {" "} + times more on military capacity than on government clinical + trials. Disease is what actually kills their citizens. +

+
+
+

+ Causation +

+

+ Some deaths were direct. Others happened because treatments were + delayed, trials were not funded, and the cure money became + hardware for organized killing. +

+
+
+

+ Damages +

+

+ The cautious floor is{" "} + {" "} + per living human. The prosecutor's base demand is{" "} + + . +

+
+
+
+

The three counts @@ -153,7 +309,7 @@ export default async function HumanityVGovernmentPage() {
  1. - Count 1 — Direct Killing + Count 1 — Death by War

    - War and conflict deaths since 1900. Defendants chose the policy - and proceeded. + The defendants, between 1900 and the present, did willfully and + with premeditation engage in the organized killing of 310 million + of their own employers.

  2. - Count 2 — Regulatory Delay + Count 2 — Death by Regulatory Delay

    - Patients who died waiting 8.2 years for already-safe drugs to be - proven effective. 1962 Kefauver-Harris Amendments through today. + The defendants required an additional 8.2 years of efficacy + testing before letting humans access drugs already proven safe. + 53 years of warnings. 102 million dead. “We did not + know” is no longer available as a defense.

  3. - Count 3 — Misallocation + Count 3 — Death by Misallocation

    {" "} - deaths + trial-years

    - 20th-century government democide. Resources spent on killing, - not on the disease that kills 150,000 humans every day. + Damages here are the counterfactual: what humanity would have + had if governments had frozen real military spending at 1900 + levels and redirected the rest to keeping their citizens alive. + The war budget since 1913 alone could have funded 37,778 years + of government clinical trials.

@@ -204,88 +366,149 @@ export default async function HumanityVGovernmentPage() {

- Demanded recovery, per plaintiff + The damages demand

- Primary theory (NPV) + Cautious floor

- Lost-prosperity-only, NPV at 3%, no-cure baseline. + Per living human, before punitive theories.

-
-

- Headline (cohort) +

+

+ Prosecutor demand

- $25.2M +

-

- Lost-prosperity-only, lifetime, representative full-life cohort. +

+ Adds drugs never developed because the trials were never funded.

-
-

- Constitutional ceiling +

+

+ Triple damages

- $9.13M +

-

- State Farm-style 10× exposure, alternative pleading. +

+ The False Claims Act triples damages when a defendant defrauds + the government. Here, the defendants ARE the government, + defrauding the citizenry. Triple damages apply.

- Why $25.2M.{" "} - Governments collected $36.5T/year to "promote the general welfare" - and underdelivered. The lost-prosperity theory compares delivered - welfare to a benchmark where disease was being seriously addressed; - the gap is the per-person deficiency. This is antitrust-style - lost-profits — the framework prosecutors actually win with against - corporate defendants. One coherent number, one comparison, no double- - counting deaths. + The demand is not one suspicious monster number. It is a ledger: war + deaths, regulatory delay deaths, destroyed property, missing public + money, and the cures never developed because the research budget was + busy becoming weapons.

Alternative pleadings.{" "} - If the Court rejects the lost-prosperity theory, body-count tiers - fall back: floor{" "} + If the court rejects the wider theory, the case still has the floor:{" "} {" "} - per capita; FCA treble{" "} + per person. If it accepts a False Claims Act-style triple-damages + analogy, + exposure reaches{" "} {" "} - per capita. Each tier is independently citable. + per person. The size of the number reflects the size of the death + toll. The defendants set both. +

+
+ +
+

+ The usual defenses +

+
+
+

+ "These are policy disagreements." +

+

+ Negligent homicide requires duty, breach, causation, damages, + and foreseeable risk. The defendants meet all five. +

+
+
+

+ "You cannot sue a government." +

+

+ That is because governments wrote rules saying governments are + hard to sue. This is not a moral defense. It is a confession with + letterhead. +

+
+
+

+ "The deaths are counterfactual." +

+

+ Governments use counterfactual lives saved to justify budgets + every day. The same math counts bodies when the budget kills + people quietly instead. +

+
+
+
+ +
+

+ Plaintiffs +

+

+ Name the humans the case should count. +

+

+ The case already has{" "} + + {formatCount(plaintiffCount)} + {" "} + named plaintiffs. If someone in your family died of war, regulatory + delay, or preventable disease, add them. A civilization should at + least be able to count its dead.

- - Read the full damages analysis → - + Add a plaintiff +
- ); } diff --git a/packages/web/src/app/impact/page.tsx b/packages/web/src/app/impact/page.tsx deleted file mode 100644 index f637fedbd..000000000 --- a/packages/web/src/app/impact/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -import { getSiteMetadata } from "@/lib/metadata"; -import { requireReferendumSiteContent } from "@/lib/referendum-site-content.server"; -import { getSiteFromHeaders } from "@/lib/site"; -import { ROUTES } from "@/lib/routes"; - -export async function generateMetadata() { - const hdrs = await headers(); - const site = getSiteFromHeaders(hdrs); - const content = requireReferendumSiteContent(site); - return getSiteMetadata(site, content.metadata.impact, ROUTES.impact); -} - -export default async function ImpactPage() { - const hdrs = await headers(); - const site = getSiteFromHeaders(hdrs); - const content = requireReferendumSiteContent(site); - - redirect(content.impactUrl); -} diff --git a/packages/web/src/app/impact/route.ts b/packages/web/src/app/impact/route.ts new file mode 100644 index 000000000..e98b0ce80 --- /dev/null +++ b/packages/web/src/app/impact/route.ts @@ -0,0 +1,13 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireReferendumSiteContent } from "@/lib/referendum-site-content.server"; +import { getSiteFromHeaders } from "@/lib/site"; + +export function GET(request: NextRequest) { + const site = getSiteFromHeaders(request.headers); + const content = requireReferendumSiteContent(site); + const target = new URL(content.impactUrl); + + target.search = request.nextUrl.search; + + return NextResponse.redirect(target, 307); +} diff --git a/packages/web/src/app/legal/page.logged-out.md b/packages/web/src/app/legal/page.logged-out.md deleted file mode 100644 index becff86d2..000000000 --- a/packages/web/src/app/legal/page.logged-out.md +++ /dev/null @@ -1,47 +0,0 @@ -# /legal - -- YOUR ORGANIZATION CAN -## ENTER YOUR AUDIENCE. SEE THE SUFFERING YOU CAUSE OR PREVENT. -- 150,000 humans die from disease today. Most preventable. Your audience size decides how much of it gets to keep happening. -- 150,000 -- STEP 1 -### SIZE OF ORGANIZATION AND MEMBERSHIP. -- Same numbers, two columns. Prevented if you act. Allowed if you do not. -- YOUR AUDIENCE -- EMAIL MEMBERS -- MONTHLY SITE VISITORS -- SOCIAL AUDIENCE -- MONTHS ON WEBSITE -- ASSUMPTIONS -- AUDIENCE THAT SEES IT % -- VERIFIED VOTE RATE % -- MEMBER SHARE MULTIPLIER -- FOUNDATION $ PER VOTE -- IF YOU ACT -- 5,346 -- LIVES SAVED -- 109,058 -- YEARS OF SUFFERING PREVENTED -- IF YOU DO NOT -- PREVENTABLE DEATHS ALLOWED -- YEARS OF SUFFERING ALLOWED -- 1,980 verified votes × 2.7 lives and 55 years prevented per vote. At $2 per vote, funders fund $3,960 of outreach. -- 2.7 -- 55 -- STEP 2 — ONE HOUR, THREE ACTIONS -- 1. EMBED THE IFRAME One paste, then it works while you sleep. -- 1. EMBED THE IFRAME -- One paste, then it works while you sleep. -- 2. SEND ONE EMAIL Pre-written. Your members already trust you. That is the asset. -- 2. SEND ONE EMAIL -- Pre-written. Your members already trust you. That is the asset. -- 3. POST ONCE PER CHANNEL Link auto-credits responses to your organization. -- 3. POST ONCE PER CHANNEL -- Link auto-credits responses to your organization. -- ORGANIZATION NAME * -- WEBSITE -- JOIN AS ORGANIZATION -- LEGAL NOTES FOR ORGANIZATIONS -- READ THE 1% TREATY TEXT -- Already joined? See the organizational supporters. -- organizational supporters diff --git a/packages/web/src/app/legal/page.tsx b/packages/web/src/app/legal/page.tsx deleted file mode 100644 index 5c1cf5c5f..000000000 --- a/packages/web/src/app/legal/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { redirect } from "next/navigation"; -import { ROUTES } from "@/lib/routes"; - -export const dynamic = "force-dynamic"; - -type LegalPageProps = { - searchParams?: Promise>; -}; - -export default async function LegalPage({ searchParams }: LegalPageProps) { - const params = (await searchParams) ?? {}; - const redirectSearchParams = new URLSearchParams(); - - for (const [key, value] of Object.entries(params)) { - if (typeof value === "string") { - redirectSearchParams.set(key, value); - } else if (Array.isArray(value)) { - value.forEach((item) => redirectSearchParams.append(key, item)); - } - } - - const queryString = redirectSearchParams.toString(); - redirect( - `${ROUTES.endorse}${queryString ? `?${queryString}` : ""}#organization-legal-notes`, - ); -} diff --git a/packages/web/src/app/legislation/page.tsx b/packages/web/src/app/legislation/page.tsx index 7538a5a3e..b6a4968b0 100644 --- a/packages/web/src/app/legislation/page.tsx +++ b/packages/web/src/app/legislation/page.tsx @@ -1,12 +1,9 @@ import Link from "next/link"; -import type { Metadata } from "next"; import { getLegislationEntries } from "@/lib/legislation"; -import { getLegislationPath, ROUTES } from "@/lib/routes"; +import { getRouteMetadata } from "@/lib/metadata"; +import { getLegislationPath, legislationLink, ROUTES } from "@/lib/routes"; -export const metadata: Metadata = { - title: "Model Legislation", - description: "Evidence-based draft legislation generated from the Optimitron analysis pipeline.", -}; +export const metadata = getRouteMetadata(legislationLink); export default function LegislationPage() { const entries = getLegislationEntries(); diff --git a/packages/web/src/app/loading.tsx b/packages/web/src/app/loading.tsx new file mode 100644 index 000000000..bdded4099 --- /dev/null +++ b/packages/web/src/app/loading.tsx @@ -0,0 +1,9 @@ +import { CivilizationOsLoader } from "@/components/ui/civilization-os-loader"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/packages/web/src/app/organizations/[id]/page.tsx b/packages/web/src/app/organizations/[id]/page.tsx index 53b112b4d..b75c0e7b3 100644 --- a/packages/web/src/app/organizations/[id]/page.tsx +++ b/packages/web/src/app/organizations/[id]/page.tsx @@ -1,18 +1,21 @@ -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import Link from "next/link"; +import { OrganizationCopyField } from "@/components/organizations/OrganizationCopyField"; +import { OrganizationGrantCalculator } from "@/components/organizations/OrganizationGrantCalculator"; import { OrganizationProfileEditor } from "@/components/organizations/OrganizationProfileEditor"; +import { OrganizationSurveyFrame } from "@/components/organizations/OrganizationSurveyFrame"; import { getCurrentUser } from "@/lib/auth-utils"; +import { GLOBAL_SURVEY_NAME } from "@/lib/messaging"; import { canManageOrganization } from "@/lib/organization.server"; import { prisma } from "@/lib/prisma"; import { - getOrganizationPath, - getSignInPath, + getOrganizationSurveyPath, NONPROFIT_COALITION_STRATEGY_URL, ROUTES, } from "@/lib/routes"; +import { getHandleOrReferralCode } from "@/lib/referral.client"; import { buildOrganizationSurveyUrl } from "@/lib/site"; import { - getUserDisplayHandle, getUserDisplayName, userDisplaySelect, } from "@/lib/user-display"; @@ -26,77 +29,73 @@ export default async function OrganizationPage({ }) { const { id } = await params; const user = await getCurrentUser(); - if (!user) { - redirect(getSignInPath(getOrganizationPath(id))); - } - const [canManage, org] = await Promise.all([ - canManageOrganization(user.id, id), - prisma.organization.findUnique({ - where: { id }, - include: { - members: { - include: { - user: { select: userDisplaySelect }, - }, - orderBy: { joinedAt: "asc" }, + const org = await prisma.organization.findFirst({ + where: { + deletedAt: null, + OR: [{ id }, { slug: id }], + }, + include: { + members: { + include: { + user: { select: userDisplaySelect }, }, - referendumPositions: { - where: { deletedAt: null }, - include: { - referendum: { select: { id: true, slug: true, title: true } }, - }, - orderBy: { updatedAt: "desc" }, + orderBy: { joinedAt: "asc" }, + }, + referendumPositions: { + where: { deletedAt: null }, + include: { + referendum: { select: { id: true, slug: true, title: true } }, }, + orderBy: { updatedAt: "desc" }, }, - }), - ]); - - if (!org || org.deletedAt) notFound(); + }, + }); - const isAllowed = canManage || user.isAdmin; - if (!isAllowed) { - return ( -
-

- Access denied -

-

- You are not a manager of this organization. -

- - Return home - -
- ); - } + if (!org) notFound(); - const referralIdentifier = getUserDisplayHandle(user); + const canManage = user ? await canManageOrganization(user.id, org.id) : false; + const isManager = Boolean(user && (canManage || user.isAdmin)); + const referralIdentifier = user + ? getHandleOrReferralCode({ + handle: user.person?.handle ?? null, + referralCode: user.referralCode, + }) + : null; const organizationSurveyUrl = buildOrganizationSurveyUrl(org.slug); - const memberSurveyUrl = buildOrganizationSurveyUrl(org.slug, { - referralCode: referralIdentifier, - }); - const embedSurveyUrl = organizationSurveyUrl; - const iframeTitle = `${org.name} Clinical Trial Abundance Survey`; + const embeddedSurveyPath = getOrganizationSurveyPath(org.slug); + const memberSurveyUrl = referralIdentifier + ? buildOrganizationSurveyUrl(org.slug, { + referralCode: referralIdentifier, + }) + : null; + const iframeTitle = `${org.name} ${GLOBAL_SURVEY_NAME}`; const escapedIframeTitle = escapeHtml(iframeTitle); - const iframeCode = ``; - const buttonCode = `Take the Clinical Trial Abundance Survey`; - const emailSubject = "30 seconds on clinical trial abundance"; + const iframeCode = ``; + const buttonCode = `Take the ${GLOBAL_SURVEY_NAME}`; + const emailSubject = "30 seconds to end war and disease"; const emailBody = `Subject: ${emailSubject} Hi, ${org.name} joined the International Campaign to End War and Disease by publicly supporting the 1% Treaty: every nation should simultaneously redirect 1% of military spending to high-efficiency pragmatic clinical trials. -Please review the treaty question and record your response here: +Please answer the ${GLOBAL_SURVEY_NAME} here: -${memberSurveyUrl} +${organizationSurveyUrl} Responses from this link are credited to ${org.name}. This is a policy survey, not a candidate endorsement.`; return (
+ {org.wordmarkLogoUrl || org.squareLogoUrl ? ( + {`${org.name} + ) : null}

Organization · {org.status.toLowerCase()}

@@ -112,167 +111,164 @@ Responses from this link are credited to ${org.name}. This is a policy survey, n {org.website} ) : null} + {org.description ? ( +

+ {org.description} +

+ ) : null}
{org.status === "APPROVED" ? ( -
-

- Member survey -

-

- Your organization has trusted reach. Use it once: share the - Clinical Trial Abundance Survey link, or place the button or - iframe on your website, so your audience can review the treaty - question and record its response. -

-
    -
  1. - Share the member link in an email, newsletter, or social post. -
  2. -
  3. - Embed the website button or iframe on a page your members see. -
  4. -
  5. - Keep the organization URL intact so responses are credited here. -
  6. -
-

- For the case behind this, read{" "} - - why organizations should share this - - . -

-
- ) : null} - -
-

- Profile -

- -
- -
-

- Members -

-
    - {org.members.map((m) => ( -
  • - {getUserDisplayName(m.user)} - - {m.role} - -
  • - ))} -
-
- -
-

- Clinical Trial Abundance Survey -

- {org.status === "APPROVED" ? ( -
-

- Use the member link for email and social posts. Use the iframe - for your website. Both credit {org.name}; the member link also - credits your referral code. + <> +

+

+ Share this organization's survey +

+

+ Anyone on {org.name}'s team can share this link or put the + survey on a website. Responses stay credited to {org.name}. + Manager access is only needed to edit the profile or members.

-
-
-

- Member link -

- - {memberSurveyUrl} - -
+
    +
  1. + Share the survey URL in an email, newsletter, or social post. +
  2. +
  3. + Embed the website button or iframe on a page your members see. +
  4. +
  5. + Keep the organization URL intact so responses are credited + here. +
  6. +
+

+ For the case behind this, read{" "} + + why organizations should share this + + . +

+
+ +
+

+ {GLOBAL_SURVEY_NAME} +

+
-

- Organization-only link +

+ Take the survey

- - {organizationSurveyUrl} - +
-
-