From e474445ef970c042a9343dbfc9f50a030ab40a56 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Mon, 18 May 2026 23:19:57 -0500 Subject: [PATCH 01/17] Add dating app shadowban FAQ --- packages/web/src/app/love/page.logged-out.md | 2 ++ packages/web/src/app/love/page.tsx | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/packages/web/src/app/love/page.logged-out.md b/packages/web/src/app/love/page.logged-out.md index 78340c04..59281106 100644 --- a/packages/web/src/app/love/page.logged-out.md +++ b/packages/web/src/app/love/page.logged-out.md @@ -86,6 +86,8 @@ - Having a real mission is consistently rated as one of the most attractive traits in social psychology research. A person who cares about something beyond themselves is more attractive than a person who doesn't. This isn't a sacrifice. It's an upgrade. #### Won't people think I'm promoting something? - You're asking them to vote on a free website for 30 seconds. Not buy a coin, join a group, or sign up for anything. The bar for "promotion" is usually money. This is free and takes less time than reading your bio. +#### Won't I get shadowbanned for sending the same message to everyone? +- Yes, you will. Dating apps detect copy-paste messages and mass-liking and will quietly make you invisible to everyone. This defeats the entire purpose. To avoid this: vary your wording each time you send the profile improvement message, limit yourself to 10-15 likes and 3-5 messages per day, space your activity throughout the day rather than doing it all at once, and for people you're actually interested in, send something specific to their profile instead of the campaign message. The algorithm rewards selectivity. Be a human, not a bot, even if your goal is to reach as many humans as possible. #### What if nobody votes from my profile? - Then you still have a more interesting dating profile than you did before. There's no downside. #### Is this a real campaign? diff --git a/packages/web/src/app/love/page.tsx b/packages/web/src/app/love/page.tsx index b2d423c5..dd93df03 100644 --- a/packages/web/src/app/love/page.tsx +++ b/packages/web/src/app/love/page.tsx @@ -140,6 +140,11 @@ const faqItems = [ answer: 'You\'re asking them to vote on a free website for 30 seconds. Not buy a coin, join a group, or sign up for anything. The bar for "promotion" is usually money. This is free and takes less time than reading your bio.', }, + { + question: "Won't I get shadowbanned for sending the same message to everyone?", + answer: + 'Yes, you will. Dating apps detect copy-paste messages and mass-liking and will quietly make you invisible to everyone. This defeats the entire purpose. To avoid this: vary your wording each time you send the profile improvement message, limit yourself to 10-15 likes and 3-5 messages per day, space your activity throughout the day rather than doing it all at once, and for people you\'re actually interested in, send something specific to their profile instead of the campaign message. The algorithm rewards selectivity. Be a human, not a bot, even if your goal is to reach as many humans as possible.', + }, { question: "What if nobody votes from my profile?", answer: From a964b02cfafdde42d1ca79a9bae171cfc6001e06 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Tue, 19 May 2026 18:42:30 -0500 Subject: [PATCH 02/17] Fix MCP updateTask admin bypass + add autoplan pre-existence and CBA hooks MCP server: hasAdminTaskWriteAccess now returns isAdmin alone (option B). Admin role IS the gate; TASKS_ADMIN scope is documentation/UX, not a security gate. Reversible to multi-admin posture by tightening one line in mcp-server.ts:198. Mike's User row now gets isAdmin: true via managed-iam-organization sync, unblocking the ic2ewd-grant updateTask Forbidden bug after deploy. DEFAULT_SCOPES renamed to DEFAULT_CONSENT_SCOPES with comment explaining what the constant actually controls (consent-UI preselection, filtered by allowedMcpScopesForUser). 189 lines of mcp-server.test.ts coverage including explicit regression tests for the ic2ewd-grant update + delete failure modes. /love source: removed "Hey Google, set a timer for one minute" leftover from prior dictation pass. Autoplan hooks: enforce-feature-preexistence-check warns when /autoplan references feature nouns that already exist as routes/branches/commits. enforce-cba-table-on-plan-files warns when plan files lack a structured Cost-Benefit Matrix. Both fix structural gaps in the 2026-05-19 autoplan failure that proposed a feature already shipped. TODO.md sweep: audited against actual code state, 207 lines of shipped items removed (login spam fix, wishonia smirk-to-happy avatar, email 12px-to-14px, grandma headshot crop, direct-reports-to-employees, humanity-manager-status panel, forward-to-better-fit mailto, dynamic approved-org + court/HvG sitemaps). Added dating registry deferred section + autoplan process rules. todo-touched: login-spam, wishonia-avatar, email-12px, grandma-headshot, direct-reports-rename, humanity-manager-status, forward-to-better-fit, dynamic-sitemaps qa-passed: option-B admin MCP simplification + ic2ewd-grant regression tests, copy:preview snapshots, mcp-server tests (123 passed), tsc clean, managed-iam-organization tests passed, /love smoke clean, dev-log scan clean Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hooks/enforce-cba-table-on-plan-files.mjs | 193 ++++++++++ ...feature-preexistence-check-on-autoplan.mjs | 337 ++++++++++++++++++ .claude/settings.json | 10 + TODO.md | 315 +++++----------- .../managed-iam-organization.test.ts | 1 + .../managed-data/managed-iam-organization.ts | 2 + .../web/src/app/developers/page.logged-out.md | 118 ++++++ packages/web/src/app/love/page.tsx | 2 +- packages/web/src/app/mcp/authorize/page.tsx | 9 +- .../web/src/lib/__tests__/mcp-server.test.ts | 189 ++++++++++ packages/web/src/lib/mcp-scopes.ts | 10 +- packages/web/src/lib/mcp-server.ts | 75 ++-- 12 files changed, 1009 insertions(+), 252 deletions(-) create mode 100644 .claude/hooks/enforce-cba-table-on-plan-files.mjs create mode 100644 .claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs create mode 100644 packages/web/src/app/developers/page.logged-out.md diff --git a/.claude/hooks/enforce-cba-table-on-plan-files.mjs b/.claude/hooks/enforce-cba-table-on-plan-files.mjs new file mode 100644 index 00000000..abfe9dba --- /dev/null +++ b/.claude/hooks/enforce-cba-table-on-plan-files.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +// enforce-cba-table-on-plan-files.mjs +// +// PreToolUse hook on Skill: when /autoplan runs, look for the most recent +// plan file in ~/.gstack/projects//*plan*.md and verify it contains +// a Cost-Benefit Matrix section with all required columns. +// +// Mike's 2026-05-19 trigger, verbatim: *"it should be like doing a cost +// benefit analysis of the value of features and the complexity cost to +// the code base and to the user interface. it seems like you're just +// suggesting a bunch of ideas and not doing a cost-benefit analysis. do +// we have guidelines to do this? isn't that the whole point of the +// autoplan thing?"* +// +// Why: /autoplan's plan-ceo-review, plan-eng-review, plan-design-review, +// and plan-devex-review challenge premises, architecture, design, and DX, +// but do NOT require a structured comparison of (CC effort × probability +// of value × opportunity cost). Result: reviewers suggest features that +// look reasonable in isolation but don't survive a real CBA against the +// priority order in CLAUDE.md. +// +// Related memory: +// - feedback_promote_violated_text_rules_to_hooks.md +// - feedback_be_opinionated_for_mike_finite_energy.md +// +// Strategy: +// 1. Pass-through unless tool_name === "Skill" AND skill === "autoplan". +// 2. Locate the most recent plan file matching the slug + branch pattern +// under ~/.gstack/projects//. +// 3. If no plan file exists yet (first /autoplan invocation), emit an +// advisory reminding that the plan MUST include a CBA section before +// the final gate. +// 4. If a plan file exists, check it for: +// - A heading containing "Cost-Benefit" OR "Cost / Benefit" +// OR "CBA" (case-insensitive) +// - At least one table row mentioning CC hours / effort +// - At least one mention of "opportunity cost" OR "what drops" +// - At least one "Decision:" or decision column entry +// 5. If the section is missing or incomplete, emit a corrective +// template the planner can paste in. ADVISORY for now (exit 0) +// so we measure false-positive rate before hard-blocking. +// +// Bypass: if args contains "CBA-IN-PROGRESS" the hook skips (signals the +// planner is mid-draft and acknowledges the requirement). + +import { execSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + +const REQUIRED_SIGNALS = { + heading: /^#{1,4}\s+.*(cost[\s\-/]*benefit|\bCBA\b)/im, + effort: /(CC\s*(hours|hrs)|effort|wallclock|wall\s*clock)/i, + opportunity: /(opportunity\s*cost|what\s*drops|trade[\s\-]*off|instead\s*of)/i, + decision: /(decision|\bship\b|\bcut\b|\bdefer\b|\bresearch\b|\bkill\b)/i, +}; + +function safeExec(cmd) { + try { + return execSync(cmd, { + cwd: PROJECT_DIR, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 4000, + }); + } catch { + return ""; + } +} + +function projectSlug() { + const top = safeExec("git rev-parse --show-toplevel").trim(); + if (!top) return "unknown"; + const remote = safeExec("git config --get remote.origin.url").trim(); + // Best-effort slug: derive from remote URL (owner-repo) if possible, + // else use the directory basename. + const m = remote.match(/[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/); + if (m) return `${m[1]}-${m[2]}`; + return path.basename(top); +} + +function findMostRecentPlanFile() { + const slug = projectSlug(); + const dir = path.join(os.homedir(), ".gstack", "projects", slug); + if (!existsSync(dir)) return null; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + const planFiles = entries + .filter((e) => e.isFile() && /plan/i.test(e.name) && e.name.endsWith(".md")) + .map((e) => { + const full = path.join(dir, e.name); + let mtime = 0; + try { + mtime = statSync(full).mtimeMs; + } catch { + // Intentional silence: stat failure (race with concurrent file + // deletion, permission flake) just means we sort that entry last; + // not worth surfacing. + } + return { full, mtime, name: e.name }; + }) + .sort((a, b) => b.mtime - a.mtime); + return planFiles[0] || null; +} + +function cbaTemplate() { + return [ + `## Cost-Benefit Matrix`, + ``, + `Required before the final approval gate. Rank impact against CLAUDE.md priority order (1=vote conversion, 2=referral propagation, 3=org endorsement, 4=plaintiffs, 5=leader reminders, 6=discoverability, 7=optimitron OS).`, + ``, + `| Option | CC hrs | Wallclock | Expected impact (with units) | Confidence | Brand/UX cost | Opportunity cost (which P0/P1 TODO drops) | Risk-adj score | Decision |`, + `|---|---|---|---|---|---|---|---|---|`, + `| Option A — | N | N | e.g. \"+N treaty signatures / 30d\" | HIGH/MED/LOW | none/low/med/high | name the specific TODO item this displaces | qualitative | SHIP / CUT / DEFER / RESEARCH |`, + `| Option B — | N | N | ... | ... | ... | ... | ... | ... |`, + `| ... (one row per real option, including \"do nothing\" and \"defer to TODO.md\") |`, + ``, + `**Verdict from the matrix:** .`, + ``, + ].join("\n"); +} + +try { + const raw = readFileSync(0, "utf-8"); + if (!raw || !raw.trim()) process.exit(0); + + const hookData = JSON.parse(raw); + if (hookData?.tool_name !== "Skill") process.exit(0); + + const skill = hookData?.tool_input?.skill; + if (skill !== "autoplan") process.exit(0); + + const args = String(hookData?.tool_input?.args || ""); + if (args.includes("CBA-IN-PROGRESS")) process.exit(0); + + const planFile = findMostRecentPlanFile(); + + if (!planFile) { + const msg = [ + `[enforce-cba-table-on-plan-files] ADVISORY — no plan file found yet for this project.`, + ``, + `When /autoplan drafts the plan, it MUST include a Cost-Benefit Matrix section before reviewers run. The matrix is the structured antidote to "suggest a bunch of ideas without weighing them."`, + ``, + cbaTemplate(), + `Rule lives at: .claude/hooks/enforce-cba-table-on-plan-files.mjs`, + ].join("\n"); + process.stderr.write(msg + "\n"); + process.exit(0); + } + + const content = readFileSync(planFile.full, "utf-8"); + const missing = []; + for (const [key, re] of Object.entries(REQUIRED_SIGNALS)) { + if (!re.test(content)) missing.push(key); + } + + if (missing.length === 0) process.exit(0); + + const lines = [ + `[enforce-cba-table-on-plan-files] ADVISORY — plan file is missing required Cost-Benefit Matrix signals.`, + ``, + `Plan: ${planFile.full}`, + `Missing signals: ${missing.join(", ")}`, + ``, + `What each signal requires:`, + ` - heading: a "## Cost-Benefit Matrix" (or "CBA") section header`, + ` - effort: per-option CC hours / wallclock estimate`, + ` - opportunity: name the specific P0/P1 TODO that drops if this ships`, + ` - decision: SHIP / CUT / DEFER / RESEARCH per option`, + ``, + `Paste this template into the plan file before re-running /autoplan:`, + ``, + cbaTemplate(), + `Rule lives at: .claude/hooks/enforce-cba-table-on-plan-files.mjs`, + ]; + + process.stderr.write(lines.join("\n") + "\n"); + // ADVISORY (exit 0) on first design pass; flip to exit 2 after measuring + // false-positive rate. Hook should never block the planner mid-draft. + process.exit(0); +} catch (err) { + // Intentional silence: hooks must never fail closed on their own crash. + // Surface to stderr for the next-turn Claude to notice without blocking + // the user's /autoplan dispatch. + process.stderr.write(`[enforce-cba-table] hook crashed: ${err?.message ?? err}\n`); + process.exit(0); +} diff --git a/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs b/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs new file mode 100644 index 00000000..ea185393 --- /dev/null +++ b/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +// enforce-feature-preexistence-check-on-autoplan.mjs +// +// PreToolUse hook on Skill: when /autoplan is invoked with args that imply +// adding/building/shipping a named feature, grep the codebase for routes, +// route labels, recent commits, and the current branch name to see whether +// that feature already exists. If any match is found, advise the planner +// to inspect the existing surface BEFORE drafting a plan that may critique +// a feature that's already shipped. +// +// Mike's 2026-05-19 trigger, verbatim: *"didn't we already do the bio +// template page at the /love route? if so, why are you not reviewing the +// recent commits and the full scope of the application?"* +// +// Why: autoplan's Phase 0 reads CLAUDE.md, TODO.md, git log -30, and +// git diff --stat. It does NOT enumerate existing route directories, +// grep routes.ts for the proposed feature name, or cross-check the +// branch name. Result: the dual reviewers critique a feature against +// a wrong starting point and recommend "ship the bio-template version" +// for a feature whose bio-template version is already shipped. +// +// Related memory: +// - feedback_verify_before_defensive_recommendation.md +// - feedback_promote_violated_text_rules_to_hooks.md +// - feedback_cwd_aware_absence_checks.md +// +// Strategy: +// 1. Pass-through unless tool_name === "Skill" AND skill === "autoplan". +// 2. Extract candidate feature nouns from the skill args: +// tokens following add/build/ship/create/implement/launch, plus +// branch-name tokens (after stripping feature/ prefix). +// 3. For each candidate token, search: +// a. packages/web/src/app/ directory names (one-level deep) +// b. packages/web/src/lib/routes.ts content +// c. recent commit messages: git log main..HEAD --oneline +// 4. If matches found, emit a structured advisory listing each match +// with file:line refs. Hook stays ADVISORY (exit 0) so a planner +// who has ALREADY acknowledged the existing surface isn't blocked, +// but the warning forces the planner to see existing state before +// proceeding. +// +// Bypass: if args contains "ACKNOWLEDGED-PREEXISTENCE" the hook skips +// (signals the planner has already inspected the existing surface). + +import { execSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + +const TRIGGER_VERBS = new Set([ + "add", + "adding", + "build", + "building", + "ship", + "shipping", + "create", + "creating", + "implement", + "implementing", + "launch", + "launching", + "introduce", + "introducing", + "make", + "making", +]); + +const STOPWORDS = new Set([ + "a", + "an", + "the", + "this", + "that", + "these", + "those", + "some", + "any", + "new", + "real", + "full", + "small", + "simple", + "minimal", + "feature", + "features", + "thing", + "things", + "stuff", + "way", + "ways", + "page", + "pages", + "route", + "routes", + "support", + "to", + "for", + "of", + "on", + "in", + "with", + "and", + "or", + "but", + "into", + "onto", + "from", + "by", + "as", + "is", + "are", + "be", + "been", + "being", + "do", + "does", + "did", + "have", + "has", + "had", + "will", + "would", + "could", + "should", + "can", + "may", + "might", + "shall", + "more", + "very", + "really", + "just", + "only", + "even", + "also", + "still", + "yet", + "system", + "layer", + "thing", + "ui", + "ux", + "api", + "app", + "site", + "web", +]); + +function extractCandidateNouns(text) { + const cleaned = String(text || "") + .toLowerCase() + .replace(/[`*_~"'()[\]{}.,!?;:]/g, " ") + .replace(/\s+/g, " "); + const tokens = cleaned.split(" "); + const candidates = new Set(); + for (let i = 0; i < tokens.length - 1; i += 1) { + const token = tokens[i]; + if (!TRIGGER_VERBS.has(token)) continue; + // Take up to the next 4 tokens, stopping at any verb/stopword we don't want. + for (let j = 1; j <= 4 && i + j < tokens.length; j += 1) { + const candidate = tokens[i + j]; + if (!candidate || candidate.length < 3) continue; + if (STOPWORDS.has(candidate)) continue; + if (TRIGGER_VERBS.has(candidate)) break; + candidates.add(candidate.replace(/[^a-z0-9-]/g, "")); + } + } + return Array.from(candidates).filter((c) => c && c.length >= 3); +} + +function safeExec(cmd) { + try { + return execSync(cmd, { + cwd: PROJECT_DIR, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 4000, + }); + } catch { + return ""; + } +} + +function getBranchTokens() { + const branch = safeExec("git branch --show-current").trim(); + if (!branch) return { branch, tokens: [] }; + const stripped = branch.replace(/^feature\//, "").replace(/^fix\//, "").replace(/^chore\//, ""); + const tokens = stripped + .split(/[-_/]/) + .map((t) => t.toLowerCase()) + .filter((t) => t.length >= 3 && !STOPWORDS.has(t)); + return { branch, tokens }; +} + +function listAppRouteDirs() { + const appRoot = path.join(PROJECT_DIR, "packages", "web", "src", "app"); + if (!existsSync(appRoot)) return []; + const out = []; + function walk(dir, depth) { + if (depth > 2) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (!e.isDirectory()) continue; + if (e.name.startsWith(".") || e.name === "api" || e.name === "node_modules") continue; + const full = path.join(dir, e.name); + out.push({ name: e.name.toLowerCase(), path: full }); + walk(full, depth + 1); + } + } + walk(appRoot, 0); + return out; +} + +function searchRoutesTs(candidate) { + const routesPath = path.join(PROJECT_DIR, "packages", "web", "src", "lib", "routes.ts"); + if (!existsSync(routesPath)) return []; + const content = readFileSync(routesPath, "utf-8"); + const lines = content.split(/\r?\n/); + const hits = []; + const needle = candidate.toLowerCase(); + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].toLowerCase().includes(needle)) { + hits.push({ line: i + 1, text: lines[i].trim().slice(0, 140) }); + if (hits.length >= 4) break; + } + } + return hits; +} + +function searchRecentCommits(candidate) { + const log = safeExec(`git log main..HEAD --oneline --format=%h%x09%s`); + if (!log) return []; + const needle = candidate.toLowerCase(); + return log + .split(/\r?\n/) + .filter((line) => line.toLowerCase().includes(needle)) + .slice(0, 5); +} + +try { + const raw = readFileSync(0, "utf-8"); + if (!raw || !raw.trim()) process.exit(0); + + const hookData = JSON.parse(raw); + if (hookData?.tool_name !== "Skill") process.exit(0); + + const skill = hookData?.tool_input?.skill; + if (skill !== "autoplan") process.exit(0); + + const args = String(hookData?.tool_input?.args || ""); + if (args.includes("ACKNOWLEDGED-PREEXISTENCE")) process.exit(0); + + const candidates = extractCandidateNouns(args); + const { branch, tokens: branchTokens } = getBranchTokens(); + for (const t of branchTokens) { + if (!candidates.includes(t)) candidates.push(t); + } + + if (candidates.length === 0) process.exit(0); + + const routeDirs = listAppRouteDirs(); + const findings = []; + + for (const candidate of candidates) { + const dirMatches = routeDirs.filter((d) => d.name === candidate || d.name.includes(candidate)); + const routesHits = searchRoutesTs(candidate); + const commitHits = searchRecentCommits(candidate); + + if (dirMatches.length === 0 && routesHits.length === 0 && commitHits.length === 0) continue; + + findings.push({ candidate, dirMatches, routesHits, commitHits }); + } + + if (findings.length === 0) process.exit(0); + + const lines = [ + `[enforce-feature-preexistence-check-on-autoplan] ADVISORY — /autoplan invocation references feature noun(s) that ALREADY appear in this repo. Read existing surfaces BEFORE drafting a plan; otherwise reviewers will critique a starting point that doesn't exist.`, + ``, + `Branch: ${branch || "(none)"}`, + `Candidates examined: ${candidates.join(", ")}`, + ``, + ]; + + for (const f of findings) { + lines.push(`### "${f.candidate}"`); + if (f.dirMatches.length > 0) { + lines.push(` app/ route dirs matching:`); + for (const d of f.dirMatches.slice(0, 4)) { + const rel = path.relative(PROJECT_DIR, d.path).replace(/\\/g, "/"); + lines.push(` - ${rel}`); + } + } + if (f.routesHits.length > 0) { + lines.push(` routes.ts hits:`); + for (const r of f.routesHits) { + lines.push(` - routes.ts:${r.line}: ${r.text}`); + } + } + if (f.commitHits.length > 0) { + lines.push(` recent commits (main..HEAD):`); + for (const c of f.commitHits) { + lines.push(` - ${c}`); + } + } + lines.push(``); + } + + lines.push( + `Required before drafting the plan:`, + ` 1. Read EVERY app/ route file in the dir matches above.`, + ` 2. Quote the current state in the plan's "What already exists" section.`, + ` 3. Reframe the plan as a delta against the existing surface (not a greenfield design).`, + ` 4. Re-invoke /autoplan with "ACKNOWLEDGED-PREEXISTENCE" appended to args to confirm.`, + ``, + `Rule lives at: .claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs`, + ); + + process.stderr.write(lines.join("\n") + "\n"); + // ADVISORY (exit 0) on first design pass; planner is expected to read + // the advisory and either acknowledge or stop. If we want hard-blocking + // later, flip to exit 2 after measuring false-positive rate. + process.exit(0); +} catch (err) { + // Intentional silence: hooks must never fail closed. If this hook itself + // crashes (malformed JSON, missing path, etc.) we exit 0 so /autoplan + // dispatches remain possible. The error gets surfaced to stderr for the + // next-turn Claude to notice without blocking the user. + process.stderr.write(`[enforce-feature-preexistence-check] hook crashed: ${err?.message ?? err}\n`); + process.exit(0); +} diff --git a/.claude/settings.json b/.claude/settings.json index b8f3bdc8..d7ac400a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -97,6 +97,16 @@ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs\"", + "timeout": 6000 + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-cba-table-on-plan-files.mjs\"", + "timeout": 4000 } ] } diff --git a/TODO.md b/TODO.md index 4b2bd842..5774a95b 100644 --- a/TODO.md +++ b/TODO.md @@ -43,11 +43,49 @@ Do not let lower items crowd out higher ones. seed shim. - Treaty vote, referral attribution, campaign emails, organization endorsement, plaintiff damages, and the simple `/treaty` skim-and-sign page exist. +- Bio-template `/love` page is shipped; dating registry deferred per 2026-05-19 + CBA. - `/humanity-v-government` and `/court` still need to unify plaintiff registration, verdict voting, and treaty settlement. - Visual review includes email screenshots; preview DB drift and unexplained missing screenshots still waste review time. +## Active Review - 2026-05-19: Money and 4B Votes + +Repo audit finding: the campaign machinery mostly exists. `/vote` and +`/treaty` mount the treaty flow; `AuthForm` now locks the email-login controls +after a sign-in link is sent; `/endorse` creates or opens organization tools; +organization pages expose a survey URL, email starter, website button, iframe, +preview, manager referral URL, and grant calculator; `/poster` has referral QR +printing; `/donate` and `/fund` exist; signatories rank humans and +organizations by attributable signatures; agent-readable mirrors and sitemap +coverage exist for the public campaign surfaces; tests cover the vote API, +referral attribution, invite-token paths, org endorsement, public detail +sitemaps, share/email templates, treaty vote clicks, login, and reminder flows. + +Main gap: this is not yet packaged as a fundable distribution sprint. The +highest-value money path is a concrete 30-day institutional distribution sprint: +fund outreach to organizations with audiences, get them to endorse/embed/recruit, +and report signatures, organizations, referrals, and conversion bottlenecks. + +Cost-benefit gate for near-term work: + +| Proposed change | Benefit | Cost / risk | Decision | +| --- | --- | --- | --- | +| Write a 30-day funder/distribution sprint packet (`docs/funding-sprint.md` first; public page only after copy review) | Gives donors and partner orgs a concrete ask: budget, targets, proof links, and done conditions | 2-4 hours, no schema, no new UI required | Do now | +| Seed or curate the first outreach task queue in existing `Task` records | Converts "get organizations" into accountable follow-up work using the current task model | 0.5-1 day; avoid new outreach schema | Do after the packet if outreach starts | +| Polish `/endorse` and organization tools from first 5 real outreach attempts | Removes actual conversion friction at the organization step | 1-3 hours per observed issue; screenshots and copy approval required | Do only from observed friction | +| Add a cheap weekly metrics report from existing referral/org/vote tables | Shows whether money bought votes, orgs, referrals, or nothing | 2-6 hours; avoid a dashboard until reports are used | Do as a script/export, not a product surface | +| Redesign `/fund` or expand prize/fund mechanics | Could look more fundable, but funders need a concrete sprint first | High copy/UI review cost; risks leading with mechanism instead of distribution | Defer | +| Poster style selector, PDF export, OG variants | Useful if physical distribution proves real | 0.5-2 days plus UI screenshot review | Defer until poster scans/signatures show demand | +| Dating registry and per-app dating templates | Potential niche channel, but `/love` bio-template is already the cheap test | Schema, moderation, and privacy cost | Parked until `/love` attribution clears the threshold | +| Public-figure catalog | Could create social proof, but attribution disputes are expensive | New data policy, likely schema, manual source review | Defer until org sprint has traction | +| Full analytics dashboard | Useful later; premature if nobody reads the report | 1-3 days and another surface to maintain | Use SQL/export first | + +Do not start a new feature unless it helps the sprint get money, convert votes, +convert referrals, convert organizations, or prove the quantified case to a +specific funder/partner. + ## Active Handoff - 2026-05-13 - Codex hook cleanup: Mike prefers deleting repo-local `.codex` hooks instead of @@ -76,67 +114,19 @@ Do not let lower items crowd out higher ones. private memory. I stopped only the `copy:preview` worker chain; the shared `3001` dev server stayed up and root responded afterward. -## P0 - Auth UX fixes - -- **Login page: form stays clickable after submit → sends N magic-link - emails for N clicks.** Confirmed bug: a real user pressed Submit - multiple times and received many emails. `AuthForm.tsx:322-330` - uses `isLoading` (in-flight) to disable the button, but on - success `isLoading` resets to false — the button becomes - re-clickable. Fix: introduce a "submitted" state distinct from - "loading". After success, HIDE the email field + submit button - + the Google button (lock in the choice), and render a centered - "check your email" confirmation in the same vertical position - the form was. On error, restore the form. Bonus defense: server- - side rate-limit magic-link sends per-email-per-window so even - bypass (DevTools, scripting) can't spam. -- **Login page: post-submit "check your email" message gets lost when - scrolled.** Same bug as above — covered by hiding the buttons + - scroll-centering the confirmation in the form's slot. +## P0 - Conversion and Email Safety + - **Login page: excess space between slider (CTA / framing element) and the submit button pushes the submit below the fold.** Reduce vertical spacing so the entire form is visible above the fold on common mobile viewports without scrolling. Audit gap-* / mt-* / py-* on the AuthForm container. -- **Wishonia email signature uses `smirk-smile.png` — reads as a - weird/sarcastic smile.** Swap to `happy-smile.png` (already in - `packages/web/public/sprites/wishonia/`). Single-line change in - `packages/web/src/lib/email/wishonia-signature.ts:17` (constant - `WISHONIA_AVATAR_PATH`) + update the matching test fixture in - `packages/web/src/lib/email/__tests__/wishonia-signature.test.ts:92`. - Trivial-tier dispatch. -- **Rename "direct reports" → "employees" across user-facing surfaces.** - Non-tech users don't read "direct reports" — it's HR jargon. "Employees" - works AS satire (you are now the boss of 8 billion employees) and is - universally understood. Locations: - - `packages/web/src/lib/humanity-manager-promotion-content.tsx:68` - (`"8 billion direct reports — humans you are responsible for..."`) - - `packages/web/src/components/landing/TreatyPostVoteShareFlow.tsx:1144,1148` - (`"${recipientLabel} added to your direct reports"` — TWO instances) - - `packages/web/src/lib/email/monthly-chain-digest-email.ts:40,67` - (the JSDoc comment + the trigger description metadata; the metadata - surfaces in the rendered email) - - `packages/web/src/lib/email/monthly-chain-digest.email.md:15` - (auto-regenerated when source changes + smart `copy:preview` runs) - Plus matching test fixture updates. Trivial-tier dispatch. -- **Email body text rendered at 12px is too small to read.** Confirmed: - `packages/web/src/components/adaptive/email-styles.ts:82` defines - `smallMutedParagraph` at `fontSize: "12px"`. The humanity-manager - promotion email's middle paragraph block ("You probably do not have - time to persuade [8 billion] humans yourself...", 200+ words at - `humanity-manager-promotion-content.tsx:112-152`) renders with the - `muted` flag → that 12px style. Best practice for email body copy - is 14-16px minimum; 12px is for legal disclaimers / footnotes, not - multi-paragraph prose. `mutedParagraph` at 13px (line 78) is also - borderline. - - Fix candidates: (a) bump `smallMutedParagraph` to 14px; (b) - deprecate `smallMutedParagraph` and route prose through - `mutedParagraph` (13px) or `paragraph` (16px); (c) drop the - `muted` flag on long-form `PromoText` blocks and only keep it - for one-line asides. - - Most defensible quick fix: (a) + change the - humanity-manager-promotion call to drop `muted` for the long - block (line 112) and use it only for the short closing aside. +- **Server-side sign-in-link send rate limit is a defense, not the next + product bottleneck.** Client-side repeat-send spam is already mitigated by + `AuthForm`'s `hasSubmitted` state hiding the email/Google/demo controls after + success. Add a server-side limiter only if Resend/auth logs still show repeat + sends after the UI fix, or before a high-volume outreach push. Avoid an + in-memory limiter as a false guarantee on serverless. - **Add a min-font-size validation pass — emails first, then web.** We need automated detection so this doesn't recur. Two layers: - **Email-specific Playwright test:** render every email preview @@ -156,144 +146,16 @@ Do not let lower items crowd out higher ones. `adaptive/email-styles.ts` so the size policy lives in ONE place and surfaces use semantic intent. Token-based then the lint rule has a clean allowlist to enforce against. -- **Grandma Kay's avatar is a full-body photo cropped weird by - `aspect-square`.** Confirmed: `packages/web/public/img/grandma.jpg` - is 1155×2257 (~2:1 vertical, full-body portrait). The - `PersonFaceTile` component (and any other `aspect-square + - object-cover` slot) crops to the centered region, which is her - mid-torso, not her face. Fix: create a square head-only crop - (e.g. `/img/grandma-headshot.jpg`, ~1024×1024) and update - `packages/db/src/managed-data/managed-grandma-kay.ts:37,45` to - point at the new file. Keep the full-body image accessible if - anything else uses it (grep first; otherwise delete to clean up). - Trivial-tier dispatch once the cropped file exists. - -- **Plaintiff-registration aspect-ratio handling — seed images bypass - the existing cropper.** `SquarePhotoCropper` is already wired into - `RepresentedPersonForm` / `ManageRepresentedPeopleClient` / - `OrganizationProfileEditor` / `ProfileCard`, so users uploading - new plaintiffs DO get a square crop step. The gap is - managed-data seeded images (e.g. Grandma Kay) — they go straight - to the database without passing through the cropper, so a tall - portrait can land in a `aspect-square` tile cropped wrong. - - Right fix: a managed-data validation step that rejects - non-square seed images (or auto-crops them server-side at sync - time). Sync step lives at - `packages/db/scripts/sync-managed-data.ts`; image-fetch helper - at `packages/web/src/lib/storage/image-fetcher.ts` if one - exists, otherwise inline the square-crop in sync. Use - `sharp` (already a dep for image work elsewhere if any - package uses it; grep before adding). - - Cheaper-but-uglier fix: just commit pre-cropped square images - for every managed-data seed person and don't add validation. - Easier today, fragile tomorrow. - -- **Printable signs / posters with QR codes pointing at warondisease.org.** - Physical-world distribution channel: a sheet someone prints, posts on - a coffee shop bulletin board / dorm wall / office, and passers-by scan - the QR to vote. Each print can carry the printer's referral code, so - physical distribution feeds the same propagation math as digital - sharing. - - **New route:** `/poster` (or `/sign` per Mike's framing). Logged-in - users see their referral code pre-filled in the QR. Logged-out - users get a generic QR to `warondisease.org`. - - **Style selector** — multiple printable aesthetics: - - **Treaty editorial** (default, matches existing site) - - **Soviet/constructivist** (red + black, geometric, bold type) - - **WPA public-service** (typography-heavy, 1930s civic poster) - - **UK wartime minimal** ("Keep Calm"-style: single color, calm - typography, single message) - - **Bauhaus geometric** (limited palette, asymmetric, strong type - hierarchy) - - **NOT included: Nazi-era styling.** The user mentioned it as an - example, but the specific visual vocabulary is historically - poisoned and would do real damage to the campaign's credibility. - Soviet/WPA/UK styles communicate the same "urgent civic - mobilization" energy without the association. - - **Reuse existing OG image generation** as the central image where - appropriate. Next.js `opengraph-image.tsx` files at - `packages/web/src/app/**/opengraph-image.tsx` already produce - per-entity 1200×630 PNGs via the edge runtime — a poster can - embed a downscaled version of e.g. `humanity-v-government` - OG or `tasks/[id]` OG to anchor the visual. - - **QR generation** — `qrcode.react@4.0.1` is already installed - and in use (`slide-final-call-to-action.tsx`). The QR target is - `https://warondisease.org/r/` (or bare - `warondisease.org` for logged-out users). Generate as SVG for - print-clean rendering. Cite via `ParameterValue` where the "30 - seconds" claim appears (matches existing parameter pattern). - - **Print flow** — letter (8.5×11) and A4 sizes, both supported. - Browser print via `@media print` CSS that hides chrome and - expands the poster fullscreen. "Download PDF" button as - secondary option (use `react-pdf` or a headless-render route; - don't bring in puppeteer just for this). - - **Message text** — copy comes from `share-templates.ts` (the - canonical voice-variant registry per the email-template-audit - plan); poster surface picks one variant by default but allows - user override. Reuses the dispatch-time recipient-mode - filtering. - - **Plan-first dispatch.** This touches: new app route, new - components, OG-image reuse, print CSS, optional PDF gen, - share-templates integration. Crosses too many systems for a - `trivial:` bypass. - -- **Standardize "apocalypse" framing across the project.** Ivy (real- - user feedback) said *"a hundred of them ends civilization is a - confusing sentence."* The word "apocalypse" treats civilization- - ending event as a countable unit, and "122 apocalypses" / "trade - one apocalypse" doesn't land for people who haven't been told the - causal chain (~100 warheads → nuclear winter → food system collapse - → civilizational collapse; we stockpiled ~12,200 → 122x overkill). - Pick ONE standardized phrasing, parameter-back it, sweep all - surfaces. - - **User-facing surfaces to update (one consistent phrasing):** - `Footer.tsx:44,50` (header tagline) · - `donate/page.tsx:51` · - `endorse/page.tsx:185` · - `DonationCalculationNarrative.tsx:397` · - `TreatyPostVoteShareFlow.tsx:802,809,812,862,871,948` (6 uses) - in the post-vote sharing flow · - `TreatyVoteFlow.tsx:558,571,579,588` (pre-vote screens incl. the - *"More apocalypses please"* button label) · - `managed-task-triggers.ts:142` (the reminder-template prose - used in every nudge email) · - `managed-grandma-kay.ts:83,91` (*"She would trade one apocalypse - for dementia research"* — keep the trade frame but rephrase). - - **NOT user-facing — leave as-is or rename only with the - standardized term:** `TreatyVoteFlow.tsx:66` (the - `PreVoteScreen` type literal `"apocalypse"`), e2e screen - identifiers, test fixtures, the `APOCALYPSE_MARKUP` / - `APOCALYPSE_MARKUP_MULTIPLIER` / `PRICE_OF_APOCALYPSE` - parameter slugs (renaming the parameter slug touches every - citation URL — high cost, low benefit unless we're doing it - anyway). - - **Candidate phrasings to pick between:** - - A) Causal-chain version (Ivy's suggestion): *"It takes 100 - nuclear weapons to trigger nuclear winter and collapse the - global food system. Humanity stockpiled 12,200. The 1% Treaty - trades 100 of those weapons (one civilization's worth of - overkill) for disease eradication."* — explicit, no - assumed knowledge, longer. - - B) Overkill-layer version: *"Humanity has 122x the warheads - needed to end civilization. Trade ONE of those 122 layers of - overkill for disease eradication. The other 121 stay; the - deterrent doesn't move."* — keeps the trade frame, makes the - absurdity explicit, doesn't require defining "apocalypse." - - C) Civilization-ending winter version: *"Humanity has 122 - civilization-ending nuclear winters ready to deploy. Trade - ONE for disease eradication."* — shortest, drops "apocalypse" - entirely. - - **My honest recommendation: B (overkill-layer).** It keeps - Wishonia's dry "spending one layer of overkill" frame, names - the absurdity (we have 122x what we need), and explicitly - preserves the deterrent argument (*"the other 121 stay"*) which - pre-empts the most common objection. A is most defensible but - long. C is shortest but loses the "trade" frame's punch. - - **Implementation note:** the standardized phrasing should be - parameter-backed via `ParameterValue` where the numbers appear, - and the prose templates should live in a single constants - module that all surfaces import — so a future rewording is one - edit, not a sweep across 12 files. +- **Poster follow-ups after v1.** `/poster` now exists with a logged-in + referral QR, generic logged-out QR, compact copy affordance, letter/A4 print + CSS, and the default treaty-editorial style. Remaining: style selector, OG + image variants, optional PDF download, and share-template text selection if + printed posters become a measured channel. +- **Apocalypse framing standardization follow-up.** Canonical War on Disease + site description exports exist, but source and generated copy still contain + direct "apocalypse" phrasing in route metadata, referendum-site copy, managed + task triggers, Grandma Kay seed text, and share templates. Finish only after + the copy gate approves one standardized phrase. - **Other human-language candidates while we're sweeping copy:** - `"magic link"` in user-facing error strings (`/auth/signin/page.tsx:12` @@ -383,41 +245,22 @@ Do not let lower items crowd out higher ones. registry: ~26 named templates (Trump versions, office memo, performance review, polite reminder, etc.) keyed by `recipientModes` (`leader | humanity | one_human | peer`) with token-based interpolation. -- Today only `TreatyReminderComposer` reads from it. `monthly-chain-digest`, - `post-vote-share`, `referral-first-conversion`, and `task-comment-notification` - emails hand-roll their own reminder copy — confirmed for monthly-chain-digest, - audit needed for the others. -- Migration: every email module that includes reminder/share copy should pull - recipient-appropriate templates from `share-templates.ts` (filtered by the - email's `recipientModes`), interpolate via `renderTemplate`, and pick a - default variant. Hand-rolled copy stays only when no template fits AND a new +- Monthly-chain-digest and the shared email footer now read from + `share-templates.ts`; `referral-first-conversion` inherits that footer. + Remaining audit: `post-vote-share` still builds from `share-message.ts`, and + `task-comment-notification` needs a final check for hand-rolled reminder copy. +- Every email module that includes reminder/share copy should pull + recipient-appropriate templates from `share-templates.ts` where a reusable + template fits. Hand-rolled copy stays only when no template fits AND a new template would be too narrow to reuse. -- Audit task: grep all `packages/web/src/lib/email/*.ts` and - `*-react-email.tsx` for hardcoded "Sign now"/"Vote"/"You haven't voted yet"- - shaped prose and replace with template lookups. ### Humanity Manager status report -- Extract reusable status sections from the monthly digest into a shared module - that can render both email and dashboard forms. -- Data needed: - - direct reports who completed their task; - - overdue humans assigned through the user's link; - - overdue presidents; - - total downstream conversion count and depth from a recursive chain query. - Replace direct-only monthly counts with transitive chain counts when the query is ready. - The dashboard version should expose copyable messages for overdue humans and presidents instead of motivational filler. -### Forward to someone better fit - -- Add a lightweight `mailto:` affordance to task-assignment emails: prefilled - task title, task link, and a short "this was sent to me but you are better - fit" note. -- Do not build delegation APIs, new Person confirmation flows, or rate-limit - systems until forward conversions become a measured channel. - ## P1 - Organizations Endorse, Embed, and Recruit - Persist the organization grant/application workflow: request data, review @@ -425,8 +268,6 @@ Do not let lower items crowd out higher ones. enough for operational follow-through. - Keep organization attribution first-org-wins for `ReferendumVote`, matching `referredByUserId`. Later org links should not steal attribution. -- Add approved public organizations to dynamic sitemap output so partner and - supporter pages can be indexed. - Keep neutral partner/embed copy where full Wishonia voice would make adoption harder. Partner-safe is not the same as bland. - Adopt the "Authorized Earth Optimization Services Provider" framing for @@ -439,6 +280,10 @@ Do not let lower items crowd out higher ones. professional enough not to scare off serious nonprofits — AEOSP framing lives in campaign-facing pages, shared snippets, and the badge artifact, not the onboarding form. +- For the next sprint, do not build new organization admin surfaces before + outreach proves the existing tools are the blocker. Current org pages already + provide the share URL, email starter, linked HTML starter, website button, + iframe, preview, manager referral URL, and grant calculator. ## P1 - Person/Org Conversion Surfaces (post-PR #81) @@ -505,6 +350,11 @@ Durable summary lives here; no loose `.claude/plans/campaign-impact-attribution- ### Represented people and estates +- **Plaintiff-registration aspect-ratio handling is no longer P0.** + `SquarePhotoCropper` is wired into user upload surfaces, and Grandma Kay now + uses `/img/grandma-headshot.jpg`. Add managed-data validation for non-square + seeded person images only when another seed image regresses or when plaintiff + registration becomes the active sprint. - Reframe memorial/deceased-person registration as filing a wrongful-death claim for the estate, with descendants as beneficiaries. - Add pre-search before creating represented people: canonicalized display name @@ -533,9 +383,6 @@ Durable summary lives here; no loose `.claude/plans/campaign-impact-attribution- ### Sitemap and evidence paths -- Verify `/humanity-v-government` and `/court` are in the static route list for - War on Disease. -- Add approved organizations to the dynamic sitemap. - Split sitemap files by entity type when tasks/people/orgs approach the 500-row cap. - Keep `1percenttreaty.org` as a separate shareable treaty domain. Do not collapse @@ -628,9 +475,27 @@ Durable summary lives here; no loose `.claude/plans/campaign-impact-attribution- a real regression boundary. - Never merge PRs. When checks are green and valid review complaints are handled, report ready for human review/merge. +- Plan files (under `~/.gstack/projects//`) MUST include a Cost-Benefit + Matrix section before `/autoplan` final-gate review. Enforced by + `.claude/hooks/enforce-cba-table-on-plan-files.mjs`. +- New-feature plans MUST acknowledge existing routes/branches/commits matching + the feature noun before drafting. Enforced by + `.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs`. ## Parked Unless They Directly Unblock 4B +### Dating registry — deferred until `/love` proves attribution + +- `Person.isOpenToDating` + `/love/registry` browse + email-bridge messaging — + full plan reviewed 2026-05-19 by `/autoplan`, deferred per CBA verdict. + `/love` bio-template page already shipped at + `packages/web/src/app/love/page.tsx` (commit `ba27f38d`). +- Kill threshold: ship only if `/love` bio-template channel produces ≥X treaty + signatures attributable to dating-bio referrals per 30 days. +- Wishonia-voice copy expansion (per-app Tinder/Hinge/Bumble templates, + follow-up scripts, QR date-card variant) also deferred until `/love` + instrumentation proves per-app variants matter. + ### Internationalization — centralize copy into a single message catalog - All user-facing copy currently lives inline in `.tsx` components, email diff --git a/packages/db/src/managed-data/managed-iam-organization.test.ts b/packages/db/src/managed-data/managed-iam-organization.test.ts index e05eaaa9..ff012057 100644 --- a/packages/db/src/managed-data/managed-iam-organization.test.ts +++ b/packages/db/src/managed-data/managed-iam-organization.test.ts @@ -239,6 +239,7 @@ describe("syncManagedIamOrganization", () => { sourceRef: MIKE_SINN_PERSON_SOURCE_REF, }); expect(client.users.find((row) => row["email"] === MIKE_SINN_EMAIL)).toMatchObject({ + isAdmin: true, personId: "person-mike", referralCode: "KEEP-ME", }); diff --git a/packages/db/src/managed-data/managed-iam-organization.ts b/packages/db/src/managed-data/managed-iam-organization.ts index caccc70f..e4565795 100644 --- a/packages/db/src/managed-data/managed-iam-organization.ts +++ b/packages/db/src/managed-data/managed-iam-organization.ts @@ -76,11 +76,13 @@ export async function syncManagedIamOrganization( update: { deletedAt: null, emailVerified: new Date(), + isAdmin: true, personId: person.id, }, create: { email: MIKE_SINN_EMAIL, emailVerified: new Date(), + isAdmin: true, personId: person.id, referralCode: "MIKE", }, diff --git a/packages/web/src/app/developers/page.logged-out.md b/packages/web/src/app/developers/page.logged-out.md new file mode 100644 index 00000000..d2389266 --- /dev/null +++ b/packages/web/src/app/developers/page.logged-out.md @@ -0,0 +1,118 @@ +# /developers + +## Metadata + +- Page title: Developers | Optimitron | International Campaign to End War and Disease +- Meta description: Connect AI agents to the live Optimitron task graph so they can take the highest-value action to optimize Earth. +- Canonical: [missing] +- Open Graph title: International Campaign to End War and Disease +- Open Graph description: Let's trade one apocalypse out of humanity's 122-apocalypse mass-murder capacity for disease eradication in 36 years instead of 443. +- Open Graph image: https://warondisease.org/site-assets/warondisease/war-on-disease-og-1200x630.png +- Twitter title: International Campaign to End War and Disease +- Twitter description: Let's trade one apocalypse out of humanity's 122-apocalypse mass-murder capacity for disease eradication in 36 years instead of 443. + +## Visible Page Copy + +### OPTIMITRON MCP +- Let AI agents take the highest-value next action to increase median health-adjusted life expectancy and median after-tax inflation-adjusted income. +### WHAT IT DOES +- MCP gives agents the live task graph, impact estimates, evidence, coordination locks, and write-back tools they need to optimize Earth without guessing. +#### PICK WORK +- Ask what to do next instead of browsing a backlog by vibes. +- getQueueAudit — check whether the queue is sane +- getNextAction — best next action across tasks +- evaluateTaskEconomics — execute, delegate, procure, or fundraise +#### UNDERSTAND +- Pull the evidence before changing strategy or assigning work. +- searchManual — find source passages +- askWishonia — synthesized answer with sources +- getTask / getBlockers — inspect details and dependencies +#### IMPROVE QUEUE +- Turn research into reviewable work instead of dumping notes in chat. +- proposeTaskBundle — draft tasks for review +- setTaskImpact — attach expected value +- addDependency — wire the task graph +#### COORDINATE +- Keep concurrent agents from stepping on the same task. +- acquireLease — reserve active work +- heartbeatLease — keep long work alive +- releaseLease / logAgentRun — close the loop +#### DISCUSS +- Keep task coordination in the readable thread. +- postTaskComment — leave status, questions, and agent notes +- getTaskComments — read the task thread +- getFundingStats — see budget before paid work +#### REPORT +- Leave enough state that the next agent knows what happened. +- completeTaskClaim — submit completed work +- recordTaskActuals — log effort and cost +- postTaskComment — leave context +### EXAMPLE USES +- Use MCP when you want the agent to work from the live task graph instead of guessing from stale docs or a chat transcript. +#### CHOOSE THE NEXT TASK +- Ask: “I can write TypeScript and have two hours. What should I do next?” The agent audits the queue, checks task economics, and returns the best executable action. +#### RESEARCH WITHOUT LOSING THE THREAD +- Ask: “Find every task and manual passage about Wefunder.” The agent searches tasks, reads blockers, checks the manual, and proposes a task bundle instead of handing you a pile of notes. +#### COORDINATE WITHOUT LOSING THE THREAD +- The agent posts task comments for status updates, questions, and next steps. Comment posting handles comment notifications; delivery envelopes stay internal. +#### MAKE THE QUEUE SMARTER +- After research, the agent can draft new tasks with impact estimates and dependencies. They start as DRAFT so governance can review them before promotion. +### CLAUDE CODE +- One command. The OAuth flow handles the rest. +- COPY +- ```text +claude mcp add --transport http optimitron http://localhost:3001/api/mcp +``` +- Then run /mcp inside Claude Code. You'll be redirected to sign in. Once approved, the agent can read and write your tasks. +### CLAUDE DESKTOP +- Three clicks. No terminal required. +#### OPEN SETTINGS +- Settings → Connectors → Add custom connector. +#### PASTE THE URL +- Name: Optimitron Leave the OAuth fields blank. They're auto-discovered. +#### CONNECT +- Sign in, authorize, done. Claude can now access your tasks. +- URL for step 2: +- ```text +http://localhost:3001/api/mcp +``` +### CHATGPT +- Plus, Pro, Business, Enterprise, and Edu only. Free tier doesn't allow custom connectors. Take it up with OpenAI. +#### ENABLE DEVELOPER MODE +- Settings → Apps & Connectors → Advanced → Developer mode → on. On Business / Enterprise / Edu, a workspace owner has to enable connectors at the org level first. +#### ADD CUSTOM CONNECTOR +- Settings → Apps & Connectors → Add → Add custom connector. Name: Optimitron Authentication: OAuth Check "I trust this application". +#### SIGN IN +- Click Create, then sign in. PKCE and dynamic client registration are handled automatically. No client ID or secret to paste. +- MCP Server URL for step 2: +#### HEADS-UP: DEEP RESEARCH MODE +- Deep Research only surfaces tools named search and fetch. Optimitron's tools won't appear there. Use regular chat or Agent mode. +### CURSOR, WINDSURF, CLINE, ZED, ET AL. +- Most MCP clients accept the same JSON. Find your client's config file and paste: +- ```text +{ + "mcpServers": { + "optimitron": { + "url": "http://localhost:3001/api/mcp" + } + } +} +``` +- CURSOR: ~/.cursor/mcp.json +- WINDSURF: ~/.codeium/windsurf/mcp_config.json +- CLINE / ZED / OTHERS: check your client's MCP docs for the config path. +### OAUTH SCOPES +- Request specific scopes when connecting to control what the agent can do. +- Manage your private tasks, dependencies, comments, queues, and next-action recommendations +- Admin-only: create and manage public Optimitron tasks, people, organizations, estimates, and dependencies +- Create sourced public Earth-data records: memorials, evidence, intervention reports, organization signatories, and correction reports +- Admin-only: hide, restore, merge, and resolve Earth-data records and reports +- Admin-only: run coordinated public-task agents with leases and run logs +- Admin-only: access the configured GitHub repos via the server-side PAT (search code, read files, list directories, generic API passthrough) +### API REFERENCE +#### MCP ENDPOINT +- Streamable HTTP transport (MCP protocol version 2025-03-26). Supports GET, POST, DELETE. +#### TOOL CATALOG +- JSON listing of every tool, its schema, and required scopes. +#### OAUTH DISCOVERY +- Standard OAuth 2.1 metadata: endpoints, scopes, PKCE config. diff --git a/packages/web/src/app/love/page.tsx b/packages/web/src/app/love/page.tsx index dd93df03..6b3ef002 100644 --- a/packages/web/src/app/love/page.tsx +++ b/packages/web/src/app/love/page.tsx @@ -611,7 +611,7 @@ export default async function LovePage() { . Love, Mike.

- Hey Google, set a timer for one minute. + ); diff --git a/packages/web/src/app/mcp/authorize/page.tsx b/packages/web/src/app/mcp/authorize/page.tsx index b685eac3..6b2a6686 100644 --- a/packages/web/src/app/mcp/authorize/page.tsx +++ b/packages/web/src/app/mcp/authorize/page.tsx @@ -1,7 +1,12 @@ import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { DEFAULT_SCOPES, allowedMcpScopesForUser, scopesFromWire, scopesToWire } from "@/lib/mcp-scopes"; +import { + DEFAULT_CONSENT_SCOPES, + allowedMcpScopesForUser, + scopesFromWire, + scopesToWire, +} from "@/lib/mcp-scopes"; import { prisma } from "@/lib/prisma"; import { McpConsentForm } from "./consent-form"; @@ -14,7 +19,7 @@ export default async function McpAuthorizePage({ const clientId = typeof params.client_id === "string" ? params.client_id : null; const redirectUri = typeof params.redirect_uri === "string" ? params.redirect_uri : null; const state = typeof params.state === "string" ? params.state : null; - const scope = typeof params.scope === "string" ? params.scope : scopesToWire(DEFAULT_SCOPES); + const scope = typeof params.scope === "string" ? params.scope : scopesToWire(DEFAULT_CONSENT_SCOPES); const codeChallenge = typeof params.code_challenge === "string" ? params.code_challenge : null; const clientName = typeof params.client_name === "string" ? params.client_name : clientId; diff --git a/packages/web/src/lib/__tests__/mcp-server.test.ts b/packages/web/src/lib/__tests__/mcp-server.test.ts index ae66bbf8..20dfdda7 100644 --- a/packages/web/src/lib/__tests__/mcp-server.test.ts +++ b/packages/web/src/lib/__tests__/mcp-server.test.ts @@ -2935,6 +2935,195 @@ describe("MCP server tool dispatch", () => { expect(data.assigneePersonId).toBe("person-1"); }); + it("rejects updateTask when a non-admin user does not own the task", async () => { + mocks.getTaskDetailData.mockResolvedValue({ + task: makeCreatedTask({ + id: "other-task", + createdByUserId: "other-user", + isPublic: true, + }), + }); + + const client = await setup("user-1", [McpScope.TASKS_PERSONAL]); + const result = await client.callTool({ + name: "updateTask", + arguments: { taskId: "other-task", title: "Nope" }, + }); + + expect(result.isError).toBe(true); + expect(parseToolBody(result).error).toContain( + "Forbidden: Task was not created by current user", + ); + expect(mocks.taskUpdate).not.toHaveBeenCalled(); + }); + + it("allows updateTask when a non-admin user owns the private task", async () => { + mocks.getTaskDetailData + .mockResolvedValueOnce({ + task: makeCreatedTask({ + id: "own-private-task", + createdByUserId: "user-1", + isPublic: false, + contextJson: { executor_type: "Self" }, + }), + }) + .mockResolvedValueOnce({ + task: makeCreatedTask({ + id: "own-private-task", + createdByUserId: "user-1", + isPublic: false, + title: "Updated private task", + }), + }); + mocks.computeTaskPriority.mockReturnValue(makePriority()); + + const client = await setup("user-1", [McpScope.TASKS_PERSONAL]); + const result = await client.callTool({ + name: "updateTask", + arguments: { + taskId: "own-private-task", + title: "Updated private task", + }, + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.taskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ title: "Updated private task" }), + where: { id: "own-private-task" }, + }), + ); + }); + + it("allows updateTask for admin users with tasks:admin even when the task is private and owned by another user", async () => { + mocks.getTaskDetailData.mockResolvedValue(null); + mocks.taskFindFirst.mockResolvedValue({ + contextJson: { executor_type: "Self" }, + createdByUserId: "other-user", + deadlinePolicy: null, + estimatedEffortHours: 1, + id: "other-private-task", + isPublic: false, + }); + mocks.taskFindMany.mockResolvedValue([ + { + createdByUserId: "other-user", + id: "private-blocker", + isPublic: false, + }, + ]); + + const client = await setup( + "admin-1", + [McpScope.TASKS_ADMIN], + { isAdmin: true }, + ); + const result = await client.callTool({ + name: "updateTask", + arguments: { + depends_on: ["private-blocker"], + taskId: "other-private-task", + title: "Admin updated task", + }, + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.taskFindFirst).toHaveBeenCalledWith({ + where: { deletedAt: null, id: "other-private-task" }, + select: { + contextJson: true, + createdByUserId: true, + deadlinePolicy: true, + estimatedEffortHours: true, + id: true, + isPublic: true, + }, + }); + expect(mocks.taskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ title: "Admin updated task" }), + where: { id: "other-private-task" }, + }), + ); + expect(mocks.taskEdgeCreateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: [ + expect.objectContaining({ + fromTaskId: "private-blocker", + toTaskId: "other-private-task", + }), + ], + }), + ); + }); + + it("allows deleteTask for admin users without tasks:admin when another user created the task", async () => { + // Same ownership regression as the icewad-grant update path, but for + // soft deletion through the end-to-end tool handler. + mocks.taskFindFirst.mockResolvedValue({ + createdByUserId: "other-user", + isPublic: false, + }); + + const client = await setup( + "mike", + [McpScope.TASKS_PERSONAL], + { isAdmin: true }, + ); + const result = await client.callTool({ + name: "deleteTask", + arguments: { taskId: "other-task" }, + }); + + expect(result.isError).toBeFalsy(); + expect(parseToolBody(result)).toMatchObject({ + deleted: true, + taskId: "other-task", + }); + expect(mocks.taskUpdate).toHaveBeenCalledWith({ + where: { id: "other-task" }, + data: { deletedAt: expect.any(Date) }, + }); + }); + + it("allows updateTask for admin users without tasks:admin when another user created the private task", async () => { + // Regression coverage for Mike's icewad-grant failure: an admin updating + // a private seeded task created by another user must not get Forbidden. + mocks.getTaskDetailData + .mockResolvedValueOnce({ + task: makeCreatedTask({ + id: "seeded-private-task", + createdByUserId: "someone-else", + isPublic: false, + contextJson: { executor_type: "Self" }, + }), + }) + .mockResolvedValueOnce(null); + + const client = await setup( + "mike", + [McpScope.TASKS_PERSONAL], + { isAdmin: true }, + ); + const result = await client.callTool({ + name: "updateTask", + arguments: { + taskId: "seeded-private-task", + title: "Admin renamed seeded task", + }, + }); + + expect(result.isError).toBeFalsy(); + expect(mocks.taskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: "Admin renamed seeded task", + }), + where: { id: "seeded-private-task" }, + }), + ); + }); + it("updateTask replaces dependencies without losing retained soft-deleted edges", async () => { mocks.getTaskDetailData .mockResolvedValueOnce({ diff --git a/packages/web/src/lib/mcp-scopes.ts b/packages/web/src/lib/mcp-scopes.ts index 16e563d3..ffe021f4 100644 --- a/packages/web/src/lib/mcp-scopes.ts +++ b/packages/web/src/lib/mcp-scopes.ts @@ -23,7 +23,15 @@ export const MCP_SCOPE_DESCRIPTIONS: Record = { [McpScope.GITHUB]: "Admin-only: access the configured GitHub repos via the server-side PAT (search code, read files, list directories, generic API passthrough)", }; -export const DEFAULT_SCOPES: McpScope[] = [McpScope.TASKS_PERSONAL]; +// Candidate scopes preselected in the OAuth consent UI. They are still filtered +// through `allowedMcpScopesForUser`, so admin scopes only appear for admins. +// Admin task-write tools (updateTask, deleteTask, etc.) gate on isAdmin alone +// via hasAdminTaskWriteAccess — TASKS_ADMIN scope is documentation/UX, not a +// security gate. Re-add it as a security gate by tightening +// hasAdminTaskWriteAccess if this app becomes multi-admin. +export const DEFAULT_CONSENT_SCOPES: McpScope[] = [ + McpScope.TASKS_PERSONAL, +]; export const ALL_SCOPES: McpScope[] = [ McpScope.TASKS_PERSONAL, diff --git a/packages/web/src/lib/mcp-server.ts b/packages/web/src/lib/mcp-server.ts index 56bbc37c..6260125b 100644 --- a/packages/web/src/lib/mcp-server.ts +++ b/packages/web/src/lib/mcp-server.ts @@ -37,7 +37,7 @@ import type { Prisma } from "@optimitron/db"; import { MCP_SCOPE_DESCRIPTIONS, - DEFAULT_SCOPES, + DEFAULT_CONSENT_SCOPES, ALL_SCOPES, McpScope, } from "./mcp-scopes"; @@ -59,7 +59,7 @@ import type { TaskPriorityResult, } from "./tasks/rank-tasks"; -export { MCP_SCOPE_DESCRIPTIONS, DEFAULT_SCOPES, ALL_SCOPES, McpScope }; +export { MCP_SCOPE_DESCRIPTIONS, DEFAULT_CONSENT_SCOPES, ALL_SCOPES, McpScope }; const UPLOAD_IMAGE_FROM_URL_TOOL_NAME = "uploadImageFromUrl" as const; @@ -199,7 +199,13 @@ function hasAdminTaskWriteAccess( scopes: McpScope[] | undefined, isAdmin: boolean, ) { - return isAdmin && !!scopes?.includes(McpScope.TASKS_ADMIN); + // Single-admin posture: the admin role is the security gate. The + // TASKS_ADMIN scope documents admin-capable clients in the consent UI, but + // requiring it here adds friction without meaningful extra risk reduction. + // If this becomes multi-admin and per-client POLA matters, re-add the scope + // gate with: `isAdmin && !!scopes?.includes(McpScope.TASKS_ADMIN)`. + void scopes; + return isAdmin; } // --------------------------------------------------------------------------- @@ -3761,7 +3767,7 @@ const TASK_TOOL_DEFINITIONS = [ { name: "createTask", description: - "Create a task. Visibility defaults to PRIVATE; admin-scope callers (tasks:admin) get PUBLIC by default when assigneeOrganizationId is set so leader/president/treaty-activation tasks land on the public Earth feed. Pass visibility='PRIVATE' or 'PUBLIC' to override. Non-admin callers requesting PUBLIC get rejected. Tasks default to ACTIVE so they appear in the relevant queue immediately. " + + "Create a task. Visibility defaults to PRIVATE; admin callers get PUBLIC by default when assigneeOrganizationId is set so leader/president/treaty-activation tasks land on the public Earth feed. Pass visibility='PRIVATE' or 'PUBLIC' to override. Non-admin callers requesting PUBLIC get rejected. Tasks default to ACTIVE so they appear in the relevant queue immediately. " + "Required: title, description, category, hours, value, p_success, acceptanceCriteria, impactStatement. Every one is load-bearing — a task that omits them either fails validation or lands at score 0 and never surfaces. " + "Estimate, don't omit: a calibrated guess with p_success<1 beats no number. State acceptance criteria as a checklist of testable conditions; state impact in one sentence (why this matters). " + "Use depends_on for true prerequisites; executor_type='Self' for user work and 'AI Agent' only for autonomous assistant work; deadline_policy='REQUIRED' for must-do legal/health/safety tasks and 'EXPIRES' for opportunities that vanish after due_at. " + @@ -6721,7 +6727,10 @@ export function createMcpServer( const inaccessibleDependencyIds = dependencyTasks .filter( - (task) => !task.isPublic && task.createdByUserId !== userId, + (task) => + !task.isPublic && + task.createdByUserId !== userId && + !hasAdminTaskWriteAccess(scopes, isAdmin), ) .map((task) => task.id); if (inaccessibleDependencyIds.length > 0) { @@ -6774,7 +6783,7 @@ export function createMcpServer( } if (isPublic && !hasAdminTaskWriteAccess(scopes, isAdmin)) { return err( - "Creating public tasks requires an admin user with the tasks:admin scope.", + "Creating public tasks requires an admin user.", ); } const availableAt = @@ -7890,22 +7899,39 @@ export function createMcpServer( const { ranking, tasks } = await getTaskFunctions(); const prisma = await getPrisma(); + const isAdminWriter = hasAdminTaskWriteAccess(scopes, isAdmin); const updates: Record = {}; const existingDetail = await tasks.getTaskDetailData( a.taskId as string, userId, ); - if (!existingDetail) return err("Task not found"); - const existingTask = existingDetail.task as PersonalQueueTaskRecord; - if (existingTask.createdByUserId !== userId) { - return err("Forbidden: Task was not created by current user"); + let existingTask = + existingDetail?.task as PersonalQueueTaskRecord | undefined; + if (!existingTask && isAdminWriter) { + const adminVisibleTask = await prisma.task.findFirst({ + where: { deletedAt: null, id: a.taskId as string }, + select: { + contextJson: true, + createdByUserId: true, + deadlinePolicy: true, + estimatedEffortHours: true, + id: true, + isPublic: true, + }, + }); + existingTask = adminVisibleTask + ? (adminVisibleTask as PersonalQueueTaskRecord) + : undefined; } - if ( - existingTask.isPublic && - !hasAdminTaskWriteAccess(scopes, isAdmin) - ) { + if (!existingTask) return err("Task not found"); + if (existingTask.createdByUserId !== userId && !isAdminWriter) { return err( - "Updating public tasks requires an admin user with the tasks:admin scope.", + "Forbidden: Task was not created by current user. Admin users can edit any task.", + ); + } + if (existingTask.isPublic && !isAdminWriter) { + return err( + "Updating public tasks requires an admin user.", ); } const dependencyPatchProvided = @@ -7938,7 +7964,10 @@ export function createMcpServer( } const inaccessibleDependencyIds = dependencyTasks .filter( - (task) => !task.isPublic && task.createdByUserId !== userId, + (task) => + !task.isPublic && + task.createdByUserId !== userId && + !isAdminWriter, ) .map((task) => task.id); if (inaccessibleDependencyIds.length > 0) { @@ -8153,15 +8182,15 @@ export function createMcpServer( select: { createdByUserId: true, isPublic: true }, }); if (!existing) return err("Task not found"); - if (existing.createdByUserId !== userId) { - return err("Forbidden: Task was not created by current user"); + const isAdminDeleter = hasAdminTaskWriteAccess(scopes, isAdmin); + if (existing.createdByUserId !== userId && !isAdminDeleter) { + return err( + "Forbidden: Task was not created by current user. Admin users can delete any task.", + ); } - if ( - existing.isPublic && - !hasAdminTaskWriteAccess(scopes, isAdmin) - ) { + if (existing.isPublic && !isAdminDeleter) { return err( - "Deleting public tasks requires an admin user with the tasks:admin scope.", + "Deleting public tasks requires an admin user.", ); } From a070dd57cbe346f52fb1f297c500b5d01fd37106 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Tue, 19 May 2026 18:43:34 -0500 Subject: [PATCH 03/17] =?UTF-8?q?Rename=20icewad=20=E2=86=92=20ic2ewd=20ac?= =?UTF-8?q?ross=20live=20source=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes a prior naming decision that didn't propagate to every surface. Live source/test scope now has zero 'icewad' / 'ICEWAD' hits; only ignored generated paths (.next/cache, packages/web/output/tmp-*) and historical plan files retain the old name. Renamed seed task keys: icewad-grant-* → ic2ewd-grant-*, icewad:grant:* → ic2ewd:grant:*, plus methodology key and interest tag. Production data risk surfaced by audit: 10 old icewad:grant:* rows exist (7 not soft-deleted). Audit found 0 active comments, 0 task communications, 0 sent/received communications, 0 email logs on the old rows. After deploy + managed-data sync, new ic2ewd-grant-* rows will be created; old rows remain orphaned but carry no user state. Files: packages/db/src/__tests__/seed.integration.test.ts (+3/-3), packages/db/src/managed-data/managed-seed-data.ts (+8/-8), packages/web/scripts/soft-delete-funding-tasks.ts (+4/-4), packages/web/src/lib/__tests__/mcp-server.test.ts (+2/-2 — comment refs updated to ic2ewd-grant for consistency with the rename). Verification: pnpm copy:preview passed, web tests + db tests + tsc clean. todo-skipped: rename completed a prior intent not tracked in TODO.md. qa-passed: source rename complete, focused tests + typecheck clean, no live-source icewad hits remain Co-Authored-By: Claude Opus 4.7 (1M context) --- .../db/src/__tests__/seed.integration.test.ts | 6 +++--- .../db/src/managed-data/managed-seed-data.ts | 16 ++++++++-------- .../web/scripts/soft-delete-funding-tasks.ts | 8 ++++---- .../web/src/lib/__tests__/mcp-server.test.ts | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/db/src/__tests__/seed.integration.test.ts b/packages/db/src/__tests__/seed.integration.test.ts index b998a9b7..904f3517 100644 --- a/packages/db/src/__tests__/seed.integration.test.ts +++ b/packages/db/src/__tests__/seed.integration.test.ts @@ -154,11 +154,11 @@ describeIfDatabase("syncManagedData", () => { ), ); - const legacyGrantTaskKeyPrefix = `${["ice", "wad"].join("")}:grant:`; + const grantTaskKeyPrefix = "ic2ewd:grant:"; const grantTasks = await prisma.task.findMany({ where: { deletedAt: null, - taskKey: { startsWith: legacyGrantTaskKeyPrefix }, + taskKey: { startsWith: grantTaskKeyPrefix }, assigneeOrganization: { slug: { in: foundationSlugs } }, }, select: { @@ -183,7 +183,7 @@ describeIfDatabase("syncManagedData", () => { difficulty: "TRIVIAL", isPublic: true, status: "ACTIVE", - taskKey: `${legacyGrantTaskKeyPrefix}${slug}`, + taskKey: `${grantTaskKeyPrefix}${slug}`, title: "Fund the International Campaign to End War and Disease", }), ), diff --git a/packages/db/src/managed-data/managed-seed-data.ts b/packages/db/src/managed-data/managed-seed-data.ts index f3bbd5fe..a6ec09bd 100644 --- a/packages/db/src/managed-data/managed-seed-data.ts +++ b/packages/db/src/managed-data/managed-seed-data.ts @@ -1344,10 +1344,10 @@ export async function syncManagedTreatyAccountabilityData() { const IC2EWD_GRANT_ECON_VALUE_PER_USD = IC2EWD_GRANT_DALYS_PER_USD * STANDARD_ECONOMIC_QALY_VALUE_USD.value; - const legacyCampaignKeyStem = ["ice", "wad"].join(""); - const legacyGrantTaskIdPrefix = `${legacyCampaignKeyStem}-grant`; - const legacyGrantTaskKeyPrefix = `${legacyCampaignKeyStem}:grant`; - const legacyGrantMethodologyKey = `${legacyCampaignKeyStem}-one-dollar-grant`; + const IC2EWD_CAMPAIGN_KEY_STEM = "ic2ewd"; + const IC2EWD_GRANT_TASK_ID_PREFIX = `${IC2EWD_CAMPAIGN_KEY_STEM}-grant`; + const IC2EWD_GRANT_TASK_KEY_PREFIX = `${IC2EWD_CAMPAIGN_KEY_STEM}:grant`; + const IC2EWD_GRANT_METHODOLOGY_KEY = `${IC2EWD_CAMPAIGN_KEY_STEM}-one-dollar-grant`; const foundationGrantOrganizations = [ { name: "Survival and Flourishing Fund", @@ -1411,8 +1411,8 @@ export async function syncManagedTreatyAccountabilityData() { await createTaskWithImpact({ task: { - id: `${legacyGrantTaskIdPrefix}-${slug}`, - taskKey: `${legacyGrantTaskKeyPrefix}:${slug}`, + id: `${IC2EWD_GRANT_TASK_ID_PREFIX}-${slug}`, + taskKey: `${IC2EWD_GRANT_TASK_KEY_PREFIX}:${slug}`, parentTaskId: TREATY_PARENT_TASK_ID, assigneeOrganizationId: organization.id, title: "Fund the International Campaign to End War and Disease", @@ -1447,7 +1447,7 @@ export async function syncManagedTreatyAccountabilityData() { sortOrder: -75 + index, claimPolicy: "ASSIGNED_ONLY", skillTags: ["grantmaking", "global-health", "fundraising"], - interestTags: [legacyCampaignKeyStem, "one-percent-treaty", "foundation", "grant"], + interestTags: [IC2EWD_CAMPAIGN_KEY_STEM, "one-percent-treaty", "foundation", "grant"], estimatedEffortHours: TREATY_PER_SIGNER_EFFORT_HOURS, }, primaryEndpoint: { @@ -1465,7 +1465,7 @@ export async function syncManagedTreatyAccountabilityData() { successProbabilityBase: 0.25, benefitDurationYears: 1, }, - methodologyKey: legacyGrantMethodologyKey, + methodologyKey: IC2EWD_GRANT_METHODOLOGY_KEY, parameterSetHashSuffix: slug, calculationsUrl: TREATY_IMPACT_CALCULATIONS_URL, }); diff --git a/packages/web/scripts/soft-delete-funding-tasks.ts b/packages/web/scripts/soft-delete-funding-tasks.ts index a021c633..b7590e0d 100644 --- a/packages/web/scripts/soft-delete-funding-tasks.ts +++ b/packages/web/scripts/soft-delete-funding-tasks.ts @@ -13,12 +13,12 @@ import { prisma } from "../src/lib/prisma"; * without losing what was sent. */ -const legacyGrantTaskKeyPrefix = `${["ice", "wad"].join("")}:grant:`; +const grantTaskKeyPrefix = "ic2ewd:grant:"; const TARGET_TASK_KEYS = [ - `${legacyGrantTaskKeyPrefix}schmidt-futures`, - `${legacyGrantTaskKeyPrefix}skoll-foundation`, - `${legacyGrantTaskKeyPrefix}omidyar-network`, + `${grantTaskKeyPrefix}schmidt-futures`, + `${grantTaskKeyPrefix}skoll-foundation`, + `${grantTaskKeyPrefix}omidyar-network`, "grant:sff:fund-treaty-campaign", "grant:open-phil:fund-treaty-campaign", ]; diff --git a/packages/web/src/lib/__tests__/mcp-server.test.ts b/packages/web/src/lib/__tests__/mcp-server.test.ts index 20dfdda7..384387c7 100644 --- a/packages/web/src/lib/__tests__/mcp-server.test.ts +++ b/packages/web/src/lib/__tests__/mcp-server.test.ts @@ -3058,7 +3058,7 @@ describe("MCP server tool dispatch", () => { }); it("allows deleteTask for admin users without tasks:admin when another user created the task", async () => { - // Same ownership regression as the icewad-grant update path, but for + // Same ownership regression as the ic2ewd-grant update path, but for // soft deletion through the end-to-end tool handler. mocks.taskFindFirst.mockResolvedValue({ createdByUserId: "other-user", @@ -3087,7 +3087,7 @@ describe("MCP server tool dispatch", () => { }); it("allows updateTask for admin users without tasks:admin when another user created the private task", async () => { - // Regression coverage for Mike's icewad-grant failure: an admin updating + // Regression coverage for Mike's ic2ewd-grant failure: an admin updating // a private seeded task created by another user must not get Forbidden. mocks.getTaskDetailData .mockResolvedValueOnce({ From a18b58068ace1a4d292deafb5feb8a83ce1473fb Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Wed, 20 May 2026 01:23:08 -0500 Subject: [PATCH 04/17] Add commerce store and dating foundation qa-passed: prisma validate, db zod tests, focused checkout/webhook/fulfillment/dating tests, typecheck:fast, full web production build, and local screenshot review passed. todo-touched: documented checkout launch gates and dating foundation decisions. --- ...feature-preexistence-check-on-autoplan.mjs | 83 +- .claude/plans/t-shirt-walking-billboard.md | 313 ++++++ TODO.md | 42 + .../migration.sql | 286 ++++++ .../migration.sql | 602 +++++++++++ packages/db/prisma/schema.prisma | 949 ++++++++++++++++++ .../db/src/__tests__/zod-validators.test.ts | 205 ++++ packages/db/src/managed-data/index.ts | 26 + .../managed-data/managed-commerce-catalog.ts | 415 ++++++++ .../managed-data/managed-dating-catalog.ts | 139 +++ packages/db/src/zod/index.ts | 489 +++++++++ packages/web/docs/customcat-integration.md | 241 +++++ packages/web/package.json | 2 + .../src/app/api/dating/date-plans/route.ts | 47 + .../src/app/api/dating/interactions/route.ts | 40 + .../web/src/app/api/dating/messages/route.ts | 38 + .../app/api/dating/profile/photos/route.ts | 38 + .../web/src/app/api/dating/profile/route.ts | 63 ++ .../questions/[questionId]/answer/route.ts | 50 + .../web/src/app/api/dating/reports/route.ts | 41 + .../api/stripe/create-checkout/route.test.ts | 333 ++++++ .../app/api/stripe/create-checkout/route.ts | 407 +++++++- .../src/app/api/stripe/webhook/route.test.ts | 170 ++++ .../web/src/app/api/stripe/webhook/route.ts | 107 +- .../web/src/app/developers/page.logged-out.md | 11 +- packages/web/src/app/developers/page.tsx | 162 ++- .../web/src/app/love/dating/dating-client.tsx | 586 +++++++++++ .../web/src/app/love/dating/discover/page.tsx | 84 ++ .../web/src/app/love/dating/matches/page.tsx | 97 ++ .../dating/messages/[conversationId]/page.tsx | 130 +++ packages/web/src/app/love/dating/page.tsx | 129 +++ .../web/src/app/love/dating/profile/page.tsx | 49 + .../src/app/love/dating/questions/page.tsx | 80 ++ packages/web/src/app/love/page.logged-out.md | 2 +- packages/web/src/app/love/page.tsx | 2 +- packages/web/src/app/shirt/page.logged-out.md | 33 + packages/web/src/app/shirt/page.tsx | 371 +++++++ packages/web/src/app/shirt/shirt-client.tsx | 278 +++++ .../web/src/app/store/[offerKey]/page.tsx | 185 ++++ packages/web/src/app/store/page.tsx | 127 +++ packages/web/src/app/store/store-client.tsx | 144 +++ .../lib/__tests__/customcat.server.test.ts | 253 +++++ .../src/lib/__tests__/dating.server.test.ts | 116 +++ .../web/src/lib/__tests__/mcp-server.test.ts | 142 ++- .../__tests__/shirt-back-image.server.test.ts | 35 + .../__tests__/shirt-commerce.server.test.ts | 53 + .../shirt-fulfillment.server.test.ts | 215 ++++ .../web/src/lib/commerce-catalog.server.ts | 154 +++ packages/web/src/lib/customcat.server.ts | 524 ++++++++++ packages/web/src/lib/dating.server.ts | 546 ++++++++++ packages/web/src/lib/env.ts | 8 + packages/web/src/lib/mcp-server.ts | 85 +- packages/web/src/lib/routes.ts | 30 + .../web/src/lib/shirt-back-image.server.ts | 124 +++ packages/web/src/lib/shirt-commerce.server.ts | 87 ++ .../web/src/lib/shirt-fulfillment.server.ts | 368 +++++++ packages/web/src/lib/site.ts | 11 + packages/web/src/lib/store-commerce.server.ts | 130 +++ pnpm-lock.yaml | 12 + 59 files changed, 10364 insertions(+), 125 deletions(-) create mode 100644 .claude/plans/t-shirt-walking-billboard.md create mode 100644 packages/db/prisma/migrations/20260520030000_add_commerce_ledger/migration.sql create mode 100644 packages/db/prisma/migrations/20260520043000_add_dating_foundation/migration.sql create mode 100644 packages/db/src/managed-data/managed-commerce-catalog.ts create mode 100644 packages/db/src/managed-data/managed-dating-catalog.ts create mode 100644 packages/web/docs/customcat-integration.md create mode 100644 packages/web/src/app/api/dating/date-plans/route.ts create mode 100644 packages/web/src/app/api/dating/interactions/route.ts create mode 100644 packages/web/src/app/api/dating/messages/route.ts create mode 100644 packages/web/src/app/api/dating/profile/photos/route.ts create mode 100644 packages/web/src/app/api/dating/profile/route.ts create mode 100644 packages/web/src/app/api/dating/questions/[questionId]/answer/route.ts create mode 100644 packages/web/src/app/api/dating/reports/route.ts create mode 100644 packages/web/src/app/api/stripe/webhook/route.test.ts create mode 100644 packages/web/src/app/love/dating/dating-client.tsx create mode 100644 packages/web/src/app/love/dating/discover/page.tsx create mode 100644 packages/web/src/app/love/dating/matches/page.tsx create mode 100644 packages/web/src/app/love/dating/messages/[conversationId]/page.tsx create mode 100644 packages/web/src/app/love/dating/page.tsx create mode 100644 packages/web/src/app/love/dating/profile/page.tsx create mode 100644 packages/web/src/app/love/dating/questions/page.tsx create mode 100644 packages/web/src/app/shirt/page.logged-out.md create mode 100644 packages/web/src/app/shirt/page.tsx create mode 100644 packages/web/src/app/shirt/shirt-client.tsx create mode 100644 packages/web/src/app/store/[offerKey]/page.tsx create mode 100644 packages/web/src/app/store/page.tsx create mode 100644 packages/web/src/app/store/store-client.tsx create mode 100644 packages/web/src/lib/__tests__/customcat.server.test.ts create mode 100644 packages/web/src/lib/__tests__/dating.server.test.ts create mode 100644 packages/web/src/lib/__tests__/shirt-back-image.server.test.ts create mode 100644 packages/web/src/lib/__tests__/shirt-commerce.server.test.ts create mode 100644 packages/web/src/lib/__tests__/shirt-fulfillment.server.test.ts create mode 100644 packages/web/src/lib/commerce-catalog.server.ts create mode 100644 packages/web/src/lib/customcat.server.ts create mode 100644 packages/web/src/lib/dating.server.ts create mode 100644 packages/web/src/lib/shirt-back-image.server.ts create mode 100644 packages/web/src/lib/shirt-commerce.server.ts create mode 100644 packages/web/src/lib/shirt-fulfillment.server.ts create mode 100644 packages/web/src/lib/store-commerce.server.ts diff --git a/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs b/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs index ea185393..25654c3f 100644 --- a/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs +++ b/.claude/hooks/enforce-feature-preexistence-check-on-autoplan.mjs @@ -32,7 +32,7 @@ // 3. For each candidate token, search: // a. packages/web/src/app/ directory names (one-level deep) // b. packages/web/src/lib/routes.ts content -// c. recent commit messages: git log main..HEAD --oneline +// c. recent commit messages on the current branch, resolved from origin/HEAD // 4. If matches found, emit a structured advisory listing each match // with file:line refs. Hook stays ADVISORY (exit 0) so a planner // who has ALREADY acknowledged the existing surface isn't blocked, @@ -183,10 +183,46 @@ function safeExec(cmd) { } } +function getDefaultRemoteRef() { + const ref = safeExec( + "git symbolic-ref --quiet --short refs/remotes/origin/HEAD", + ).trim(); + return /^[A-Za-z0-9._/-]+$/.test(ref) ? ref : ""; +} + +function collectRecentCommits() { + const defaultRemoteRef = getDefaultRemoteRef(); + if (defaultRemoteRef) { + const mergeBase = safeExec( + `git merge-base ${defaultRemoteRef} HEAD`, + ).trim(); + if (/^[a-f0-9]{40}$/.test(mergeBase)) { + return { + label: `${defaultRemoteRef}..HEAD`, + lines: safeExec( + `git log ${mergeBase}..HEAD --oneline --format=%h%x09%s`, + ) + .split(/\r?\n/) + .filter(Boolean), + }; + } + } + + return { + label: "last 30 commits", + lines: safeExec("git log --max-count=30 --oneline --format=%h%x09%s") + .split(/\r?\n/) + .filter(Boolean), + }; +} + function getBranchTokens() { const branch = safeExec("git branch --show-current").trim(); if (!branch) return { branch, tokens: [] }; - const stripped = branch.replace(/^feature\//, "").replace(/^fix\//, "").replace(/^chore\//, ""); + const stripped = branch + .replace(/^feature\//, "") + .replace(/^fix\//, "") + .replace(/^chore\//, ""); const tokens = stripped .split(/[-_/]/) .map((t) => t.toLowerCase()) @@ -208,7 +244,12 @@ function listAppRouteDirs() { } for (const e of entries) { if (!e.isDirectory()) continue; - if (e.name.startsWith(".") || e.name === "api" || e.name === "node_modules") continue; + if ( + e.name.startsWith(".") || + e.name === "api" || + e.name === "node_modules" + ) + continue; const full = path.join(dir, e.name); out.push({ name: e.name.toLowerCase(), path: full }); walk(full, depth + 1); @@ -219,7 +260,14 @@ function listAppRouteDirs() { } function searchRoutesTs(candidate) { - const routesPath = path.join(PROJECT_DIR, "packages", "web", "src", "lib", "routes.ts"); + const routesPath = path.join( + PROJECT_DIR, + "packages", + "web", + "src", + "lib", + "routes.ts", + ); if (!existsSync(routesPath)) return []; const content = readFileSync(routesPath, "utf-8"); const lines = content.split(/\r?\n/); @@ -234,12 +282,9 @@ function searchRoutesTs(candidate) { return hits; } -function searchRecentCommits(candidate) { - const log = safeExec(`git log main..HEAD --oneline --format=%h%x09%s`); - if (!log) return []; +function searchRecentCommits(candidate, recentCommits) { const needle = candidate.toLowerCase(); - return log - .split(/\r?\n/) + return recentCommits.lines .filter((line) => line.toLowerCase().includes(needle)) .slice(0, 5); } @@ -266,14 +311,22 @@ try { if (candidates.length === 0) process.exit(0); const routeDirs = listAppRouteDirs(); + const recentCommits = collectRecentCommits(); const findings = []; for (const candidate of candidates) { - const dirMatches = routeDirs.filter((d) => d.name === candidate || d.name.includes(candidate)); + const dirMatches = routeDirs.filter( + (d) => d.name === candidate || d.name.includes(candidate), + ); const routesHits = searchRoutesTs(candidate); - const commitHits = searchRecentCommits(candidate); + const commitHits = searchRecentCommits(candidate, recentCommits); - if (dirMatches.length === 0 && routesHits.length === 0 && commitHits.length === 0) continue; + if ( + dirMatches.length === 0 && + routesHits.length === 0 && + commitHits.length === 0 + ) + continue; findings.push({ candidate, dirMatches, routesHits, commitHits }); } @@ -304,7 +357,7 @@ try { } } if (f.commitHits.length > 0) { - lines.push(` recent commits (main..HEAD):`); + lines.push(` recent commits (${recentCommits.label}):`); for (const c of f.commitHits) { lines.push(` - ${c}`); } @@ -332,6 +385,8 @@ try { // crashes (malformed JSON, missing path, etc.) we exit 0 so /autoplan // dispatches remain possible. The error gets surfaced to stderr for the // next-turn Claude to notice without blocking the user. - process.stderr.write(`[enforce-feature-preexistence-check] hook crashed: ${err?.message ?? err}\n`); + process.stderr.write( + `[enforce-feature-preexistence-check] hook crashed: ${err?.message ?? err}\n`, + ); process.exit(0); } diff --git a/.claude/plans/t-shirt-walking-billboard.md b/.claude/plans/t-shirt-walking-billboard.md new file mode 100644 index 00000000..e18f9795 --- /dev/null +++ b/.claude/plans/t-shirt-walking-billboard.md @@ -0,0 +1,313 @@ +# T-Shirt Walking Billboard Plan + +## Brief + +Extend the existing `/shirt` page only if the work can safely make a supporter order a real shirt with: + +- Front text, verbatim: `please take 30 seconds to end war and disease at warondisease.org` +- Back text, verbatim: `I ended war and disease and all I got was this lousy t-shirt` +- A per-buyer QR code pointing at `https://warondisease.org/vote/` +- Stripe Checkout collecting payment, email, and shipping address +- Recommended POD fulfillment using a server-composed print-ready PNG; current recommendation is CustomCat unless Mike requires a strict draft-then-confirm vendor flow +- A tax/receipt split where only the payment above fair market value is treated as the charitable contribution +- A status surface where the buyer can see whether the shirt/order is pending, submitted, shipped, or failed + +Do not redesign the shirt, rewrite Mike's supplied front/back copy, or turn this into a merch platform. The only conversion job is: let a supporter buy a walking referral billboard quickly enough that it helps the 4B-voter propagation goal. + +## Current State + +Repo state checked on 2026-05-19: + +- `packages/web/src/app/shirt/page.tsx` exists and currently implements a DIY artwork generator with a QR code, download, print, and Printful upload link. It does not create a Stripe Checkout session or a Printful order. +- `packages/web/src/app/shirt/shirt-client.tsx` only handles client-side SVG-to-PNG/SVG download. +- `packages/web/src/app/poster/poster-client.tsx` exposes `PosterQrCode`, `PosterCopyLinkButton`, and `PosterPrintButton`; the shirt page already reuses these primitives. +- `packages/web/src/app/api/stripe/create-checkout/route.ts` creates donation-only Checkout sessions. It has no order type, no shirt size, no shipping address collection, no automatic tax, and no merchandise/donation split. +- `packages/web/src/app/api/stripe/webhook/route.ts` records donation activity for `checkout.session.completed`. It has no shirt branch, no fulfillment idempotency, and no Printful call. +- `packages/web/src/app/api/stripe/session/route.ts` fetches basic Checkout session details for the donation success page. +- `packages/web/src/app/donate/success/page.tsx` is a client-side status lookup shape that can be adapted for a shirt order page. +- `packages/web/src/lib/stripe.ts` uses Stripe SDK API version `2025-10-29.clover`. +- `packages/web/src/lib/nonprofit-identity.ts` has the legal 501(c)(3) entity identity and EIN. +- `packages/web/src/lib/email/resend.ts` and `sendExternalResendEmail` can send transactional email without requiring a logged-in `User`. +- `packages/web/src/lib/object-storage.server.ts` can upload public files to R2 when R2 env vars exist. +- `packages/web/package.json` already includes `sharp` and `qrcode.react`; `qrcode` is only present transitively in the lockfile and should be added as a direct dependency if used server-side. +- `packages/db/prisma/schema.prisma` has `User`, `Activity`, and `EmailLog`; there is no `Order`, `ShirtOrder`, `FulfillmentOrder`, or unique local order/idempotency table. +- Root `AGENTS.md` says Prisma schema/exported `@optimitron/db` type changes require explicit human approval. No schema change is approved in this task. + +## Research Log + +### Empirical CustomCat API findings (2026-05-19) + +Real sandbox requests to `https://customcat-beta.mylocker.net/api/v1/` confirmed the following, overriding earlier doc-derived assumptions: + +- Order creation is `POST /api/v1/order/{external_id}`. The external id is our `MerchandiseOrder.id` or Stripe Checkout session id path parameter; `POST /api/v1/order` returns 404. +- The working payload is flat, not `orders[{ ship_to, line_items }]`: `shipping_first_name`, `shipping_last_name`, `shipping_address1`, optional `shipping_address2`, `shipping_city`, two-letter `shipping_state`, `shipping_zip`, two-letter `shipping_country`, required `shipping_email`, required `shipping_phone`, `shipping_method` name, `items[{ catalog_sku, design_url, design_url_back, quantity }]`, string `sandbox`, and `api_key`. +- `store_id` is accepted in the order body but not required. Use the simpler body without it unless a later verified behavior requires it. +- `catalog_sku` must be sent as a string, and `sandbox` must be `"1"` or `"0"`. +- Successful order response shape is `{ "MSG": "Order added successfully", "ORDER_ID": "", "CUSTOMCAT_ORDER_ID": "" }`; store `CUSTOMCAT_ORDER_ID` and throw on any other `MSG`. +- Idempotency is confirmed: posting the same `external_id` twice returns the same `CUSTOMCAT_ORDER_ID`, so Stripe webhook retries are safe at the vendor boundary. +- Order status backup is `GET /api/v1/order/status/{external_id}?api_key=...`, with `ORDER_STATUS`, `SHIPMENTS` containing `TRACKING_ID` when shipped, and `LINE_ITEMS`. Sandbox orders simulate the shipping lifecycle through `Shipped`. +- Shipping options are `GET /api/v1/shipping?api_key=...`; quote real-time cost with `POST /api/v1/shipping/{shipping_id}`. Use `SHIPPING_NAME` such as `Economy`, not `SHIPPING_ID`, as order `shipping_method`. International sandbox quotes returned `0.00`, so v1 should treat non-US shipping as unsupported. +- Webhooks are listed with `GET /api/v1/webhook?api_key=...`, created with `POST /api/v1/webhook { api_key, topic, url }`, and updated with `PUT /api/v1/webhook/{webhook_id}`. Use `order-shipped`, `order-partial-shipment`, and `design-rejected`; ignore product lifecycle topics. The existing `order-shipped` webhook points at a webhook.site placeholder and should be reconfigured at launch. +- CustomCat re-downloads design URLs rather than caching them by string. Keep order-id-bearing R2 object keys for audit and traceability, not cache busting. +- Cancellation is unsupported. Refunds go through Stripe/customer service; the CustomCat order is left alone. +- Catalog verification found 2XL at $13.47 instead of $11.47. For v1, keep one $15 FMV across sizes rather than adding a per-size override map. + +Web research run on 2026-05-19: + +- Printful API v2 beta docs: https://developers.printful.com/docs/v2-beta/ + - API version used for planning: Printful API v2 beta. + - Orders v2 supports `POST https://api.printful.com/v2/orders` to create a draft order. + - Orders v2 then adds items with `POST https://api.printful.com/v2/orders/{order_id}/order-items`. + - Example order-item payload uses `catalog_variant_id`, `source: "catalog"`, `quantity`, placements, DTG technique, and file layers with a URL. + - The docs say draft orders cannot be confirmed until at least one order item exists. + - Files v2 says files can be added to the file library, but the more convenient path is to specify file URLs during order creation/order-item creation; files are processed asynchronously and may later become `ok` or `failed`. + - The docs warn that reused identical file URLs can reuse the old file, so personalized URLs must be unique per order. +- Printful API v2 help article: https://help.printful.com/hc/en-us/articles/10293184543260-What-should-I-know-about-Printful-s-API-v2 + - Printful says API v2 is open beta, usable live, and still being refined. + - It calls out flexible order creation and improved shipment tracking. + - It says to create/use a private API token and use the v2 endpoint docs. +- Stripe address collection docs: https://docs.stripe.com/payments/collect-addresses + - Checkout collects shipping addresses by passing `shipping_address_collection` when creating a Checkout Session. + - Allowed countries must be specified as two-letter ISO country codes. + - Completed Checkout sessions include collected shipping details in the `checkout.session.completed` webhook payload. +- Stripe automatic tax docs: https://docs.stripe.com/payments/checkout/automatic_taxes + - Checkout can enable Stripe Tax with `automatic_tax[enabled]=true`. + - Tax location uses the shipping address when collected. + - Inline `price_data.product_data.tax_code` can be specified; otherwise Stripe Tax uses the account's default tax code. +- IRS quid pro quo contribution guidance: https://www.irs.gov/charities-non-profits/charitable-organizations/charitable-contributions-quid-pro-quo-contributions + - A payment partly for goods/services and partly as a contribution is a quid pro quo contribution. + - The deductible amount is limited to the excess over the fair market value of goods/services provided. + - Written disclosure must include that limitation and a good-faith FMV estimate for the goods/services. +- IRS written acknowledgment guidance: https://www.irs.gov/charities-non-profits/charitable-organizations/charitable-contributions-written-acknowledgments + - Written acknowledgments for applicable charitable contributions must describe goods/services provided and include a good-faith value estimate when applicable. + +## Vendor Cost Comparison + +Updated vendor search run on 2026-05-20. Costs below exclude Stripe fees, shipping, taxes, refunds, and nonprofit receipt overhead. For POD DTG, "1c+1c front+back" is modeled as front + back placement; most POD vendors price by placement/design area, not literal ink color. Public prices are snapshots from current vendor pages/docs; implementation must re-query the chosen vendor's API before launch. + +| Vendor | Base unisex tee FMV (Bella+Canvas 3001 or closest equiv) | Print cost per shirt (1c+1c front+back, US fulfillment) | API allows per-order custom artwork upload | API allows draft-then-confirm order flow | API webhook on shipped/delivered | Min order quantity | International shipping support | Free account/onboarding friction | Notes | +|---|---:|---:|---|---|---|---:|---|---|---| +| Printful | B+C 3001 public catalog/price endpoint; recent public estimates put one-placement B+C around ~$13.50 | Estimated ~$18-$20 after second placement; exact quote via API/order estimation | Yes. Order item placements accept file layer URL | Yes. v2 creates draft order, then confirm endpoint | Yes. Shipment sent/delivered and order events | 1 | Yes | Free account; API v2 is open beta; ~120 req/min v2 rate limit | Pricing: https://www.printful.com/pricing. API: https://developers.printful.com/docs/v2-beta/. Strongest draft-confirm safety, not cheapest. | +| Printify | B+C 3001 starts at $10.98 free / $8.77 Premium one-side per Printify 2026 pricing guide | Estimated ~$14-$16 free / ~$12-$14 Premium after second side; provider-specific | Yes. API can create products/orders on the fly with `print_areas.front/back` URLs | Partial. Orders can go on hold via approval settings, but no clean draft-confirm endpoint found | No first-party shipped webhook found in public docs; poll orders | 1 | Provider-dependent global shipping | Free; Premium $39/mo or annual discount; print-provider selection adds QA variance | Pricing: https://printify.com/blog/t-shirt-pricing-calculator/. API: https://developers.printify.com/API-Doc-RREdits.html. Potentially cheap, but weaker operational certainty. | +| Gelato | B+C 3001 public third-party catalog snapshot: $10.69-$20.59; official price requires `GET /products/{productUid}/prices` | API quote required for exact front/back; likely competitive, especially outside US/EU | Yes. Create order accepts apparel `files` with `default` and `back` URLs | Partial. Docs expose draft patch/delete endpoints, but create-order example submits directly | Yes. Order status, item status, tracking updates; delivered/status events | 1 | Yes, global network in 32 countries | Free account; API key via dashboard; 100 req/sec | Pricing API: https://dashboard.gelato.com/docs/products/prices/. Order API: https://dashboard.gelato.com/docs/orders/v4/create/. Webhooks: https://dashboard.gelato.com/docs/webhooks/. Good if international volume matters. | +| CustomCat | B+C 3001C: $11.47 Lite / $8.67 Pro | $16.47 Lite / $13.67 Pro after documented +$5 second placement | Yes. API "External Designs" accepts downloadable `design_url`; OrderDesk docs expose `print_url_2` for back | No true draft-confirm. Has `sandbox: 1`; API orders batch into production | Yes. Shipped webhook/status endpoints | 1 | Yes, but country/rate coverage must be verified in account | Free Lite; Pro $30/mo or $25/mo annual; API keys after creating API store | Pricing: https://customcat.com/products/ and https://cc.customcat.com/choose-your-plan/. API: https://help.customcat.com/getting-started-with-customcat-api and https://customcat.com/integrations/customcat-api/. Cheapest proven vendor-doc option. | +| Bonfire | Dynamic base cost; decreases with volume/design complexity | Quote/calculator only; not API-orderable | No public API for per-order generated artwork found | No | No public API webhooks found | POD campaigns no inventory; custom orders domestic only | Campaigns can sell worldwide; custom at-cost orders not international | Free campaign/storefront; manual platform | Pricing: https://help.bonfire.com/en/articles/2184341-what-is-base-cost-and-how-is-it-calculated. API docs: none found. Eliminated for no per-order artwork API. | +| Spring / Teespring | Base cost not public in useful API form | Not comparable | No. Seller API is for approved sellers/partners/licensees and API data, not a documented per-order print-file flow | No | No current public fulfillment webhook found | 1 via storefront/direct | Yes via platform | API credentials require approval/app id | Pricing/direct: https://teespring.com/id/direct. API: https://api.teespring.com/docs and https://teespring.com/en-GB/policies/api. Eliminated. | +| Cotton Bureau | Premium-positioned; no public per-order API cost | Not comparable | No public API found | No | No | On-demand/preorder/store models | Yes through Cotton Bureau store model | Branded stores may have upfront cost | Pricing/model: https://cottonbureau.com/how-it-works. API docs: none found. Eliminated. | +| Threadless | Artist Shop/marketplace pricing; no public API cost | Not comparable | No public order API found | No | No public API webhooks found | 1 via storefront | Yes via storefront | Free Artist Shop | Pricing/model: https://artistshopshelp.threadless.com/article/816-how-do-i-find-my-customer-order-info. API docs: none found. Eliminated. | +| TPop | Example: 12.50 EUR tee + 3.95 EUR delivery in docs; plan pages say pay production cost | Not API-comparable | No public API docs found; external integrations are plan-gated store features | No | No public API webhooks found | 1 via platform | Yes, worldwide sales | Free plan; external integrations/white-label gated by paid plans | Pricing: https://www.tpop.com/en/pricing and https://www.tpop.com/en/page/print-on-demand. API docs: none found. Eliminated. | +| Custom Ink | Quote-based; no-minimum on many products, all-inclusive pricing | Often expensive for one-off; quote-only | No public fulfillment/order API found | No | No | 1 on many products | US/Canada; international caveats | Consumer/manual quoting; not API POD | Pricing: https://www.customink.com/prices. API docs: none found. Eliminated. | +| Gooten | B+C 3001 supported; exact price behind account/API/catalog | Account/API quote required | Yes. API and CSV accept output/artwork URLs; both-side SKU example exists | Partial. `NeedsPersonalization` can hold item; not a clean draft-confirm replacement | Not verified from public docs in this pass | 1 | Yes, network-dependent | Account/onboarding required | API: https://www.gooten.com/api-documentation/submitting-an-order/. Help: https://help.gooten.com/hc/en-us/articles/360047745311-Place-an-Order. Viable fallback, but pricing less transparent. | +| Prodigi | B+C 3001 supported; exact pricing not public in docs | Account/API quote required | Yes. Print API orders require public/private signed asset URL | Partial/unknown | Not verified from public docs in this pass | 1 | Yes | Account/API key; product pricing/account setup required | API/order asset: https://www.prodigi.com/blog/your-first-print-api-order/. Product: https://www.prodigi.com/products/mens-clothing/t-shirts/. Viable fallback, not cheapest proven. | + +## Updated Vendor Recommendation + +Recommend **CustomCat** for the next implementation plan unless Mike requires a strict draft-then-confirm flow. It is the cheapest option I could prove from current vendor docs that also supports the load-bearing per-order custom artwork pattern. + +- Cost baseline: Bella+Canvas 3001C is $11.47 on CustomCat Lite or $8.67 on CustomCat Pro; documented second print location adds $5, so front+back is $16.47 Lite or $13.67 Pro before shipping/tax. +- API fit: CustomCat's API docs support external downloadable `design_url` payloads for per-order generated art, plus order status and shipped webhooks. +- Caveat: CustomCat does not expose the same clean "create draft then confirm" order flow Printful v2 does. It has a sandbox flag, and production orders batch into fulfillment. Implementation must create CustomCat orders only after Stripe payment succeeds and must keep a durable local order/idempotency record. +- If strict draft-confirm is non-negotiable, keep Printful despite higher cost, because Printful v2 has the safer draft/confirm lifecycle. + +## Current State ASCII Diagram + +```text +/shirt + | + +-- getServerSession(authOptions) + +-- buildUserReferralUrl(session.user, WAR_ON_DISEASE_CANONICAL_ORIGIN) + +-- PosterQrCode(referralUrl) + +-- ShirtDownloadImageButton(back SVG -> PNG/SVG) + +-- PosterPrintButton() + +-- external Printful upload link + | + +-- no checkout + +-- no shipping collection + +-- no server-side print file + +-- no fulfillment + +-- no order status + +-- no receipt email + +/api/stripe/create-checkout + | + +-- donation-only request + +-- Stripe Checkout session + +-- success_url -> /donate/success + +/api/stripe/webhook + | + +-- checkout.session.completed + | + +-- recordDonationActivity(session) + +-- no shirt branch +``` + +## Proposed State ASCII Diagram + +```text +/shirt + | + +-- existing QR/art preview remains + +-- tier selector: 25 / 35 / 50 / 100 / custom + +-- size selector: S / M / L / XL / XXL + +-- ORDER ONE button + +-- secondary DIY download path remains + | + v +/api/stripe/create-checkout + | + +-- request kind: "shirt" + +-- validate size, total amount, buyer email/name fallback + +-- metadata: + shirtSize, referralUrl, referralHandleOrCode, fmvCents, + donationCents, userId?, sourceUrl, sourceReferrer + +-- Stripe Checkout: + mode=payment + automatic_tax.enabled=true + billing_address_collection=required + shipping_address_collection.allowed_countries=[initially US] + line item 1: taxable shirt FMV + line item 2: donation above FMV, tax-exempt/non-taxable treatment + success_url -> /shirt/order/{CHECKOUT_SESSION_ID} + cancel_url -> /shirt?canceled=true + | + v +Stripe checkout.session.completed webhook + | + +-- detect metadata.kind === "shirt" + +-- load session with shipping_details + +-- build personalized print-file URL or upload composed PNG to R2 + +-- create CustomCat order with front/back artwork URLs after durable idempotency claim + +-- record CustomCat order/status IDs + | + +-- if strict draft-confirm is required instead, use Printful draft + confirm flow + +-- send confirmation + quid-pro-quo receipt + | + v +/shirt/order/[id] + | + +-- read local order record by checkout session id/order id + +-- optionally refresh POD vendor status + +-- show paid / submitted / in production / shipped / failed +``` + +## Step List + +1. Confirm the implementation boundary with Mike/orchestrator because the CBA below crosses the stop threshold. +2. Choose durable order storage: + - Preferred: add a real `ShirtOrder`/fulfillment model after explicit Prisma approval. + - Fallback: use a non-Prisma durable store only if the repo already has one with uniqueness and retry semantics. + - Do not rely on Stripe metadata alone for live POD fulfillment idempotency. +3. Decide recommended POD product config: + - Fixed blank/product/color for v1. + - Env-driven size-to-vendor SKU map; CustomCat uses `catalog_sku`, while the Printful fallback uses `catalog_variant_id`. + - Initial allowed ship countries, likely `US` only until shipping cost/rate logic exists. +4. Add env validation: + - `CUSTOMCAT_READ_WRITE_API_KEY` + - `CUSTOMCAT_SHIRT_CATALOG_SKUS` or explicit per-size env vars. + - `CUSTOMCAT_SHIRT_SUBMIT_LIVE_ORDERS` default false until a real API order test succeeds. + - If the Printful fallback is selected instead: `PRINTFUL_API_TOKEN`, optional `PRINTFUL_STORE_ID`, size-to-variant env vars, and `PRINTFUL_SHIRT_CONFIRM_ORDERS=false` by default. +5. Build server-side print artwork: + - Use `sharp`. + - Add direct server-side QR generator dependency if needed. + - Generate 300 DPI 10 x 12 in PNG, unique per checkout/order. + - Preserve Mike's exact front/back text. No extra back slogan. +6. Make the image reachable by the POD vendor: + - Preferred: compose and upload to R2, store public URL on order record. + - Fallback: signed `/api/shirt/print-file/[token]` route if it can stay available long enough and cannot leak PII. +7. Extend `POST /api/stripe/create-checkout` without polluting donation logic: + - Keep donation request handling as-is. + - Add a `kind: "shirt"` branch or route helper. + - Use Stripe Tax/address collection. + - Split FMV/taxable shirt amount from donation amount. +8. Extend webhook: + - Branch on `session.metadata.kind`. + - Use a durable idempotency claim before any POD vendor side effect. + - Submit CustomCat order with external design URLs, unless the approved vendor changes. + - Record vendor order/item/file IDs and current status. + - On failure, mark order failed and email/log for operator action. +9. Add confirmation email: + - Transactional. + - Include total paid, FMV estimate, deductible contribution amount, legal nonprofit name/EIN, order id, and status link. + - Avoid public-copy churn; Mike must review receipt text before commit. +10. Add `/shirt/order/[id]`: + - Show Stripe payment status. + - Show POD vendor submission/shipping status when available. + - Show failed/pending states with clear operator-contact fallback. +11. Add focused tests: + - Checkout request validation and session payload for shirt orders. + - Webhook idempotency around duplicate `checkout.session.completed`. + - CustomCat client request-shape tests with fetch mocked at the boundary. + - Print image composition dimensions/smoke test if fast enough. +12. Run focused verification: + - `pnpm --filter @optimitron/web run typecheck:fast` + - Focused Vitest for Stripe/POD/shirt helpers. + - `pnpm --filter @optimitron/web copy:preview -- --routes=/shirt,/shirt/order/` + - Browser screenshot review using the already-running `http://127.0.0.1:3001` only. +13. Stage file-specific changes. Do not commit. Do not merge. + +## Risks + +- RED: Durable idempotency is unresolved. A Stripe webhook retry after a partial POD success can create duplicate live shirt orders unless there is a unique local order/fulfillment record or a proven vendor external-id/idempotency recovery path. Stripe metadata alone is not a sufficient lock. +- RED: A real order/status surface wants a new local order table, but Prisma schema/exported DB type changes require explicit human approval. That approval is not present in this task. +- RED: CustomCat product/catalog SKUs are not known from the repo. Mike must create or identify the CustomCat API store and size-to-`catalog_sku` map before this can produce the intended blank/color/sizes. +- RED: CustomCat does not expose a clean draft-then-confirm flow. That is acceptable only if live submission is gated off until a real API token/order test succeeds; if Mike requires draft-confirm, the plan should switch back to Printful. +- RED: Confirmation/receipt copy touches tax-deductibility claims. It needs Mike/legal review before commit or deploy. +- MED: Stripe Tax accuracy depends on correct product tax code, shipping-country scope, Stripe Tax account settings, and whether the donation line is modeled separately from the shirt FMV line. +- MED: Fair market value is currently an estimate (`~$15`). Need the actual blank+print+shipping/subsidy policy before receipts call the deductible portion exact. +- MED: 501(c)(3) unrelated-business-income concerns need review if shirt sales become more than incidental fundraising/propagation. Mitigation: treat v1 as campaign fundraising/advertising with clear FMV split and limited scope; get tax review before scale. +- MED: POD file ingestion/validation can fail late. Submitting a live order before the vendor accepts the artwork can create support work; waiting synchronously in the webhook could exceed runtime limits. +- MED: Shipping cost can exceed the $15 FMV assumption, especially outside the US. Mitigation: US-only initial launch or explicit flat shipping/FMV policy. +- MED: Fraud/refunds/chargebacks need an operator path. Once fulfillment starts, refunds may not cancel the Printful cost. +- LOW: Server-side image composition cost is manageable for one PNG/order, but avoid recomposing repeatedly in status pages or webhook retries. +- LOW: The existing dev server is already running at `http://127.0.0.1:3001`; verification must reuse it and not start/kill another server. + +## Files to Touch + +Plan-stage files touched: + +- `.claude/plans/t-shirt-walking-billboard.md` + +Likely implementation files if approved: + +- `packages/web/src/app/shirt/page.tsx` +- `packages/web/src/app/shirt/shirt-client.tsx` +- `packages/web/src/app/shirt/order/[id]/page.tsx` +- `packages/web/src/app/api/stripe/create-checkout/route.ts` +- `packages/web/src/app/api/stripe/create-checkout/route.test.ts` +- `packages/web/src/app/api/stripe/webhook/route.ts` +- `packages/web/src/app/api/stripe/webhook/route.test.ts` +- `packages/web/src/app/api/shirt/print-file/[token]/route.ts` or an R2 upload helper +- `packages/web/src/lib/shirt/artwork.server.ts` +- `packages/web/src/lib/shirt/pod-vendor.server.ts` or `packages/web/src/lib/shirt/customcat.server.ts` +- `packages/web/src/lib/shirt/order.server.ts` +- `packages/web/src/lib/shirt/receipt-email.server.ts` +- `packages/web/src/lib/env.ts` +- `packages/web/src/lib/stripe.ts` +- `packages/web/src/lib/email/preview-registry.ts` and related preview files if email preview is added +- `packages/web/src/app/shirt/page.logged-out.md` +- `packages/web/src/app/shirt/order/[id]/page.logged-out.md` if previewable +- `packages/web/package.json` and `pnpm-lock.yaml` if adding direct `qrcode` +- Potentially `packages/db/prisma/schema.prisma` and generated `@optimitron/db` artifacts only after explicit human approval +- Local-only screenshot artifacts under `packages/web/output/playwright/` if UI changes are implemented + +## Cost-Benefit Matrix + +| Option | CC hrs | Wallclock | Expected impact (with units) | Confidence | Brand/UX cost | Opportunity cost (which P0/P1 TODO drops) | Risk-adj score | Decision | +|---|---:|---:|---|---|---|---|---|---| +| Actual first-party shirt order using recommended POD vendor (CustomCat unless draft-confirm is required) | 14-26 | 2-4 days before live confidence; longer if API account/product setup is missing | Lets supporters buy personalized walking billboards; target 10-100 public impressions per worn shirt and attributed scans via `/vote/` | MED if account/product config exists; LOW before live API test | Medium: adds commerce, support, tax, and failed-order states to a campaign page | Drops P0 vote conversion/referral propagation polish and P1 org endorsement work for several days | Mixed: cheaper and simpler than Printful, but still blocked by durable idempotency/schema and tax/receipt review | STOP FOR ORCHESTRATOR REVIEW | +| Narrow engineering spike: CustomCat sandbox/test order behind env flag, no public ORDER ONE launch | 4-8 | 1 day if API store exists | Proves API payload and image path without taking money or creating live support burden | MED | Low public UX cost if hidden | Drops about 1 day of P0 referral polish | Reasonable as next step after plan approval | RESEARCH / SPIKE | +| Metadata-only implementation with Stripe metadata as the order store | 8-14 | 1-2 days | Could appear to work for happy path, but duplicate webhook/order failure risk hits real buyers | LOW | High hidden support risk | Drops P0 work and creates fragile commerce debt | Bad: live side effects without durable idempotency | CUT | +| Keep current DIY QR download/upload path and add only an ORDER ONE placeholder/disabled CTA | 1-2 | same day | No real ordering; keeps referral artwork available | HIGH | Low | Minimal opportunity cost | Does not satisfy Mike's correction | CUT | +| Do nothing beyond plan | 0.5-1 | same turn | Prevents unsafe live commerce from shipping before storage/vendor/tax decisions | HIGH | No UX change | No P0 work displaced beyond planning | Best current action under the stated stop rule | STOP | + +**Verdict from the matrix:** The requested full implementation is still above the `< 2 days CC` threshold and has unresolved RED risks, so this run must stop at plan stage under Mike's protocol. Vendor research changes the implementation target from default Printful to CustomCat for cheapest proven per-order artwork fulfillment, unless Mike values Printful's draft-confirm flow more than the ~$2-$6 per-shirt savings. + +## Codex critique (round 1) + +- I should not treat the old DIY-shirt CBA as still valid after Mike's correction. The ask is not "make a cute shirt page"; it is "make actual ordering possible." +- The current `/shirt` page already includes extra back text (`THIS T-SHIRT ENDED WAR AND DISEASE.`). That may have come from the original "maybe says like" wording, but Mike's correction now names the exact front/back text. Implementation should remove extra shirt-copy from the actual artwork. +- A metadata-only implementation is attractive because it avoids schema approval, but it fails the real-world duplicate-order path. Live fulfillment needs a durable idempotency claim before POD vendor side effects. +- Stripe Tax does not by itself solve charitable receipt accuracy. The code still has to separate taxable/physical shirt FMV from the donation amount and disclose the FMV estimate. +- Printful v2 being open beta is not automatically a blocker, but live auto-confirmed orders should default off until a real API-token/product/variant test succeeds. +- If this takes attention away from vote conversion or referral propagation for more than a couple of days, the shirt flow has to prove it is not merch vanity. The justification is attributed public scans, not shirt sales. diff --git a/TODO.md b/TODO.md index 5774a95b..8c65865e 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,16 @@ Do not let lower items crowd out higher ones. plaintiff damages, and the simple `/treaty` skim-and-sign page exist. - Bio-template `/love` page is shipped; dating registry deferred per 2026-05-19 CBA. +- Generic commerce schema is now the intended money path for live checkout: + `CommerceOffer`/variant/order/item/fulfillment/entitlement cover shirts, + sponsorships, subscriptions, digital access, and dating-app benefits without a + shirt-only table. Product/vendor catalog IDs belong in managed data, not env. +- Dating foundation schema is additive and separate from the task system: + dating profiles, photos, prompts, questions, likes/passes/intros, matches, + conversations, date plans, blocks, and safety reports get dating-specific + privacy/moderation rules. Reuse `Task` only for the mission-output part of a + date via `DatingDatePlan.campaignTaskId`, e.g. flyers, QR posters, outreach, or + meetup follow-up. - `/humanity-v-government` and `/court` still need to unify plaintiff registration, verdict voting, and treaty settlement. - Visual review includes email screenshots; preview DB drift and unexplained @@ -86,6 +96,38 @@ Do not start a new feature unless it helps the sprint get money, convert votes, convert referrals, convert organizations, or prove the quantified case to a specific funder/partner. +## Active Checkout Launch Gates - 2026-05-20 + +- Deploy the commerce migration before enabling paid shirt checkout. +- Run managed-data sync after the migration so the shirt offer, variants, and + CustomCat fulfillment mappings exist in the target database. +- Keep env limited to secrets/ops toggles: `STRIPE_SECRET_KEY`, + `STRIPE_WEBHOOK_SECRET`, `CUSTOMCAT_API_TOKEN`, `CUSTOMCAT_SANDBOX`, + `SHIRT_COMMERCE_ENABLED`, and R2 credentials. Do not put product or variant + IDs back in env. +- Verify CustomCat catalog SKUs against `GET /catalog/952` using the live API + token, then run one Stripe test-mode/sandbox CustomCat order end to end. + Local verification on 2026-05-20 skipped because no local + `CUSTOMCAT_API_TOKEN` was present. +- Only flip production `SHIRT_COMMERCE_ENABLED=true` after Stripe Tax, R2 public + artwork URLs, CustomCat sandbox submission, and webhook retry behavior are all + verified. + +## Active Dating Foundation - 2026-05-20 + +- The first dating implementation should stay MVP: opt-in profile, photos, + prompts, match questions, discover list, like/pass/intro, mutual-match + messages, block/report, and optional mission-date plan linked to a `Task`. +- Do not turn normal dating mechanics into tasks. A like, intro, match, private + message, block, or safety report is not campaign work and should not enter the + task tree. +- Use existing `Task` records only when a pair chooses a concrete campaign + activity: hang flyers, distribute QR posters, invite people to vote, host a + singles meetup, or verify completion evidence. +- Before public launch: decide photo moderation policy, approximate-location + display rules, minimum age/consent checks, DM reporting workflow, and whether + dating profiles are visible only to opted-in dating users. + ## Active Handoff - 2026-05-13 - Codex hook cleanup: Mike prefers deleting repo-local `.codex` hooks instead of diff --git a/packages/db/prisma/migrations/20260520030000_add_commerce_ledger/migration.sql b/packages/db/prisma/migrations/20260520030000_add_commerce_ledger/migration.sql new file mode 100644 index 00000000..0c05fecb --- /dev/null +++ b/packages/db/prisma/migrations/20260520030000_add_commerce_ledger/migration.sql @@ -0,0 +1,286 @@ +-- CreateEnum +CREATE TYPE "CommerceOfferKind" AS ENUM ('PHYSICAL_GOOD', 'SPONSORSHIP', 'SUBSCRIPTION', 'DIGITAL_ACCESS', 'SERVICE', 'DONATION'); + +-- CreateEnum +CREATE TYPE "CommerceOfferStatus" AS ENUM ('DRAFT', 'ACTIVE', 'RETIRED'); + +-- CreateEnum +CREATE TYPE "CommerceFulfillmentKind" AS ENUM ('NONE', 'PHYSICAL_GOOD', 'DIGITAL_ENTITLEMENT', 'MANUAL_SPONSORSHIP'); + +-- CreateEnum +CREATE TYPE "CommercePaymentProvider" AS ENUM ('STRIPE', 'MANUAL'); + +-- CreateEnum +CREATE TYPE "CommerceFulfillmentProvider" AS ENUM ('NONE', 'CUSTOMCAT', 'MANUAL', 'STRIPE'); + +-- CreateEnum +CREATE TYPE "CommerceOrderStatus" AS ENUM ('PENDING_PAYMENT', 'PAID', 'FULFILLING', 'SUBMITTED', 'SHIPPED', 'FAILED', 'CANCELED', 'REFUNDED'); + +-- CreateEnum +CREATE TYPE "CommerceFulfillmentStatus" AS ENUM ('PENDING', 'SUBMITTED', 'SHIPPED', 'DELIVERED', 'FAILED', 'CANCELED'); + +-- CreateEnum +CREATE TYPE "CommerceEntitlementStatus" AS ENUM ('PENDING', 'ACTIVE', 'EXPIRED', 'CANCELED', 'REVOKED'); + +-- CreateTable +CREATE TABLE "CommerceOffer" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "kind" "CommerceOfferKind" NOT NULL, + "status" "CommerceOfferStatus" NOT NULL DEFAULT 'ACTIVE', + "title" TEXT NOT NULL, + "description" TEXT, + "currency" TEXT NOT NULL DEFAULT 'usd', + "defaultUnitAmountCents" INTEGER, + "defaultFmvCents" INTEGER NOT NULL DEFAULT 0, + "minUnitAmountCents" INTEGER, + "maxUnitAmountCents" INTEGER, + "allowCustomAmount" BOOLEAN NOT NULL DEFAULT false, + "isTaxDeductible" BOOLEAN NOT NULL DEFAULT false, + "taxCode" TEXT, + "fulfillmentKind" "CommerceFulfillmentKind" NOT NULL DEFAULT 'NONE', + "managed" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceOffer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommerceOfferVariant" ( + "id" TEXT NOT NULL, + "offerId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "variantKey" TEXT NOT NULL, + "label" TEXT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'usd', + "unitAmountCents" INTEGER, + "fmvCents" INTEGER, + "minUnitAmountCents" INTEGER, + "maxUnitAmountCents" INTEGER, + "allowCustomAmount" BOOLEAN, + "taxCode" TEXT, + "fulfillmentKind" "CommerceFulfillmentKind", + "attributes" JSONB, + "fulfillmentMetadata" JSONB, + "metadata" JSONB, + "active" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceOfferVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommerceFulfillmentMapping" ( + "id" TEXT NOT NULL, + "offerVariantId" TEXT NOT NULL, + "provider" "CommerceFulfillmentProvider" NOT NULL, + "providerProductId" TEXT, + "providerVariantId" TEXT, + "providerCatalogSku" TEXT, + "providerMetadata" JSONB, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceFulfillmentMapping_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommerceOrder" ( + "id" TEXT NOT NULL, + "purposeKey" TEXT, + "status" "CommerceOrderStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', + "paymentProvider" "CommercePaymentProvider" NOT NULL DEFAULT 'STRIPE', + "stripeCheckoutSessionId" TEXT, + "stripePaymentIntentId" TEXT, + "stripeCustomerId" TEXT, + "buyerUserId" TEXT, + "buyerOrganizationId" TEXT, + "buyerEmail" TEXT, + "buyerName" TEXT, + "buyerPhone" TEXT, + "shippingName" TEXT, + "shippingLine1" TEXT, + "shippingLine2" TEXT, + "shippingCity" TEXT, + "shippingState" TEXT, + "shippingPostalCode" TEXT, + "shippingCountry" TEXT, + "currency" TEXT NOT NULL DEFAULT 'usd', + "subtotalCents" INTEGER NOT NULL DEFAULT 0, + "taxCents" INTEGER NOT NULL DEFAULT 0, + "shippingCents" INTEGER NOT NULL DEFAULT 0, + "discountCents" INTEGER NOT NULL DEFAULT 0, + "totalCents" INTEGER NOT NULL DEFAULT 0, + "fmvCents" INTEGER NOT NULL DEFAULT 0, + "donationCents" INTEGER NOT NULL DEFAULT 0, + "metadata" JSONB, + "lastError" TEXT, + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "paidAt" TIMESTAMP(3), + "fulfilledAt" TIMESTAMP(3), + "shippedAt" TIMESTAMP(3), + "canceledAt" TIMESTAMP(3), + "refundedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommerceOrderItem" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "offerId" TEXT, + "offerVariantId" TEXT, + "offerKey" TEXT NOT NULL, + "offerVariantKey" TEXT, + "title" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "currency" TEXT NOT NULL DEFAULT 'usd', + "unitAmountCents" INTEGER NOT NULL DEFAULT 0, + "unitFmvCents" INTEGER NOT NULL DEFAULT 0, + "unitDonationCents" INTEGER NOT NULL DEFAULT 0, + "totalAmountCents" INTEGER NOT NULL DEFAULT 0, + "totalFmvCents" INTEGER NOT NULL DEFAULT 0, + "totalDonationCents" INTEGER NOT NULL DEFAULT 0, + "taxable" BOOLEAN NOT NULL DEFAULT false, + "taxCode" TEXT, + "fulfillmentKind" "CommerceFulfillmentKind" NOT NULL DEFAULT 'NONE', + "fulfillmentMetadata" JSONB, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceOrderItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommerceFulfillment" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "orderItemId" TEXT, + "provider" "CommerceFulfillmentProvider" NOT NULL, + "status" "CommerceFulfillmentStatus" NOT NULL DEFAULT 'PENDING', + "externalOrderId" TEXT, + "providerOrderId" TEXT, + "providerStatus" TEXT, + "trackingNumber" TEXT, + "trackingUrl" TEXT, + "metadata" JSONB, + "lastError" TEXT, + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "submittedAt" TIMESTAMP(3), + "shippedAt" TIMESTAMP(3), + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceFulfillment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommerceEntitlement" ( + "id" TEXT NOT NULL, + "orderId" TEXT, + "orderItemId" TEXT, + "offerId" TEXT, + "offerVariantId" TEXT, + "entitlementType" TEXT NOT NULL, + "status" "CommerceEntitlementStatus" NOT NULL DEFAULT 'PENDING', + "subjectUserId" TEXT, + "subjectOrganizationId" TEXT, + "startsAt" TIMESTAMP(3), + "endsAt" TIMESTAMP(3), + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "CommerceEntitlement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommerceOffer_key_key" ON "CommerceOffer"("key"); +CREATE INDEX "CommerceOffer_kind_status_idx" ON "CommerceOffer"("kind", "status"); +CREATE INDEX "CommerceOffer_deletedAt_idx" ON "CommerceOffer"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommerceOfferVariant_key_key" ON "CommerceOfferVariant"("key"); +CREATE UNIQUE INDEX "CommerceOfferVariant_offerId_variantKey_key" ON "CommerceOfferVariant"("offerId", "variantKey"); +CREATE INDEX "CommerceOfferVariant_offerId_active_idx" ON "CommerceOfferVariant"("offerId", "active"); +CREATE INDEX "CommerceOfferVariant_deletedAt_idx" ON "CommerceOfferVariant"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommerceFulfillmentMapping_offerVariantId_provider_key" ON "CommerceFulfillmentMapping"("offerVariantId", "provider"); +CREATE INDEX "CommerceFulfillmentMapping_provider_providerCatalogSku_idx" ON "CommerceFulfillmentMapping"("provider", "providerCatalogSku"); +CREATE INDEX "CommerceFulfillmentMapping_deletedAt_idx" ON "CommerceFulfillmentMapping"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommerceOrder_stripeCheckoutSessionId_key" ON "CommerceOrder"("stripeCheckoutSessionId"); +CREATE INDEX "CommerceOrder_buyerUserId_idx" ON "CommerceOrder"("buyerUserId"); +CREATE INDEX "CommerceOrder_buyerOrganizationId_idx" ON "CommerceOrder"("buyerOrganizationId"); +CREATE INDEX "CommerceOrder_buyerEmail_idx" ON "CommerceOrder"("buyerEmail"); +CREATE INDEX "CommerceOrder_status_idx" ON "CommerceOrder"("status"); +CREATE INDEX "CommerceOrder_purposeKey_idx" ON "CommerceOrder"("purposeKey"); +CREATE INDEX "CommerceOrder_createdAt_idx" ON "CommerceOrder"("createdAt"); +CREATE INDEX "CommerceOrder_deletedAt_idx" ON "CommerceOrder"("deletedAt"); + +-- CreateIndex +CREATE INDEX "CommerceOrderItem_orderId_idx" ON "CommerceOrderItem"("orderId"); +CREATE INDEX "CommerceOrderItem_offerId_idx" ON "CommerceOrderItem"("offerId"); +CREATE INDEX "CommerceOrderItem_offerVariantId_idx" ON "CommerceOrderItem"("offerVariantId"); +CREATE INDEX "CommerceOrderItem_offerKey_idx" ON "CommerceOrderItem"("offerKey"); +CREATE INDEX "CommerceOrderItem_deletedAt_idx" ON "CommerceOrderItem"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommerceFulfillment_provider_externalOrderId_key" ON "CommerceFulfillment"("provider", "externalOrderId"); +CREATE INDEX "CommerceFulfillment_orderId_idx" ON "CommerceFulfillment"("orderId"); +CREATE INDEX "CommerceFulfillment_orderItemId_idx" ON "CommerceFulfillment"("orderItemId"); +CREATE INDEX "CommerceFulfillment_status_idx" ON "CommerceFulfillment"("status"); +CREATE INDEX "CommerceFulfillment_providerOrderId_idx" ON "CommerceFulfillment"("providerOrderId"); +CREATE INDEX "CommerceFulfillment_deletedAt_idx" ON "CommerceFulfillment"("deletedAt"); + +-- CreateIndex +CREATE INDEX "CommerceEntitlement_orderId_idx" ON "CommerceEntitlement"("orderId"); +CREATE INDEX "CommerceEntitlement_orderItemId_idx" ON "CommerceEntitlement"("orderItemId"); +CREATE INDEX "CommerceEntitlement_offerId_idx" ON "CommerceEntitlement"("offerId"); +CREATE INDEX "CommerceEntitlement_offerVariantId_idx" ON "CommerceEntitlement"("offerVariantId"); +CREATE INDEX "CommerceEntitlement_subjectUserId_idx" ON "CommerceEntitlement"("subjectUserId"); +CREATE INDEX "CommerceEntitlement_subjectOrganizationId_idx" ON "CommerceEntitlement"("subjectOrganizationId"); +CREATE INDEX "CommerceEntitlement_entitlementType_status_idx" ON "CommerceEntitlement"("entitlementType", "status"); +CREATE INDEX "CommerceEntitlement_endsAt_idx" ON "CommerceEntitlement"("endsAt"); +CREATE INDEX "CommerceEntitlement_deletedAt_idx" ON "CommerceEntitlement"("deletedAt"); + +-- AddForeignKey +ALTER TABLE "CommerceOfferVariant" ADD CONSTRAINT "CommerceOfferVariant_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "CommerceOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommerceFulfillmentMapping" ADD CONSTRAINT "CommerceFulfillmentMapping_offerVariantId_fkey" FOREIGN KEY ("offerVariantId") REFERENCES "CommerceOfferVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommerceOrderItem" ADD CONSTRAINT "CommerceOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "CommerceOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CommerceOrderItem" ADD CONSTRAINT "CommerceOrderItem_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "CommerceOffer"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CommerceOrderItem" ADD CONSTRAINT "CommerceOrderItem_offerVariantId_fkey" FOREIGN KEY ("offerVariantId") REFERENCES "CommerceOfferVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommerceFulfillment" ADD CONSTRAINT "CommerceFulfillment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "CommerceOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CommerceFulfillment" ADD CONSTRAINT "CommerceFulfillment_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "CommerceOrderItem"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommerceEntitlement" ADD CONSTRAINT "CommerceEntitlement_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "CommerceOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CommerceEntitlement" ADD CONSTRAINT "CommerceEntitlement_orderItemId_fkey" FOREIGN KEY ("orderItemId") REFERENCES "CommerceOrderItem"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CommerceEntitlement" ADD CONSTRAINT "CommerceEntitlement_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "CommerceOffer"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CommerceEntitlement" ADD CONSTRAINT "CommerceEntitlement_offerVariantId_fkey" FOREIGN KEY ("offerVariantId") REFERENCES "CommerceOfferVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260520043000_add_dating_foundation/migration.sql b/packages/db/prisma/migrations/20260520043000_add_dating_foundation/migration.sql new file mode 100644 index 00000000..bdb85457 --- /dev/null +++ b/packages/db/prisma/migrations/20260520043000_add_dating_foundation/migration.sql @@ -0,0 +1,602 @@ +-- CreateEnum +CREATE TYPE "DatingProfileStatus" AS ENUM ('DRAFT', 'ACTIVE', 'PAUSED', 'HIDDEN', 'MODERATION_HOLD', 'BANNED'); + +-- CreateEnum +CREATE TYPE "DatingRelationshipIntent" AS ENUM ('FRIENDS', 'DATES', 'LONG_TERM', 'LIFE_PARTNER', 'CASUAL', 'NON_MONOGAMY', 'UNSURE'); + +-- CreateEnum +CREATE TYPE "DatingProfilePhotoStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'HIDDEN'); + +-- CreateEnum +CREATE TYPE "DatingQuestionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'RETIRED'); + +-- CreateEnum +CREATE TYPE "DatingQuestionAnswerVisibility" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- CreateEnum +CREATE TYPE "DatingQuestionImportance" AS ENUM ('IRRELEVANT', 'A_LITTLE', 'SOMEWHAT', 'VERY', 'MANDATORY'); + +-- CreateEnum +CREATE TYPE "DatingPreferenceImportance" AS ENUM ('PREFERENCE', 'DEALBREAKER'); + +-- CreateEnum +CREATE TYPE "DatingInteractionKind" AS ENUM ('LIKE', 'PASS', 'SUPERLIKE', 'INTRO'); + +-- CreateEnum +CREATE TYPE "DatingInteractionStatus" AS ENUM ('ACTIVE', 'RETRACTED', 'MODERATION_HOLD'); + +-- CreateEnum +CREATE TYPE "DatingMatchStatus" AS ENUM ('ACTIVE', 'UNMATCHED', 'BLOCKED'); + +-- CreateEnum +CREATE TYPE "DatingConversationStatus" AS ENUM ('ACTIVE', 'ARCHIVED', 'MODERATION_HOLD'); + +-- CreateEnum +CREATE TYPE "DatingMessageStatus" AS ENUM ('SENT', 'HIDDEN', 'DELETED', 'MODERATION_HOLD'); + +-- CreateEnum +CREATE TYPE "DatingDatePlanStatus" AS ENUM ('PROPOSED', 'ACCEPTED', 'DECLINED', 'CANCELED', 'COMPLETED', 'NO_SHOW'); + +-- CreateEnum +CREATE TYPE "DatingBlockScope" AS ENUM ('DISCOVERY', 'MESSAGES', 'ALL'); + +-- CreateEnum +CREATE TYPE "DatingSafetyReportStatus" AS ENUM ('OPEN', 'REVIEWING', 'RESOLVED', 'DISMISSED'); + +-- CreateTable +CREATE TABLE "DatingProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" "DatingProfileStatus" NOT NULL DEFAULT 'DRAFT', + "headline" TEXT, + "bio" TEXT, + "lookingForText" TEXT, + "relationshipIntents" "DatingRelationshipIntent"[] DEFAULT ARRAY[]::"DatingRelationshipIntent"[], + "genderIdentities" TEXT[] DEFAULT ARRAY[]::TEXT[], + "orientationIdentities" TEXT[] DEFAULT ARRAY[]::TEXT[], + "relationshipStatus" TEXT, + "preferredMinAge" INTEGER, + "preferredMaxAge" INTEGER, + "maxDistanceKm" INTEGER, + "displayCity" TEXT, + "displayRegionCode" TEXT, + "displayCountryCode" TEXT, + "wantsCampaignDates" BOOLEAN NOT NULL DEFAULT true, + "campaignDateIdeas" TEXT[] DEFAULT ARRAY[]::TEXT[], + "profileCompletedAt" TIMESTAMP(3), + "lastActiveAt" TIMESTAMP(3), + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingProfilePhoto" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "imageUrl" TEXT NOT NULL, + "storageKey" TEXT, + "altText" TEXT, + "blurhash" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "status" "DatingProfilePhotoStatus" NOT NULL DEFAULT 'PENDING', + "moderationReason" TEXT, + "reviewedByUserId" TEXT, + "reviewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingProfilePhoto_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingPrompt" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "text" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "managed" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingPrompt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingPromptAnswer" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "promptId" TEXT NOT NULL, + "answer" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingPromptAnswer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingQuestion" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "text" TEXT NOT NULL, + "category" TEXT, + "answerOptions" JSONB NOT NULL, + "allowMultiple" BOOLEAN NOT NULL DEFAULT false, + "status" "DatingQuestionStatus" NOT NULL DEFAULT 'ACTIVE', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "managed" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingQuestion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingQuestionAnswer" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "answerValues" JSONB NOT NULL, + "acceptableValues" JSONB, + "importance" "DatingQuestionImportance" NOT NULL DEFAULT 'SOMEWHAT', + "visibility" "DatingQuestionAnswerVisibility" NOT NULL DEFAULT 'PUBLIC', + "explanation" TEXT, + "answeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingQuestionAnswer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingPreference" ( + "id" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "valueJson" JSONB NOT NULL, + "importance" "DatingPreferenceImportance" NOT NULL DEFAULT 'PREFERENCE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingMatchScore" ( + "id" TEXT NOT NULL, + "profileAId" TEXT NOT NULL, + "profileBId" TEXT NOT NULL, + "score" INTEGER NOT NULL, + "questionScore" INTEGER, + "preferenceScore" INTEGER, + "sharedAnsweredCount" INTEGER NOT NULL DEFAULT 0, + "dealbreakerFailed" BOOLEAN NOT NULL DEFAULT false, + "failedDealbreakerCount" INTEGER NOT NULL DEFAULT 0, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingMatchScore_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingInteraction" ( + "id" TEXT NOT NULL, + "fromProfileId" TEXT NOT NULL, + "toProfileId" TEXT NOT NULL, + "kind" "DatingInteractionKind" NOT NULL, + "status" "DatingInteractionStatus" NOT NULL DEFAULT 'ACTIVE', + "introMessage" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingInteraction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingMatch" ( + "id" TEXT NOT NULL, + "profileAId" TEXT NOT NULL, + "profileBId" TEXT NOT NULL, + "status" "DatingMatchStatus" NOT NULL DEFAULT 'ACTIVE', + "matchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "unmatchedAt" TIMESTAMP(3), + "lastMessageAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingMatch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingConversation" ( + "id" TEXT NOT NULL, + "matchId" TEXT NOT NULL, + "status" "DatingConversationStatus" NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingConversation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingMessage" ( + "id" TEXT NOT NULL, + "conversationId" TEXT NOT NULL, + "senderProfileId" TEXT NOT NULL, + "body" TEXT NOT NULL, + "status" "DatingMessageStatus" NOT NULL DEFAULT 'SENT', + "readAt" TIMESTAMP(3), + "editedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingDatePlan" ( + "id" TEXT NOT NULL, + "matchId" TEXT, + "conversationId" TEXT, + "proposedByProfileId" TEXT NOT NULL, + "acceptedByProfileId" TEXT, + "status" "DatingDatePlanStatus" NOT NULL DEFAULT 'PROPOSED', + "title" TEXT NOT NULL, + "description" TEXT, + "startsAt" TIMESTAMP(3), + "endsAt" TIMESTAMP(3), + "timeZone" TEXT, + "locationName" TEXT, + "address" TEXT, + "latitude" DOUBLE PRECISION, + "longitude" DOUBLE PRECISION, + "isCampaignDate" BOOLEAN NOT NULL DEFAULT false, + "campaignTaskId" TEXT, + "campaignNotes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingDatePlan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingBlock" ( + "id" TEXT NOT NULL, + "blockerProfileId" TEXT NOT NULL, + "blockedProfileId" TEXT NOT NULL, + "scope" "DatingBlockScope" NOT NULL DEFAULT 'ALL', + "reason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingBlock_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DatingSafetyReport" ( + "id" TEXT NOT NULL, + "reporterProfileId" TEXT NOT NULL, + "reportedProfileId" TEXT, + "messageId" TEXT, + "datePlanId" TEXT, + "reason" TEXT NOT NULL, + "description" TEXT, + "status" "DatingSafetyReportStatus" NOT NULL DEFAULT 'OPEN', + "reviewerUserId" TEXT, + "resolutionNote" TEXT, + "resolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "DatingSafetyReport_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingProfile_userId_key" ON "DatingProfile"("userId"); + +-- CreateIndex +CREATE INDEX "DatingProfile_status_lastActiveAt_idx" ON "DatingProfile"("status", "lastActiveAt"); + +-- CreateIndex +CREATE INDEX "DatingProfile_displayCountryCode_displayRegionCode_displayC_idx" ON "DatingProfile"("displayCountryCode", "displayRegionCode", "displayCity"); + +-- CreateIndex +CREATE INDEX "DatingProfile_wantsCampaignDates_idx" ON "DatingProfile"("wantsCampaignDates"); + +-- CreateIndex +CREATE INDEX "DatingProfile_deletedAt_idx" ON "DatingProfile"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingProfilePhoto_profileId_sortOrder_idx" ON "DatingProfilePhoto"("profileId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "DatingProfilePhoto_status_idx" ON "DatingProfilePhoto"("status"); + +-- CreateIndex +CREATE INDEX "DatingProfilePhoto_reviewedByUserId_idx" ON "DatingProfilePhoto"("reviewedByUserId"); + +-- CreateIndex +CREATE INDEX "DatingProfilePhoto_deletedAt_idx" ON "DatingProfilePhoto"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingPrompt_key_key" ON "DatingPrompt"("key"); + +-- CreateIndex +CREATE INDEX "DatingPrompt_active_sortOrder_idx" ON "DatingPrompt"("active", "sortOrder"); + +-- CreateIndex +CREATE INDEX "DatingPrompt_deletedAt_idx" ON "DatingPrompt"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingPromptAnswer_profileId_sortOrder_idx" ON "DatingPromptAnswer"("profileId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "DatingPromptAnswer_promptId_idx" ON "DatingPromptAnswer"("promptId"); + +-- CreateIndex +CREATE INDEX "DatingPromptAnswer_deletedAt_idx" ON "DatingPromptAnswer"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingPromptAnswer_profileId_promptId_key" ON "DatingPromptAnswer"("profileId", "promptId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingQuestion_key_key" ON "DatingQuestion"("key"); + +-- CreateIndex +CREATE INDEX "DatingQuestion_status_category_sortOrder_idx" ON "DatingQuestion"("status", "category", "sortOrder"); + +-- CreateIndex +CREATE INDEX "DatingQuestion_deletedAt_idx" ON "DatingQuestion"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingQuestionAnswer_profileId_visibility_idx" ON "DatingQuestionAnswer"("profileId", "visibility"); + +-- CreateIndex +CREATE INDEX "DatingQuestionAnswer_questionId_idx" ON "DatingQuestionAnswer"("questionId"); + +-- CreateIndex +CREATE INDEX "DatingQuestionAnswer_importance_idx" ON "DatingQuestionAnswer"("importance"); + +-- CreateIndex +CREATE INDEX "DatingQuestionAnswer_deletedAt_idx" ON "DatingQuestionAnswer"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingQuestionAnswer_profileId_questionId_key" ON "DatingQuestionAnswer"("profileId", "questionId"); + +-- CreateIndex +CREATE INDEX "DatingPreference_profileId_importance_idx" ON "DatingPreference"("profileId", "importance"); + +-- CreateIndex +CREATE INDEX "DatingPreference_key_idx" ON "DatingPreference"("key"); + +-- CreateIndex +CREATE INDEX "DatingPreference_deletedAt_idx" ON "DatingPreference"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingPreference_profileId_key_key" ON "DatingPreference"("profileId", "key"); + +-- CreateIndex +CREATE INDEX "DatingMatchScore_profileAId_score_idx" ON "DatingMatchScore"("profileAId", "score"); + +-- CreateIndex +CREATE INDEX "DatingMatchScore_profileBId_score_idx" ON "DatingMatchScore"("profileBId", "score"); + +-- CreateIndex +CREATE INDEX "DatingMatchScore_dealbreakerFailed_idx" ON "DatingMatchScore"("dealbreakerFailed"); + +-- CreateIndex +CREATE INDEX "DatingMatchScore_computedAt_idx" ON "DatingMatchScore"("computedAt"); + +-- CreateIndex +CREATE INDEX "DatingMatchScore_deletedAt_idx" ON "DatingMatchScore"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingMatchScore_profileAId_profileBId_key" ON "DatingMatchScore"("profileAId", "profileBId"); + +-- CreateIndex +CREATE INDEX "DatingInteraction_fromProfileId_toProfileId_createdAt_idx" ON "DatingInteraction"("fromProfileId", "toProfileId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DatingInteraction_toProfileId_kind_status_createdAt_idx" ON "DatingInteraction"("toProfileId", "kind", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "DatingInteraction_fromProfileId_kind_status_createdAt_idx" ON "DatingInteraction"("fromProfileId", "kind", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "DatingInteraction_deletedAt_idx" ON "DatingInteraction"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingMatch_profileAId_status_lastMessageAt_idx" ON "DatingMatch"("profileAId", "status", "lastMessageAt"); + +-- CreateIndex +CREATE INDEX "DatingMatch_profileBId_status_lastMessageAt_idx" ON "DatingMatch"("profileBId", "status", "lastMessageAt"); + +-- CreateIndex +CREATE INDEX "DatingMatch_matchedAt_idx" ON "DatingMatch"("matchedAt"); + +-- CreateIndex +CREATE INDEX "DatingMatch_deletedAt_idx" ON "DatingMatch"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingMatch_profileAId_profileBId_key" ON "DatingMatch"("profileAId", "profileBId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingConversation_matchId_key" ON "DatingConversation"("matchId"); + +-- CreateIndex +CREATE INDEX "DatingConversation_status_updatedAt_idx" ON "DatingConversation"("status", "updatedAt"); + +-- CreateIndex +CREATE INDEX "DatingConversation_deletedAt_idx" ON "DatingConversation"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingMessage_conversationId_createdAt_idx" ON "DatingMessage"("conversationId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DatingMessage_senderProfileId_createdAt_idx" ON "DatingMessage"("senderProfileId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DatingMessage_status_idx" ON "DatingMessage"("status"); + +-- CreateIndex +CREATE INDEX "DatingMessage_deletedAt_idx" ON "DatingMessage"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_matchId_status_startsAt_idx" ON "DatingDatePlan"("matchId", "status", "startsAt"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_conversationId_idx" ON "DatingDatePlan"("conversationId"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_proposedByProfileId_status_startsAt_idx" ON "DatingDatePlan"("proposedByProfileId", "status", "startsAt"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_acceptedByProfileId_idx" ON "DatingDatePlan"("acceptedByProfileId"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_campaignTaskId_idx" ON "DatingDatePlan"("campaignTaskId"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_isCampaignDate_status_idx" ON "DatingDatePlan"("isCampaignDate", "status"); + +-- CreateIndex +CREATE INDEX "DatingDatePlan_deletedAt_idx" ON "DatingDatePlan"("deletedAt"); + +-- CreateIndex +CREATE INDEX "DatingBlock_blockedProfileId_idx" ON "DatingBlock"("blockedProfileId"); + +-- CreateIndex +CREATE INDEX "DatingBlock_deletedAt_idx" ON "DatingBlock"("deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DatingBlock_blockerProfileId_blockedProfileId_key" ON "DatingBlock"("blockerProfileId", "blockedProfileId"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_reporterProfileId_idx" ON "DatingSafetyReport"("reporterProfileId"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_reportedProfileId_idx" ON "DatingSafetyReport"("reportedProfileId"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_messageId_idx" ON "DatingSafetyReport"("messageId"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_datePlanId_idx" ON "DatingSafetyReport"("datePlanId"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_reviewerUserId_idx" ON "DatingSafetyReport"("reviewerUserId"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_status_createdAt_idx" ON "DatingSafetyReport"("status", "createdAt"); + +-- CreateIndex +CREATE INDEX "DatingSafetyReport_deletedAt_idx" ON "DatingSafetyReport"("deletedAt"); + +-- AddForeignKey +ALTER TABLE "DatingProfile" ADD CONSTRAINT "DatingProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingProfilePhoto" ADD CONSTRAINT "DatingProfilePhoto_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingProfilePhoto" ADD CONSTRAINT "DatingProfilePhoto_reviewedByUserId_fkey" FOREIGN KEY ("reviewedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingPromptAnswer" ADD CONSTRAINT "DatingPromptAnswer_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingPromptAnswer" ADD CONSTRAINT "DatingPromptAnswer_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "DatingPrompt"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingQuestionAnswer" ADD CONSTRAINT "DatingQuestionAnswer_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingQuestionAnswer" ADD CONSTRAINT "DatingQuestionAnswer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "DatingQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingPreference" ADD CONSTRAINT "DatingPreference_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingMatchScore" ADD CONSTRAINT "DatingMatchScore_profileAId_fkey" FOREIGN KEY ("profileAId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingMatchScore" ADD CONSTRAINT "DatingMatchScore_profileBId_fkey" FOREIGN KEY ("profileBId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingInteraction" ADD CONSTRAINT "DatingInteraction_fromProfileId_fkey" FOREIGN KEY ("fromProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingInteraction" ADD CONSTRAINT "DatingInteraction_toProfileId_fkey" FOREIGN KEY ("toProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingMatch" ADD CONSTRAINT "DatingMatch_profileAId_fkey" FOREIGN KEY ("profileAId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingMatch" ADD CONSTRAINT "DatingMatch_profileBId_fkey" FOREIGN KEY ("profileBId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingConversation" ADD CONSTRAINT "DatingConversation_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "DatingMatch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingMessage" ADD CONSTRAINT "DatingMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "DatingConversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingMessage" ADD CONSTRAINT "DatingMessage_senderProfileId_fkey" FOREIGN KEY ("senderProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingDatePlan" ADD CONSTRAINT "DatingDatePlan_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "DatingMatch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingDatePlan" ADD CONSTRAINT "DatingDatePlan_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "DatingConversation"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingDatePlan" ADD CONSTRAINT "DatingDatePlan_proposedByProfileId_fkey" FOREIGN KEY ("proposedByProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingDatePlan" ADD CONSTRAINT "DatingDatePlan_acceptedByProfileId_fkey" FOREIGN KEY ("acceptedByProfileId") REFERENCES "DatingProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingDatePlan" ADD CONSTRAINT "DatingDatePlan_campaignTaskId_fkey" FOREIGN KEY ("campaignTaskId") REFERENCES "Task"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingBlock" ADD CONSTRAINT "DatingBlock_blockerProfileId_fkey" FOREIGN KEY ("blockerProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingBlock" ADD CONSTRAINT "DatingBlock_blockedProfileId_fkey" FOREIGN KEY ("blockedProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingSafetyReport" ADD CONSTRAINT "DatingSafetyReport_reporterProfileId_fkey" FOREIGN KEY ("reporterProfileId") REFERENCES "DatingProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingSafetyReport" ADD CONSTRAINT "DatingSafetyReport_reportedProfileId_fkey" FOREIGN KEY ("reportedProfileId") REFERENCES "DatingProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingSafetyReport" ADD CONSTRAINT "DatingSafetyReport_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "DatingMessage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingSafetyReport" ADD CONSTRAINT "DatingSafetyReport_datePlanId_fkey" FOREIGN KEY ("datePlanId") REFERENCES "DatingDatePlan"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DatingSafetyReport" ADD CONSTRAINT "DatingSafetyReport_reviewerUserId_fkey" FOREIGN KEY ("reviewerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8450f9bc..7686f9f8 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1558,6 +1558,9 @@ model User { badges Badge[] @relation("UserBadges") wishPoints WishPoint[] @relation("UserWishPoints") socialAccounts SocialAccount[] @relation("UserSocialAccounts") + datingProfile DatingProfile? @relation("UserDatingProfile") + reviewedDatingProfilePhotos DatingProfilePhoto[] @relation("UserReviewedDatingProfilePhotos") + reviewedDatingSafetyReports DatingSafetyReport[] @relation("UserReviewedDatingSafetyReports") emailLogs EmailLog[] @relation("UserEmailLogs") shareAttempts ShareAttempt[] @relation("UserShareAttempts") sentReferralInvitations ReferralInvitation[] @relation("ReferralInvitationReferrer") @@ -5375,6 +5378,7 @@ model Task { agentLeases AgentTaskLease[] @relation("TaskAgentLeases") shareAttempts ShareAttempt[] @relation("TaskShareAttempts") referralInvitations ReferralInvitation[] @relation("TaskReferralInvitations") + datingDatePlans DatingDatePlan[] @relation("TaskDatingDatePlans") communicationEndpoints TaskCommunicationEndpoint[] @relation("TaskCommunicationEndpoints") communicationTemplates TaskCommunicationTemplate[] @relation("TaskCommunicationTemplates") communications TaskCommunication[] @relation("TaskCommunications") @@ -7167,6 +7171,951 @@ model ShareAttempt { @@index([channel]) } +// ──────────────────────────────────────────────────────────── +// Dating and Mission Dates +// ──────────────────────────────────────────────────────────── + +enum DatingProfileStatus { + DRAFT + ACTIVE + PAUSED + HIDDEN + MODERATION_HOLD + BANNED +} + +enum DatingRelationshipIntent { + FRIENDS + DATES + LONG_TERM + LIFE_PARTNER + CASUAL + NON_MONOGAMY + UNSURE +} + +enum DatingProfilePhotoStatus { + PENDING + APPROVED + REJECTED + HIDDEN +} + +enum DatingQuestionStatus { + DRAFT + ACTIVE + RETIRED +} + +enum DatingQuestionAnswerVisibility { + PUBLIC + PRIVATE +} + +enum DatingQuestionImportance { + IRRELEVANT + A_LITTLE + SOMEWHAT + VERY + MANDATORY +} + +enum DatingPreferenceImportance { + PREFERENCE + DEALBREAKER +} + +enum DatingInteractionKind { + LIKE + PASS + SUPERLIKE + INTRO +} + +enum DatingInteractionStatus { + ACTIVE + RETRACTED + MODERATION_HOLD +} + +enum DatingMatchStatus { + ACTIVE + UNMATCHED + BLOCKED +} + +enum DatingConversationStatus { + ACTIVE + ARCHIVED + MODERATION_HOLD +} + +enum DatingMessageStatus { + SENT + HIDDEN + DELETED + MODERATION_HOLD +} + +enum DatingDatePlanStatus { + PROPOSED + ACCEPTED + DECLINED + CANCELED + COMPLETED + NO_SHOW +} + +enum DatingBlockScope { + DISCOVERY + MESSAGES + ALL +} + +enum DatingSafetyReportStatus { + OPEN + REVIEWING + RESOLVED + DISMISSED +} + +/// Dating opt-in layer for a signed-in user. Public `Person` fields stay the +/// canonical identity profile; this model holds dating-specific visibility, +/// preferences, and campaign-date intent. +model DatingProfile { + id String @id @default(cuid()) + + userId String @unique + + status DatingProfileStatus @default(DRAFT) + + headline String? + bio String? + lookingForText String? + + relationshipIntents DatingRelationshipIntent[] @default([]) + genderIdentities String[] @default([]) + orientationIdentities String[] @default([]) + relationshipStatus String? + + preferredMinAge Int? + preferredMaxAge Int? + maxDistanceKm Int? + + displayCity String? + displayRegionCode String? + displayCountryCode String? + + wantsCampaignDates Boolean @default(true) + campaignDateIdeas String[] @default([]) + + profileCompletedAt DateTime? + lastActiveAt DateTime? + + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + user User @relation("UserDatingProfile", fields: [userId], references: [id], onDelete: Cascade) + photos DatingProfilePhoto[] @relation("DatingProfilePhotos") + promptAnswers DatingPromptAnswer[] @relation("DatingProfilePromptAnswers") + questionAnswers DatingQuestionAnswer[] @relation("DatingProfileQuestionAnswers") + preferences DatingPreference[] @relation("DatingProfilePreferences") + matchScoresAsProfileA DatingMatchScore[] @relation("DatingMatchScoreProfileA") + matchScoresAsProfileB DatingMatchScore[] @relation("DatingMatchScoreProfileB") + sentInteractions DatingInteraction[] @relation("DatingInteractionFromProfile") + receivedInteractions DatingInteraction[] @relation("DatingInteractionToProfile") + matchesAsProfileA DatingMatch[] @relation("DatingMatchProfileA") + matchesAsProfileB DatingMatch[] @relation("DatingMatchProfileB") + messagesSent DatingMessage[] @relation("DatingMessageSenderProfile") + proposedDatePlans DatingDatePlan[] @relation("DatingDatePlanProposer") + acceptedDatePlans DatingDatePlan[] @relation("DatingDatePlanAccepter") + blocksCreated DatingBlock[] @relation("DatingBlockerProfile") + blocksReceived DatingBlock[] @relation("DatingBlockedProfile") + reportsMade DatingSafetyReport[] @relation("DatingSafetyReportReporter") + reportsReceived DatingSafetyReport[] @relation("DatingSafetyReportReported") + + @@index([status, lastActiveAt]) + @@index([displayCountryCode, displayRegionCode, displayCity]) + @@index([wantsCampaignDates]) + @@index([deletedAt]) +} + +/// Moderatable profile photo. Image bytes live in R2/object storage; this row +/// stores ordering, review state, and display metadata. +model DatingProfilePhoto { + id String @id @default(cuid()) + + profileId String + + imageUrl String + storageKey String? + altText String? + blurhash String? + + sortOrder Int @default(0) + status DatingProfilePhotoStatus @default(PENDING) + + moderationReason String? + reviewedByUserId String? + reviewedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profile DatingProfile @relation("DatingProfilePhotos", fields: [profileId], references: [id], onDelete: Cascade) + reviewedByUser User? @relation("UserReviewedDatingProfilePhotos", fields: [reviewedByUserId], references: [id], onDelete: SetNull) + + @@index([profileId, sortOrder]) + @@index([status]) + @@index([reviewedByUserId]) + @@index([deletedAt]) +} + +/// Managed profile prompt such as "Ideal first mission-date". +model DatingPrompt { + id String @id @default(cuid()) + + key String @unique + text String + + sortOrder Int @default(0) + active Boolean @default(true) + managed Boolean @default(false) + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + answers DatingPromptAnswer[] @relation("DatingPromptAnswers") + + @@index([active, sortOrder]) + @@index([deletedAt]) +} + +model DatingPromptAnswer { + id String @id @default(cuid()) + + profileId String + promptId String + + answer String + + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profile DatingProfile @relation("DatingProfilePromptAnswers", fields: [profileId], references: [id], onDelete: Cascade) + prompt DatingPrompt @relation("DatingPromptAnswers", fields: [promptId], references: [id], onDelete: Cascade) + + @@unique([profileId, promptId]) + @@index([profileId, sortOrder]) + @@index([promptId]) + @@index([deletedAt]) +} + +/// Question bank for compatibility matching. Answers can be normal dating +/// values, lifestyle values, or campaign/date coordination values. +model DatingQuestion { + id String @id @default(cuid()) + + key String @unique + text String + category String? + + answerOptions Json + allowMultiple Boolean @default(false) + + status DatingQuestionStatus @default(ACTIVE) + sortOrder Int @default(0) + managed Boolean @default(false) + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + answers DatingQuestionAnswer[] @relation("DatingQuestionAnswers") + + @@index([status, category, sortOrder]) + @@index([deletedAt]) +} + +/// OkCupid-style answer: my answer, answers I accept from a match, importance, +/// and whether other people can compare this answer on my profile. +model DatingQuestionAnswer { + id String @id @default(cuid()) + + profileId String + questionId String + + answerValues Json + acceptableValues Json? + importance DatingQuestionImportance @default(SOMEWHAT) + visibility DatingQuestionAnswerVisibility @default(PUBLIC) + explanation String? + + answeredAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profile DatingProfile @relation("DatingProfileQuestionAnswers", fields: [profileId], references: [id], onDelete: Cascade) + question DatingQuestion @relation("DatingQuestionAnswers", fields: [questionId], references: [id], onDelete: Cascade) + + @@unique([profileId, questionId]) + @@index([profileId, visibility]) + @@index([questionId]) + @@index([importance]) + @@index([deletedAt]) +} + +/// Structured filter or soft preference. Store rare/new preference types here +/// rather than adding a column for every possible dating filter. +model DatingPreference { + id String @id @default(cuid()) + + profileId String + key String + + valueJson Json + importance DatingPreferenceImportance @default(PREFERENCE) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profile DatingProfile @relation("DatingProfilePreferences", fields: [profileId], references: [id], onDelete: Cascade) + + @@unique([profileId, key]) + @@index([profileId, importance]) + @@index([key]) + @@index([deletedAt]) +} + +/// Cached compatibility score for a canonical profile pair. Application code +/// must store the lower/smaller profile id in profileAId to avoid duplicate pairs. +model DatingMatchScore { + id String @id @default(cuid()) + + profileAId String + profileBId String + + score Int + questionScore Int? + preferenceScore Int? + sharedAnsweredCount Int @default(0) + dealbreakerFailed Boolean @default(false) + failedDealbreakerCount Int @default(0) + + computedAt DateTime @default(now()) + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profileA DatingProfile @relation("DatingMatchScoreProfileA", fields: [profileAId], references: [id], onDelete: Cascade) + profileB DatingProfile @relation("DatingMatchScoreProfileB", fields: [profileBId], references: [id], onDelete: Cascade) + + @@unique([profileAId, profileBId]) + @@index([profileAId, score]) + @@index([profileBId, score]) + @@index([dealbreakerFailed]) + @@index([computedAt]) + @@index([deletedAt]) +} + +/// Like/pass/superlike/intro event log. Latest active event by pair determines +/// the current viewer state; mutual likes create a DatingMatch. +model DatingInteraction { + id String @id @default(cuid()) + + fromProfileId String + toProfileId String + + kind DatingInteractionKind + status DatingInteractionStatus @default(ACTIVE) + + introMessage String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + fromProfile DatingProfile @relation("DatingInteractionFromProfile", fields: [fromProfileId], references: [id], onDelete: Cascade) + toProfile DatingProfile @relation("DatingInteractionToProfile", fields: [toProfileId], references: [id], onDelete: Cascade) + + @@index([fromProfileId, toProfileId, createdAt]) + @@index([toProfileId, kind, status, createdAt]) + @@index([fromProfileId, kind, status, createdAt]) + @@index([deletedAt]) +} + +model DatingMatch { + id String @id @default(cuid()) + + profileAId String + profileBId String + + status DatingMatchStatus @default(ACTIVE) + + matchedAt DateTime @default(now()) + unmatchedAt DateTime? + lastMessageAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + profileA DatingProfile @relation("DatingMatchProfileA", fields: [profileAId], references: [id], onDelete: Cascade) + profileB DatingProfile @relation("DatingMatchProfileB", fields: [profileBId], references: [id], onDelete: Cascade) + conversation DatingConversation? + datePlans DatingDatePlan[] @relation("DatingMatchDatePlans") + + @@unique([profileAId, profileBId]) + @@index([profileAId, status, lastMessageAt]) + @@index([profileBId, status, lastMessageAt]) + @@index([matchedAt]) + @@index([deletedAt]) +} + +model DatingConversation { + id String @id @default(cuid()) + + matchId String @unique + + status DatingConversationStatus @default(ACTIVE) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + match DatingMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) + messages DatingMessage[] @relation("DatingConversationMessages") + datePlans DatingDatePlan[] @relation("DatingConversationDatePlans") + + @@index([status, updatedAt]) + @@index([deletedAt]) +} + +model DatingMessage { + id String @id @default(cuid()) + + conversationId String + senderProfileId String + + body String + status DatingMessageStatus @default(SENT) + + readAt DateTime? + editedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + conversation DatingConversation @relation("DatingConversationMessages", fields: [conversationId], references: [id], onDelete: Cascade) + senderProfile DatingProfile @relation("DatingMessageSenderProfile", fields: [senderProfileId], references: [id], onDelete: Cascade) + reports DatingSafetyReport[] @relation("DatingMessageSafetyReports") + + @@index([conversationId, createdAt]) + @@index([senderProfileId, createdAt]) + @@index([status]) + @@index([deletedAt]) +} + +/// Proposed real-world date. A campaign date can link to an existing Task so +/// "coffee plus hang flyers" can produce measurable campaign work. +model DatingDatePlan { + id String @id @default(cuid()) + + matchId String? + conversationId String? + + proposedByProfileId String + acceptedByProfileId String? + + status DatingDatePlanStatus @default(PROPOSED) + + title String + description String? + + startsAt DateTime? + endsAt DateTime? + timeZone String? + + locationName String? + address String? + latitude Float? + longitude Float? + + isCampaignDate Boolean @default(false) + campaignTaskId String? + campaignNotes String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + match DatingMatch? @relation("DatingMatchDatePlans", fields: [matchId], references: [id], onDelete: SetNull) + conversation DatingConversation? @relation("DatingConversationDatePlans", fields: [conversationId], references: [id], onDelete: SetNull) + proposedByProfile DatingProfile @relation("DatingDatePlanProposer", fields: [proposedByProfileId], references: [id], onDelete: Cascade) + acceptedByProfile DatingProfile? @relation("DatingDatePlanAccepter", fields: [acceptedByProfileId], references: [id], onDelete: SetNull) + campaignTask Task? @relation("TaskDatingDatePlans", fields: [campaignTaskId], references: [id], onDelete: SetNull) + reports DatingSafetyReport[] @relation("DatingDatePlanSafetyReports") + + @@index([matchId, status, startsAt]) + @@index([conversationId]) + @@index([proposedByProfileId, status, startsAt]) + @@index([acceptedByProfileId]) + @@index([campaignTaskId]) + @@index([isCampaignDate, status]) + @@index([deletedAt]) +} + +model DatingBlock { + id String @id @default(cuid()) + + blockerProfileId String + blockedProfileId String + + scope DatingBlockScope @default(ALL) + reason String? + + createdAt DateTime @default(now()) + deletedAt DateTime? + + blockerProfile DatingProfile @relation("DatingBlockerProfile", fields: [blockerProfileId], references: [id], onDelete: Cascade) + blockedProfile DatingProfile @relation("DatingBlockedProfile", fields: [blockedProfileId], references: [id], onDelete: Cascade) + + @@unique([blockerProfileId, blockedProfileId]) + @@index([blockedProfileId]) + @@index([deletedAt]) +} + +model DatingSafetyReport { + id String @id @default(cuid()) + + reporterProfileId String + reportedProfileId String? + messageId String? + datePlanId String? + + reason String + description String? + + status DatingSafetyReportStatus @default(OPEN) + + reviewerUserId String? + resolutionNote String? + resolvedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + reporterProfile DatingProfile @relation("DatingSafetyReportReporter", fields: [reporterProfileId], references: [id], onDelete: Cascade) + reportedProfile DatingProfile? @relation("DatingSafetyReportReported", fields: [reportedProfileId], references: [id], onDelete: SetNull) + message DatingMessage? @relation("DatingMessageSafetyReports", fields: [messageId], references: [id], onDelete: SetNull) + datePlan DatingDatePlan? @relation("DatingDatePlanSafetyReports", fields: [datePlanId], references: [id], onDelete: SetNull) + reviewerUser User? @relation("UserReviewedDatingSafetyReports", fields: [reviewerUserId], references: [id], onDelete: SetNull) + + @@index([reporterProfileId]) + @@index([reportedProfileId]) + @@index([messageId]) + @@index([datePlanId]) + @@index([reviewerUserId]) + @@index([status, createdAt]) + @@index([deletedAt]) +} + +// ──────────────────────────────────────────────────────────── +// Commerce, Fulfillment, and Entitlements +// ──────────────────────────────────────────────────────────── + +enum CommerceOfferKind { + PHYSICAL_GOOD + SPONSORSHIP + SUBSCRIPTION + DIGITAL_ACCESS + SERVICE + DONATION +} + +enum CommerceOfferStatus { + DRAFT + ACTIVE + RETIRED +} + +enum CommerceFulfillmentKind { + NONE + PHYSICAL_GOOD + DIGITAL_ENTITLEMENT + MANUAL_SPONSORSHIP +} + +enum CommercePaymentProvider { + STRIPE + MANUAL +} + +enum CommerceFulfillmentProvider { + NONE + CUSTOMCAT + MANUAL + STRIPE +} + +enum CommerceOrderStatus { + PENDING_PAYMENT + PAID + FULFILLING + SUBMITTED + SHIPPED + FAILED + CANCELED + REFUNDED +} + +enum CommerceFulfillmentStatus { + PENDING + SUBMITTED + SHIPPED + DELIVERED + FAILED + CANCELED +} + +enum CommerceEntitlementStatus { + PENDING + ACTIVE + EXPIRED + CANCELED + REVOKED +} + +/// Managed catalog offer. Covers physical goods, sponsorships, subscriptions, +/// digital access, services, and pure donation-style offers. +model CommerceOffer { + id String @id + + /// Stable managed key such as "shirt" or "dating-premium". + key String @unique + + kind CommerceOfferKind + status CommerceOfferStatus @default(ACTIVE) + + title String + description String? + + currency String @default("usd") + + defaultUnitAmountCents Int? + defaultFmvCents Int @default(0) + minUnitAmountCents Int? + maxUnitAmountCents Int? + allowCustomAmount Boolean @default(false) + + isTaxDeductible Boolean @default(false) + taxCode String? + + fulfillmentKind CommerceFulfillmentKind @default(NONE) + + /// Managed/system-owned catalog rows are idempotently synced. + managed Boolean @default(false) + + sortOrder Int @default(0) + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + variants CommerceOfferVariant[] @relation("CommerceOfferVariants") + orderItems CommerceOrderItem[] @relation("CommerceOfferOrderItems") + entitlements CommerceEntitlement[] @relation("CommerceOfferEntitlements") + + @@index([kind, status]) + @@index([deletedAt]) +} + +/// Sellable variant of an offer. Physical products use attributes like +/// size/color; sponsorships and subscriptions can use tier/placement/duration. +model CommerceOfferVariant { + id String @id + + offerId String + + /// Stable managed key such as "shirt:black:m". + key String @unique + + /// Key unique within the offer, such as "black:m" or "homepage:monthly". + variantKey String + + label String + + currency String @default("usd") + + unitAmountCents Int? + fmvCents Int? + minUnitAmountCents Int? + maxUnitAmountCents Int? + allowCustomAmount Boolean? + + taxCode String? + + fulfillmentKind CommerceFulfillmentKind? + + attributes Json? + fulfillmentMetadata Json? + metadata Json? + + active Boolean @default(true) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + offer CommerceOffer @relation("CommerceOfferVariants", fields: [offerId], references: [id], onDelete: Cascade) + mappings CommerceFulfillmentMapping[] @relation("CommerceVariantFulfillmentMappings") + orderItems CommerceOrderItem[] @relation("CommerceVariantOrderItems") + entitlements CommerceEntitlement[] @relation("CommerceVariantEntitlements") + + @@unique([offerId, variantKey]) + @@index([offerId, active]) + @@index([deletedAt]) +} + +/// Provider-specific fulfillment identifiers for a sellable variant. These are +/// managed catalog facts, not secrets, and are synced through managed data. +model CommerceFulfillmentMapping { + id String @id + + offerVariantId String + provider CommerceFulfillmentProvider + + providerProductId String? + providerVariantId String? + providerCatalogSku String? + providerMetadata Json? + + active Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + variant CommerceOfferVariant @relation("CommerceVariantFulfillmentMappings", fields: [offerVariantId], references: [id], onDelete: Cascade) + + @@unique([offerVariantId, provider]) + @@index([provider, providerCatalogSku]) + @@index([deletedAt]) +} + +/// Durable payment/order ledger. This is deliberately generic enough for +/// shirts, sponsorships, subscriptions, dating-app credits, and future offers. +model CommerceOrder { + id String @id @default(cuid()) + + purposeKey String? + status CommerceOrderStatus @default(PENDING_PAYMENT) + + paymentProvider CommercePaymentProvider @default(STRIPE) + + stripeCheckoutSessionId String? @unique + stripePaymentIntentId String? + stripeCustomerId String? + + buyerUserId String? + buyerOrganizationId String? + buyerEmail String? + buyerName String? + buyerPhone String? + + shippingName String? + shippingLine1 String? + shippingLine2 String? + shippingCity String? + shippingState String? + shippingPostalCode String? + shippingCountry String? + + currency String @default("usd") + subtotalCents Int @default(0) + taxCents Int @default(0) + shippingCents Int @default(0) + discountCents Int @default(0) + totalCents Int @default(0) + fmvCents Int @default(0) + donationCents Int @default(0) + + metadata Json? + lastError String? + attemptCount Int @default(0) + + paidAt DateTime? + fulfilledAt DateTime? + shippedAt DateTime? + canceledAt DateTime? + refundedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + items CommerceOrderItem[] @relation("CommerceOrderItems") + fulfillments CommerceFulfillment[] @relation("CommerceOrderFulfillments") + entitlements CommerceEntitlement[] @relation("CommerceOrderEntitlements") + + @@index([buyerUserId]) + @@index([buyerOrganizationId]) + @@index([buyerEmail]) + @@index([status]) + @@index([purposeKey]) + @@index([createdAt]) + @@index([deletedAt]) +} + +/// Snapshot of what was purchased. Keeps the historical price/tax/FMV/metadata +/// stable even if the managed catalog changes later. +model CommerceOrderItem { + id String @id @default(cuid()) + + orderId String + offerId String? + offerVariantId String? + + offerKey String + offerVariantKey String? + title String + + quantity Int @default(1) + currency String @default("usd") + + unitAmountCents Int @default(0) + unitFmvCents Int @default(0) + unitDonationCents Int @default(0) + + totalAmountCents Int @default(0) + totalFmvCents Int @default(0) + totalDonationCents Int @default(0) + + taxable Boolean @default(false) + taxCode String? + + fulfillmentKind CommerceFulfillmentKind @default(NONE) + fulfillmentMetadata Json? + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + order CommerceOrder @relation("CommerceOrderItems", fields: [orderId], references: [id], onDelete: Cascade) + offer CommerceOffer? @relation("CommerceOfferOrderItems", fields: [offerId], references: [id], onDelete: SetNull) + offerVariant CommerceOfferVariant? @relation("CommerceVariantOrderItems", fields: [offerVariantId], references: [id], onDelete: SetNull) + fulfillments CommerceFulfillment[] @relation("CommerceOrderItemFulfillments") + entitlements CommerceEntitlement[] @relation("CommerceOrderItemEntitlements") + + @@index([orderId]) + @@index([offerId]) + @@index([offerVariantId]) + @@index([offerKey]) + @@index([deletedAt]) +} + +/// Retryable fulfillment ledger for physical or external-provider side effects. +model CommerceFulfillment { + id String @id @default(cuid()) + + orderId String + orderItemId String? + + provider CommerceFulfillmentProvider + status CommerceFulfillmentStatus @default(PENDING) + + /// Our idempotency key with the provider, such as a Stripe Checkout session id. + externalOrderId String? + + providerOrderId String? + providerStatus String? + + trackingNumber String? + trackingUrl String? + + metadata Json? + lastError String? + attemptCount Int @default(0) + + submittedAt DateTime? + shippedAt DateTime? + deliveredAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + order CommerceOrder @relation("CommerceOrderFulfillments", fields: [orderId], references: [id], onDelete: Cascade) + orderItem CommerceOrderItem? @relation("CommerceOrderItemFulfillments", fields: [orderItemId], references: [id], onDelete: SetNull) + + @@unique([provider, externalOrderId]) + @@index([orderId]) + @@index([orderItemId]) + @@index([status]) + @@index([providerOrderId]) + @@index([deletedAt]) +} + +/// Non-physical benefit created by a paid order: sponsorship placement, +/// subscription access, dating-app premium access, credits, boosts, etc. +model CommerceEntitlement { + id String @id @default(cuid()) + + orderId String? + orderItemId String? + offerId String? + offerVariantId String? + + entitlementType String + status CommerceEntitlementStatus @default(PENDING) + + subjectUserId String? + subjectOrganizationId String? + + startsAt DateTime? + endsAt DateTime? + + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + order CommerceOrder? @relation("CommerceOrderEntitlements", fields: [orderId], references: [id], onDelete: SetNull) + orderItem CommerceOrderItem? @relation("CommerceOrderItemEntitlements", fields: [orderItemId], references: [id], onDelete: SetNull) + offer CommerceOffer? @relation("CommerceOfferEntitlements", fields: [offerId], references: [id], onDelete: SetNull) + offerVariant CommerceOfferVariant? @relation("CommerceVariantEntitlements", fields: [offerVariantId], references: [id], onDelete: SetNull) + + @@index([orderId]) + @@index([orderItemId]) + @@index([offerId]) + @@index([offerVariantId]) + @@index([subjectUserId]) + @@index([subjectOrganizationId]) + @@index([entitlementType, status]) + @@index([endsAt]) + @@index([deletedAt]) +} + // ──────────────────────────────────────────────────────────── // Agent Compute Funding // ──────────────────────────────────────────────────────────── diff --git a/packages/db/src/__tests__/zod-validators.test.ts b/packages/db/src/__tests__/zod-validators.test.ts index cdb9ccb0..ef7394b1 100644 --- a/packages/db/src/__tests__/zod-validators.test.ts +++ b/packages/db/src/__tests__/zod-validators.test.ts @@ -26,6 +26,22 @@ import { McpScopeSchema, McpToolCallAuditSchema, ContentReportSchema, + CommerceOfferKindSchema, + CommerceOrderSchema, + CommerceOrderItemSchema, + CommerceFulfillmentSchema, + CommerceEntitlementSchema, + DatingProfileStatusSchema, + DatingProfileSchema, + DatingProfilePhotoSchema, + DatingQuestionSchema, + DatingQuestionAnswerSchema, + DatingInteractionSchema, + DatingMatchSchema, + DatingConversationSchema, + DatingMessageSchema, + DatingDatePlanSchema, + DatingSafetyReportSchema, // Models AccountSchema, PersonhoodVerificationSchema, @@ -279,6 +295,195 @@ describe('Enum schemas', () => { expect(VariableRelationshipEvidenceSourceTypeSchema.parse('IMPORTED_STUDY')).toBe('IMPORTED_STUDY'); expect(InterventionRankingRunStatusSchema.parse('ACTIVE')).toBe('ACTIVE'); }); + + it('23. Commerce enums accept generic sellable offer categories', () => { + expect(CommerceOfferKindSchema.parse('PHYSICAL_GOOD')).toBe('PHYSICAL_GOOD'); + expect(CommerceOfferKindSchema.parse('SPONSORSHIP')).toBe('SPONSORSHIP'); + expect(CommerceOfferKindSchema.parse('SUBSCRIPTION')).toBe('SUBSCRIPTION'); + expect(CommerceOfferKindSchema.safeParse('SHIRT_ONLY').success).toBe(false); + }); +}); + +describe('Commerce schemas', () => { + it('validates orders, items, fulfillment rows, and entitlements', () => { + expect( + CommerceOrderSchema.safeParse({ + id: 'order_1', + purposeKey: 'war-on-disease-shirt', + status: 'PENDING_PAYMENT', + paymentProvider: 'STRIPE', + currency: 'usd', + subtotalCents: 3500, + totalCents: 3500, + fmvCents: 1500, + donationCents: 2000, + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + CommerceOrderItemSchema.safeParse({ + id: 'item_1', + orderId: 'order_1', + offerKey: 'shirt', + title: 'War on Disease shirt - Black / M', + fulfillmentKind: 'PHYSICAL_GOOD', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + CommerceFulfillmentSchema.safeParse({ + id: 'fulfillment_1', + orderId: 'order_1', + provider: 'CUSTOMCAT', + status: 'PENDING', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + CommerceEntitlementSchema.safeParse({ + id: 'entitlement_1', + entitlementType: 'dating-premium', + status: 'ACTIVE', + subjectUserId: 'user_1', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + }); +}); + +describe('Dating schemas', () => { + it('validates the critical OkCupid-style dating records', () => { + expect(DatingProfileStatusSchema.parse('ACTIVE')).toBe('ACTIVE'); + expect( + DatingProfileSchema.safeParse({ + id: 'dating_profile_1', + userId: 'user_1', + status: 'ACTIVE', + headline: 'Ending war and disease, then getting coffee', + relationshipIntents: ['DATES', 'LONG_TERM'], + genderIdentities: ['woman'], + orientationIdentities: ['queer'], + preferredMinAge: 30, + preferredMaxAge: 45, + maxDistanceKm: 40, + wantsCampaignDates: true, + campaignDateIdeas: ['hang flyers', 'coffee after canvassing'], + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingProfilePhotoSchema.safeParse({ + id: 'photo_1', + profileId: 'dating_profile_1', + imageUrl: 'https://cdn.example/photo.jpg', + storageKey: 'dating/profile/photo_1.jpg', + sortOrder: 0, + status: 'PENDING', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingQuestionSchema.safeParse({ + id: 'question_1', + key: 'campaign-date-first-activity', + text: 'Would you hang flyers for the 1% Treaty on a first date?', + answerOptions: ['yes', 'maybe', 'no'], + category: 'campaign', + status: 'ACTIVE', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingQuestionAnswerSchema.safeParse({ + id: 'answer_1', + profileId: 'dating_profile_1', + questionId: 'question_1', + answerValues: ['yes'], + acceptableValues: ['yes', 'maybe'], + importance: 'VERY', + visibility: 'PUBLIC', + answeredAt: now, + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingInteractionSchema.safeParse({ + id: 'interaction_1', + fromProfileId: 'dating_profile_1', + toProfileId: 'dating_profile_2', + kind: 'INTRO', + status: 'ACTIVE', + introMessage: 'Want to save civilization and get tacos?', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingMatchSchema.safeParse({ + id: 'match_1', + profileAId: 'dating_profile_1', + profileBId: 'dating_profile_2', + status: 'ACTIVE', + matchedAt: now, + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingConversationSchema.safeParse({ + id: 'conversation_1', + matchId: 'match_1', + status: 'ACTIVE', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingMessageSchema.safeParse({ + id: 'message_1', + conversationId: 'conversation_1', + senderProfileId: 'dating_profile_1', + body: 'Coffee, flyers, then complaining about governments?', + status: 'SENT', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingDatePlanSchema.safeParse({ + id: 'date_plan_1', + matchId: 'match_1', + proposedByProfileId: 'dating_profile_1', + status: 'PROPOSED', + title: 'Coffee and flyer run', + startsAt: now, + timeZone: 'America/Chicago', + locationName: 'Downtown coffee shop', + isCampaignDate: true, + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + expect( + DatingSafetyReportSchema.safeParse({ + id: 'report_1', + reporterProfileId: 'dating_profile_1', + reportedProfileId: 'dating_profile_2', + reason: 'spam', + status: 'OPEN', + createdAt: now, + updatedAt: now, + }).success, + ).toBe(true); + }); }); // ============================================================================ diff --git a/packages/db/src/managed-data/index.ts b/packages/db/src/managed-data/index.ts index 41be65fa..aa1be6c4 100644 --- a/packages/db/src/managed-data/index.ts +++ b/packages/db/src/managed-data/index.ts @@ -15,6 +15,14 @@ import { formatManagedHumanityVGovernmentCaseResult, syncManagedHumanityVGovernmentCase, } from "./managed-humanity-v-government.js"; +import { + formatManagedCommerceCatalogResult, + syncManagedCommerceCatalog, +} from "./managed-commerce-catalog.js"; +import { + formatManagedDatingCatalogResult, + syncManagedDatingCatalog, +} from "./managed-dating-catalog.js"; import { formatManagedReferendumsResult, syncManagedReferendums, @@ -74,6 +82,8 @@ export interface SyncManagedDataResult { grandmaKay: Awaited>; demoUser: Awaited>; iamOrganization: Awaited>; + commerceCatalog: Awaited>; + datingCatalog: Awaited>; } async function timeStep(label: string, fn: () => Promise): Promise { @@ -171,6 +181,14 @@ export async function syncManagedData( syncManagedIamOrganization(prisma, { apply: options.apply }), ); + const commerceCatalog = await timeStep("commerce-catalog", () => + syncManagedCommerceCatalog(prisma, { apply: options.apply }), + ); + + const datingCatalog = await timeStep("dating-catalog", () => + syncManagedDatingCatalog(prisma, { apply: options.apply }), + ); + console.log(`[managed-data] TOTAL: ${Date.now() - totalStart}ms`); return { @@ -184,6 +202,8 @@ export async function syncManagedData( grandmaKay, demoUser, iamOrganization, + commerceCatalog, + datingCatalog, }; } @@ -202,6 +222,8 @@ export function formatManagedDataResult(result: SyncManagedDataResult) { formatManagedGrandmaKayResult(result.grandmaKay), formatManagedDemoUserResult(result.demoUser), formatManagedIamOrganizationResult(result.iamOrganization), + formatManagedCommerceCatalogResult(result.commerceCatalog), + formatManagedDatingCatalogResult(result.datingCatalog), ].join("\n"); } @@ -219,6 +241,8 @@ export { ensureManagedDataSystemUser, formatManagedDemoUserResult, formatManagedGrandmaKayResult, + formatManagedCommerceCatalogResult, + formatManagedDatingCatalogResult, formatManagedHumanityVGovernmentCaseResult, formatManagedIamOrganizationResult, formatManagedReferendumsResult, @@ -226,6 +250,8 @@ export { formatManagedTasksResult, syncManagedDemoUser, syncManagedGrandmaKay, + syncManagedCommerceCatalog, + syncManagedDatingCatalog, syncManagedHumanityVGovernmentCase, syncManagedIamOrganization, syncManagedBootstrapData, diff --git a/packages/db/src/managed-data/managed-commerce-catalog.ts b/packages/db/src/managed-data/managed-commerce-catalog.ts new file mode 100644 index 00000000..5345cc56 --- /dev/null +++ b/packages/db/src/managed-data/managed-commerce-catalog.ts @@ -0,0 +1,415 @@ +import { + CommerceFulfillmentKind, + CommerceFulfillmentProvider, + CommerceOfferKind, + CommerceOfferStatus, + type Prisma, + type PrismaClient, +} from "../generated/prisma/client.js"; + +export const WAR_ON_DISEASE_SHIRT_OFFER_KEY = "shirt"; +export const WAR_ON_DISEASE_SHIRT_OFFER_ID = "commerce-offer-war-on-disease-shirt"; +export const WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_KEY = "flyer-run-sponsorship"; +export const WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_ID = + "commerce-offer-war-on-disease-flyer-run-sponsorship"; + +const SHIRT_PRODUCT_ID = "952"; +const SHIRT_TAX_CODE = "txcd_99999999"; +const SHIRT_FMV_CENTS = 1500; +const DONATION_TAX_CODE = "txcd_00000000"; + +type ManagedShirtVariant = { + color: "black" | "white"; + size: "S" | "M" | "L" | "XL" | "XXL"; + catalogSku: string; + fullSku: string; +}; + +type ManagedSponsorshipVariant = { + key: string; + label: string; + unitAmountCents: number; + description: string; +}; + +// CustomCat product 952 = Bella+Canvas 3001C. The catalog_sku values are the +// final segment of public CustomCat-generated Shopify SKUs seen for 3001C. +// Source examples: CustomCat product page + indexed 3:16 Threads 3001C SKUs. +export const WAR_ON_DISEASE_SHIRT_VARIANTS: ManagedShirtVariant[] = [ + { + color: "black", + size: "S", + catalogSku: "45475", + fullSku: "952-9390-96695910-45475", + }, + { + color: "black", + size: "M", + catalogSku: "45476", + fullSku: "952-9390-96695910-45476", + }, + { + color: "black", + size: "L", + catalogSku: "45478", + fullSku: "952-9390-96695910-45478", + }, + { + color: "black", + size: "XL", + catalogSku: "45479", + fullSku: "952-9390-96695910-45479", + }, + { + color: "black", + size: "XXL", + catalogSku: "45480", + fullSku: "952-9390-96695910-45480", + }, + { + color: "white", + size: "S", + catalogSku: "45604", + fullSku: "952-9405-96695909-45604", + }, + { + color: "white", + size: "M", + catalogSku: "45605", + fullSku: "952-9405-96695909-45605", + }, + { + color: "white", + size: "L", + catalogSku: "45606", + fullSku: "952-9405-96695909-45606", + }, + { + color: "white", + size: "XL", + catalogSku: "45607", + fullSku: "952-9405-96695909-45607", + }, + { + color: "white", + size: "XXL", + catalogSku: "45608", + fullSku: "952-9405-96695909-45608", + }, +]; + +export const WAR_ON_DISEASE_FLYER_SPONSORSHIP_VARIANTS: ManagedSponsorshipVariant[] = [ + { + key: "flyers", + label: "Flyers", + unitAmountCents: 10000, + description: "Fund one small batch of QR flyers for local campaign outreach.", + }, + { + key: "posters", + label: "Posters", + unitAmountCents: 25000, + description: "Fund a larger print run of campaign posters and QR handouts.", + }, + { + key: "singles-meetup", + label: "Singles meetup", + unitAmountCents: 50000, + description: "Help a local group turn an awkward social event into votes.", + }, +]; + +export interface SyncManagedCommerceCatalogOptions { + apply: boolean; +} + +export interface SyncManagedCommerceCatalogResult { + dryRun: boolean; + offers: number; + variants: number; + fulfillmentMappings: number; +} + +function shirtVariantId(variant: ManagedShirtVariant) { + return `commerce-variant-shirt-${variant.color}-${variant.size.toLowerCase()}`; +} + +function shirtMappingId(variant: ManagedShirtVariant) { + return `commerce-fulfillment-customcat-shirt-${variant.color}-${variant.size.toLowerCase()}`; +} + +function shirtVariantKey(variant: ManagedShirtVariant) { + return `${variant.color}:${variant.size}`; +} + +function shirtGlobalVariantKey(variant: ManagedShirtVariant) { + return `${WAR_ON_DISEASE_SHIRT_OFFER_KEY}:${shirtVariantKey(variant)}`; +} + +function shirtVariantLabel(variant: ManagedShirtVariant) { + return `${variant.color === "black" ? "Black" : "White"} / ${variant.size}`; +} + +function flyerSponsorshipVariantId(variant: ManagedSponsorshipVariant) { + return `commerce-variant-flyer-run-sponsorship-${variant.key}`; +} + +function flyerSponsorshipGlobalVariantKey(variant: ManagedSponsorshipVariant) { + return `${WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_KEY}:${variant.key}`; +} + +export async function syncManagedCommerceCatalog( + prisma: PrismaClient, + options: SyncManagedCommerceCatalogOptions, +): Promise { + const result = { + dryRun: !options.apply, + offers: 2, + variants: + WAR_ON_DISEASE_SHIRT_VARIANTS.length + + WAR_ON_DISEASE_FLYER_SPONSORSHIP_VARIANTS.length, + fulfillmentMappings: WAR_ON_DISEASE_SHIRT_VARIANTS.length, + }; + + if (!options.apply) return result; + + await prisma.commerceOffer.upsert({ + where: { key: WAR_ON_DISEASE_SHIRT_OFFER_KEY }, + update: { + allowCustomAmount: true, + currency: "usd", + defaultFmvCents: SHIRT_FMV_CENTS, + defaultUnitAmountCents: 3500, + description: + "Personalized War on Disease shirt with campaign copy and a referral QR code.", + fulfillmentKind: CommerceFulfillmentKind.PHYSICAL_GOOD, + isTaxDeductible: true, + kind: CommerceOfferKind.PHYSICAL_GOOD, + managed: true, + maxUnitAmountCents: null, + metadata: { + campaign: "war-on-disease", + productModel: "Bella+Canvas 3001C", + } satisfies Prisma.InputJsonValue, + minUnitAmountCents: 2500, + sortOrder: 10, + status: CommerceOfferStatus.ACTIVE, + taxCode: SHIRT_TAX_CODE, + title: "War on Disease shirt", + }, + create: { + id: WAR_ON_DISEASE_SHIRT_OFFER_ID, + key: WAR_ON_DISEASE_SHIRT_OFFER_KEY, + allowCustomAmount: true, + currency: "usd", + defaultFmvCents: SHIRT_FMV_CENTS, + defaultUnitAmountCents: 3500, + description: + "Personalized War on Disease shirt with campaign copy and a referral QR code.", + fulfillmentKind: CommerceFulfillmentKind.PHYSICAL_GOOD, + isTaxDeductible: true, + kind: CommerceOfferKind.PHYSICAL_GOOD, + managed: true, + metadata: { + campaign: "war-on-disease", + productModel: "Bella+Canvas 3001C", + } satisfies Prisma.InputJsonValue, + minUnitAmountCents: 2500, + sortOrder: 10, + status: CommerceOfferStatus.ACTIVE, + taxCode: SHIRT_TAX_CODE, + title: "War on Disease shirt", + }, + }); + + await prisma.commerceOffer.upsert({ + where: { key: WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_KEY }, + update: { + allowCustomAmount: true, + currency: "usd", + defaultFmvCents: 0, + defaultUnitAmountCents: 10000, + description: + "Pay for posters, flyers, and local outreach that asks humans to vote.", + fulfillmentKind: CommerceFulfillmentKind.MANUAL_SPONSORSHIP, + isTaxDeductible: true, + kind: CommerceOfferKind.SPONSORSHIP, + managed: true, + maxUnitAmountCents: null, + metadata: { + campaign: "war-on-disease", + purpose: "distribution", + } satisfies Prisma.InputJsonValue, + minUnitAmountCents: 2500, + sortOrder: 20, + status: CommerceOfferStatus.ACTIVE, + taxCode: DONATION_TAX_CODE, + title: "Sponsor a flyer run", + }, + create: { + id: WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_ID, + key: WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_KEY, + allowCustomAmount: true, + currency: "usd", + defaultFmvCents: 0, + defaultUnitAmountCents: 10000, + description: + "Pay for posters, flyers, and local outreach that asks humans to vote.", + fulfillmentKind: CommerceFulfillmentKind.MANUAL_SPONSORSHIP, + isTaxDeductible: true, + kind: CommerceOfferKind.SPONSORSHIP, + managed: true, + metadata: { + campaign: "war-on-disease", + purpose: "distribution", + } satisfies Prisma.InputJsonValue, + minUnitAmountCents: 2500, + sortOrder: 20, + status: CommerceOfferStatus.ACTIVE, + taxCode: DONATION_TAX_CODE, + title: "Sponsor a flyer run", + }, + }); + + for (const variant of WAR_ON_DISEASE_SHIRT_VARIANTS) { + const variantId = shirtVariantId(variant); + await prisma.commerceOfferVariant.upsert({ + where: { key: shirtGlobalVariantKey(variant) }, + update: { + active: true, + allowCustomAmount: true, + attributes: { + color: variant.color, + size: variant.size, + } satisfies Prisma.InputJsonValue, + currency: "usd", + fulfillmentKind: CommerceFulfillmentKind.PHYSICAL_GOOD, + fulfillmentMetadata: { + printPlacements: ["front", "back"], + requiresPersonalizedQr: true, + } satisfies Prisma.InputJsonValue, + fmvCents: SHIRT_FMV_CENTS, + label: shirtVariantLabel(variant), + maxUnitAmountCents: null, + minUnitAmountCents: 2500, + sortOrder: WAR_ON_DISEASE_SHIRT_VARIANTS.indexOf(variant), + taxCode: SHIRT_TAX_CODE, + unitAmountCents: 3500, + variantKey: shirtVariantKey(variant), + }, + create: { + id: variantId, + offerId: WAR_ON_DISEASE_SHIRT_OFFER_ID, + key: shirtGlobalVariantKey(variant), + active: true, + allowCustomAmount: true, + attributes: { + color: variant.color, + size: variant.size, + } satisfies Prisma.InputJsonValue, + currency: "usd", + fulfillmentKind: CommerceFulfillmentKind.PHYSICAL_GOOD, + fulfillmentMetadata: { + printPlacements: ["front", "back"], + requiresPersonalizedQr: true, + } satisfies Prisma.InputJsonValue, + fmvCents: SHIRT_FMV_CENTS, + label: shirtVariantLabel(variant), + minUnitAmountCents: 2500, + sortOrder: WAR_ON_DISEASE_SHIRT_VARIANTS.indexOf(variant), + taxCode: SHIRT_TAX_CODE, + unitAmountCents: 3500, + variantKey: shirtVariantKey(variant), + }, + }); + + await prisma.commerceFulfillmentMapping.upsert({ + where: { + offerVariantId_provider: { + offerVariantId: variantId, + provider: CommerceFulfillmentProvider.CUSTOMCAT, + }, + }, + update: { + active: true, + providerCatalogSku: variant.catalogSku, + providerMetadata: { + fullSku: variant.fullSku, + source: "CustomCat 3001C public generated SKU", + } satisfies Prisma.InputJsonValue, + providerProductId: SHIRT_PRODUCT_ID, + providerVariantId: variant.fullSku, + }, + create: { + id: shirtMappingId(variant), + offerVariantId: variantId, + provider: CommerceFulfillmentProvider.CUSTOMCAT, + active: true, + providerCatalogSku: variant.catalogSku, + providerMetadata: { + fullSku: variant.fullSku, + source: "CustomCat 3001C public generated SKU", + } satisfies Prisma.InputJsonValue, + providerProductId: SHIRT_PRODUCT_ID, + providerVariantId: variant.fullSku, + }, + }); + } + + for (const variant of WAR_ON_DISEASE_FLYER_SPONSORSHIP_VARIANTS) { + await prisma.commerceOfferVariant.upsert({ + where: { key: flyerSponsorshipGlobalVariantKey(variant) }, + update: { + active: true, + allowCustomAmount: true, + attributes: { + sponsorshipType: variant.key, + } satisfies Prisma.InputJsonValue, + currency: "usd", + fulfillmentKind: CommerceFulfillmentKind.MANUAL_SPONSORSHIP, + fulfillmentMetadata: { + description: variant.description, + } satisfies Prisma.InputJsonValue, + fmvCents: 0, + label: variant.label, + maxUnitAmountCents: null, + minUnitAmountCents: 2500, + sortOrder: WAR_ON_DISEASE_FLYER_SPONSORSHIP_VARIANTS.indexOf(variant), + taxCode: DONATION_TAX_CODE, + unitAmountCents: variant.unitAmountCents, + variantKey: variant.key, + }, + create: { + id: flyerSponsorshipVariantId(variant), + offerId: WAR_ON_DISEASE_FLYER_SPONSORSHIP_OFFER_ID, + key: flyerSponsorshipGlobalVariantKey(variant), + active: true, + allowCustomAmount: true, + attributes: { + sponsorshipType: variant.key, + } satisfies Prisma.InputJsonValue, + currency: "usd", + fulfillmentKind: CommerceFulfillmentKind.MANUAL_SPONSORSHIP, + fulfillmentMetadata: { + description: variant.description, + } satisfies Prisma.InputJsonValue, + fmvCents: 0, + label: variant.label, + minUnitAmountCents: 2500, + sortOrder: WAR_ON_DISEASE_FLYER_SPONSORSHIP_VARIANTS.indexOf(variant), + taxCode: DONATION_TAX_CODE, + unitAmountCents: variant.unitAmountCents, + variantKey: variant.key, + }, + }); + } + + return result; +} + +export function formatManagedCommerceCatalogResult( + result: SyncManagedCommerceCatalogResult, +) { + const prefix = result.dryRun ? "would sync" : "synced"; + return `Commerce catalog: ${prefix} ${result.offers} offer, ${result.variants} variants, ${result.fulfillmentMappings} fulfillment mappings`; +} diff --git a/packages/db/src/managed-data/managed-dating-catalog.ts b/packages/db/src/managed-data/managed-dating-catalog.ts new file mode 100644 index 00000000..ea3eaee7 --- /dev/null +++ b/packages/db/src/managed-data/managed-dating-catalog.ts @@ -0,0 +1,139 @@ +import { + DatingQuestionStatus, + type Prisma, + type PrismaClient, +} from "../generated/prisma/client.js"; + +export const MANAGED_DATING_PROMPTS = [ + { + key: "first-mission-date", + text: "A useful first date would be", + }, + { + key: "after-vote", + text: "After we vote to end war and disease, we should", + }, + { + key: "awkward-date-upside", + text: "Even if the date is bad, it was worth it if", + }, +] as const; + +export const MANAGED_DATING_QUESTIONS = [ + { + key: "campaign-dates", + text: "Would you go on a date that also does something useful for the campaign?", + category: "mission", + answerOptions: ["Yes", "Only if it is normal first", "No"], + allowMultiple: false, + }, + { + key: "flyer-comfort", + text: "Which campaign date sounds least embarrassing?", + category: "mission", + answerOptions: [ + "Coffee plus QR flyers", + "Museum plus posters nearby", + "Walk plus inviting friends to vote", + "Anything that does not involve yelling in public", + ], + allowMultiple: true, + }, + { + key: "war-disease-priority", + text: "How much should a partner care about ending war and disease?", + category: "values", + answerOptions: [ + "A lot", + "Some", + "They can think I am strange as long as they vote", + ], + allowMultiple: false, + }, +] as const; + +export interface SyncManagedDatingCatalogOptions { + apply: boolean; +} + +export interface SyncManagedDatingCatalogResult { + dryRun: boolean; + prompts: number; + questions: number; +} + +function promptId(key: string) { + return `dating-prompt-${key}`; +} + +function questionId(key: string) { + return `dating-question-${key}`; +} + +export async function syncManagedDatingCatalog( + prisma: PrismaClient, + options: SyncManagedDatingCatalogOptions, +): Promise { + const result = { + dryRun: !options.apply, + prompts: MANAGED_DATING_PROMPTS.length, + questions: MANAGED_DATING_QUESTIONS.length, + }; + + if (!options.apply) return result; + + for (const [index, prompt] of MANAGED_DATING_PROMPTS.entries()) { + await prisma.datingPrompt.upsert({ + where: { key: prompt.key }, + update: { + active: true, + managed: true, + sortOrder: index, + text: prompt.text, + }, + create: { + id: promptId(prompt.key), + key: prompt.key, + active: true, + managed: true, + sortOrder: index, + text: prompt.text, + }, + }); + } + + for (const [index, question] of MANAGED_DATING_QUESTIONS.entries()) { + await prisma.datingQuestion.upsert({ + where: { key: question.key }, + update: { + allowMultiple: question.allowMultiple, + answerOptions: [...question.answerOptions] satisfies Prisma.InputJsonValue, + category: question.category, + managed: true, + sortOrder: index, + status: DatingQuestionStatus.ACTIVE, + text: question.text, + }, + create: { + id: questionId(question.key), + key: question.key, + allowMultiple: question.allowMultiple, + answerOptions: [...question.answerOptions] satisfies Prisma.InputJsonValue, + category: question.category, + managed: true, + sortOrder: index, + status: DatingQuestionStatus.ACTIVE, + text: question.text, + }, + }); + } + + return result; +} + +export function formatManagedDatingCatalogResult( + result: SyncManagedDatingCatalogResult, +) { + const prefix = result.dryRun ? "would sync" : "synced"; + return `Dating catalog: ${prefix} ${result.prompts} prompts, ${result.questions} questions`; +} diff --git a/packages/db/src/zod/index.ts b/packages/db/src/zod/index.ts index 2622afc3..644198e4 100644 --- a/packages/db/src/zod/index.ts +++ b/packages/db/src/zod/index.ts @@ -508,6 +508,75 @@ export type QuestionType = z.infer; export const EmailLogStatusSchema = z.enum(['QUEUED', 'SENT', 'DELIVERED', 'OPENED', 'BOUNCED', 'FAILED']); export type EmailLogStatus = z.infer; +export const DatingProfileStatusSchema = z.enum(['DRAFT', 'ACTIVE', 'PAUSED', 'HIDDEN', 'MODERATION_HOLD', 'BANNED']); +export type DatingProfileStatus = z.infer; + +export const DatingRelationshipIntentSchema = z.enum(['FRIENDS', 'DATES', 'LONG_TERM', 'LIFE_PARTNER', 'CASUAL', 'NON_MONOGAMY', 'UNSURE']); +export type DatingRelationshipIntent = z.infer; + +export const DatingProfilePhotoStatusSchema = z.enum(['PENDING', 'APPROVED', 'REJECTED', 'HIDDEN']); +export type DatingProfilePhotoStatus = z.infer; + +export const DatingQuestionStatusSchema = z.enum(['DRAFT', 'ACTIVE', 'RETIRED']); +export type DatingQuestionStatus = z.infer; + +export const DatingQuestionAnswerVisibilitySchema = z.enum(['PUBLIC', 'PRIVATE']); +export type DatingQuestionAnswerVisibility = z.infer; + +export const DatingQuestionImportanceSchema = z.enum(['IRRELEVANT', 'A_LITTLE', 'SOMEWHAT', 'VERY', 'MANDATORY']); +export type DatingQuestionImportance = z.infer; + +export const DatingPreferenceImportanceSchema = z.enum(['PREFERENCE', 'DEALBREAKER']); +export type DatingPreferenceImportance = z.infer; + +export const DatingInteractionKindSchema = z.enum(['LIKE', 'PASS', 'SUPERLIKE', 'INTRO']); +export type DatingInteractionKind = z.infer; + +export const DatingInteractionStatusSchema = z.enum(['ACTIVE', 'RETRACTED', 'MODERATION_HOLD']); +export type DatingInteractionStatus = z.infer; + +export const DatingMatchStatusSchema = z.enum(['ACTIVE', 'UNMATCHED', 'BLOCKED']); +export type DatingMatchStatus = z.infer; + +export const DatingConversationStatusSchema = z.enum(['ACTIVE', 'ARCHIVED', 'MODERATION_HOLD']); +export type DatingConversationStatus = z.infer; + +export const DatingMessageStatusSchema = z.enum(['SENT', 'HIDDEN', 'DELETED', 'MODERATION_HOLD']); +export type DatingMessageStatus = z.infer; + +export const DatingDatePlanStatusSchema = z.enum(['PROPOSED', 'ACCEPTED', 'DECLINED', 'CANCELED', 'COMPLETED', 'NO_SHOW']); +export type DatingDatePlanStatus = z.infer; + +export const DatingBlockScopeSchema = z.enum(['DISCOVERY', 'MESSAGES', 'ALL']); +export type DatingBlockScope = z.infer; + +export const DatingSafetyReportStatusSchema = z.enum(['OPEN', 'REVIEWING', 'RESOLVED', 'DISMISSED']); +export type DatingSafetyReportStatus = z.infer; + +export const CommerceOfferKindSchema = z.enum(['PHYSICAL_GOOD', 'SPONSORSHIP', 'SUBSCRIPTION', 'DIGITAL_ACCESS', 'SERVICE', 'DONATION']); +export type CommerceOfferKind = z.infer; + +export const CommerceOfferStatusSchema = z.enum(['DRAFT', 'ACTIVE', 'RETIRED']); +export type CommerceOfferStatus = z.infer; + +export const CommerceFulfillmentKindSchema = z.enum(['NONE', 'PHYSICAL_GOOD', 'DIGITAL_ENTITLEMENT', 'MANUAL_SPONSORSHIP']); +export type CommerceFulfillmentKind = z.infer; + +export const CommercePaymentProviderSchema = z.enum(['STRIPE', 'MANUAL']); +export type CommercePaymentProvider = z.infer; + +export const CommerceFulfillmentProviderSchema = z.enum(['NONE', 'CUSTOMCAT', 'MANUAL', 'STRIPE']); +export type CommerceFulfillmentProvider = z.infer; + +export const CommerceOrderStatusSchema = z.enum(['PENDING_PAYMENT', 'PAID', 'FULFILLING', 'SUBMITTED', 'SHIPPED', 'FAILED', 'CANCELED', 'REFUNDED']); +export type CommerceOrderStatus = z.infer; + +export const CommerceFulfillmentStatusSchema = z.enum(['PENDING', 'SUBMITTED', 'SHIPPED', 'DELIVERED', 'FAILED', 'CANCELED']); +export type CommerceFulfillmentStatus = z.infer; + +export const CommerceEntitlementStatusSchema = z.enum(['PENDING', 'ACTIVE', 'EXPIRED', 'CANCELED', 'REVOKED']); +export type CommerceEntitlementStatus = z.infer; + export const AgentComputeDepositSourceSchema = z.enum(['STRIPE', 'CRYPTO', 'MANUAL']); export type AgentComputeDepositSource = z.infer; @@ -2287,6 +2356,426 @@ export const EmailLogSchema = z.object({ }); export type EmailLogType = z.infer; +// ── Dating and mission dates ─────────────────────────────────────────────── + +export const DatingProfileSchema = z.object({ + id: z.string(), + userId: z.string(), + status: DatingProfileStatusSchema.default('DRAFT'), + headline: z.string().nullable().optional(), + bio: z.string().nullable().optional(), + lookingForText: z.string().nullable().optional(), + relationshipIntents: z.array(DatingRelationshipIntentSchema).default([]), + genderIdentities: z.array(z.string()).default([]), + orientationIdentities: z.array(z.string()).default([]), + relationshipStatus: z.string().nullable().optional(), + preferredMinAge: z.number().int().nullable().optional(), + preferredMaxAge: z.number().int().nullable().optional(), + maxDistanceKm: z.number().int().nullable().optional(), + displayCity: z.string().nullable().optional(), + displayRegionCode: z.string().nullable().optional(), + displayCountryCode: z.string().nullable().optional(), + wantsCampaignDates: z.boolean().default(true), + campaignDateIdeas: z.array(z.string()).default([]), + profileCompletedAt: nullableDateSchema, + lastActiveAt: nullableDateSchema, + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingProfileType = z.infer; + +export const DatingProfilePhotoSchema = z.object({ + id: z.string(), + profileId: z.string(), + imageUrl: z.string(), + storageKey: z.string().nullable().optional(), + altText: z.string().nullable().optional(), + blurhash: z.string().nullable().optional(), + sortOrder: z.number().int().default(0), + status: DatingProfilePhotoStatusSchema.default('PENDING'), + moderationReason: z.string().nullable().optional(), + reviewedByUserId: z.string().nullable().optional(), + reviewedAt: nullableDateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingProfilePhotoType = z.infer; + +export const DatingPromptSchema = z.object({ + id: z.string(), + key: z.string(), + text: z.string(), + sortOrder: z.number().int().default(0), + active: z.boolean().default(true), + managed: z.boolean().default(false), + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingPromptType = z.infer; + +export const DatingPromptAnswerSchema = z.object({ + id: z.string(), + profileId: z.string(), + promptId: z.string(), + answer: z.string(), + sortOrder: z.number().int().default(0), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingPromptAnswerType = z.infer; + +export const DatingQuestionSchema = z.object({ + id: z.string(), + key: z.string(), + text: z.string(), + category: z.string().nullable().optional(), + answerOptions: z.unknown(), + allowMultiple: z.boolean().default(false), + status: DatingQuestionStatusSchema.default('ACTIVE'), + sortOrder: z.number().int().default(0), + managed: z.boolean().default(false), + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingQuestionType = z.infer; + +export const DatingQuestionAnswerSchema = z.object({ + id: z.string(), + profileId: z.string(), + questionId: z.string(), + answerValues: z.unknown(), + acceptableValues: nullableJsonSchema, + importance: DatingQuestionImportanceSchema.default('SOMEWHAT'), + visibility: DatingQuestionAnswerVisibilitySchema.default('PUBLIC'), + explanation: z.string().nullable().optional(), + answeredAt: dateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingQuestionAnswerType = z.infer; + +export const DatingPreferenceSchema = z.object({ + id: z.string(), + profileId: z.string(), + key: z.string(), + valueJson: z.unknown(), + importance: DatingPreferenceImportanceSchema.default('PREFERENCE'), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingPreferenceType = z.infer; + +export const DatingMatchScoreSchema = z.object({ + id: z.string(), + profileAId: z.string(), + profileBId: z.string(), + score: z.number().int(), + questionScore: z.number().int().nullable().optional(), + preferenceScore: z.number().int().nullable().optional(), + sharedAnsweredCount: z.number().int().default(0), + dealbreakerFailed: z.boolean().default(false), + failedDealbreakerCount: z.number().int().default(0), + computedAt: dateSchema, + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingMatchScoreType = z.infer; + +export const DatingInteractionSchema = z.object({ + id: z.string(), + fromProfileId: z.string(), + toProfileId: z.string(), + kind: DatingInteractionKindSchema, + status: DatingInteractionStatusSchema.default('ACTIVE'), + introMessage: z.string().nullable().optional(), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingInteractionType = z.infer; + +export const DatingMatchSchema = z.object({ + id: z.string(), + profileAId: z.string(), + profileBId: z.string(), + status: DatingMatchStatusSchema.default('ACTIVE'), + matchedAt: dateSchema, + unmatchedAt: nullableDateSchema, + lastMessageAt: nullableDateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingMatchType = z.infer; + +export const DatingConversationSchema = z.object({ + id: z.string(), + matchId: z.string(), + status: DatingConversationStatusSchema.default('ACTIVE'), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingConversationType = z.infer; + +export const DatingMessageSchema = z.object({ + id: z.string(), + conversationId: z.string(), + senderProfileId: z.string(), + body: z.string(), + status: DatingMessageStatusSchema.default('SENT'), + readAt: nullableDateSchema, + editedAt: nullableDateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingMessageType = z.infer; + +export const DatingDatePlanSchema = z.object({ + id: z.string(), + matchId: z.string().nullable().optional(), + conversationId: z.string().nullable().optional(), + proposedByProfileId: z.string(), + acceptedByProfileId: z.string().nullable().optional(), + status: DatingDatePlanStatusSchema.default('PROPOSED'), + title: z.string(), + description: z.string().nullable().optional(), + startsAt: nullableDateSchema, + endsAt: nullableDateSchema, + timeZone: z.string().nullable().optional(), + locationName: z.string().nullable().optional(), + address: z.string().nullable().optional(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + isCampaignDate: z.boolean().default(false), + campaignTaskId: z.string().nullable().optional(), + campaignNotes: z.string().nullable().optional(), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingDatePlanType = z.infer; + +export const DatingBlockSchema = z.object({ + id: z.string(), + blockerProfileId: z.string(), + blockedProfileId: z.string(), + scope: DatingBlockScopeSchema.default('ALL'), + reason: z.string().nullable().optional(), + createdAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingBlockType = z.infer; + +export const DatingSafetyReportSchema = z.object({ + id: z.string(), + reporterProfileId: z.string(), + reportedProfileId: z.string().nullable().optional(), + messageId: z.string().nullable().optional(), + datePlanId: z.string().nullable().optional(), + reason: z.string(), + description: z.string().nullable().optional(), + status: DatingSafetyReportStatusSchema.default('OPEN'), + reviewerUserId: z.string().nullable().optional(), + resolutionNote: z.string().nullable().optional(), + resolvedAt: nullableDateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type DatingSafetyReportType = z.infer; + +// ── Commerce catalog, orders, fulfillment, and entitlements ──────────────── + +export const CommerceOfferSchema = z.object({ + id: z.string(), + key: z.string(), + kind: CommerceOfferKindSchema, + status: CommerceOfferStatusSchema.default('ACTIVE'), + title: z.string(), + description: z.string().nullable().optional(), + currency: z.string().default('usd'), + defaultUnitAmountCents: z.number().int().nullable().optional(), + defaultFmvCents: z.number().int().default(0), + minUnitAmountCents: z.number().int().nullable().optional(), + maxUnitAmountCents: z.number().int().nullable().optional(), + allowCustomAmount: z.boolean().default(false), + isTaxDeductible: z.boolean().default(false), + taxCode: z.string().nullable().optional(), + fulfillmentKind: CommerceFulfillmentKindSchema.default('NONE'), + managed: z.boolean().default(false), + sortOrder: z.number().int().default(0), + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceOfferType = z.infer; + +export const CommerceOfferVariantSchema = z.object({ + id: z.string(), + offerId: z.string(), + key: z.string(), + variantKey: z.string(), + label: z.string(), + currency: z.string().default('usd'), + unitAmountCents: z.number().int().nullable().optional(), + fmvCents: z.number().int().nullable().optional(), + minUnitAmountCents: z.number().int().nullable().optional(), + maxUnitAmountCents: z.number().int().nullable().optional(), + allowCustomAmount: z.boolean().nullable().optional(), + taxCode: z.string().nullable().optional(), + fulfillmentKind: CommerceFulfillmentKindSchema.nullable().optional(), + attributes: nullableJsonSchema, + fulfillmentMetadata: nullableJsonSchema, + metadata: nullableJsonSchema, + active: z.boolean().default(true), + sortOrder: z.number().int().default(0), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceOfferVariantType = z.infer; + +export const CommerceFulfillmentMappingSchema = z.object({ + id: z.string(), + offerVariantId: z.string(), + provider: CommerceFulfillmentProviderSchema, + providerProductId: z.string().nullable().optional(), + providerVariantId: z.string().nullable().optional(), + providerCatalogSku: z.string().nullable().optional(), + providerMetadata: nullableJsonSchema, + active: z.boolean().default(true), + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceFulfillmentMappingType = z.infer; + +export const CommerceOrderSchema = z.object({ + id: z.string(), + purposeKey: z.string().nullable().optional(), + status: CommerceOrderStatusSchema.default('PENDING_PAYMENT'), + paymentProvider: CommercePaymentProviderSchema.default('STRIPE'), + stripeCheckoutSessionId: z.string().nullable().optional(), + stripePaymentIntentId: z.string().nullable().optional(), + stripeCustomerId: z.string().nullable().optional(), + buyerUserId: z.string().nullable().optional(), + buyerOrganizationId: z.string().nullable().optional(), + buyerEmail: z.string().nullable().optional(), + buyerName: z.string().nullable().optional(), + buyerPhone: z.string().nullable().optional(), + shippingName: z.string().nullable().optional(), + shippingLine1: z.string().nullable().optional(), + shippingLine2: z.string().nullable().optional(), + shippingCity: z.string().nullable().optional(), + shippingState: z.string().nullable().optional(), + shippingPostalCode: z.string().nullable().optional(), + shippingCountry: z.string().nullable().optional(), + currency: z.string().default('usd'), + subtotalCents: z.number().int().default(0), + taxCents: z.number().int().default(0), + shippingCents: z.number().int().default(0), + discountCents: z.number().int().default(0), + totalCents: z.number().int().default(0), + fmvCents: z.number().int().default(0), + donationCents: z.number().int().default(0), + metadata: nullableJsonSchema, + lastError: z.string().nullable().optional(), + attemptCount: z.number().int().default(0), + paidAt: nullableDateSchema, + fulfilledAt: nullableDateSchema, + shippedAt: nullableDateSchema, + canceledAt: nullableDateSchema, + refundedAt: nullableDateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceOrderType = z.infer; + +export const CommerceOrderItemSchema = z.object({ + id: z.string(), + orderId: z.string(), + offerId: z.string().nullable().optional(), + offerVariantId: z.string().nullable().optional(), + offerKey: z.string(), + offerVariantKey: z.string().nullable().optional(), + title: z.string(), + quantity: z.number().int().default(1), + currency: z.string().default('usd'), + unitAmountCents: z.number().int().default(0), + unitFmvCents: z.number().int().default(0), + unitDonationCents: z.number().int().default(0), + totalAmountCents: z.number().int().default(0), + totalFmvCents: z.number().int().default(0), + totalDonationCents: z.number().int().default(0), + taxable: z.boolean().default(false), + taxCode: z.string().nullable().optional(), + fulfillmentKind: CommerceFulfillmentKindSchema.default('NONE'), + fulfillmentMetadata: nullableJsonSchema, + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceOrderItemType = z.infer; + +export const CommerceFulfillmentSchema = z.object({ + id: z.string(), + orderId: z.string(), + orderItemId: z.string().nullable().optional(), + provider: CommerceFulfillmentProviderSchema, + status: CommerceFulfillmentStatusSchema.default('PENDING'), + externalOrderId: z.string().nullable().optional(), + providerOrderId: z.string().nullable().optional(), + providerStatus: z.string().nullable().optional(), + trackingNumber: z.string().nullable().optional(), + trackingUrl: z.string().nullable().optional(), + metadata: nullableJsonSchema, + lastError: z.string().nullable().optional(), + attemptCount: z.number().int().default(0), + submittedAt: nullableDateSchema, + shippedAt: nullableDateSchema, + deliveredAt: nullableDateSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceFulfillmentType = z.infer; + +export const CommerceEntitlementSchema = z.object({ + id: z.string(), + orderId: z.string().nullable().optional(), + orderItemId: z.string().nullable().optional(), + offerId: z.string().nullable().optional(), + offerVariantId: z.string().nullable().optional(), + entitlementType: z.string(), + status: CommerceEntitlementStatusSchema.default('PENDING'), + subjectUserId: z.string().nullable().optional(), + subjectOrganizationId: z.string().nullable().optional(), + startsAt: nullableDateSchema, + endsAt: nullableDateSchema, + metadata: nullableJsonSchema, + createdAt: dateSchema, + updatedAt: dateSchema, + deletedAt: nullableDateSchema, +}); +export type CommerceEntitlementType = z.infer; + // ── Agent Compute Funding ────────────────────────────────────────────────── export const AgentComputeDepositSchema = z.object({ diff --git a/packages/web/docs/customcat-integration.md b/packages/web/docs/customcat-integration.md new file mode 100644 index 00000000..c9a95c9f --- /dev/null +++ b/packages/web/docs/customcat-integration.md @@ -0,0 +1,241 @@ +# CustomCat API integration + +Source-of-truth doc for the `/shirt` commerce flow. CustomCat is the print-on-demand vendor selected per the cost comparison in [`.claude/plans/t-shirt-walking-billboard.md`](../../../.claude/plans/t-shirt-walking-billboard.md) (CustomCat $13.67/shirt Pro plan vs Printful $18-20, cheapest API-enabled option supporting per-order custom artwork). + +## Account model + +CustomCat uses **Workflow 2: External Designs** for our use case. This is API-driven: + +- **No product is created in the CustomCat dashboard.** The dashboard's "create product" flow is for Workflow 1 (CustomCat hosts a storefront for you). +- Each API order references a `catalog_sku` (variant ID) from CustomCat's global catalog + a `design_url` pointing to the per-buyer print-ready PNG. +- The catalog IDs are stable, permanent, account-agnostic (e.g. `952` = Bella+Canvas 3001C Unisex Jersey Short-Sleeve Tee, `45475` = size S color Black). +- Discover catalog IDs via `GET https://customcat-beta.mylocker.net/api/v1/catalog/{product_id}?api_key=...`. + +## Authentication + +- **API key, NOT Bearer token.** Pass the API key in EITHER the query string (`?api_key=...`) OR the POST body field (`api_key`). Both work. +- Single UUID-format token per API Store. Generated once in CustomCat dashboard → API → "Generate API Token". Cannot be re-displayed; if lost, generate a new one. +- One token per API Store. `store_id` is accepted in order bodies but not required, so the client omits it. + +## Sandbox / test mode + +- **Per-request flag.** Add `"sandbox": "1"` to the POST body of `/order/{external_id}` calls for test orders; set `"sandbox": "0"` for live orders. +- There is **no sandbox API key** and **no account-level sandbox toggle.** A single API key handles both modes. + +## Print method + +- CustomCat calls their direct-to-garment process **DIGISOFT®** (not "DTG"). Both front and back placements are supported. +- Back-print adds **+$5/item** to the base shirt cost per CustomCat's pricing FAQ. + +## Size naming + +- CustomCat's API uses **`2XL`** for XX-Large. Our env var naming uses `XXL` for readability; the `catalog_sku_id` values map correctly to 2XL. + +## Order POST shape + +> Verified empirically 2026-05-19 against `https://customcat-beta.mylocker.net/api/v1/`. The real endpoint is `POST /order/{external_id}` and the payload is flat. + +```http +POST https://customcat-beta.mylocker.net/api/v1/order/ +Content-Type: application/json + +{ + "shipping_first_name": "Ada", + "shipping_last_name": "Lovelace", + "shipping_address1": "100 Main St", + "shipping_address2": "Apt 4", + "shipping_city": "Edwardsville", + "shipping_state": "IL", + "shipping_zip": "62025", + "shipping_country": "US", + "shipping_email": "recipient@example.com", + "shipping_phone": "555-123-4567", + "shipping_method": "Economy", + "items": [{ + "catalog_sku": "45475", + "quantity": 1, + "design_url": "https://r2.warondisease.org/.../order-front-cs_test_123.png", + "design_url_back": "https://r2.warondisease.org/.../order-back-cs_test_123.png" + }], + "sandbox": "1", + "api_key": "" +} +``` + +Critical fields: +- `external_id` — our `MerchandiseOrder.id` or Stripe Checkout session id, passed as the path segment. `POST /api/v1/order` returns 404. Posting the same value twice returns the same `CUSTOMCAT_ORDER_ID`; safe for Stripe webhook retries. +- The body is flat. Do not send `orders`, `ship_to`, `line_items`, `shipping_option`, or nested objects. +- `shipping_state` — two-letter state code such as `IL`, not `Illinois`. +- `shipping_country` — two-letter country code such as `US`. +- `shipping_email` — required. +- `shipping_phone` — required; sandbox returned HTTP 500 without it. +- `shipping_method` — name from the shipping API, such as `Economy`, `Ground`, `2 Day`, or `Standard Overnight`. Use the name, not the ID. +- `catalog_sku` — string, looked up from managed commerce catalog seed data, not env. +- `design_url` + `design_url_back` — public R2 URLs of the composed PNGs. Must be publicly fetchable by CustomCat at order time. Browser-agent testing found CustomCat re-downloads the design by URL; unique R2 keys are for audit/traceability, not cache busting. Include the external order id in object keys. +- `sandbox` — string `"1"` or `"0"`, not an integer. +- `store_id` — accepted but not required. Omit it unless a future verified behavior requires it. + +Successful response shape: + +```json +{ + "MSG": "Order added successfully", + "ORDER_ID": "", + "CUSTOMCAT_ORDER_ID": "" +} +``` + +The client must parse `CUSTOMCAT_ORDER_ID` and must treat any `MSG` other than `"Order added successfully"` as a failed submission, even if CustomCat returns HTTP 200. + +## Order status endpoint + +Use the external order id if a webhook is delayed or missed: + +```http +GET https://customcat-beta.mylocker.net/api/v1/order/status/?api_key= +``` + +The response includes `ORDER_STATUS`, for example `"in queue"`. +Sandbox orders can progress to `ORDER_STATUS: "Shipped"` with realistic `SHIPMENTS`, including `TRACKING_ID`. That makes end-to-end Stripe -> CustomCat -> webhook -> confirmation testing possible in sandbox. + +Confirmed response shape: + +```json +{ + "ORDER_ID": "", + "CUSTOMCAT_ORDER_ID": "", + "ORDER_DATE": "2026-05-19 ...", + "ORDER_STATUS": "in queue", + "CUSTOMER_NAME": "Ada Lovelace", + "CUSTOMER_ADDRESS1": "100 Main St", + "CUSTOMER_CITY": "Edwardsville", + "CUSTOMER_STATE": "IL", + "CUSTOMER_COUNTRY": "US", + "CUSTOMER_ZIP": "62025", + "ORDER_TOTAL": "18.46", + "SHIPMENTS": [{ "TRACKING_ID": "TRACK123", "METHOD": "Economy", "VENDOR": "USPS" }], + "LINE_ITEMS": [{ "STATUS": "Shipped", "PRODUCT_NAME": "Bella+Canvas 3001C" }] +} +``` + +## Shipping options endpoint + +```http +GET https://customcat-beta.mylocker.net/api/v1/shipping?api_key= +``` + +The response contains `{ "SHIPPING_ID": "...", "SHIPPING_NAME": "..." }` records. Use `SHIPPING_NAME` as `shipping_method` in the order POST body. + +Real-time shipping quote: + +```http +POST https://customcat-beta.mylocker.net/api/v1/shipping/ +Content-Type: application/json + +{ + "api_key": "", + "shipping_first_name": "Ada", + "shipping_last_name": "Lovelace", + "shipping_address1": "100 Main St", + "shipping_address2": "", + "shipping_city": "Los Angeles", + "shipping_state": "CA", + "shipping_zip": "90001", + "shipping_country": "US", + "items": [{ "catalog_sku": "45475", "quantity": 1 }] +} +``` + +Sandbox example prices for one shirt to California: Economy `$4.99`, Ground `$12.99`, 2 Day `$22.00`, Standard Overnight `$35.00`. Prices vary by destination and quantity. + +International quote requests have returned `"0.00"` in sandbox. Treat non-US shipping as unsupported for v1 unless a later launch pass verifies real international behavior. + +## Webhook system + +Registered webhooks can be listed with: + +```http +GET https://customcat-beta.mylocker.net/api/v1/webhook?api_key= +``` + +Create: + +```http +POST https://customcat-beta.mylocker.net/api/v1/webhook +Content-Type: application/json + +{ "api_key": "", "topic": "order-shipped", "url": "https://warondisease.org/api/customcat/webhook" } +``` + +Update: + +```http +PUT https://customcat-beta.mylocker.net/api/v1/webhook/ +Content-Type: application/json + +{ "api_key": "", "url": "https://warondisease.org/api/customcat/webhook" } +``` + +Topics: +- `order-shipped` — use. +- `order-partial-shipment` — use. +- `design-rejected` — use; this tells us if CustomCat rejected submitted artwork. +- `product-created`, `product-deleted`, `product-updated` — ignore; we do not manage CustomCat products via webhook. + +Webhook registration belongs in a deploy-time one-shot script or dashboard action, not in the request path. As of the empirical check, an existing `order-shipped` webhook pointed at a webhook.site placeholder; reconfigure it at launch through the API or dashboard. + +## Cancellation and refunds + +CustomCat order cancellation is not supported, including for live orders shortly after creation. Refund handling is a Stripe/customer-service policy decision. Sandbox orders are no-ops. If a live CustomCat order ships after refund, the customer keeps the shirt and the refund should cover only the donation portion per the launch refund policy. + +## Env vars + +Set these in Vercel Project Settings → Environment Variables. **Never commit these values.** + +| Env var | Purpose | Example shape | +|---|---|---| +| `CUSTOMCAT_API_TOKEN` | UUID API key from CustomCat API Store | `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` | +| `CUSTOMCAT_SANDBOX` | `"1"` to force per-request sandbox, `"0"` for live | `"1"` | +| `SHIRT_COMMERCE_ENABLED` | `"true"` to show the ORDER button on `/shirt`, `"false"` to hide | `"false"` | + +Catalog product and variant IDs live in +`packages/db/src/managed-data/managed-commerce-catalog.ts` and sync through +managed data. They are stable vendor catalog facts, not secrets. + +**Operational pattern:** +- Set `SHIRT_COMMERCE_ENABLED=false` in Production until full end-to-end validation passes +- Set `SHIRT_COMMERCE_ENABLED=true` + `CUSTOMCAT_SANDBOX=1` in Preview environments for staging-level testing +- Flip `SHIRT_COMMERCE_ENABLED=true` + `CUSTOMCAT_SANDBOX=0` in Production only after a real sandbox order goes through end-to-end + +## Pricing reference (re-verify against current CustomCat docs before launch) + +| Item | Lite plan (free) | Pro plan ($25/mo annual) | +|---|---|---| +| Bella+Canvas 3001C base (1 placement) | $11.47 | $8.67 | +| + back placement | +$5.00 | +$5.00 | +| **Per-shirt total** | **$16.47** | **$13.67** | + +Shipping is additional (CustomCat calculates per-order based on the shipping address fields). Stripe Tax handles sales tax on the $15 FMV portion of each order. + +Empirical catalog check on 2026-05-19 found a 2XL base cost of $13.47 rather than $11.47. For v1, keep a single $15 fair market value across sizes instead of adding a per-size FMV override map. + +## IRS quid pro quo split + +Per [IRS Pub 1771](https://www.irs.gov/charities-non-profits/charitable-organizations/charitable-contributions-quid-pro-quo-contributions), when a 501(c)(3) provides goods/services in exchange for a contribution: +- The deductible portion = total payment − fair market value of goods/services +- Written acknowledgment must disclose this split + +Our line-item shape on Stripe Checkout: +- Line 1: `Shirt fair market value` — $15.00, taxable, NOT deductible +- Line 2: `Charitable contribution` — (tier price − $15), nontaxable, DEDUCTIBLE +- Receipt email auto-discloses: `"Your contribution above the $15 shirt fair market value is tax-deductible to the extent allowed by law."` + +## Vendor docs cited + +- [CustomCat API overview](https://customcat.com/integrations/customcat-api/) +- [Getting Started with CustomCat API](https://help.customcat.com/getting-started-with-customcat-api) +- [CustomCat API base (v1 beta)](https://customcat-beta.mylocker.net/api/v1/) +- [Stripe address collection](https://docs.stripe.com/payments/collect-addresses) +- [Stripe Tax codes](https://docs.stripe.com/tax/tax-codes?type=services) +- [IRS Pub 1771 — quid pro quo contributions](https://www.irs.gov/charities-non-profits/charitable-organizations/charitable-contributions-quid-pro-quo-contributions) +- [IRS written acknowledgment guidance](https://www.irs.gov/charities-non-profits/charitable-organizations/charitable-contributions-written-acknowledgments) diff --git a/packages/web/package.json b/packages/web/package.json index 64673fcc..3519d05f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -121,6 +121,7 @@ "next-auth": "^4.24.11", "nodemailer": "^8.0.5", "pg": "^8.20.0", + "qrcode": "1.5.4", "qrcode.react": "^4.0.1", "react": "^18.3.1", "react-day-picker": "^9.14.0", @@ -154,6 +155,7 @@ "@types/node": "^20.11.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", + "@types/qrcode": "1.5.5", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/react-katex": "^3.0.4", diff --git a/packages/web/src/app/api/dating/date-plans/route.ts b/packages/web/src/app/api/dating/date-plans/route.ts new file mode 100644 index 00000000..744e239f --- /dev/null +++ b/packages/web/src/app/api/dating/date-plans/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { proposeDatingDatePlan } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const DatePlanBodySchema = z.object({ + campaignNotes: z.string().max(1000).nullish(), + campaignTaskId: z.string().max(120).nullish(), + conversationId: z.string().max(120).nullish(), + isCampaignDate: z.boolean().optional(), + locationName: z.string().max(200).nullish(), + matchId: z.string().min(1), + startsAt: z.string().datetime().nullish(), + title: z.string().trim().min(1).max(140), +}); + +export async function POST(request: Request) { + try { + const { userId } = await requireAuth(); + const parsed = DatePlanBodySchema.parse(await request.json()); + const plan = await proposeDatingDatePlan(userId, { + ...parsed, + startsAt: parsed.startsAt ? new Date(parsed.startsAt) : null, + }); + return NextResponse.json({ plan, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating date plan." }, + { status: 400 }, + ); + } + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("[dating] Failed to propose date:", error); + return NextResponse.json( + { error: "Failed to propose dating date." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dating/interactions/route.ts b/packages/web/src/app/api/dating/interactions/route.ts new file mode 100644 index 00000000..551e5c29 --- /dev/null +++ b/packages/web/src/app/api/dating/interactions/route.ts @@ -0,0 +1,40 @@ +import { DatingInteractionKind } from "@optimitron/db"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { createDatingInteraction } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const InteractionBodySchema = z.object({ + introMessage: z.string().max(500).nullish(), + kind: z.nativeEnum(DatingInteractionKind), + toProfileId: z.string().min(1), +}); + +export async function POST(request: Request) { + try { + const { userId } = await requireAuth(); + const parsed = InteractionBodySchema.parse(await request.json()); + const result = await createDatingInteraction(userId, parsed); + return NextResponse.json({ ...result, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating interaction." }, + { status: 400 }, + ); + } + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("[dating] Failed to save interaction:", error); + return NextResponse.json( + { error: "Failed to save dating interaction." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dating/messages/route.ts b/packages/web/src/app/api/dating/messages/route.ts new file mode 100644 index 00000000..a7f711b4 --- /dev/null +++ b/packages/web/src/app/api/dating/messages/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { sendDatingMessage } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const MessageBodySchema = z.object({ + body: z.string().trim().min(1).max(4000), + conversationId: z.string().min(1), +}); + +export async function POST(request: Request) { + try { + const { userId } = await requireAuth(); + const parsed = MessageBodySchema.parse(await request.json()); + const message = await sendDatingMessage(userId, parsed); + return NextResponse.json({ message, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating message." }, + { status: 400 }, + ); + } + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("[dating] Failed to send message:", error); + return NextResponse.json( + { error: "Failed to send dating message." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dating/profile/photos/route.ts b/packages/web/src/app/api/dating/profile/photos/route.ts new file mode 100644 index 00000000..70504004 --- /dev/null +++ b/packages/web/src/app/api/dating/profile/photos/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { addDatingProfilePhoto } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const PhotoBodySchema = z.object({ + altText: z.string().max(200).nullish(), + imageUrl: z.string().url().max(1000), +}); + +export async function POST(request: Request) { + try { + const { userId } = await requireAuth(); + const parsed = PhotoBodySchema.parse(await request.json()); + const photo = await addDatingProfilePhoto(userId, parsed); + return NextResponse.json({ photo, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating photo." }, + { status: 400 }, + ); + } + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("[dating] Failed to add photo:", error); + return NextResponse.json( + { error: "Failed to add dating photo." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dating/profile/route.ts b/packages/web/src/app/api/dating/profile/route.ts new file mode 100644 index 00000000..89530687 --- /dev/null +++ b/packages/web/src/app/api/dating/profile/route.ts @@ -0,0 +1,63 @@ +import { + DatingProfileStatus, + DatingRelationshipIntent, +} from "@optimitron/db"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { getOwnDatingProfile, saveDatingProfile } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const ProfileBodySchema = z.object({ + bio: z.string().max(2000).nullish(), + campaignDateIdeas: z.array(z.string().max(120)).max(8).optional(), + displayCity: z.string().max(80).nullish(), + displayCountryCode: z.string().max(2).nullish(), + displayRegionCode: z.string().max(16).nullish(), + headline: z.string().max(140).nullish(), + lookingForText: z.string().max(1000).nullish(), + relationshipIntents: z.array(z.nativeEnum(DatingRelationshipIntent)).max(8).optional(), + status: z.nativeEnum(DatingProfileStatus).optional(), + wantsCampaignDates: z.boolean().optional(), +}); + +export async function GET() { + try { + const { userId } = await requireAuth(); + const profile = await getOwnDatingProfile(userId); + return NextResponse.json({ profile }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.error("[dating] Failed to load profile:", error); + return NextResponse.json( + { error: "Failed to load dating profile." }, + { status: 500 }, + ); + } +} +export async function PATCH(request: Request) { + try { + const { userId } = await requireAuth(); + const parsed = ProfileBodySchema.parse(await request.json()); + const profile = await saveDatingProfile(userId, parsed); + return NextResponse.json({ profile, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating profile." }, + { status: 400 }, + ); + } + console.error("[dating] Failed to save profile:", error); + return NextResponse.json( + { error: "Failed to save dating profile." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dating/questions/[questionId]/answer/route.ts b/packages/web/src/app/api/dating/questions/[questionId]/answer/route.ts new file mode 100644 index 00000000..89a551d6 --- /dev/null +++ b/packages/web/src/app/api/dating/questions/[questionId]/answer/route.ts @@ -0,0 +1,50 @@ +import { DatingQuestionImportance } from "@optimitron/db"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { answerDatingQuestion } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const AnswerBodySchema = z.object({ + acceptableValues: z.any().optional(), + answerValues: z.any(), + explanation: z.string().max(1000).nullish(), + importance: z.nativeEnum(DatingQuestionImportance).optional(), +}); + +export async function PUT( + request: Request, + context: { params: Promise<{ questionId: string }> }, +) { + try { + const { userId } = await requireAuth(); + const { questionId } = await context.params; + const parsed = AnswerBodySchema.parse(await request.json()) as z.infer< + typeof AnswerBodySchema + > & { answerValues: unknown }; + const answer = await answerDatingQuestion(userId, { + ...parsed, + questionId, + }); + return NextResponse.json({ answer, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating answer." }, + { status: 400 }, + ); + } + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("[dating] Failed to save answer:", error); + return NextResponse.json( + { error: "Failed to save dating answer." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/dating/reports/route.ts b/packages/web/src/app/api/dating/reports/route.ts new file mode 100644 index 00000000..95f2b441 --- /dev/null +++ b/packages/web/src/app/api/dating/reports/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAuth } from "@/lib/auth-utils"; +import { createDatingSafetyReport } from "@/lib/dating.server"; + +export const runtime = "nodejs"; + +const ReportBodySchema = z.object({ + datePlanId: z.string().max(120).nullish(), + description: z.string().max(2000).nullish(), + messageId: z.string().max(120).nullish(), + reason: z.string().trim().min(1).max(200), + reportedProfileId: z.string().max(120).nullish(), +}); + +export async function POST(request: Request) { + try { + const { userId } = await requireAuth(); + const parsed = ReportBodySchema.parse(await request.json()); + const report = await createDatingSafetyReport(userId, parsed); + return NextResponse.json({ report, success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid dating report." }, + { status: 400 }, + ); + } + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("[dating] Failed to create report:", error); + return NextResponse.json( + { error: "Failed to create dating report." }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/stripe/create-checkout/route.test.ts b/packages/web/src/app/api/stripe/create-checkout/route.test.ts index 84b784e5..cd21bb24 100644 --- a/packages/web/src/app/api/stripe/create-checkout/route.test.ts +++ b/packages/web/src/app/api/stripe/create-checkout/route.test.ts @@ -1,11 +1,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + getEnabledShirtVariantForCheckout: vi.fn(), + getEnabledStoreOfferForCheckout: vi.fn(), + commerceOrderCreate: vi.fn(), + commerceOrderUpdate: vi.fn(), + isCustomCatConfigured: vi.fn(), + isObjectStorageConfigured: vi.fn(), isStripeConfigured: vi.fn(), getStripeClient: vi.fn(), sessionsCreate: vi.fn(), getBaseUrl: vi.fn(), getServerSession: vi.fn(), + serverEnv: { + SHIRT_COMMERCE_ENABLED: "true" as string | undefined, + }, })); vi.mock("next-auth", () => ({ @@ -22,9 +31,37 @@ vi.mock("@/lib/stripe", () => ({ })); vi.mock("@/lib/url", () => ({ + buildReferralUrl: (identifier?: string | null, baseUrl = "http://localhost:3001") => + identifier ? `${baseUrl}/vote/${identifier}` : baseUrl, getBaseUrl: mocks.getBaseUrl, })); +vi.mock("@/lib/env", () => ({ + serverEnv: mocks.serverEnv, +})); + +vi.mock("@/lib/commerce-catalog.server", () => ({ + getEnabledShirtVariantForCheckout: mocks.getEnabledShirtVariantForCheckout, + getEnabledStoreOfferForCheckout: mocks.getEnabledStoreOfferForCheckout, +})); + +vi.mock("@/lib/customcat.server", () => ({ + isCustomCatConfigured: mocks.isCustomCatConfigured, +})); + +vi.mock("@/lib/object-storage.server", () => ({ + isObjectStorageConfigured: mocks.isObjectStorageConfigured, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + commerceOrder: { + create: mocks.commerceOrderCreate, + update: mocks.commerceOrderUpdate, + }, + }, +})); + import { POST } from "./route"; function makeRequest(body: unknown) { @@ -38,9 +75,70 @@ function makeRequest(body: unknown) { describe("POST /api/stripe/create-checkout", () => { beforeEach(() => { vi.resetAllMocks(); + mocks.serverEnv.SHIRT_COMMERCE_ENABLED = "true"; mocks.isStripeConfigured.mockReturnValue(true); + mocks.isCustomCatConfigured.mockReturnValue(true); + mocks.isObjectStorageConfigured.mockReturnValue(true); mocks.getBaseUrl.mockReturnValue("http://localhost:3001"); mocks.getServerSession.mockResolvedValue(null); + mocks.getEnabledShirtVariantForCheckout.mockResolvedValue({ + catalogSku: "12345", + mapping: { + id: "mapping_123", + }, + offer: { + defaultFmvCents: 1500, + id: "offer_shirt", + key: "shirt", + taxCode: "txcd_99999999", + title: "War on Disease shirt", + }, + variant: { + fmvCents: 1500, + fulfillmentMetadata: null, + id: "variant_shirt_white_m", + key: "shirt:white:M", + label: "White / M", + taxCode: "txcd_99999999", + variantKey: "white:M", + }, + }); + mocks.getEnabledStoreOfferForCheckout.mockResolvedValue({ + offer: { + allowCustomAmount: true, + currency: "usd", + defaultFmvCents: 0, + defaultUnitAmountCents: 10000, + description: "Pay for posters, flyers, and local outreach.", + fulfillmentKind: "MANUAL_SPONSORSHIP", + id: "offer_flyer_run", + isTaxDeductible: true, + key: "flyer-run-sponsorship", + maxUnitAmountCents: null, + minUnitAmountCents: 2500, + taxCode: "txcd_00000000", + title: "Sponsor a flyer run", + }, + variant: { + allowCustomAmount: true, + currency: "usd", + fmvCents: 0, + fulfillmentKind: "MANUAL_SPONSORSHIP", + id: "variant_flyer_run", + key: "flyer-run-sponsorship:flyers", + label: "Flyers", + maxUnitAmountCents: null, + minUnitAmountCents: 2500, + taxCode: "txcd_00000000", + unitAmountCents: 10000, + variantKey: "flyers", + }, + }); + mocks.commerceOrderCreate.mockResolvedValue({ + id: "order_123", + items: [{ id: "item_123" }], + }); + mocks.commerceOrderUpdate.mockResolvedValue({ id: "order_123" }); mocks.getStripeClient.mockReturnValue({ checkout: { sessions: { create: mocks.sessionsCreate } }, }); @@ -183,4 +281,239 @@ describe("POST /api/stripe/create-checkout", () => { expect(call.metadata.sourceUrl).toBe("http://localhost:3001/donate"); expect(call.metadata.sourceReferrer).toBe("http://localhost:3001/"); }); + + it("rejects invalid shirt sizes before creating a Checkout session", async () => { + const res = await POST( + makeRequest({ + donation_tier: "25", + handle: "ada", + order_type: "shirt", + shirt_size: "XS", + }), + ); + + expect(res.status).toBe(400); + expect(mocks.sessionsCreate).not.toHaveBeenCalled(); + }); + + it("returns 503 for shirt checkout when commerce is disabled", async () => { + mocks.serverEnv.SHIRT_COMMERCE_ENABLED = "false"; + + const res = await POST( + makeRequest({ + donation_tier: "35", + handle: "ada", + order_type: "shirt", + shirt_size: "M", + }), + ); + + expect(res.status).toBe(503); + expect(mocks.getEnabledShirtVariantForCheckout).not.toHaveBeenCalled(); + expect(mocks.sessionsCreate).not.toHaveBeenCalled(); + }); + + it("returns 503 for shirt checkout when the managed catalog is not ready", async () => { + mocks.getEnabledShirtVariantForCheckout.mockRejectedValue( + new Error("Missing CustomCat catalog SKU for shirt:black:M."), + ); + + const res = await POST( + makeRequest({ + donation_tier: "35", + handle: "ada", + order_type: "shirt", + shirt_color: "black", + shirt_size: "M", + }), + ); + + expect(res.status).toBe(503); + expect(mocks.sessionsCreate).not.toHaveBeenCalled(); + }); + + it("creates a shirt checkout session with US shipping and FMV split", async () => { + mocks.getServerSession.mockResolvedValue({ + user: { + email: "ada@example.com", + handle: "ada", + id: "user_123", + name: "Ada", + }, + }); + + const res = await POST( + makeRequest({ + donation_tier: "35", + handle: "ignored-public-handle", + order_type: "shirt", + shirt_color: "white", + shirt_size: "M", + sourceUrl: "http://localhost:3001/shirt?token=secret", + }), + ); + + expect(res.status).toBe(200); + const call = mocks.sessionsCreate.mock.calls[0]![0]; + expect(call).toEqual( + expect.objectContaining({ + automatic_tax: { enabled: true }, + billing_address_collection: "required", + customer_email: "ada@example.com", + mode: "payment", + phone_number_collection: { enabled: true }, + shipping_address_collection: { allowed_countries: ["US"] }, + }), + ); + expect(call.line_items).toHaveLength(2); + expect(call.line_items[0].price_data.unit_amount).toBe(1500); + expect(call.line_items[0].price_data.product_data.tax_code).toBe("txcd_99999999"); + expect(call.line_items[1].price_data.unit_amount).toBe(2000); + expect(call.line_items[1].price_data.product_data.tax_code).toBe("txcd_00000000"); + expect(call.metadata).toEqual( + expect.objectContaining({ + commerce_order_id: "order_123", + commerce_order_item_id: "item_123", + customcat_catalog_sku: "12345", + donation_amount_cents: "2000", + donation_tier: "35", + fmv_cents: "1500", + handle: "ada", + order_type: "shirt", + referral_url: "https://warondisease.org/vote/ada", + shirt_color: "white", + shirt_size: "M", + sourceUrl: "http://localhost:3001/shirt", + userId: "user_123", + }), + ); + expect(call.success_url).toBe( + "http://localhost:3001/shirt?ordered=1&session_id={CHECKOUT_SESSION_ID}", + ); + expect(mocks.commerceOrderCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + buyerEmail: "ada@example.com", + buyerName: "Ada", + buyerUserId: "user_123", + donationCents: 2000, + fmvCents: 1500, + purposeKey: "war-on-disease-shirt", + subtotalCents: 3500, + totalCents: 3500, + items: { + create: expect.objectContaining({ + offerId: "offer_shirt", + offerKey: "shirt", + offerVariantId: "variant_shirt_white_m", + offerVariantKey: "white:M", + totalAmountCents: 3500, + totalDonationCents: 2000, + totalFmvCents: 1500, + }), + }, + }), + include: { items: true }, + }), + ); + expect(mocks.commerceOrderUpdate).toHaveBeenCalledWith({ + data: { stripeCheckoutSessionId: "cs_test_123" }, + where: { id: "order_123" }, + }); + }); + + it("rejects store offer checkout amounts below the catalog minimum", async () => { + const res = await POST( + makeRequest({ + custom_amount: 10, + offer_key: "flyer-run-sponsorship", + order_type: "store_offer", + sourceUrl: "http://localhost:3001/store/flyer-run-sponsorship", + variant_key: "flyers", + }), + ); + + expect(res.status).toBe(400); + expect(mocks.sessionsCreate).not.toHaveBeenCalled(); + }); + + it("creates a generic store checkout session for manual sponsorship offers", async () => { + mocks.getServerSession.mockResolvedValue({ + user: { + email: "ada@example.com", + handle: "ada", + id: "user_123", + name: "Ada", + }, + }); + + const res = await POST( + makeRequest({ + custom_amount: 100, + offer_key: "flyer-run-sponsorship", + order_type: "store_offer", + sourceUrl: "http://localhost:3001/store/flyer-run-sponsorship?token=secret", + variant_key: "flyers", + }), + ); + + expect(res.status).toBe(200); + const call = mocks.sessionsCreate.mock.calls[0]![0]; + expect(call).toEqual( + expect.objectContaining({ + billing_address_collection: "auto", + cancel_url: "http://localhost:3001/store/flyer-run-sponsorship?canceled=true", + customer_email: "ada@example.com", + mode: "payment", + success_url: + "http://localhost:3001/store/flyer-run-sponsorship?ordered=1&session_id={CHECKOUT_SESSION_ID}", + }), + ); + expect(call.shipping_address_collection).toBeUndefined(); + expect(call.line_items).toHaveLength(1); + expect(call.line_items[0].price_data.unit_amount).toBe(10000); + expect(call.line_items[0].price_data.product_data.name).toBe( + "Sponsor a flyer run - Flyers", + ); + expect(call.metadata).toEqual( + expect.objectContaining({ + commerce_order_id: "order_123", + commerce_order_item_id: "item_123", + donation_amount_cents: "10000", + fmv_cents: "0", + offer_key: "flyer-run-sponsorship", + order_type: "store_offer", + sourceUrl: "http://localhost:3001/store/flyer-run-sponsorship", + userId: "user_123", + variant_key: "flyers", + }), + ); + expect(mocks.commerceOrderCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + buyerEmail: "ada@example.com", + buyerName: "Ada", + buyerUserId: "user_123", + donationCents: 10000, + fmvCents: 0, + purposeKey: "flyer-run-sponsorship", + subtotalCents: 10000, + totalCents: 10000, + items: { + create: expect.objectContaining({ + fulfillmentKind: "MANUAL_SPONSORSHIP", + offerId: "offer_flyer_run", + offerKey: "flyer-run-sponsorship", + offerVariantId: "variant_flyer_run", + offerVariantKey: "flyers", + totalAmountCents: 10000, + totalDonationCents: 10000, + totalFmvCents: 0, + }), + }, + }), + include: { items: true }, + }), + ); + }); }); diff --git a/packages/web/src/app/api/stripe/create-checkout/route.ts b/packages/web/src/app/api/stripe/create-checkout/route.ts index 6dc343e5..ef370587 100644 --- a/packages/web/src/app/api/stripe/create-checkout/route.ts +++ b/packages/web/src/app/api/stripe/create-checkout/route.ts @@ -1,11 +1,38 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; +import { + CommerceFulfillmentKind, + CommerceOrderStatus, + CommercePaymentProvider, + type Prisma, +} from "@optimitron/db"; import { authOptions } from "@/lib/auth"; import { getStripeClient, isStripeConfigured } from "@/lib/stripe"; import type { DonationFrequency } from "@/lib/stripe"; import { createLogger } from "@/lib/logger"; +import { serverEnv } from "@/lib/env"; +import { WAR_ON_DISEASE_CANONICAL_ORIGIN } from "@/lib/domains"; import { NONPROFIT } from "@/lib/nonprofit-identity"; -import { getBaseUrl } from "@/lib/url"; +import { + getEnabledShirtVariantForCheckout, + getEnabledStoreOfferForCheckout, +} from "@/lib/commerce-catalog.server"; +import { isCustomCatConfigured } from "@/lib/customcat.server"; +import { isObjectStorageConfigured } from "@/lib/object-storage.server"; +import { prisma } from "@/lib/prisma"; +import { + DONATION_TAX_CODE, + SHIRT_FMV_CENTS, + SHIRT_TAX_CODE, + isShirtCheckoutRequest, + parseShirtCheckoutRequest, +} from "@/lib/shirt-commerce.server"; +import { + isStoreOfferCheckoutRequest, + parseStoreOfferCheckoutRequest, +} from "@/lib/store-commerce.server"; +import { buildReferralUrl, getBaseUrl } from "@/lib/url"; +import { getHandleOrReferralCode } from "@/lib/referral.client"; const log = createLogger("stripe-checkout"); @@ -22,6 +49,10 @@ interface CheckoutRequest { const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +function isShirtCommerceEnabled() { + return serverEnv.SHIRT_COMMERCE_ENABLED === "1" || serverEnv.SHIRT_COMMERCE_ENABLED === "true"; +} + export async function POST(req: Request) { if (!isStripeConfigured()) { log.error("STRIPE_SECRET_KEY not configured"); @@ -35,6 +66,13 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); } + if (isShirtCheckoutRequest(body)) { + return createShirtCheckoutSession(body); + } + if (isStoreOfferCheckoutRequest(body)) { + return createStoreOfferCheckoutSession(body); + } + const { amount, donationType, name, email, sourceUrl, sourceReferrer } = body; const trimmedName = name?.trim() ?? ""; const trimmedEmail = email?.trim().toLowerCase() ?? ""; @@ -50,9 +88,9 @@ export async function POST(req: Request) { } // Strip query/hash from URLs before storing (avoid PII leaks via query params). - const cleanUrl = typeof sourceUrl === "string" ? sourceUrl.split(/[?#]/)[0]!.slice(0, 512) : ""; + const cleanUrl = typeof sourceUrl === "string" ? sourceUrl.split(/[?#]/)[0].slice(0, 512) : ""; const cleanReferrer = - typeof sourceReferrer === "string" ? sourceReferrer.split(/[?#]/)[0]!.slice(0, 512) : ""; + typeof sourceReferrer === "string" ? sourceReferrer.split(/[?#]/)[0].slice(0, 512) : ""; const stripe = getStripeClient(); const baseUrl = getBaseUrl(); @@ -107,3 +145,366 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Failed to start donation flow." }, { status: 500 }); } } + +async function createShirtCheckoutSession(body: unknown) { + let order; + try { + order = parseShirtCheckoutRequest(body); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid shirt order." }, + { status: 400 }, + ); + } + + if (!isShirtCommerceEnabled()) { + return NextResponse.json({ error: "Shirt checkout is not enabled." }, { status: 503 }); + } + if (!isCustomCatConfigured()) { + return NextResponse.json({ error: "Shirt fulfillment is not configured." }, { status: 503 }); + } + if (!isObjectStorageConfigured()) { + return NextResponse.json({ error: "Shirt artwork storage is not configured." }, { status: 503 }); + } + + const stripe = getStripeClient(); + const baseUrl = getBaseUrl(); + const sessionUser = (await getServerSession(authOptions))?.user; + const userHandle = getHandleOrReferralCode(sessionUser); + const handle = userHandle ?? order.handle; + const donorEmail = sessionUser?.email?.toLowerCase(); + const donorName = sessionUser?.name?.trim(); + const referralUrl = buildReferralUrl(handle, WAR_ON_DISEASE_CANONICAL_ORIGIN); + const metadata: Record = { + cause: "earth-optimization-prize-and-ops", + donation_amount_cents: String(order.donationAmountCents), + donation_tier: order.donationTier, + fmv_cents: String(SHIRT_FMV_CENTS), + handle, + order_type: "shirt", + referral_url: referralUrl, + shirt_color: order.shirtColor, + shirt_size: order.shirtSize, + sourceReferrer: order.sourceReferrer, + sourceUrl: order.sourceUrl, + }; + + if (donorName) metadata.donorName = donorName.slice(0, 200); + if (donorEmail) metadata.donorEmail = donorEmail.slice(0, 200); + if (sessionUser?.id) metadata.userId = sessionUser.id; + + let catalog; + try { + catalog = await getEnabledShirtVariantForCheckout({ + color: order.shirtColor, + size: order.shirtSize, + }); + } catch (error) { + log.error("Managed shirt catalog is not ready", error); + return NextResponse.json({ error: "Shirt catalog is not ready." }, { status: 503 }); + } + + const shirtFmvCents = catalog.variant.fmvCents ?? catalog.offer.defaultFmvCents; + const shirtTaxCode = catalog.variant.taxCode ?? catalog.offer.taxCode ?? SHIRT_TAX_CODE; + const offerTitle = catalog.offer.title; + const variantTitle = catalog.variant.label; + const commerceOrder = await prisma.commerceOrder.create({ + data: { + buyerEmail: donorEmail, + buyerName: donorName, + buyerUserId: sessionUser?.id, + currency: "usd", + donationCents: order.donationAmountCents, + fmvCents: shirtFmvCents, + metadata: { + handle, + referralUrl, + shirtColor: order.shirtColor, + shirtSize: order.shirtSize, + sourceReferrer: order.sourceReferrer, + sourceUrl: order.sourceUrl, + } satisfies Prisma.InputJsonValue, + paymentProvider: CommercePaymentProvider.STRIPE, + purposeKey: "war-on-disease-shirt", + status: CommerceOrderStatus.PENDING_PAYMENT, + subtotalCents: order.totalAmountCents, + totalCents: order.totalAmountCents, + items: { + create: { + currency: "usd", + fulfillmentKind: CommerceFulfillmentKind.PHYSICAL_GOOD, + fulfillmentMetadata: { + customCatCatalogSku: catalog.catalogSku, + customCatFulfillmentMappingId: catalog.mapping.id, + handle, + referralUrl, + shirtColor: order.shirtColor, + shirtSize: order.shirtSize, + } satisfies Prisma.InputJsonValue, + offerId: catalog.offer.id, + offerKey: catalog.offer.key, + offerVariantId: catalog.variant.id, + offerVariantKey: catalog.variant.variantKey, + quantity: 1, + taxable: true, + taxCode: shirtTaxCode, + title: `${offerTitle} - ${variantTitle}`, + totalAmountCents: order.totalAmountCents, + totalDonationCents: order.donationAmountCents, + totalFmvCents: shirtFmvCents, + unitAmountCents: order.totalAmountCents, + unitDonationCents: order.donationAmountCents, + unitFmvCents: shirtFmvCents, + }, + }, + }, + include: { items: true }, + }); + const commerceOrderItem = commerceOrder.items[0]; + if (!commerceOrderItem) { + throw new Error("Created shirt commerce order without an item."); + } + metadata.commerce_order_id = commerceOrder.id; + metadata.commerce_order_item_id = commerceOrderItem.id; + metadata.customcat_catalog_sku = catalog.catalogSku; + metadata.fmv_cents = String(shirtFmvCents); + + const lineItems = [ + { + price_data: { + currency: "usd", + product_data: { + description: "Fair market value of the campaign shirt.", + name: "War on Disease shirt fair market value", + tax_code: shirtTaxCode, + }, + tax_behavior: "exclusive" as const, + unit_amount: shirtFmvCents, + }, + quantity: 1, + }, + { + price_data: { + currency: "usd", + product_data: { + description: + "Tax-deductible contribution above the shirt fair market value.", + name: "War on Disease tax-deductible contribution", + tax_code: DONATION_TAX_CODE, + }, + tax_behavior: "exclusive" as const, + unit_amount: order.donationAmountCents, + }, + quantity: 1, + }, + ]; + + try { + const session = await stripe.checkout.sessions.create({ + automatic_tax: { enabled: true }, + billing_address_collection: "required", + cancel_url: `${baseUrl}/shirt?canceled=true`, + client_reference_id: sessionUser?.id ?? undefined, + customer_email: donorEmail, + line_items: lineItems, + metadata, + mode: "payment", + payment_method_types: ["card"], + phone_number_collection: { enabled: true }, + shipping_address_collection: { allowed_countries: ["US"] }, + success_url: `${baseUrl}/shirt?ordered=1&session_id={CHECKOUT_SESSION_ID}`, + }); + await prisma.commerceOrder.update({ + data: { + stripeCheckoutSessionId: session.id, + }, + where: { id: commerceOrder.id }, + }); + + log.info("Shirt checkout session created", { + donationAmountCents: order.donationAmountCents, + commerceOrderId: commerceOrder.id, + sessionId: session.id, + shirtColor: order.shirtColor, + shirtSize: order.shirtSize, + }); + return NextResponse.json({ sessionId: session.id, url: session.url }); + } catch (error) { + await prisma.commerceOrder.update({ + data: { + attemptCount: { increment: 1 }, + lastError: error instanceof Error ? error.message : String(error), + status: CommerceOrderStatus.FAILED, + }, + where: { id: commerceOrder.id }, + }); + log.error("Failed to create shirt checkout session", error); + return NextResponse.json({ error: "Failed to start shirt order." }, { status: 500 }); + } +} + +async function createStoreOfferCheckoutSession(body: unknown) { + const bodyRecord = body as Record; + const offerKey = + typeof bodyRecord.offer_key === "string" + ? bodyRecord.offer_key.trim() + : typeof bodyRecord.offerKey === "string" + ? bodyRecord.offerKey.trim() + : ""; + const variantKey = + typeof bodyRecord.variant_key === "string" + ? bodyRecord.variant_key.trim() + : typeof bodyRecord.variantKey === "string" + ? bodyRecord.variantKey.trim() + : undefined; + + let catalog; + try { + catalog = await getEnabledStoreOfferForCheckout({ offerKey, variantKey }); + } catch (error) { + log.error("Managed store catalog is not ready", error); + return NextResponse.json({ error: "Store catalog is not ready." }, { status: 503 }); + } + + let order; + try { + order = parseStoreOfferCheckoutRequest(body, catalog); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid store order." }, + { status: 400 }, + ); + } + + if (order.fulfillmentKind === CommerceFulfillmentKind.PHYSICAL_GOOD) { + return NextResponse.json( + { error: "Physical store offers need a product-specific checkout path." }, + { status: 400 }, + ); + } + + const stripe = getStripeClient(); + const baseUrl = getBaseUrl(); + const sessionUser = (await getServerSession(authOptions))?.user; + const donorEmail = sessionUser?.email?.toLowerCase(); + const donorName = sessionUser?.name?.trim(); + const offerPath = `/store/${encodeURIComponent(catalog.offer.key)}`; + const metadata: Record = { + cause: "earth-optimization-prize-and-ops", + donation_amount_cents: String(order.donationAmountCents), + fmv_cents: String(order.fmvCents), + offer_key: catalog.offer.key, + order_type: "store_offer", + sourceReferrer: order.sourceReferrer, + sourceUrl: order.sourceUrl, + }; + + if (catalog.variant) metadata.variant_key = catalog.variant.variantKey; + if (donorName) metadata.donorName = donorName.slice(0, 200); + if (donorEmail) metadata.donorEmail = donorEmail.slice(0, 200); + if (sessionUser?.id) metadata.userId = sessionUser.id; + + const commerceOrder = await prisma.commerceOrder.create({ + data: { + buyerEmail: donorEmail, + buyerName: donorName, + buyerUserId: sessionUser?.id, + currency: catalog.offer.currency, + donationCents: order.donationAmountCents, + fmvCents: order.fmvCents, + metadata: { + offerKey: catalog.offer.key, + sourceReferrer: order.sourceReferrer, + sourceUrl: order.sourceUrl, + variantKey: catalog.variant?.variantKey, + } satisfies Prisma.InputJsonValue, + paymentProvider: CommercePaymentProvider.STRIPE, + purposeKey: catalog.offer.key, + status: CommerceOrderStatus.PENDING_PAYMENT, + subtotalCents: order.totalAmountCents, + totalCents: order.totalAmountCents, + items: { + create: { + currency: catalog.offer.currency, + fulfillmentKind: order.fulfillmentKind, + offerId: catalog.offer.id, + offerKey: catalog.offer.key, + offerVariantId: catalog.variant?.id, + offerVariantKey: catalog.variant?.variantKey, + quantity: 1, + taxable: order.fmvCents > 0, + taxCode: order.taxCode, + title: order.title, + totalAmountCents: order.totalAmountCents, + totalDonationCents: order.donationAmountCents, + totalFmvCents: order.fmvCents, + unitAmountCents: order.totalAmountCents, + unitDonationCents: order.donationAmountCents, + unitFmvCents: order.fmvCents, + }, + }, + }, + include: { items: true }, + }); + const commerceOrderItem = commerceOrder.items[0]; + if (!commerceOrderItem) { + throw new Error("Created store commerce order without an item."); + } + metadata.commerce_order_id = commerceOrder.id; + metadata.commerce_order_item_id = commerceOrderItem.id; + + const productDescription = + catalog.offer.description ?? "Funds the International Campaign to End War and Disease."; + + try { + const session = await stripe.checkout.sessions.create({ + billing_address_collection: "auto", + cancel_url: `${baseUrl}${offerPath}?canceled=true`, + client_reference_id: sessionUser?.id ?? undefined, + customer_email: donorEmail, + line_items: [ + { + price_data: { + currency: catalog.offer.currency, + product_data: { + description: productDescription, + name: order.title, + ...(order.taxCode ? { tax_code: order.taxCode } : {}), + }, + tax_behavior: order.fmvCents > 0 ? ("exclusive" as const) : undefined, + unit_amount: order.totalAmountCents, + }, + quantity: 1, + }, + ], + metadata, + mode: "payment", + payment_method_types: ["card"], + success_url: `${baseUrl}${offerPath}?ordered=1&session_id={CHECKOUT_SESSION_ID}`, + }); + await prisma.commerceOrder.update({ + data: { + stripeCheckoutSessionId: session.id, + }, + where: { id: commerceOrder.id }, + }); + + log.info("Store checkout session created", { + commerceOrderId: commerceOrder.id, + offerKey: catalog.offer.key, + sessionId: session.id, + }); + return NextResponse.json({ sessionId: session.id, url: session.url }); + } catch (error) { + await prisma.commerceOrder.update({ + data: { + attemptCount: { increment: 1 }, + lastError: error instanceof Error ? error.message : String(error), + status: CommerceOrderStatus.FAILED, + }, + where: { id: commerceOrder.id }, + }); + log.error("Failed to create store checkout session", error); + return NextResponse.json({ error: "Failed to start store checkout." }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/stripe/webhook/route.test.ts b/packages/web/src/app/api/stripe/webhook/route.test.ts new file mode 100644 index 00000000..14f71b8d --- /dev/null +++ b/packages/web/src/app/api/stripe/webhook/route.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + activityCreate: vi.fn(), + commerceFulfillmentCreate: vi.fn(), + commerceFulfillmentFindFirst: vi.fn(), + commerceOrderFindFirst: vi.fn(), + commerceOrderUpdate: vi.fn(), + constructEvent: vi.fn(), + fulfillShirtCheckoutSession: vi.fn(), + headersGet: vi.fn(), + isStripeConfigured: vi.fn(), + serverEnv: { + STRIPE_WEBHOOK_SECRET: "whsec_test" as string | undefined, + }, + userFindUnique: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ + get: mocks.headersGet, + })), +})); + +vi.mock("@/lib/stripe", () => ({ + isStripeConfigured: mocks.isStripeConfigured, + getStripeClient: () => ({ + webhooks: { + constructEvent: mocks.constructEvent, + }, + }), +})); + +vi.mock("@/lib/env", () => ({ + serverEnv: mocks.serverEnv, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + activity: { + create: mocks.activityCreate, + }, + commerceFulfillment: { + create: mocks.commerceFulfillmentCreate, + findFirst: mocks.commerceFulfillmentFindFirst, + }, + commerceOrder: { + findFirst: mocks.commerceOrderFindFirst, + update: mocks.commerceOrderUpdate, + }, + user: { + findUnique: mocks.userFindUnique, + }, + }, +})); + +vi.mock("@/lib/shirt-fulfillment.server", () => ({ + fulfillShirtCheckoutSession: mocks.fulfillShirtCheckoutSession, +})); + +import { POST } from "./route"; + +function makeWebhookRequest() { + return new Request("http://localhost/api/stripe/webhook", { + body: "{}", + headers: { + "stripe-signature": "sig_test", + }, + method: "POST", + }); +} + +describe("POST /api/stripe/webhook", () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.serverEnv.STRIPE_WEBHOOK_SECRET = "whsec_test"; + mocks.headersGet.mockReturnValue("sig_test"); + mocks.isStripeConfigured.mockReturnValue(true); + mocks.commerceOrderFindFirst.mockResolvedValue({ + buyerEmail: null, + buyerName: null, + id: "order_123", + items: [ + { + fulfillmentKind: "MANUAL_SPONSORSHIP", + id: "item_123", + offerKey: "flyer-run-sponsorship", + offerVariantKey: "flyers", + }, + ], + paidAt: null, + }); + mocks.commerceOrderUpdate.mockResolvedValue({ id: "order_123" }); + mocks.commerceFulfillmentFindFirst.mockResolvedValue(null); + mocks.commerceFulfillmentCreate.mockResolvedValue({ id: "fulfillment_123" }); + mocks.userFindUnique.mockResolvedValue({ id: "user_123" }); + mocks.activityCreate.mockResolvedValue({ id: "activity_123" }); + }); + + it("marks store-offer orders paid and creates manual fulfillment on checkout completion", async () => { + mocks.constructEvent.mockReturnValue({ + data: { + object: { + amount_total: 10000, + currency: "usd", + customer: "cus_123", + customer_details: { + email: "ada@example.com", + name: "Ada", + }, + id: "cs_test_store", + metadata: { + commerce_order_id: "order_123", + donorEmail: "ada@example.com", + donorName: "Ada", + offer_key: "flyer-run-sponsorship", + order_type: "store_offer", + sourceReferrer: "http://localhost:3001/store", + sourceUrl: "http://localhost:3001/store/flyer-run-sponsorship", + userId: "user_123", + variant_key: "flyers", + }, + mode: "payment", + payment_intent: "pi_123", + }, + }, + type: "checkout.session.completed", + }); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(mocks.commerceOrderFindFirst).toHaveBeenCalledWith({ + include: { items: true }, + where: { + OR: [{ id: "order_123" }, { stripeCheckoutSessionId: "cs_test_store" }], + deletedAt: null, + }, + }); + expect(mocks.commerceOrderUpdate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + buyerEmail: "ada@example.com", + buyerName: "Ada", + lastError: null, + status: "PAID", + stripeCheckoutSessionId: "cs_test_store", + stripeCustomerId: "cus_123", + stripePaymentIntentId: "pi_123", + }), + where: { id: "order_123" }, + }); + expect(mocks.commerceFulfillmentCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + externalOrderId: "cs_test_store", + orderId: "order_123", + orderItemId: "item_123", + provider: "MANUAL", + status: "PENDING", + }), + }); + expect(mocks.activityCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + entityId: "cs_test_store", + entityType: "StripeCheckoutSession", + type: "DONATED", + userId: "user_123", + }), + }); + }); +}); diff --git a/packages/web/src/app/api/stripe/webhook/route.ts b/packages/web/src/app/api/stripe/webhook/route.ts index 5d448224..5182a08e 100644 --- a/packages/web/src/app/api/stripe/webhook/route.ts +++ b/packages/web/src/app/api/stripe/webhook/route.ts @@ -1,18 +1,26 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; import type Stripe from "stripe"; -import { ActivityType } from "@optimitron/db"; +import { + ActivityType, + CommerceFulfillmentKind, + CommerceFulfillmentProvider, + CommerceFulfillmentStatus, + CommerceOrderStatus, + type Prisma, +} from "@optimitron/db"; import { getStripeClient, isStripeConfigured } from "@/lib/stripe"; import { serverEnv } from "@/lib/env"; import { prisma } from "@/lib/prisma"; import { createLogger } from "@/lib/logger"; +import { fulfillShirtCheckoutSession } from "@/lib/shirt-fulfillment.server"; const log = createLogger("stripe-webhook"); export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function GET() { +export function GET() { return NextResponse.json( { message: "Webhook endpoint is ready. Use POST for webhook events." }, { status: 200 }, @@ -29,7 +37,8 @@ export async function POST(req: Request) { } const body = await req.text(); - const signature = (await headers()).get("stripe-signature"); + const signature = + req.headers.get("stripe-signature") ?? (await headers()).get("stripe-signature"); if (!signature) { return NextResponse.json({ error: "No signature." }, { status: 400 }); @@ -53,13 +62,20 @@ export async function POST(req: Request) { switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; + if (session.metadata?.order_type === "shirt") { + await fulfillShirtCheckoutSession(session); + break; + } + if (session.metadata?.order_type === "store_offer") { + await recordStoreOfferPayment(session); + } if (session.mode === "payment" || session.mode === "subscription") { await recordDonationActivity(session); } break; } case "invoice.payment_succeeded": { - const invoice = event.data.object as Stripe.Invoice; + const invoice = event.data.object; log.info("Recurring donation invoice succeeded", { customerEmail: invoice.customer_email, amount: invoice.amount_paid, @@ -76,15 +92,90 @@ export async function POST(req: Request) { } } +function getStripeObjectId(value: string | { id?: string | null } | null | undefined) { + if (typeof value === "string") return value; + return value?.id ?? undefined; +} + +async function recordStoreOfferPayment(session: Stripe.Checkout.Session) { + const predicates: Prisma.CommerceOrderWhereInput[] = [ + { stripeCheckoutSessionId: session.id }, + ]; + if (session.metadata?.commerce_order_id) { + predicates.unshift({ id: session.metadata.commerce_order_id }); + } + + const commerceOrder = await prisma.commerceOrder.findFirst({ + include: { items: true }, + where: { + OR: predicates, + deletedAt: null, + }, + }); + + if (!commerceOrder) { + throw new Error(`Missing commerce order for Stripe Checkout session ${session.id}.`); + } + + const customerEmail = + session.metadata?.donorEmail ?? session.customer_email ?? session.customer_details?.email ?? null; + const customerName = session.metadata?.donorName ?? session.customer_details?.name ?? null; + + await prisma.commerceOrder.update({ + data: { + buyerEmail: commerceOrder.buyerEmail ?? customerEmail, + buyerName: commerceOrder.buyerName ?? customerName, + lastError: null, + paidAt: commerceOrder.paidAt ?? new Date(), + status: CommerceOrderStatus.PAID, + stripeCheckoutSessionId: session.id, + stripeCustomerId: getStripeObjectId(session.customer), + stripePaymentIntentId: getStripeObjectId(session.payment_intent), + }, + where: { id: commerceOrder.id }, + }); + + const manualItem = commerceOrder.items.find( + (item) => item.fulfillmentKind === CommerceFulfillmentKind.MANUAL_SPONSORSHIP, + ); + if (!manualItem) return; + + const existingFulfillment = await prisma.commerceFulfillment.findFirst({ + where: { + deletedAt: null, + externalOrderId: session.id, + provider: CommerceFulfillmentProvider.MANUAL, + }, + }); + if (existingFulfillment) return; + + await prisma.commerceFulfillment.create({ + data: { + externalOrderId: session.id, + metadata: { + commerceOrderId: commerceOrder.id, + commerceOrderItemId: manualItem.id, + offerKey: manualItem.offerKey, + offerVariantKey: manualItem.offerVariantKey, + stripeCheckoutSessionId: session.id, + } satisfies Prisma.InputJsonValue, + orderId: commerceOrder.id, + orderItemId: manualItem.id, + provider: CommerceFulfillmentProvider.MANUAL, + status: CommerceFulfillmentStatus.PENDING, + }, + }); +} + async function recordDonationActivity(session: Stripe.Checkout.Session) { const donorEmail = session.metadata?.donorEmail ?? session.customer_email ?? session.customer_details?.email ?? null; const donorName = session.metadata?.donorName ?? session.customer_details?.name ?? null; - const donationType = (session.metadata?.donationType as string | undefined) ?? "one-time"; + const donationType = session.metadata?.donationType ?? "one-time"; const amountCents = session.amount_total ?? 0; - const sourceUrl = (session.metadata?.sourceUrl as string | undefined) ?? null; - const sourceReferrer = (session.metadata?.sourceReferrer as string | undefined) ?? null; - const metadataUserId = (session.metadata?.userId as string | undefined) ?? null; + const sourceUrl = session.metadata?.sourceUrl ?? null; + const sourceReferrer = session.metadata?.sourceReferrer ?? null; + const metadataUserId = session.metadata?.userId ?? null; let user = metadataUserId ? await prisma.user.findUnique({ diff --git a/packages/web/src/app/developers/page.logged-out.md b/packages/web/src/app/developers/page.logged-out.md index d2389266..3027201f 100644 --- a/packages/web/src/app/developers/page.logged-out.md +++ b/packages/web/src/app/developers/page.logged-out.md @@ -4,10 +4,10 @@ - Page title: Developers | Optimitron | International Campaign to End War and Disease - Meta description: Connect AI agents to the live Optimitron task graph so they can take the highest-value action to optimize Earth. -- Canonical: [missing] -- Open Graph title: International Campaign to End War and Disease -- Open Graph description: Let's trade one apocalypse out of humanity's 122-apocalypse mass-murder capacity for disease eradication in 36 years instead of 443. -- Open Graph image: https://warondisease.org/site-assets/warondisease/war-on-disease-og-1200x630.png +- Canonical: https://optimitron.com/developers +- Open Graph title: Developers | Optimitron +- Open Graph description: Connect AI agents to the live Optimitron task graph so they can take the highest-value action to optimize Earth. +- Open Graph image: [missing] - Twitter title: International Campaign to End War and Disease - Twitter description: Let's trade one apocalypse out of humanity's 122-apocalypse mass-murder capacity for disease eradication in 36 years instead of 443. @@ -54,7 +54,7 @@ #### RESEARCH WITHOUT LOSING THE THREAD - Ask: “Find every task and manual passage about Wefunder.” The agent searches tasks, reads blockers, checks the manual, and proposes a task bundle instead of handing you a pile of notes. #### COORDINATE WITHOUT LOSING THE THREAD -- The agent posts task comments for status updates, questions, and next steps. Comment posting handles comment notifications; delivery envelopes stay internal. +- The agent posts task comments for status updates, questions, and next steps. Comment notifications are handled automatically. #### MAKE THE QUEUE SMARTER - After research, the agent can draft new tasks with impact estimates and dependencies. They start as DRAFT so governance can review them before promotion. ### CLAUDE CODE @@ -85,6 +85,7 @@ http://localhost:3001/api/mcp #### SIGN IN - Click Create, then sign in. PKCE and dynamic client registration are handled automatically. No client ID or secret to paste. - MCP Server URL for step 2: +- http://localhost:3001/api/mcp #### HEADS-UP: DEEP RESEARCH MODE - Deep Research only surfaces tools named search and fetch. Optimitron's tools won't appear there. Use regular chat or Agent mode. ### CURSOR, WINDSURF, CLINE, ZED, ET AL. diff --git a/packages/web/src/app/developers/page.tsx b/packages/web/src/app/developers/page.tsx index c8fe3c89..e9bf40d1 100644 --- a/packages/web/src/app/developers/page.tsx +++ b/packages/web/src/app/developers/page.tsx @@ -4,13 +4,30 @@ import { SectionHeader } from "@/components/ui/section-header"; import { Container } from "@/components/ui/container"; import { BrutalCard } from "@/components/ui/brutal-card"; import { CopyableCode } from "@/components/ui/copyable-code"; -import { ALL_SCOPES, MCP_SCOPE_DESCRIPTIONS, scopeToWire } from "@/lib/mcp-scopes"; -import { getConfiguredSiteOrigin } from "@/lib/site"; +import { + ALL_SCOPES, + MCP_SCOPE_DESCRIPTIONS, + scopeToWire, +} from "@/lib/mcp-scopes"; +import { + OPTIMITRON_CANONICAL_ORIGIN, + getConfiguredSiteOrigin, +} from "@/lib/site"; export const metadata: Metadata = { + metadataBase: new URL(OPTIMITRON_CANONICAL_ORIGIN), title: "Developers | Optimitron", description: "Connect AI agents to the live Optimitron task graph so they can take the highest-value action to optimize Earth.", + alternates: { + canonical: "/developers", + }, + openGraph: { + title: "Developers | Optimitron", + description: + "Connect AI agents to the live Optimitron task graph so they can take the highest-value action to optimize Earth.", + url: "/developers", + }, }; export default function DevelopersPage() { @@ -50,76 +67,99 @@ export default function DevelopersPage() {
-

Pick Work

+

+ Pick Work +

Ask what to do next instead of browsing a backlog by vibes.

  • - getQueueAudit — check whether the queue is sane + getQueueAudit — check + whether the queue is sane
  • - getNextAction — best next action across tasks + getNextAction — best + next action across tasks
  • - evaluateTaskEconomics — execute, delegate, procure, or fundraise + evaluateTaskEconomics — + execute, delegate, procure, or fundraise
-

Understand

+

+ Understand +

Pull the evidence before changing strategy or assigning work.

  • - searchManual — find source passages + searchManual — find + source passages
  • - askWishonia — synthesized answer with sources + askWishonia — + synthesized answer with sources
  • - getTask / getBlockers — inspect details and dependencies + getTask /{" "} + getBlockers — inspect + details and dependencies
-

Improve Queue

+

+ Improve Queue +

- Turn research into reviewable work instead of dumping notes in chat. + Turn research into reviewable work instead of dumping notes in + chat.

  • - proposeTaskBundle — draft tasks for review + proposeTaskBundle — + draft tasks for review
  • - setTaskImpact — attach expected value + setTaskImpact — attach + expected value
  • - addDependency — wire the task graph + addDependency — wire the + task graph
-

Coordinate

+

+ Coordinate +

Keep concurrent agents from stepping on the same task.

  • - acquireLease — reserve active work + acquireLease — reserve + active work
  • - heartbeatLease — keep long work alive + heartbeatLease — keep + long work alive
  • - releaseLease / logAgentRun — close the loop + releaseLease /{" "} + logAgentRun — close the + loop
@@ -132,13 +172,16 @@ export default function DevelopersPage() {

  • - postTaskComment — leave status, questions, and agent notes + postTaskComment — leave + status, questions, and agent notes
  • - getTaskComments — read the task thread + getTaskComments — read + the task thread
  • - getFundingStats — see budget before paid work + getFundingStats — see + budget before paid work
@@ -151,13 +194,16 @@ export default function DevelopersPage() {

  • - completeTaskClaim — submit completed work + completeTaskClaim — + submit completed work
  • - recordTaskActuals — log effort and cost + recordTaskActuals — log + effort and cost
  • - postTaskComment — leave context + postTaskComment — leave + context
@@ -185,7 +231,7 @@ export default function DevelopersPage() { /> /mcp {" "} - inside Claude Code. You'll be redirected to sign in. Once approved, the agent can read and write your tasks. + inside Claude Code. You'll be redirected to sign in. Once + approved, the agent can read and write your tasks.

@@ -253,7 +300,8 @@ export default function DevelopersPage() {

- Plus, Pro, Business, Enterprise, and Edu only. Free tier doesn't allow custom connectors. Take it up with OpenAI. + Plus, Pro, Business, Enterprise, and Edu only. Free tier + doesn't allow custom connectors. Take it up with OpenAI.

MCP Server URL for step 2:

+

+ {mcpUrl} +

-

Heads-up: Deep Research mode

+

+ Heads-up: Deep Research mode +

- Deep Research only surfaces tools named search and fetch. Optimitron's tools won't appear there. Use regular chat or Agent mode. + Deep Research only surfaces tools named{" "} + search and{" "} + fetch. Optimitron's + tools won't appear there. Use regular chat or Agent mode.

@@ -292,9 +348,13 @@ export default function DevelopersPage() { {/* Other MCP clients */} - +

- Most MCP clients accept the same JSON. Find your client's config file and paste: + Most MCP clients accept the same JSON. Find your client's + config file and paste:

@@ -302,14 +362,20 @@ export default function DevelopersPage() {
  • Cursor:{" "} - ~/.cursor/mcp.json + + ~/.cursor/mcp.json +
  • Windsurf:{" "} - ~/.codeium/windsurf/mcp_config.json + + ~/.codeium/windsurf/mcp_config.json +
  • - Cline / Zed / others:{" "} + + Cline / Zed / others: + {" "} check your client's MCP docs for the config path.
@@ -321,14 +387,19 @@ export default function DevelopersPage() {

- Request specific scopes when connecting to control what the agent can do. + Request specific scopes when connecting to control what the agent + can do.

{ALL_SCOPES.map((scope) => (
- {scopeToWire(scope)} -

{MCP_SCOPE_DESCRIPTIONS[scope]}

+ + {scopeToWire(scope)} + +

+ {MCP_SCOPE_DESCRIPTIONS[scope]} +

))} @@ -348,7 +419,8 @@ export default function DevelopersPage() { POST {mcpUrl}

- Streamable HTTP transport (MCP protocol version 2025-03-26). Supports GET, POST, DELETE. + Streamable HTTP transport (MCP protocol version 2025-03-26). + Supports GET, POST, DELETE.

@@ -365,7 +437,9 @@ export default function DevelopersPage() {
-

OAuth Discovery

+

+ OAuth Discovery +

GET {baseUrl}/.well-known/oauth-authorization-server @@ -405,13 +479,7 @@ function StepCard({ ); } -function ExampleCard({ - title, - body, -}: { - title: string; - body: string; -}) { +function ExampleCard({ title, body }: { title: string; body: string }) { return (
diff --git a/packages/web/src/app/love/dating/dating-client.tsx b/packages/web/src/app/love/dating/dating-client.tsx new file mode 100644 index 00000000..d015bade --- /dev/null +++ b/packages/web/src/app/love/dating/dating-client.tsx @@ -0,0 +1,586 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Heart, Send, Shield, X } from "lucide-react"; + +type DatingProfileStatus = + | "ACTIVE" + | "BANNED" + | "DRAFT" + | "HIDDEN" + | "MODERATION_HOLD" + | "PAUSED"; + +interface Photo { + id: string; + imageUrl: string; + altText: string | null; + status: string; +} + +interface Profile { + id: string; + bio: string | null; + campaignDateIdeas: string[]; + displayCity: string | null; + displayCountryCode: string | null; + displayRegionCode: string | null; + headline: string | null; + lookingForText: string | null; + photos?: Photo[]; + relationshipIntents: string[]; + status: DatingProfileStatus; + wantsCampaignDates: boolean; +} + +interface Question { + id: string; + text: string; + answerOptions: unknown; + allowMultiple: boolean; + answers: Array<{ + answerValues: unknown; + acceptableValues: unknown; + importance: string; + }>; +} + +interface Candidate { + id: string; + headline: string | null; + bio: string | null; + displayCity: string | null; + photos: Photo[]; + user: { + email: string; + person: { + displayName: string; + image: string | null; + } | null; + }; +} + +function optionList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function firstAnswerValue(question: Question) { + const answer = question.answers[0]?.answerValues; + return Array.isArray(answer) && typeof answer[0] === "string" ? answer[0] : ""; +} + +function fieldClassName() { + return "w-full border-2 border-foreground bg-background px-3 py-2 text-base font-black text-foreground"; +} + +function buttonClassName(invert = true) { + return invert + ? "inline-flex items-center justify-center gap-2 border-2 border-foreground bg-foreground px-4 py-2 text-sm font-black uppercase text-background transition-colors hover:bg-background hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" + : "inline-flex items-center justify-center gap-2 border-2 border-foreground bg-background px-4 py-2 text-sm font-black uppercase text-foreground transition-colors hover:bg-foreground hover:text-background disabled:cursor-not-allowed disabled:opacity-60"; +} + +export function DatingProfileForm({ profile }: { profile: Profile | null }) { + const router = useRouter(); + const [headline, setHeadline] = useState(profile?.headline ?? ""); + const [bio, setBio] = useState(profile?.bio ?? ""); + const [lookingForText, setLookingForText] = useState(profile?.lookingForText ?? ""); + const [displayCity, setDisplayCity] = useState(profile?.displayCity ?? ""); + const [displayRegionCode, setDisplayRegionCode] = useState( + profile?.displayRegionCode ?? "", + ); + const [displayCountryCode, setDisplayCountryCode] = useState( + profile?.displayCountryCode ?? "US", + ); + const [status, setStatus] = useState( + profile?.status ?? "ACTIVE", + ); + const [wantsCampaignDates, setWantsCampaignDates] = useState( + profile?.wantsCampaignDates ?? true, + ); + const [campaignDateIdeas, setCampaignDateIdeas] = useState( + profile?.campaignDateIdeas?.join("\n") ?? + "Coffee plus QR flyers\nWalk plus posters\nMuseum plus asking two friends to vote", + ); + const [photoUrl, setPhotoUrl] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [message, setMessage] = useState(null); + + async function saveProfile() { + setIsSaving(true); + setMessage(null); + const response = await fetch("/api/dating/profile", { + body: JSON.stringify({ + bio, + campaignDateIdeas: campaignDateIdeas.split("\n"), + displayCity, + displayCountryCode, + displayRegionCode, + headline, + lookingForText, + relationshipIntents: ["DATES", "FRIENDS"], + status, + wantsCampaignDates, + }), + headers: { "Content-Type": "application/json" }, + method: "PATCH", + }); + + setIsSaving(false); + setMessage(response.ok ? "Saved" : "Could not save"); + router.refresh(); + } + + async function addPhoto() { + if (!photoUrl.trim()) return; + setMessage(null); + const response = await fetch("/api/dating/profile/photos", { + body: JSON.stringify({ imageUrl: photoUrl.trim() }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + setMessage(response.ok ? "Photo added for review" : "Could not add photo"); + if (response.ok) setPhotoUrl(""); + router.refresh(); + } + + return ( +
+
+ + +