39 Commits
Author SHA1 Message Date
jjbrunton 7d99192dbe fix(ota): guard RevenueCat production exports 2026-09-21 22:09:03 +01:00
jjbrunton e417f7f79a test(dev-local): cover native locale forwarding 2026-09-21 21:41:45 +01:00
jjbrunton 15c7894bec fix(web): redact invite replay requests 2026-09-06 20:54:19 +01:00
jjbrunton 57c7ecebe6 test(api): isolate drizzle migration ledger 2026-09-06 20:14:13 +01:00
jjbrunton 17372a9bf0 fix(mobile): stop guest warning spam 2026-09-06 19:19:56 +01:00
jjbrunton 0f9c9c2e15 fix(acquisition): use recognized privacy markers 2026-09-06 13:24:39 +01:00
jjbrunton 4a57f31615 fix(acquisition): close invite privacy gaps 2026-09-06 13:18:11 +01:00
jjbrunton 1c7084c033 merge: use safe invite cancellation 2026-09-06 12:56:31 +01:00
jjbrunton 0dfe4435b0 merge: add atomic invite cancellation 2026-09-06 12:56:31 +01:00
jjbrunton 5ac4843095 docs(couples): clarify invite cancellation retention 2026-09-06 12:56:11 +01:00
jjbrunton 8e48467414 fix(mobile): preserve joined invites on cancellation 2026-09-06 12:56:10 +01:00
jjbrunton 1fed348072 fix(couples): protect joined invites from cancellation 2026-09-06 12:54:40 +01:00
jjbrunton a1b62c3b47 merge: correct mobile invite disclosure 2026-09-06 12:50:13 +01:00
jjbrunton 4415dcf29f fix(mobile): keep pairing discoveries truthful 2026-09-06 12:49:57 +01:00
jjbrunton 95162d1889 merge: harden invite analytics redaction 2026-09-06 12:49:50 +01:00
jjbrunton dd1a47ecfa fix(web): scrub invite tokens before analytics 2026-09-06 12:49:32 +01:00
jjbrunton a02e9d531b merge: correct invite privacy copy 2026-09-06 12:47:48 +01:00
jjbrunton 49a9cd270f fix(web): clarify open answer visibility 2026-09-06 12:47:29 +01:00
jjbrunton 077f5433e1 merge: redact invite analytics URLs 2026-09-06 12:45:46 +01:00
jjbrunton ade2c42d51 fix(web): redact invite analytics URLs 2026-09-06 12:45:28 +01:00
jjbrunton daefdc6da9 merge: fix paired state cleanup tests 2026-09-06 12:41:47 +01:00
jjbrunton 029a90b338 fix(mobile): statically load paired unlock state 2026-09-06 12:41:23 +01:00
jjbrunton bb0c712ede merge: fix invite journey routing 2026-09-06 12:39:51 +01:00
jjbrunton 1c4ec2b412 fix(mobile): resolve pairing payoff route types 2026-09-06 12:39:38 +01:00
jjbrunton 21006cd041 merge: improve mobile invite journey 2026-09-06 12:38:57 +01:00
jjbrunton f9481054de feat(mobile): improve pairing invite journey 2026-09-06 12:38:16 +01:00
jjbrunton 922066f492 merge: improve web invite journey 2026-09-06 12:35:44 +01:00
jjbrunton ff2cf09170 feat(web): improve invite landing recovery 2026-09-06 12:31:48 +01:00
jjbrunton 402c027b14 merge: add mutual discovery OTA PRD 2026-09-06 09:33:45 +01:00
jjbrunton 1f5a939c3f docs(product): add mutual discovery OTA PRD 2026-09-06 09:33:35 +01:00
jjbrunton ea038c9863 merge: add sex fun market strategy 2026-09-05 22:55:52 +01:00
jjbrunton a431adad89 docs(product): add sex fun market strategy 2026-09-05 22:55:10 +01:00
jjbrunton 860986f900 Merge pull request #32 from jjbrunton/task/dare-catalogue-150
feat(dares): seed 150-item catalogue
2026-09-05 22:35:28 +01:00
jjbrunton 439f506fff feat(dares): seed 150-item catalogue 2026-09-05 22:30:39 +01:00
jjbrunton d998e32047 merge: require upfront OTA assessment 2026-09-05 22:15:42 +01:00
jjbrunton 7edd957547 docs(agents): require upfront OTA assessment 2026-09-05 22:15:29 +01:00
jjbrunton fdaa8c47bb merge: add full question catalogue draft 2026-09-05 21:41:20 +01:00
jjbrunton 04328fb3a8 fix(catalogue): integrate reviewed question sources 2026-09-05 21:36:20 +01:00
jjbrunton f373e3beb4 docs(catalogue): add full live catalogue draft 2026-09-05 21:03:46 +01:00
56 changed files with 14844 additions and 312 deletions

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
{
"version": 1,
"project": "Sauci Private Mutual Discovery, OTA-first delivery plan",
"overview": "Evolve Sauci into a private mutual-discovery journey for adult couples: choose a mood and private shared comfort ceiling, answer a short set privately, reveal only mutual interest, then choose an optional next action. Deliver independent client slices through Expo OTA where the installed binary is compatible, while keeping session privacy, consent state, catalogue safety, API, data, and operational changes as separately gated work.",
"goals": [
"Make the first useful experience a short, mood-led Quick Spark session rather than an open-ended question feed.",
"Reveal only mutual interest or both-partner responses and never reveal an unmatched preference or which partner selected a lower comfort ceiling.",
"Let a mutual match lead to an optional talk, save, or shared action without turning either partner into an assigned recipient.",
"Make defer, decline, a softer alternative, and withdrawal explicit, reversible where appropriate, and free of penalties.",
"Keep developer-authored content non-graphic, reviewed, and eligible before it can reach a customer.",
"Maximize JavaScript and bundled-asset work that can ship through OTA after live compatibility is proven."
],
"nonGoals": [
"No production deployment, OTA publish, store submission, migration execution, catalogue activation, or provider write is authorized by this PRD.",
"No explicit sexual instructions, content intended for sexual gratification, or use of UGC as a policy workaround.",
"No clinical relationship-repair service, coercion intervention, solo experience, points economy, or mandatory task assignment.",
"No disclosure of individual answers, individual comfort selections, intimate content, or prompt text through analytics or push notification previews.",
"No assumption that the legacy Supabase recommendation-function documentation describes the current standalone API implementation."
],
"successMetrics": [
"Measure only aggregate privacy-safe events: session starts, two-partner completion, mutual matches, optional talk/save/action selections, skip/defer/decline/withdraw/report/block, and 7/28-day repeat sessions.",
"No analytics event contains question text, response text, audio, photo, individual answer, individual ceiling, or intimate match detail.",
"A paired couple can complete a reviewed Quick Spark session and see only its allowed shared outcome.",
"A user can stop, defer, decline, or withdraw without losing access or creating a partner-visible attribution of their private reason.",
"Every OTA-labelled story is released only after its platform/runtime canary and final native-surface review pass."
],
"openQuestions": [
"Which reviewed mood taxonomy and initial prompt set best fit the product positioning without crossing the cross-platform content ceiling?",
"Should saved mutual interests expire automatically, be archived manually, or both?",
"What exact participant-visible wording preserves a withdrawal without falsely implying that a partner declined?",
"What age assurance, reporting, blocking, moderation ownership, rate limits, and incident process are effective in production for all relevant UGC surfaces?",
"Does the public Android build embed the same XPREM app id, channel headers, certificate, and runtime 1.0.8 configuration as the inspected source?"
],
"stack": {
"framework": "Expo React Native with Expo Router and TypeScript",
"mobileDelivery": "Self-hosted XPREM implementing Expo Updates; iOS public source runtime is 1.0.8 and commit 3659cc8 records iOS build 49, but installed-build compatibility requires a live canary",
"backend": "Standalone Node HTTP API with PostgreSQL and Drizzle migrations",
"admin": "React/Vite content administration UI",
"auth": "Hosted Supabase Auth only; product data remains in the standalone API database",
"analytics": "Existing privacy-safe telemetry conventions, expanded only after event-schema review"
},
"routes": [
{
"path": "/(app)/swipe",
"name": "Quick Spark entry and session",
"purpose": "Mood-led private short session, reusing the existing swipe feature while a dedicated route is evaluated."
},
{
"path": "/(app)/matches",
"name": "Mutual discovery outcomes",
"purpose": "Show a mutual result and route to existing conversation, saving, or a future shared-action flow."
},
{
"path": "/(app)/dares",
"name": "Existing dares",
"purpose": "Remain a separate sender-to-recipient challenge model; it must not be relabelled as a shared action."
},
{
"path": "/admin/questions and /admin/packs",
"name": "Question catalogue authoring",
"purpose": "Author, review, and activate only content whose enforced eligibility contract is proven."
}
],
"uiNotes": [
"Use the existing Swipe/Match feature colour, rose-to-purple gradient, locked flame and heart icons, dark solid surfaces, and high-contrast accessible controls.",
"Quick Spark should feel finite and calm: mood selection, a short 5-8 prompt progress state, private-answer reassurance, and a restrained completion state.",
"Comfort selection must describe a shared ceiling, not a score to compare. Never render the partner's value, a lower-value hint, or copy that attributes the ceiling to either person.",
"Outcome actions must be equal-weight choices. Talk opens the existing match conversation; Save is not shown until it has durable server semantics; shared action is not shown until its domain contract exists.",
"Safety actions must be clear, non-judgmental, and available before commitment. Avoid gamification, streak pressure, countdown pressure, or punitive copy."
],
"dataModel": [
{
"entity": "mutual_discovery_sessions",
"fields": ["id", "couple_id", "created_at", "status", "mood", "shared_comfort_ceiling", "expires_at"]
},
{
"entity": "mutual_discovery_session_participants",
"fields": ["session_id", "user_id", "private_comfort_selection", "completed_at", "withdrawn_at"]
},
{
"entity": "shared_actions",
"fields": ["id", "couple_id", "match_id", "status", "created_by", "saved_at", "deferred_at", "withdrawn_at", "expires_at"]
},
{
"entity": "shared_action_events",
"fields": ["id", "shared_action_id", "actor_id", "event_type", "created_at"]
},
{
"entity": "catalogue_review",
"fields": ["content_id", "content_kind", "content_status", "reviewed_by", "reviewed_at", "review_reason"]
}
],
"importFormat": {
"description": "No bulk import. Developer-authored prompts and packs use the existing audited admin authoring workflow and must remain unreviewed and ineligible until an editorial decision is recorded and server enforcement is verified.",
"example": {}
},
"rules": [
"Mutuality is server-calculated from couple-scoped inputs. A client must never infer mutuality from its own answer or a local cache.",
"The shared comfort ceiling is the minimum of two private selections, but neither private selection is returned to the partner or emitted in analytics.",
"A no produces no mutual match. Existing stored maybe semantics remain backwards compatible until a separately approved session contract changes them.",
"Existing `max_intensity` and pack selection gates are not a per-session private comfort protocol and must not be presented as one.",
"Current dares have sender and recipient roles. Reuse only a truthful optional handoff after the mutual shared-action domain exists; do not map shared action states onto dare states.",
"All API reads and writes enforce authenticated couple isolation server-side. Unauthorized or cross-couple identifiers return non-disclosing failures.",
"New developer-authored adult-oriented prompts cannot be activated until universal question and pack eligibility enforcement is proven in the active API path.",
"Push payloads remain generic and do not include question, answer, match, action, or intimate content text.",
"A new question interaction type, response payload, configuration shape, or match semantic updates docs/question-types.md and passes lint:harness in its implementation story.",
"OTA eligibility is assessed per final diff. JavaScript and assets are only OTA candidates when native code, dependencies, permissions, entitlements, config-plugin output, Expo SDK, ios/, android/, expo.version, and runtimeVersion remain unchanged."
],
"qualityGates": [
"npm run lint:harness",
"npm run verify:fast",
"npm run verify:full",
"Focused API route and integration tests for every changed domain",
"Focused mobile Jest tests, TypeScript check, lint, and observable iOS/Android simulator evidence for every changed mobile UI flow",
"Read-only OTA preflight and doctor checks for the targeted channel and platform before any separately authorized OTA publish",
"Live production compatibility and end-to-end read-back before a production release, including deployed API compatibility for any client change"
],
"stories": [
{
"id": "US-001",
"title": "Establish release and live-state gates for mutual discovery",
"status": "open",
"dependsOn": [],
"description": "As a release owner, I want a verified compatibility record before labelling a client slice OTA-safe so that an update reaches the intended installed app without assuming source equals production.",
"acceptanceCriteria": [
"Phase: 0, release readiness.",
"Deployment classification: operations and live-state verification, no OTA or store release in this story.",
"Record read-only evidence that public iOS source is version/runtime 1.0.8, commit 3659cc8 records build 49, and the production XPREM doctor serves v2 clients; separately record whether a real installed iOS build 49 receives a signed production manifest for runtime 1.0.8.",
"Record Android installed-build, XPREM header, certificate, channel, runtime, and manifest evidence independently; until then mark Android OTA eligibility unverified.",
"Map every deployed consumer for the proposed API contracts and identify backward-compatible response handling required before the first backend rollout.",
"Example: an installed iOS build reports runtime 1.0.8 and receives the expected signed production manifest, so a JS-only iOS Quick Spark diff may proceed to release review.",
"Negative case: source contains runtime 1.0.8 but the installed binary does not request or accept that manifest, so no OTA is published and a native/store remediation is planned."
]
},
{
"id": "US-002",
"title": "Prove universal reviewed-question eligibility before activating new content",
"status": "open",
"dependsOn": ["US-001"],
"description": "As a catalogue owner, I want the active recommendation and pack APIs to fail closed on review status so that a new Quick Spark prompt cannot become customer-visible before editorial approval.",
"acceptanceCriteria": [
"Phase: 0, safety prerequisite.",
"Deployment classification: API/data contract work, no OTA path by itself.",
"Trace the active standalone API recommendation and pack endpoints, not only legacy Supabase migrations, and document the effective review-status behavior before changing it.",
"If enforcement is absent, implement the smallest server-side, migration-backed eligibility contract with admin authoring parity and preserve current allowed content availability.",
"Ensure a stale mobile client cannot retrieve or answer an unreviewed or archived developer-authored question through any recommendation, pack-detail, or answer route.",
"Example: an allowed question in an enabled pack remains selectable for a paired couple.",
"Negative case: an unreviewed question is returned by neither a direct pack request nor a recommendation, even when a stale client sends its id."
]
},
{
"id": "US-003",
"title": "Add a Quick Spark mood entry shell",
"status": "open",
"dependsOn": ["US-001", "US-002"],
"description": "As a paired user, I want to choose a gentle mood before a short private session so that the discovery experience feels intentional rather than like an endless feed.",
"acceptanceCriteria": [
"Phase: 1, OTA-first client foundation.",
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
"Add a thin route entry and focused swipe-feature components for a reviewed fixed mood taxonomy, selected locally for this prototype with no new persistence contract.",
"Show paired, loading, no-pack, unpaired, and retry states using existing truthful states; do not promise mutual outcomes to an unpaired user whose answers remain sealed.",
"Provide stable accessibility labels or testIDs for mood choices and continue action.",
"Example: a paired user chooses 'playful' and continues into the existing question flow with a clear short-session explanation.",
"Negative case: an unpaired user cannot be shown copy implying that their private answer will match with a partner."
]
},
{
"id": "US-004",
"title": "Present a finite Quick Spark card sequence",
"status": "open",
"dependsOn": ["US-003"],
"description": "As a user in Quick Spark, I want a finite 5-8 prompt session with honest progress so that I can participate without open-ended pressure.",
"acceptanceCriteria": [
"Phase: 1, OTA-first client foundation.",
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
"Compose the sequence only from already eligible questions returned by the current API and preserve existing answer, skip, upload, retry, and sealed-answer behavior.",
"Render progress as completed prompts out of the actual session length and make skip available without punitive copy or a lost-progress state.",
"Keep route files thin and add focused tests for sequence construction, progress, and early exit.",
"Example: a session with six eligible questions shows progress from 1 of 6 through 6 of 6 and ends in a completion state.",
"Negative case: with fewer than five eligible questions, the UI does not pad with ineligible or repeated prompts and instead gives a truthful fallback."
]
},
{
"id": "US-005",
"title": "Add privacy-preserving Quick Spark completion and existing-talk handoff",
"status": "open",
"dependsOn": ["US-004"],
"description": "As a user who finishes a short session, I want a clear private completion state and a way to talk about an existing mutual match so that the app supports choice without inventing a new server state.",
"acceptanceCriteria": [
"Phase: 1, OTA-first client foundation.",
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
"Use only existing server-calculated match data and route Talk to the existing match conversation; do not persist a new Save or shared-action state in local storage as if it were couple-shared.",
"If no mutual result exists yet because the partner has not answered, show a neutral waiting state without exposing the current user's answer or implying partner rejection.",
"Make completion, Talk, and dismiss controls accessible and testable.",
"Example: both partners answer yes to an eligible swipe prompt and the completion outcome can open that existing match conversation.",
"Negative case: one partner answers yes and the other has not answered, so no prompt text, answer, or individual interest is disclosed."
]
},
{
"id": "US-006",
"title": "Create the per-session private comfort schema and API contract",
"status": "open",
"dependsOn": ["US-001", "US-002"],
"description": "As a couple, I want each partner to privately choose a comfort level that produces a shared ceiling so that selection is bounded without exposing who chose the lower level.",
"acceptanceCriteria": [
"Phase: 2, server foundation.",
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
"Add a committed Drizzle migration and a standalone API contract for a couple-scoped session and one private selection per participant, with idempotency and explicit session lifecycle validation.",
"Calculate and return only a shared ceiling suitable for the caller. Do not return either participant's private selection to the other participant or analytics payloads.",
"Enforce authentication, couple isolation, participant membership, duplicate-submission handling, and non-disclosing errors for foreign session ids.",
"Example: selections 2 and 4 yield a shared ceiling of 2 while each partner can only retrieve the shared result.",
"Negative case: a user outside the couple cannot read, set, or infer any session or individual selection."
]
},
{
"id": "US-007",
"title": "Apply shared comfort selection to server-side Quick Spark composition",
"status": "open",
"dependsOn": ["US-006"],
"description": "As a session participant, I want the prompt set selected under our shared ceiling so that the content respects both private choices.",
"acceptanceCriteria": [
"Phase: 2, server foundation.",
"Deployment classification: API deployment required; a later compatible mobile consumer can be OTA-delivered only after US-001 platform gates pass.",
"Add a couple-scoped session recommendation endpoint or extend a documented endpoint so the API filters reviewed, enabled, unanswered prompts by the calculated session ceiling.",
"Preserve existing recommendation behavior for non-session clients and document any backward-compatible response fields before rollout.",
"Ensure response ordering does not leak an excluded higher-intensity prompt or either participant's private selection.",
"Example: a shared ceiling of 2 returns only eligible intensity 1-2 reviewed prompts from enabled inventory.",
"Negative case: an intensity 3 prompt is not returned or inferable when one participant privately selected 2."
]
},
{
"id": "US-008",
"title": "Wire private comfort selection and server-backed Quick Spark into mobile",
"status": "open",
"dependsOn": ["US-004", "US-006", "US-007"],
"description": "As a paired user, I want to select my comfort privately and receive only the shared session outcome so that the client enforces the same privacy model as the API.",
"acceptanceCriteria": [
"Phase: 2, client integration.",
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
"Replace prototype-only composition with the authenticated session API and handle loading, incomplete-partner, expired-session, retry, and non-disclosing failure states.",
"Render only the caller's choice before submission and only the returned shared ceiling afterwards. Clear user-scoped local state at sign-out.",
"Do not cache, log, or send individual comfort values to analytics beyond the server request needed to create the session.",
"Example: a user chooses a private level, resumes later, and sees a waiting state until both participants have selected without seeing the partner's value.",
"Negative case: a replayed response or API error cannot leave another account's session or private value visible after sign-out/sign-in."
]
},
{
"id": "US-009",
"title": "Create a durable shared-action state machine",
"status": "open",
"dependsOn": ["US-001"],
"description": "As a couple with a mutual match, I want a shared action record with neutral lifecycle states so that Talk, Save, Defer, softer alternative, and withdrawal are truthful and do not overload the existing dare model.",
"acceptanceCriteria": [
"Phase: 3, server foundation.",
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
"Define and implement a committed migration, server-side state machine, actor permissions, expiry policy, and match linkage for shared actions independently of sent_dares.",
"Support the approved vocabulary for save, defer, decline, softer alternative, withdrawal, and completion only where their semantics are explicit; ensure a participant can withdraw without exposing a private reason.",
"Return generic, non-attributing partner-visible state copy and use generic push previews if notifications are later introduced.",
"Example: either member saves a mutual match for later and both can see the neutral saved state.",
"Negative case: a sender-to-recipient dare transition cannot be used to imply that a shared action was accepted, completed, or declined."
]
},
{
"id": "US-010",
"title": "Expose couple-isolated shared-action API routes and tests",
"status": "open",
"dependsOn": ["US-009"],
"description": "As a mobile client, I want a documented HTTP contract for shared actions so that the UI can offer optional choices without relying on client-only truth.",
"acceptanceCriteria": [
"Phase: 3, server foundation.",
"Deployment classification: API deployment required; no OTA path by itself.",
"Provide versioned read and mutation routes with schema validation, idempotency where retries are possible, non-disclosing cross-couple failures, and backward-compatible match responses.",
"Test every legal and illegal state transition, both partner roles, withdrawn and expired behavior, and match ownership.",
"Document the participant-visible contract and any interaction-type or match-semantics change in the maintained documentation in the same implementation.",
"Example: either partner can defer a saved shared action and the API returns a neutral deferred representation to both.",
"Negative case: a caller cannot mutate, list, or learn the existence of an action outside their couple."
]
},
{
"id": "US-011",
"title": "Add optional shared-action choices to mutual match outcomes",
"status": "open",
"dependsOn": ["US-005", "US-010"],
"description": "As a couple viewing a mutual match, I want equal, optional Talk, Save, and shared-action choices so that mutual discovery becomes a real-world possibility without pressure.",
"acceptanceCriteria": [
"Phase: 3, client integration.",
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
"Render shared-action UI only for server-confirmed mutual matches and use the shared-action API as the source of truth for save, defer, softer alternative, withdrawal, and completion state.",
"Keep Talk routed to existing match chat, keep existing Dares visually and semantically distinct, and make all action choices optional and dismissible.",
"Include accessible labels/testIDs and focused tests for loading, retry, stale response, and each safe state.",
"Example: a mutual match can be saved, then later shown as saved to both partners without exposing who initiated it.",
"Negative case: an unmatched question, a sealed solo answer, or a stale local match cannot create or display a shared action."
]
},
{
"id": "US-012",
"title": "Add a reviewed Tell Me Privately session variant",
"status": "open",
"dependsOn": ["US-002", "US-008"],
"description": "As a couple, I want a text or audio-led private response option with a both-partner reveal so that we can share a thought without an explicit content catalogue.",
"acceptanceCriteria": [
"Phase: 4, controlled content expansion.",
"Deployment classification: question-type changes require shared contract, API, admin, database, and mobile deployment; it is not OTA-only. A client renderer after the deployed contract may be an OTA candidate subject to US-001 gates.",
"First determine whether existing text_answer and audio types can meet the approved experience without changing payload or match semantics; prefer configuration and reviewed content if they can.",
"If a new interaction type, payload, configuration, or match semantic is necessary, change the shared union, committed database constraint, API, admin authoring, mobile renderer, docs/question-types.md, and harness contract together.",
"Ensure both-partner reveal preserves existing response-summary privacy and does not put user-authored text or audio in analytics or push previews.",
"Example: two partners submit allowed text answers and see the existing both_answered match representation with a Talk option.",
"Negative case: one partner's private text or audio cannot be revealed, notified, or analytically recorded before the other required response exists."
]
},
{
"id": "US-013",
"title": "Create privacy-safe measurement and release decision review",
"status": "open",
"dependsOn": ["US-008", "US-011", "US-012"],
"description": "As a product and safety owner, I want a reviewable aggregate measurement plan and staged release decision so that adoption never outruns consent, safety, or platform compliance.",
"acceptanceCriteria": [
"Phase: 5, controlled rollout readiness.",
"Deployment classification: analytics/API/client work plus release operations; OTA eligibility is evaluated per final client diff and does not authorize publishing.",
"Define a typed event schema containing only aggregate category and lifecycle fields, with a test that rejects prompt text, answer text, media paths, private comfort values, and intimate match detail.",
"Set pre-agreed stop and review thresholds for reports, blocks, skips, defers, declines, withdrawals, and error rates; do not frame safety signals as conversion loss to suppress.",
"Require production read-back of deployed API compatibility, content review evidence, UGC moderation and incident controls, and platform-specific OTA canary before a separately authorized staged release.",
"Example: an aggregate session_completed event records mood category and completion state but no prompt or response content.",
"Negative case: a telemetry payload containing a response text field, media path, or individual comfort value fails validation and is not sent."
]
}
]
}
@@ -0,0 +1,317 @@
-- Initial editorial dare catalogue. Catalogue IDs are deterministic so the seed
-- is replay-safe. Only rows that passed the universal store-safety review are
-- public and `allowed`; all other editorial inventory remains fail-closed.
WITH seed (
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason
) AS (VALUES
('1f5e0000-0000-4000-8000-100000000001', 'Little Things', 'Small gestures that make your partner feel seen.', '💌', false, true, false, 10, 1, 1, 1.00, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000002', 'Make Me Laugh', 'Silly challenges for a shared laugh.', '😄', false, true, false, 20, 1, 2, 1.33, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000003', 'Sweet & Romantic', 'Thoughtful dares for feeling close.', '💕', false, true, false, 30, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual romantic content'),
('1f5e0000-0000-4000-8000-100000000004', 'Surprise Me', 'Plan a little surprise for your partner.', '🎁', true, true, false, 40, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000005', 'Flirty Messages', 'Private messages with a little spark.', '💬', true, false, false, 50, 2, 3, 2.47, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-100000000006', 'Long-Distance Heat', 'Private dares for partners apart.', '📱', true, false, true, 60, 2, 3, 2.67, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000007', 'Risky Photos & Audio', 'Private photo and audio dares.', '📸', true, false, true, 70, 3, 4, 3.53, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000008', 'Hands On', 'Private in-person intimacy dares.', '🫶', true, false, true, 80, 3, 4, 3.40, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000009', 'After Dark', 'Private adult intimacy dares.', '🌙', true, false, true, 90, 4, 4, 4.00, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000010', 'Power Play', 'Private adult power-play dares.', '🔒', true, false, true, 100, 5, 5, 5.00, 'unreviewed', 'Private mature editorial inventory')
), upsert AS (
INSERT INTO dare_packs (
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason,
content_reviewed_at
)
SELECT id::uuid, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity::numeric(3, 2),
content_status::content_review_status, content_review_reason,
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
FROM seed
ON CONFLICT (id) DO UPDATE SET id = dare_packs.id
WHERE (dare_packs.name, dare_packs.description, dare_packs.icon,
dare_packs.is_premium, dare_packs.is_public, dare_packs.is_explicit,
dare_packs.sort_order, dare_packs.category_id, dare_packs.min_intensity,
dare_packs.max_intensity, dare_packs.avg_intensity, dare_packs.content_status,
dare_packs.content_review_reason, dare_packs.content_reviewed_at,
dare_packs.content_reviewed_by)
IS NOT DISTINCT FROM
(EXCLUDED.name, EXCLUDED.description, EXCLUDED.icon, EXCLUDED.is_premium,
EXCLUDED.is_public, EXCLUDED.is_explicit, EXCLUDED.sort_order,
EXCLUDED.category_id, EXCLUDED.min_intensity, EXCLUDED.max_intensity,
EXCLUDED.avg_intensity, EXCLUDED.content_status,
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
EXCLUDED.content_reviewed_by)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
WITH seed (id, pack_id, text, intensity, content_status, content_review_reason) AS (VALUES
-- Little Things
('1f5e0000-0000-4000-8000-000000000001', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a photo of something that reminded you of them.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000002', '1f5e0000-0000-4000-8000-100000000001', 'Hide a short note somewhere your partner will unexpectedly find it.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000003', '1f5e0000-0000-4000-8000-100000000001', 'Bring your partner their favourite snack without asking what they want.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000004', '1f5e0000-0000-4000-8000-100000000001', 'Take over the household job your partner hates most.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000005', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a drink exactly how they like it.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000006', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a compliment that has nothing to do with their appearance.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000007', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a proper uninterrupted hug.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000008', '1f5e0000-0000-4000-8000-100000000001', 'Choose a shared photo and tell your partner why you still love that moment.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000009', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a three-song playlist for their current mood.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000010', '1f5e0000-0000-4000-8000-100000000001', 'Leave your partner a voice note they can replay when they are having a bad day.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000011', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a homemade voucher for one chore-free evening.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000012', '1f5e0000-0000-4000-8000-100000000001', 'Put your phone away and give your partner your full attention for thirty minutes.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000013', '1f5e0000-0000-4000-8000-100000000001', 'Tell your partner about the first moment you realised they were special.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000014', '1f5e0000-0000-4000-8000-100000000001', 'Fix one small annoyance your partner has been putting off.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000015', '1f5e0000-0000-4000-8000-100000000001', 'Let your partner choose what you watch, play, or listen to together.', 1, 'allowed', 'Reviewed with Little Things pack'),
-- Make Me Laugh
('1f5e0000-0000-4000-8000-000000000016', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner the worst selfie you can take.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000017', '1f5e0000-0000-4000-8000-100000000002', 'Record a ten-second impression of your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000018', '1f5e0000-0000-4000-8000-100000000002', 'Draw your partner from memory and send them the result.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000019', '1f5e0000-0000-4000-8000-100000000002', 'Make a meme about your relationship.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000020', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner your cheesiest pickup line.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000021', '1f5e0000-0000-4000-8000-100000000002', 'Invent a ridiculous new nickname for your partner and use it until the dare expires.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000022', '1f5e0000-0000-4000-8000-100000000002', 'Record a dramatic advert explaining why someone should date your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000023', '1f5e0000-0000-4000-8000-100000000002', 'Tell your partner a painfully bad joke without laughing.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000024', '1f5e0000-0000-4000-8000-100000000002', 'Recreate one of your partner''s photos as badly as possible.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000025', '1f5e0000-0000-4000-8000-100000000002', 'Describe your relationship using only five emojis.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000026', '1f5e0000-0000-4000-8000-100000000002', 'Perform a thirty-second dance chosen by your partner.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000027', '1f5e0000-0000-4000-8000-100000000002', 'Wear a ridiculous accessory until your partner releases you from the dare.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000028', '1f5e0000-0000-4000-8000-100000000002', 'Serenade your partner with the least romantic song you can find.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000029', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner an embarrassing but harmless confession.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000030', '1f5e0000-0000-4000-8000-100000000002', 'Act out how you behaved on your first date.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
-- Sweet & Romantic
('1f5e0000-0000-4000-8000-000000000031', '1f5e0000-0000-4000-8000-100000000003', 'Write your partner a love note by hand.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000032', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a song that says what you struggle to put into words.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000033', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner three small things they do that make your life better.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000034', '1f5e0000-0000-4000-8000-100000000003', 'Plan an at-home date and keep the details secret.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000035', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a slow dance without waiting for the perfect song.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000036', '1f5e0000-0000-4000-8000-100000000003', 'Recreate one detail from your first date together.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000037', '1f5e0000-0000-4000-8000-100000000003', 'Kiss your partner like you have not seen them for a month.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000038', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a voice note describing your favourite memory of them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000039', '1f5e0000-0000-4000-8000-100000000003', 'Prepare your partner''s side of the bed exactly how they like it.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000040', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner five kisses, each in a different place.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000041', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner the quality you find most attractive in them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000042', '1f5e0000-0000-4000-8000-100000000003', 'Plan a mystery date and give your partner only one clue.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000043', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a compliment they will still remember next week.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000044', '1f5e0000-0000-4000-8000-100000000003', 'Read your partner a message you sent during the early days of your relationship.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000045', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner ten minutes of affection without looking at either of your phones.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
-- Surprise Me
('1f5e0000-0000-4000-8000-000000000046', '1f5e0000-0000-4000-8000-100000000004', 'Leave your partner''s favourite treat somewhere they will discover it.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000047', '1f5e0000-0000-4000-8000-100000000004', 'Complete one job your partner expects to do themselves.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000048', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner an unexpected food delivery.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000049', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a mystery date and tell your partner only when to be ready.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000050', '1f5e0000-0000-4000-8000-100000000004', 'Hide three clues leading your partner to a small surprise.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000051', '1f5e0000-0000-4000-8000-100000000004', 'Take your partner somewhere neither of you has visited before.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000052', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner flowers or their preferred equivalent for no particular reason.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000053', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner coordinates and ask them to meet you there.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000054', '1f5e0000-0000-4000-8000-100000000004', 'Create a playlist that reveals what you have planned.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000055', '1f5e0000-0000-4000-8000-100000000004', 'Recreate your partner''s ideal lazy morning.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000056', '1f5e0000-0000-4000-8000-100000000004', 'Put together a date using only things already in your home.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000057', '1f5e0000-0000-4000-8000-100000000004', 'Choose an outfit for your partner from clothes they already own.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000058', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner an envelope they are not allowed to open until the dare expires.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000059', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a surprise based on something your partner mentioned weeks ago.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000060', '1f5e0000-0000-4000-8000-100000000004', 'Turn an ordinary meal into a date without warning your partner first.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
-- Flirty Messages
('1f5e0000-0000-4000-8000-000000000061', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three messages that become progressively flirtier.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000062', '1f5e0000-0000-4000-8000-100000000005', 'Record yourself saying your partner''s name in the most seductive way you can.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000063', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a selfie wearing something you know they like.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000064', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner where you want them to kiss you.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000065', '1f5e0000-0000-4000-8000-100000000005', 'Describe the first thing you notice when your partner enters a room.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000066', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a compliment you would not say in front of anyone else.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000067', '1f5e0000-0000-4000-8000-100000000005', 'Invent a secret phrase that means you want your partner.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000068', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a message designed to make them blush.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000069', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner what you would do if you were alone together.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000070', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three words designed to distract them.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000071', '1f5e0000-0000-4000-8000-100000000005', 'Record a voice note telling your partner what you want later.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000072', '1f5e0000-0000-4000-8000-100000000005', 'Send a close-up photo and make your partner guess what they are looking at.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000073', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner which item of their clothing you most want to remove.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000074', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner one instruction to follow when you next meet.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000075', '1f5e0000-0000-4000-8000-100000000005', 'Describe one fantasy in a single sentence without using vague words.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
-- Long-Distance Heat
('1f5e0000-0000-4000-8000-000000000076', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo showing exactly what you are doing.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000077', '1f5e0000-0000-4000-8000-100000000006', 'Record a goodnight message your partner will want to replay.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000078', '1f5e0000-0000-4000-8000-100000000006', 'Plan a video-call date and send your partner a dress code.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000079', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo of the empty space where you wish they were.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000080', '1f5e0000-0000-4000-8000-100000000006', 'Order your partner a surprise they can enjoy during your next call.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000081', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a risky selfie within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000082', '1f5e0000-0000-4000-8000-100000000006', 'Record a voice note telling your partner exactly how much you miss their touch.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000083', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo from bed without adding any explanation.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000084', '1f5e0000-0000-4000-8000-100000000006', 'Describe what you would do if your partner appeared beside you.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000085', '1f5e0000-0000-4000-8000-100000000006', 'Give your partner a private fashion show over video.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000086', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a sequence of photos that gradually reveal more.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000087', '1f5e0000-0000-4000-8000-100000000006', 'Record yourself whispering what you want your partner to do when you reunite.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000088', '1f5e0000-0000-4000-8000-100000000006', 'Let your partner choose what you wear during your next private call.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000089', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo they can use as their private wallpaper.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000090', '1f5e0000-0000-4000-8000-100000000006', 'End your next call with a promise your partner can hold you to.', 3, 'unreviewed', 'Private mature editorial inventory'),
-- Risky Photos & Audio
('1f5e0000-0000-4000-8000-000000000091', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo captioned "Only for you."', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000092', '1f5e0000-0000-4000-8000-100000000007', 'Take a silhouette photo that leaves your partner guessing.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000093', '1f5e0000-0000-4000-8000-100000000007', 'Record a voice note describing what you are wearing.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000094', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo that reveals just enough to make them curious.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000095', '1f5e0000-0000-4000-8000-100000000007', 'Take a photo from your partner''s favourite angle.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000096', '1f5e0000-0000-4000-8000-100000000007', 'Record a risky audio clip for your partner within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000097', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo showing what you plan to wear underneath your clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000098', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a topless photo they have not seen before.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000099', '1f5e0000-0000-4000-8000-100000000007', 'Record yourself describing exactly what you want your partner to do to you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000100', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a nude using lighting or shadows creatively.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000101', '1f5e0000-0000-4000-8000-100000000007', 'Record the sound you make when your partner touches you properly.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000102', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a short striptease video.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000103', '1f5e0000-0000-4000-8000-100000000007', 'Take a nude photo without showing your face.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000104', '1f5e0000-0000-4000-8000-100000000007', 'Record a video showing your partner how badly you want them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000105', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner the most explicit photo you are both comfortable keeping.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- Hands On
('1f5e0000-0000-4000-8000-000000000106', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a ten-minute massage.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000107', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner in five places without kissing their lips.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000108', '1f5e0000-0000-4000-8000-100000000008', 'Trace your fingertips over your partner''s body for five minutes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000109', '1f5e0000-0000-4000-8000-100000000008', 'Sit on your partner''s lap and kiss them slowly.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000110', '1f5e0000-0000-4000-8000-100000000008', 'Blindfold your partner and feed them something they enjoy.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000111', '1f5e0000-0000-4000-8000-100000000008', 'Let your partner choose where you kiss them.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000112', '1f5e0000-0000-4000-8000-100000000008', 'Tease your partner for ten minutes without touching beneath their clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000113', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a massage and stop exactly when they want more.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000114', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner''s neck until they ask you to stop.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000115', '1f5e0000-0000-4000-8000-100000000008', 'Remove one item of your partner''s clothing using only one hand.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000116', '1f5e0000-0000-4000-8000-100000000008', 'Guide your partner''s hand to where you want it.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000117', '1f5e0000-0000-4000-8000-100000000008', 'Undress your partner as slowly as possible.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000118', '1f5e0000-0000-4000-8000-100000000008', 'Join your partner in the shower.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000119', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your way down your partner''s body and stop just before reaching their most sensitive spot.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000120', '1f5e0000-0000-4000-8000-100000000008', 'Pleasure your partner for ten minutes without letting them return the favour.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- After Dark
('1f5e0000-0000-4000-8000-000000000121', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly what you want, then do it together if they want it too.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000122', '1f5e0000-0000-4000-8000-100000000009', 'Give your partner oral sex.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000123', '1f5e0000-0000-4000-8000-100000000009', 'Have sex in a position chosen by your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000124', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate while your partner watches.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000125', '1f5e0000-0000-4000-8000-100000000009', 'Watch your partner masturbate without touching them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000126', '1f5e0000-0000-4000-8000-100000000009', 'Use a sex toy on your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000127', '1f5e0000-0000-4000-8000-100000000009', 'Let your partner use a sex toy on you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000128', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate together while watching each other.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000129', '1f5e0000-0000-4000-8000-100000000009', 'Blindfold your partner and pleasure them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000130', '1f5e0000-0000-4000-8000-100000000009', 'Have sex without completely removing your clothes.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000131', '1f5e0000-0000-4000-8000-100000000009', 'Take your partner somewhere private for a quickie.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000132', '1f5e0000-0000-4000-8000-100000000009', 'Act out a fantasy your partner has previously shared with you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000133', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly how to touch you while they do it.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000134', '1f5e0000-0000-4000-8000-100000000009', 'Spend twenty minutes pleasuring each other without penetration.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000135', '1f5e0000-0000-4000-8000-100000000009', 'Make your partner orgasm without using your hands.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- Power Play
('1f5e0000-0000-4000-8000-000000000136', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner three rules to follow until the dare expires.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000137', '1f5e0000-0000-4000-8000-100000000010', 'Kneel in front of your partner and wait for their first instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000138', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner restrain your wrists using an agreed quick-release restraint.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000139', '1f5e0000-0000-4000-8000-100000000010', 'Wear a blindfold and follow three instructions from your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000140', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner an agreed number of strikes using an agreed implement and intensity.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000141', '1f5e0000-0000-4000-8000-100000000010', 'Act as your partner''s submissive for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000142', '1f5e0000-0000-4000-8000-100000000010', 'Take control of your partner for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000143', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner choose your outfit, position, and posture.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000144', '1f5e0000-0000-4000-8000-100000000010', 'Send your partner a voice note promising to obey one agreed instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000145', '1f5e0000-0000-4000-8000-100000000010', 'Edge your partner and stop before they orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000146', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner decide when you are allowed to orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000147', '1f5e0000-0000-4000-8000-100000000010', 'Wear a collar or agreed symbol of submission for your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000148', '1f5e0000-0000-4000-8000-100000000010', 'Serve your partner without speaking for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000149', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner control a remote toy while you remain somewhere private.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000150', '1f5e0000-0000-4000-8000-100000000010', 'Finish the dare by giving your partner the aftercare they ask for.', 5, 'unreviewed', 'Private mature editorial inventory')
), upsert AS (
INSERT INTO dares (
id, pack_id, text, intensity, content_status, content_review_reason,
content_reviewed_at
)
SELECT id::uuid, pack_id::uuid, text, intensity,
content_status::content_review_status, content_review_reason,
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
FROM seed
ON CONFLICT (id) DO UPDATE SET id = dares.id
WHERE (dares.pack_id, dares.text, dares.intensity, dares.suggested_duration_hours,
dares.content_status, dares.content_review_reason, dares.content_reviewed_at,
dares.content_reviewed_by)
IS NOT DISTINCT FROM
(EXCLUDED.pack_id, EXCLUDED.text, EXCLUDED.intensity,
EXCLUDED.suggested_duration_hours, EXCLUDED.content_status,
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
EXCLUDED.content_reviewed_by)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
-- A migration has no authenticated human reviewer. Record the system seed as the
-- actor-less, timestamped review decision the schema explicitly permits rather than
-- fabricating a profile UUID. The entries remain durable audit provenance.
WITH seed (id, entity_type, entity_id, previous_status, new_status, reason, created_at) AS (
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dare_packs'::content_entity_type, id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, content_review_reason,
'2026-09-05T16:00:00Z'::timestamptz
FROM dare_packs
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
UNION ALL
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dares'::content_entity_type, id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, content_review_reason,
'2026-09-05T16:00:00Z'::timestamptz
FROM dares
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
), upsert AS (
INSERT INTO content_reviews (
id, entity_type, entity_id, previous_status, new_status, reason, changed_by, created_at
)
SELECT id::uuid, entity_type::content_entity_type, entity_id::uuid,
previous_status::content_review_status, new_status::content_review_status,
reason, null::uuid, created_at::timestamptz
FROM seed
ON CONFLICT (id) DO UPDATE SET id = content_reviews.id
WHERE (content_reviews.entity_type, content_reviews.entity_id,
content_reviews.previous_status, content_reviews.new_status,
content_reviews.reason, content_reviews.changed_by,
content_reviews.created_at)
IS NOT DISTINCT FROM
(EXCLUDED.entity_type, EXCLUDED.entity_id, EXCLUDED.previous_status,
EXCLUDED.new_status, EXCLUDED.reason, EXCLUDED.changed_by,
EXCLUDED.created_at)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM content_reviews r
WHERE (r.entity_type = 'dare_packs' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid AND '1f5e0000-0000-4000-8000-100000000004'::uuid)
OR (r.entity_type = 'dares' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid AND '1f5e0000-0000-4000-8000-000000000060'::uuid)
GROUP BY r.entity_type, r.entity_id
HAVING count(*) <> 1
) THEN
RAISE EXCEPTION 'catalogue seed review provenance is not one-to-one';
END IF;
IF EXISTS (
WITH expected (id, entity_type, entity_id, reason) AS (
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dare_packs'::content_entity_type, id, content_review_reason
FROM dare_packs
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
UNION ALL
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dares'::content_entity_type, id, content_review_reason
FROM dares
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
)
SELECT 1
FROM expected e
LEFT JOIN content_reviews r
ON r.entity_type = e.entity_type AND r.entity_id = e.entity_id
WHERE (r.id, r.previous_status, r.new_status, r.reason, r.changed_by,
r.created_at)
IS DISTINCT FROM
(e.id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, e.reason, null::uuid,
'2026-09-05T16:00:00Z'::timestamptz)
) THEN
RAISE EXCEPTION 'catalogue seed review provenance does not match the system seed';
END IF;
END $$;
+8 -1
View File
@@ -162,6 +162,13 @@
"when": 1788168150000,
"tag": "0022_solo_sealed_answers",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1788624000000,
"tag": "0023_dare_catalogue_150",
"breakpoints": true
}
]
}
}
@@ -33,6 +33,7 @@ export interface CoupleRepository {
getState(userId: string): Promise<CoupleStateResponse>;
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
join(userId: string, inviteCode: string): Promise<Couple>;
cancelInvite(userId: string): Promise<void>;
cancel(userId: string): Promise<void>;
close(): Promise<void>;
}
@@ -268,6 +269,43 @@ export class PostgresCoupleRepository implements CoupleRepository {
});
}
async cancelInvite(userId: string): Promise<void> {
await transaction(this.pool, async (client) => {
// Lock in the same order as join(): the caller's profile first, then the
// couple. A concurrent join therefore either completes first and makes
// this a safe no-op error, or observes that the invite no longer exists.
const profile = await profileForUpdate(client, userId);
if (!profile.couple_id) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
const coupleResult = await client.query<{ id: string }>(
'select id from couples where id = $1 for update',
[profile.couple_id],
);
if (!coupleResult.rows[0]) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
const members = await client.query<{ id: string }>(
'select id from profiles where couple_id = $1',
[profile.couple_id],
);
if (members.rows.length > 1) {
throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409);
}
if (members.rows.length !== 1 || members.rows[0]?.id !== userId) {
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
}
// At this point the couple has precisely the authenticated creator as a
// member. Removing it cannot delete paired data. Answers made before
// invite creation stay sealed; answers made while waiting are coupled
// and are deleted with the cancelled invite.
await client.query('delete from couples where id = $1', [profile.couple_id]);
});
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
+5
View File
@@ -50,4 +50,9 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
const result = await route(() => service.cancel(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/couple/invite', async (c) => {
const result = await route(() => service.cancelInvite(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
}
+5
View File
@@ -41,4 +41,9 @@ export class CoupleService {
await this.repository.cancel(userId);
return { success: true, couple_id: null };
}
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
await this.repository.cancelInvite(userId);
return { success: true, couple_id: null };
}
}
+1
View File
@@ -19,6 +19,7 @@ export type CoupleErrorCode =
| 'invalid_invite_code'
| 'couple_full'
| 'not_paired'
| 'invite_already_joined'
| 'invite_code_collision';
export class CoupleError extends Error {
@@ -68,6 +68,56 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
});
it('cancels a waiting invite only for its creator', async () => {
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const pack = 'abababab-abab-4bab-8bab-abababababab';
const sealedQuestion = 'acacacac-acac-4cac-8cac-acacacacacac';
const waitingQuestion = 'adadadad-adad-4dad-8dad-adadadadadad';
await pool.query('insert into question_packs(id, name) values ($1, $2)', [pack, 'Invite cancellation']);
await pool.query(
'insert into questions(id, pack_id, text) values ($1, $3, $2), ($4, $3, $5)',
[sealedQuestion, 'Sealed before invite', pack, waitingQuestion, 'Answered while waiting'],
);
await pool.query(
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')",
[randomUUID(), charlie, sealedQuestion],
);
await repository.create(charlie, coupleId, 'WAIT2345');
await pool.query(
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, $4, 'no')",
[randomUUID(), charlie, waitingQuestion, coupleId],
);
await repository.cancelInvite(charlie);
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [sealedQuestion])).rows[0].count).toBe(1);
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [waitingQuestion])).rows[0].count).toBe(0);
});
it('never deletes a joined couple through the invite cancellation path', async () => {
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
await repository.create(alice, coupleId, 'SAFE2345');
await repository.join(bob, 'SAFE2345');
const before = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
code: 'invite_already_joined',
status: 409,
});
const after = await pool.query<{ id: string; couple_id: string | null }>(
'select id, couple_id from profiles where id in ($1, $2) order by id',
[alice, bob],
);
expect(after.rows).toEqual(before.rows);
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
});
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
+26
View File
@@ -4,6 +4,7 @@ import type { AuthIdentity } from '../src/auth.js';
import type { CoupleRepository } from '../src/domains/couples/repository.js';
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
import { CoupleService } from '../src/domains/couples/service.js';
import { CoupleError } from '../src/domains/couples/types.js';
const identity: AuthIdentity = {
id: '11111111-1111-4111-8111-111111111111',
@@ -25,6 +26,7 @@ function setup() {
invite_code: inviteCode,
created_at: '2026-08-27T00:00:00.000Z',
})),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
};
@@ -80,4 +82,28 @@ describe('couple routes', () => {
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
});
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, couple_id: null });
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
expect(repository.cancel).not.toHaveBeenCalled();
});
it('does not turn a joined invite into relationship deletion', async () => {
const { app, repository } = setup();
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
);
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
expect(response.status).toBe(409);
expect(await response.json()).toEqual({
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
});
expect(repository.cancel).not.toHaveBeenCalled();
});
});
+10
View File
@@ -15,6 +15,7 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async () => couple),
join: vi.fn(async () => couple),
cancelInvite: vi.fn(async () => undefined),
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
...overrides,
@@ -61,4 +62,13 @@ describe('CoupleService', () => {
expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
});
it('uses the invite-only cancellation operation for a waiting creator', async () => {
const repo = repository();
const service = new CoupleService(repo);
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
expect(repo.cancel).not.toHaveBeenCalled();
});
});
+130
View File
@@ -1,5 +1,8 @@
import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
@@ -82,6 +85,133 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
});
it('seeds the 150-item editorial catalogue with fail-closed review states', async () => {
const seeded = await pool.query<{
name: string; is_premium: boolean; is_public: boolean; is_explicit: boolean;
content_status: string; min_intensity: number; max_intensity: number; avg_intensity: string; dare_count: string;
}>(`
select dp.name, dp.is_premium, dp.is_public, dp.is_explicit, dp.content_status,
dp.min_intensity, dp.max_intensity, dp.avg_intensity::text,
count(d.id)::text as dare_count
from dare_packs dp
join dares d on d.pack_id = dp.id
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
and '1f5e0000-0000-4000-8000-100000000010'::uuid
group by dp.id
order by dp.sort_order
`);
expect(seeded.rows).toEqual([
{ name: 'Little Things', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 1, avg_intensity: '1.00', dare_count: '15' },
{ name: 'Make Me Laugh', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.33', dare_count: '15' },
{ name: 'Sweet & Romantic', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
{ name: 'Surprise Me', is_premium: true, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
{ name: 'Flirty Messages', is_premium: true, is_public: false, is_explicit: false, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.47', dare_count: '15' },
{ name: 'Long-Distance Heat', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.67', dare_count: '15' },
{ name: 'Risky Photos & Audio', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.53', dare_count: '15' },
{ name: 'Hands On', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.40', dare_count: '15' },
{ name: 'After Dark', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 4, max_intensity: 4, avg_intensity: '4.00', dare_count: '15' },
{ name: 'Power Play', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 5, max_intensity: 5, avg_intensity: '5.00', dare_count: '15' },
]);
const distribution = await pool.query<{
total: string; free: string; premium: string; allowed: string; unreviewed: string; duplicate_texts: string; stat_mismatches: string;
}>(`
select count(*)::text as total,
count(*) filter (where not dp.is_premium)::text as free,
count(*) filter (where dp.is_premium)::text as premium,
count(*) filter (where d.content_status = 'allowed')::text as allowed,
count(*) filter (where d.content_status = 'unreviewed')::text as unreviewed,
(select count(*)::text from (
select text from dares
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
and '1f5e0000-0000-4000-8000-000000000150'::uuid
group by text having count(*) > 1
) duplicates) as duplicate_texts,
(select count(*)::text from (
select dp.id
from dare_packs dp
join dares expected on expected.pack_id = dp.id
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
and '1f5e0000-0000-4000-8000-100000000010'::uuid
group by dp.id
having dp.min_intensity is distinct from min(expected.intensity)
or dp.max_intensity is distinct from max(expected.intensity)
or dp.avg_intensity is distinct from round(avg(expected.intensity)::numeric, 2)
) mismatches) as stat_mismatches
from dares d
join dare_packs dp on dp.id = d.pack_id
where d.id between '1f5e0000-0000-4000-8000-000000000001'::uuid
and '1f5e0000-0000-4000-8000-000000000150'::uuid
`);
expect(distribution.rows[0]).toEqual({ total: '150', free: '45', premium: '105', allowed: '60', unreviewed: '90', duplicate_texts: '0', stat_mismatches: '0' });
const provenance = await pool.query<{ reviewed_rows: string; review_records: string; attributed_records: string }>(`
select
(select count(*)::text from (
select content_reviewed_at
from dare_packs
where id between '1f5e0000-0000-4000-8000-100000000001'::uuid
and '1f5e0000-0000-4000-8000-100000000004'::uuid
union all
select content_reviewed_at
from dares
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
and '1f5e0000-0000-4000-8000-000000000060'::uuid
) reviewed where content_reviewed_at = '2026-09-05T16:00:00Z'::timestamptz)::text as reviewed_rows,
(select count(*)::text from content_reviews
where (entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid)) as review_records,
(select count(*)::text from content_reviews
where ((entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid))
and changed_by is not null) as attributed_records
`);
expect(provenance.rows[0]).toEqual({ reviewed_rows: '64', review_records: '64', attributed_records: '0' });
});
it('is discovered by Drizzle and fails closed on a conflicting catalogue ID', async () => {
const migrationSchema = `dare_migrator_${randomUUID().replaceAll('-', '')}`;
await admin.query(`create schema "${migrationSchema}"`);
const migrationUrl = new URL(url!);
migrationUrl.searchParams.set('options', `-c search_path=${migrationSchema}`);
const migrationPool = new Pool({ connectionString: migrationUrl.toString() });
const dir = new URL('../drizzle/', import.meta.url);
try {
await migrate(drizzle(migrationPool), {
migrationsFolder: fileURLToPath(dir),
migrationsSchema: migrationSchema,
});
const discovered = await migrationPool.query<{ count: string }>(
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
);
expect(discovered.rows[0]?.count).toBe('150');
const seed = await readFile(new URL('0023_dare_catalogue_150.sql', dir), 'utf8');
const applySeed = async () => {
for (const statement of seed.split('--> statement-breakpoint')) {
if (statement.trim()) await migrationPool.query(statement);
}
};
await applySeed();
await migrationPool.query(
"update content_reviews set reason = 'Conflicting review' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
);
await expect(applySeed()).rejects.toThrow(/division by zero|review provenance/);
await migrationPool.query(
"update content_reviews set reason = 'Reviewed with Little Things pack' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
);
await migrationPool.query(
"update dares set text = 'Conflicting dare' where id = '1f5e0000-0000-4000-8000-000000000001'::uuid",
);
await expect(applySeed()).rejects.toThrow(/division by zero/);
} finally {
await migrationPool.end();
await admin.query(`drop schema if exists "${migrationSchema}" cascade`);
}
});
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
+24
View File
@@ -0,0 +1,24 @@
import { readdir, readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
interface JournalEntry {
idx: number;
tag: string;
}
describe('Drizzle migration journal', () => {
it('lists every ordered SQL migration exactly once', async () => {
const migrationsDirectory = new URL('../drizzle/', import.meta.url);
const journal = JSON.parse(await readFile(new URL('meta/_journal.json', migrationsDirectory), 'utf8')) as {
entries: JournalEntry[];
};
const migrationFiles = (await readdir(migrationsDirectory))
.filter((name) => name.endsWith('.sql'))
.sort();
const journalFiles = journal.entries.map((entry) => `${entry.tag}.sql`);
expect(journal.entries.map((entry) => entry.idx)).toEqual([...journal.entries.keys()]);
expect(new Set(journalFiles).size).toBe(journalFiles.length);
expect(journalFiles).toEqual(migrationFiles);
});
});
+3 -1
View File
@@ -76,10 +76,11 @@ export default function AppLayout() {
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnPaired = segmentStrings.includes("paired");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnPaired || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
@@ -367,6 +368,7 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen name="paired" options={{ href: null }} />
<Tabs.Screen
name="chat/[id]"
options={{
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { AccessibilityInfo, ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router, useLocalSearchParams } from "expo-router";
import { GradientBackground, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, typography } from "../../src/theme";
import { useAuthStore, useMatchStore } from "../../src/store";
import { markPairedUnlockSeen } from "../../src/lib/pairedUnlockSeen";
import { Events } from "../../src/lib/analytics";
export const POST_PAIR_ENTRY = "/(app)/swipe" as const;
const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : n <= 10 ? "6-10" : n <= 25 ? "11-25" : "26+";
export default function PairedScreen() {
const { user, couple, partner } = useAuthStore();
const { newMatchesCount, fetchMatches } = useMatchStore();
const [ready, setReady] = useState(false);
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
const heading = useRef<Text>(null);
const role = rawRole === "inviter" ? "inviter" : "joiner";
useEffect(() => {
let cancelled = false;
void fetchMatches(true).then((success) => {
if (cancelled) return;
setLoadedSuccessfully(success);
setReady(true);
});
return () => { cancelled = true; };
}, [fetchMatches]);
useEffect(() => {
if (!ready || !loadedSuccessfully || !user || !couple) return;
void markPairedUnlockSeen(user.id, couple.id);
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
const timer = setTimeout(() => {
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
}, 250);
return () => clearTimeout(timer);
}, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
const partnerName = partner?.name?.split(" ")[0] || "your partner";
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
const start = () => { Events.pairingUnlockCtaTapped(role, "start_session"); router.replace(POST_PAIR_ENTRY); };
const dismiss = () => { Events.pairingUnlockCtaTapped(role, "dismiss"); router.replace("/(app)"); };
if (!ready) {
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
}
if (!loadedSuccessfully) {
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
}
return <GradientBackground><View style={styles.container}>
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
{newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} shared discover{newMatchesCount === 1 ? "y" : "ies"} ready to explore</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See shared discoveries</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing is ready to compare yet. Answer a few questions and shared discoveries will appear here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>}
<TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity>
</View></GradientBackground>;
}
const styles = StyleSheet.create({ container:{flex:1,justifyContent:"center",padding:spacing.xl,gap:spacing.lg,alignItems:"center"}, icon:{width:88,height:88,borderRadius:44,alignItems:"center",justifyContent:"center"}, title:{...typography.largeTitle,color:colors.text,textAlign:"center"}, count:{...typography.title2,color:colors.premium.rose,textAlign:"center"}, body:{...typography.callout,color:colors.textSecondary,textAlign:"center"}, dismiss:{minHeight:44,justifyContent:"center",paddingHorizontal:spacing.lg}, dismissText:{...typography.subhead,color:colors.textSecondary} });
+216 -79
View File
@@ -10,7 +10,8 @@ import {
Linking,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
TouchableOpacity,
AccessibilityInfo,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
@@ -26,27 +27,45 @@ import { GradientBackground, GlassCard, GlassButton } from "../../src/components
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
import { ApiError } from "../../src/lib/apiClient";
import { hasSeenPairedUnlock } from "../../src/lib/pairedUnlockSeen";
import { PAIRING_PRIVACY_SUMMARY } from "../../src/lib/pairingPrivacyCopy";
export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>();
const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>();
const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [wasPrefilled, setWasPrefilled] = useState(false);
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false);
const [prefillAttempted, setPrefillAttempted] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
const [isPollOffline, setIsPollOffline] = useState(false);
const [isIncomingConflictDismissed, setIsIncomingConflictDismissed] = useState(false);
// Redirect if already paired. Pairing claims both members' sealed answers and
// computes matches server-side, so the matches store must refetch here too:
// whichever partner lands on this redirect first would otherwise show stale,
// pre-pairing matches until some unrelated screen happened to refresh it.
useEffect(() => {
if (couple && partner) {
void useMatchStore.getState().fetchMatches(true);
router.replace("/(app)");
}
}, [couple, partner]);
useEffect(() => {
if (!couple || !partner || !user) return;
let cancelled = false;
void (async () => {
if (typeof params.incomingCode === "string") {
await clearPendingInviteCode(user.id);
if (!cancelled) {
Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]);
}
return;
}
const seen = await hasSeenPairedUnlock(user.id, couple.id);
if (!cancelled) {
router.replace(seen ? "/(app)" : "/(app)/paired" as any);
}
})();
return () => { cancelled = true; };
}, [couple, partner, user, params.incomingCode]);
// One-time prefill: apply a code carried by a join link (route param), then
// a stashed code from before sign-in, and only otherwise offer a clipboard
@@ -68,15 +87,15 @@ export default function PairingScreen() {
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
if (isValidInviteCode(routeCode)) {
await applyPrefill(routeCode);
await clearPendingInviteCode();
await clearPendingInviteCode(user?.id);
if (!cancelled) setPrefillAttempted(true);
return;
}
const stashedCode = await getPendingInviteCode();
if (stashedCode) {
await applyPrefill(stashedCode);
await clearPendingInviteCode();
const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
if (stashedCode) {
await applyPrefill(stashedCode);
await clearPendingInviteCode(user?.id);
if (!cancelled) setPrefillAttempted(true);
return;
}
@@ -91,7 +110,7 @@ export default function PairingScreen() {
return () => {
cancelled = true;
};
}, [couple, params.code, prefillAttempted]);
}, [couple, params.code, prefillAttempted, user?.id]);
const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return;
@@ -113,7 +132,7 @@ export default function PairingScreen() {
// Poll every 5 seconds as fallback
const pollInterval = setInterval(() => {
fetchCouple();
void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true));
}, 5000);
return () => {
@@ -140,11 +159,12 @@ export default function PairingScreen() {
// Validate invite code format (8 alphanumeric characters)
const sanitizedCode = inviteCode.trim().toUpperCase();
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code.");
return;
setJoinError("That code isn't the right shape. Codes are 8 letters and numbers.");
return;
}
setIsSubmitting(true);
setIsSubmitting(true);
setJoinError(null);
try {
await coupleApi.join(sanitizedCode);
@@ -152,14 +172,26 @@ export default function PairingScreen() {
await fetchCouple(); // Fetch couple data
// Joining claims both members' sealed answers and computes matches
// server-side, so the joiner needs a fresh matches list too.
void useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true);
Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
]);
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
router.replace("/(app)/paired" as any);
} catch (error: any) {
const details = error instanceof ApiError ? error.details as { error?: { code?: string } } : undefined;
const code = details?.error?.code;
const message = code === "couple_full"
? "That code is already in use by two people."
: code === "already_paired"
? "You're already paired."
: code === "invalid_invite_code" && error.status === 404
? "We can't find that code. It may have been cancelled. Ask them to send a new invite."
: code === "invalid_invite_code"
? "That code isn't the right shape. Codes are 8 letters and numbers."
: /network|fetch|timeout/i.test(error?.message ?? "")
? "Can't reach Sauci. Check your connection and try again."
: getPairingError(error);
setJoinError(message);
AccessibilityInfo.announceForAccessibility(message);
} finally {
setIsSubmitting(false);
}
@@ -169,13 +201,14 @@ export default function PairingScreen() {
if (couple?.invite_code) {
await Clipboard.setStringAsync(couple.invite_code);
Events.inviteCodeCopied();
Alert.alert("Copied", "Invite code copied to clipboard");
Alert.alert("Copied", "Invite code copied to clipboard");
}
};
const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false);
setInviteCode(value);
const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false);
setInviteCode(value);
setJoinError(null);
};
const shareMessage = couple?.invite_code
@@ -204,39 +237,100 @@ export default function PairingScreen() {
}
};
const shareViaSMS = async () => {
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
await Linking.openURL(url);
Events.codeShared();
};
const shareViaSMS = async () => {
const url = `sms:&body=${encodeURIComponent(shareMessage)}`;
await Linking.openURL(url);
Events.codeShared();
};
const completeJoinedInvite = async () => {
await fetchUser();
await fetchCouple();
await useMatchStore.getState().fetchMatches(true);
router.replace("/(app)/paired" as any);
};
const cancelInvite = async (afterCancel?: () => Promise<void>) => {
try {
await coupleApi.cancelInvite();
await fetchUser();
await fetchCouple();
if (afterCancel) await afterCancel();
Events.pairingCancelled();
return true;
} catch (error: any) {
const apiCode = error instanceof ApiError
? (error.details as { error?: { code?: string } } | undefined)?.error?.code
: undefined;
if (apiCode === "invite_already_joined") {
await completeJoinedInvite();
return false;
}
Alert.alert(
"Couldn't cancel invite",
"Your invite may still be active. Check your connection and try again.",
[{ text: "Not now", style: "cancel" }, { text: "Try again", onPress: () => { void cancelInvite(afterCancel); } }],
);
return false;
}
};
const copyInviteLink = async () => {
if (couple?.invite_code) {
await Clipboard.setStringAsync(`https://sauci.app/join/${couple.invite_code}`);
}
};
const handleCancelPairing = async () => {
const handleCancelPairing = async () => {
Alert.alert(
"Cancel Pairing",
"Are you sure you want to cancel? Your invite code will be deleted.",
"Cancel this invite?",
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
[
{ text: "Keep Waiting", style: "cancel" },
{ text: "Keep invite", style: "cancel" },
{
text: "Cancel Pairing",
style: "destructive",
onPress: async () => {
setIsSubmitting(true);
try {
await coupleApi.cancel();
await fetchUser();
await fetchCouple();
Events.pairingCancelled();
} catch (error: any) {
Alert.alert("Error", getPairingError(error));
} finally {
text: "Cancel invite",
style: "destructive",
onPress: async () => {
setIsSubmitting(true);
try {
await cancelInvite();
} finally {
setIsSubmitting(false);
}
},
},
]
);
};
};
const incomingCode = typeof params.incomingCode === "string" && isValidInviteCode(normalizeInviteCode(params.incomingCode))
? normalizeInviteCode(params.incomingCode)
: null;
const handleCancelOwnAndPrefill = () => {
if (!incomingCode) return;
Alert.alert(
"Cancel this invite?",
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
[
{ text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode(user?.id) },
{
text: "Cancel mine and join", style: "destructive", onPress: async () => {
setIsSubmitting(true);
try {
await cancelInvite(async () => {
setInviteCode(incomingCode);
setWasPrefilled(true);
await clearPendingInviteCode(user?.id);
});
} finally {
setIsSubmitting(false);
}
},
},
],
);
};
if (isAuthLoading) {
return (
@@ -265,11 +359,23 @@ export default function PairingScreen() {
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Partner Code</Text>
<Text style={styles.title}>Your invite</Text>
</Animated.View>
<View style={styles.content}>
{/* Icon */}
<View style={styles.content}>
{incomingCode && !isIncomingConflictDismissed && (
<GlassCard variant="elevated">
<Text style={styles.conflictTitle}>Use this code instead?</Text>
<Text style={styles.conflictBody}>You have your own invite out. To join theirs, cancel yours first. Your code will stop working, and answers you've given since you created it will be deleted.</Text>
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
Cancel mine and join
</GlassButton>
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
<Text style={styles.cancelButtonText}>Keep my invite</Text>
</TouchableOpacity>
</GlassCard>
)}
{/* Icon */}
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.iconSection}
@@ -283,7 +389,7 @@ export default function PairingScreen() {
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Share this code with your partner to link your accounts
Your answers are ready to compare when you both answer
</Text>
</Animated.View>
@@ -295,8 +401,7 @@ export default function PairingScreen() {
>
<GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
They will unlock the moment your partner joins.
You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer.
</Text>
</GlassCard>
</Animated.View>
@@ -313,12 +418,12 @@ export default function PairingScreen() {
onPress={copyToClipboard}
activeOpacity={0.7}
>
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
<Text style={styles.code} accessibilityLabel={`Invite code ${couple.invite_code.toUpperCase().split("").join(" ")}`}>{couple.invite_code.toUpperCase()}</Text>
<View style={styles.copyIcon}>
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<Text style={styles.tapToCopy}>Tap to copy</Text>
<Text style={styles.tapToCopy}>Tap to copy your code</Text>
</GlassCard>
</Animated.View>
@@ -341,7 +446,7 @@ export default function PairingScreen() {
size="lg"
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
>
Share Invite Code
Share invite
</GlassButton>
</Animated.View>
@@ -354,10 +459,14 @@ export default function PairingScreen() {
<Ionicons name="chatbubble-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Messages</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<TouchableOpacity style={styles.quickShareButton} onPress={shareViaWhatsApp} activeOpacity={0.7}>
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>WhatsApp</Text>
</TouchableOpacity>
</TouchableOpacity>
<TouchableOpacity style={styles.quickShareButton} onPress={copyInviteLink} activeOpacity={0.7} testID="pairing-copy-link" accessibilityLabel="Copy invite link">
<Ionicons name="link-outline" size={20} color={colors.text} />
<Text style={styles.quickShareLabel}>Copy link</Text>
</TouchableOpacity>
</Animated.View>
{/* Waiting indicator */}
@@ -367,8 +476,9 @@ export default function PairingScreen() {
>
<View style={styles.waitingBadge}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.waitingText}>Waiting for your partner to join...</Text>
</View>
<Text style={styles.waitingText}>Waiting for them to join</Text>
</View>
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
</Animated.View>
{/* Cancel Button */}
@@ -382,7 +492,7 @@ export default function PairingScreen() {
style={styles.cancelButton}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
<Text style={styles.cancelButtonText}>Cancel invite</Text>
</TouchableOpacity>
</Animated.View>
</View>
@@ -427,7 +537,7 @@ export default function PairingScreen() {
<Ionicons name="link" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.subtitle}>
Link with your partner to start matching
{PAIRING_PRIVACY_SUMMARY}
</Text>
</Animated.View>
@@ -481,16 +591,21 @@ export default function PairingScreen() {
/>
{wasPrefilled && (
<Text style={styles.prefilledCaption}>
Code applied from your invite link
</Text>
)}
Code from your invite link
</Text>
)}
{joinError && (
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
{joinError}
</Text>
)}
<GlassButton
onPress={handleJoinCouple}
disabled={isSubmitting}
loading={isSubmitting}
fullWidth
>
Join Partner
Join your partner
</GlassButton>
</GlassCard>
</Animated.View>
@@ -516,7 +631,7 @@ export default function PairingScreen() {
disabled={isSubmitting}
fullWidth
>
Create New Code
Create invite code
</GlassButton>
</Animated.View>
</View>
@@ -668,7 +783,7 @@ const styles = StyleSheet.create({
textAlign: "center",
maxWidth: 280,
},
sealedCountText: {
sealedCountText: {
...typography.body,
color: colors.text,
textAlign: "center",
@@ -719,10 +834,32 @@ const styles = StyleSheet.create({
marginTop: spacing.xl,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
},
conflictTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
conflictBody: {
...typography.callout,
color: colors.textSecondary,
marginBottom: spacing.md,
},
warningText: {
...typography.caption1,
color: colors.warning,
textAlign: "center",
marginTop: spacing.sm,
},
joinError: {
...typography.callout,
color: colors.error,
marginBottom: spacing.md,
textAlign: "center",
},
cancelSection: {
alignItems: "center",
marginTop: spacing.xl,
+33 -46
View File
@@ -21,7 +21,7 @@ import {
handleNotificationResponse,
} from "../src/lib/notifications";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef } from "react";
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering
@@ -37,7 +37,8 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
};
}
import { Stack, router, usePathname } from "expo-router";
import { Stack, router, usePathname } from "expo-router";
import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
// GestureHandlerRootView is only needed on native platforms
@@ -50,7 +51,7 @@ import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
import { authClient } from "../src/lib/authClient";
import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
import { useGuestAccountWarning } from "../src/hooks/useGuestAccountWarning";
import { parseInviteLinkCode } from "../src/lib/inviteLink";
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
import { needsOnboarding } from "../src/constants/onboarding";
@@ -58,18 +59,17 @@ import { needsOnboarding } from "../src/constants/onboarding";
const queryClient = new QueryClient();
export default function RootLayout() {
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
const { fetchUser, setUser, user, isAuthenticated, isAnonymous } = useAuthStore();
const pathname = usePathname();
const appState = useRef(AppState.currentState);
const analyticsInitialized = useRef(false);
const [showGuestWarning, setShowGuestWarning] = useState(false);
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
const dismissGuestWarning = async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
};
const { visible: showGuestWarning, dismiss: dismissGuestWarning } = useGuestAccountWarning({
isAuthenticated,
isAnonymous,
userId: user?.id,
pathname,
});
// Initialize analytics after native modules are ready
@@ -89,22 +89,6 @@ export default function RootLayout() {
}
}, [pathname]);
// Warn guest users (first time only) that accounts are not recoverable
useEffect(() => {
if (!isAuthenticated || !isAnonymous) return;
if (checkingGuestWarning) return;
if (pathname?.startsWith("/(auth)")) return;
setCheckingGuestWarning(true);
hasSeenGuestAccountWarning()
.then((seen) => {
if (!seen) {
setShowGuestWarning(true);
}
})
.finally(() => setCheckingGuestWarning(false));
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
// Track warm starts when app comes back from background
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
@@ -129,14 +113,20 @@ export default function RootLayout() {
// are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
}
Events.inviteLinkOpened(inviteLink.source);
const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
if (user?.couple_id) {
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id));
if (user.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
}
} else {
await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
if (isAuthenticated && !onboardingPending) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
}
}
return;
}
@@ -264,7 +254,8 @@ export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureHandlerRootView style={{ flex: 1 }}>
<ReducedMotionConfig mode={ReduceMotion.System} />
<StatusBar style="light" />
<Stack
screenOptions={{
@@ -305,22 +296,18 @@ export default function RootLayout() {
<View style={styles.guestWarningActions}>
<TouchableOpacity
style={styles.guestWarningPrimary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
router.push("/(app)/settings/save-account" as any);
style={styles.guestWarningPrimary}
onPress={async () => {
await dismissGuestWarning();
router.push("/(app)/settings/save-account" as any);
}}
activeOpacity={0.8}
>
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.guestWarningSecondary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
}}
style={styles.guestWarningSecondary}
onPress={dismissGuestWarning}
activeOpacity={0.8}
>
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
+8 -8
View File
@@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore();
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
const [hasPendingInvite, setHasPendingInvite] = useState(false);
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
// Check once, after auth resolves, whether an invite code is waiting to be
// applied (e.g. the user tapped a join link before signing in).
@@ -18,16 +17,17 @@ export default function Index() {
}
let cancelled = false;
getPendingInviteCode().then((code) => {
if (!user?.id) return;
setPendingInviteCheck(null);
getPendingInviteCode(user.id).then((code) => {
if (cancelled) return;
setHasPendingInvite(!!code);
setPendingCodeChecked(true);
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
});
return () => {
cancelled = true;
};
}, [isLoading, isAuthenticated, user?.couple_id]);
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
// Show loading state while auth is being determined
if (isLoading) {
@@ -56,7 +56,7 @@ export default function Index() {
// Wait for the one-time pending invite code check before deciding the
// route, so a stashed code from a join link is applied without an extra
// trip through the home screen.
if (!pendingCodeChecked) {
if (pendingInviteCheck?.userId !== user?.id) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
@@ -64,7 +64,7 @@ export default function Index() {
);
}
if (hasPendingInvite) {
if (pendingInviteCheck?.hasInvite) {
return <Redirect href="/(app)/pairing" />;
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { isAbsolute } from 'node:path';
const requiredProductionRevenueCatConfig = [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
];
function fail(message) {
console.error(`EOAS Expo export guard: ${message}`);
process.exit(1);
}
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
const value = process.env[key];
const expected = process.env[`SAUCI_EOAS_EXPECTED_${key}`];
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required`);
if (prefix && !value.startsWith(prefix)) fail(`${key} must be a valid RevenueCat public key`);
if (value !== expected) fail(`${key} does not match the resolved production profile`);
}
const npx = process.env.SAUCI_EOAS_NPX;
if (!npx || !isAbsolute(npx)) fail('missing resolved npx executable');
const result = spawnSync(npx, process.argv.slice(2), { env: process.env, stdio: 'inherit' });
if (result.error) throw result.error;
process.exit(result.status ?? 1);
+103 -9
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { delimiter, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
@@ -27,6 +27,13 @@ const requiredPublicKeys = [
'EXPO_PUBLIC_SUPABASE_URL',
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
];
const requiredProductionRevenueCatConfig = [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
];
const EOAS_PACKAGE_RUNNER = 'sauci-eoas-npx';
const EOAS_RUNNER_BIN_DIR = join(projectDir, 'scripts', 'bin');
function fail(message) {
throw new Error(`OTA publish preflight: ${message}`);
@@ -67,9 +74,87 @@ export function resolvePublishEnvironment(target, environment = process.env, con
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`);
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`);
if (target === 'production') {
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
const value = env[key];
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required for production export`);
if (prefix && !value.startsWith(prefix)) {
fail(`${key} must be a valid ${prefix} RevenueCat public key`);
}
}
}
return { branch: profile.branch, env };
}
function exportFiles(outputDir) {
return readdirSync(outputDir, { withFileTypes: true }).flatMap((entry) => {
const entryPath = join(outputDir, entry.name);
return entry.isDirectory() ? exportFiles(entryPath) : [entryPath];
});
}
/**
* Expo inlines EXPO_PUBLIC_* values into the JavaScript bundle. Verify the
* isolated production export rather than trusting only the process environment:
* a missing key otherwise becomes an empty string on installed devices.
*/
export function assertProductionExportContainsRevenueCatConfig(outputDir, environment) {
const files = exportFiles(outputDir).filter((filePath) => statSync(filePath).isFile());
for (const [key] of requiredProductionRevenueCatConfig) {
const expectedValue = environment[key];
const present = files.some((filePath) => readFileSync(filePath).includes(expectedValue));
if (!present) fail(`production export does not contain ${key}`);
}
}
function resolveExecutable(command, pathValue) {
for (const directory of pathValue.split(delimiter)) {
if (!directory) continue;
const candidate = join(directory, command);
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// Continue looking through PATH.
}
}
fail(`could not resolve ${command} before configuring the EOAS package runner`);
}
/**
* EOAS starts its own Expo subprocess with EXPO_NO_DOTENV=1. Pin its package
* runner to our checked-in wrapper so the actual EOAS export fails before it
* starts if its inherited RevenueCat configuration differs from this profile.
*/
export function buildEoasEnvironment(environment) {
const originalPath = environment.PATH || process.env.PATH || '';
const expected = Object.fromEntries(requiredProductionRevenueCatConfig.map(([key]) => [
`SAUCI_EOAS_EXPECTED_${key}`,
environment[key],
]));
return {
...environment,
EXPO_NO_DOTENV: '1',
EOAS_PACKAGE_RUNNER,
PATH: `${EOAS_RUNNER_BIN_DIR}${delimiter}${originalPath}`,
SAUCI_EOAS_NPX: resolveExecutable('npx', originalPath),
...expected,
};
}
export function buildEoasPublishArgs(branch, rawPublishArgs, useProductionGuard = false) {
const args = [
'eoas',
'publish',
'--branch',
branch,
...parsePublishArgs(rawPublishArgs),
];
if (useProductionGuard) args.splice(4, 0, '--packageRunner', EOAS_PACKAGE_RUNNER);
return args;
}
export function parsePublishArgs(args) {
const parsed = [];
for (let index = 0; index < args.length; index += 1) {
@@ -112,6 +197,18 @@ function run(command, args, env) {
if (result.status !== 0) process.exit(result.status ?? 1);
}
function exportAndVerify(target, environment) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], environment);
if (target === 'production') {
assertProductionExportContainsRevenueCatConfig(outputDir, environment);
}
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
}
export function main(args = process.argv.slice(2), environment = process.env) {
const [target, ...remaining] = args;
const preflight = remaining.includes('--preflight');
@@ -122,12 +219,7 @@ export function main(args = process.argv.slice(2), environment = process.env) {
const { branch, env } = resolvePublishEnvironment(target, environment);
const exportEnv = { ...env, EXPO_NO_DOTENV: '1' };
if (preflight) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], exportEnv);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
exportAndVerify(target, exportEnv);
console.log(`OTA ${target} export preflight passed`);
return;
}
@@ -138,7 +230,9 @@ export function main(args = process.argv.slice(2), environment = process.env) {
return;
}
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
exportAndVerify(target, exportEnv);
const eoasEnvironment = target === 'production' ? buildEoasEnvironment(exportEnv) : exportEnv;
run('npx', buildEoasPublishArgs(branch, rawPublishArgs, target === 'production'), eoasEnvironment);
}
// Compare real filesystem paths. A URL pathname percent-encodes spaces, so
+88 -1
View File
@@ -1,6 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parsePublishArgs, resolvePublishEnvironment } from './ota-publish.mjs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
assertProductionExportContainsRevenueCatConfig,
buildEoasEnvironment,
buildEoasPublishArgs,
parsePublishArgs,
resolvePublishEnvironment,
} from './ota-publish.mjs';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const config = {
build: {
@@ -9,6 +20,9 @@ const config = {
EXPO_PUBLIC_API_URL: 'https://api.sauci.app',
EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key',
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY: 'appl_test_ios_public_key',
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: 'goog_test_android_public_key',
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID: 'Sauci Pro',
},
},
preview: {
@@ -28,6 +42,79 @@ test('production export uses the checked-in production profile', () => {
assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.sauci.app');
});
test('production export fails closed without valid RevenueCat public configuration', () => {
for (const [key, value] of [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', ''],
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'goog_wrong_platform_key'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', ''],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'appl_wrong_platform_key'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', ''],
]) {
const productionEnv = { ...config.build.production.env, [key]: value };
assert.throws(
() => resolvePublishEnvironment('production', {}, {
build: { ...config.build, production: { env: productionEnv } },
}),
new RegExp(key),
);
}
});
test('production preflight proves the compiled bundle contains RevenueCat configuration', () => {
const outputDir = mkdtempSync(join(tmpdir(), 'sauci-ota-export-test-'));
const environment = config.build.production.env;
try {
writeFileSync(
join(outputDir, 'index.js'),
[
environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY,
environment.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY,
environment.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID,
].join(','),
);
assert.doesNotThrow(() => assertProductionExportContainsRevenueCatConfig(outputDir, environment));
writeFileSync(join(outputDir, 'index.js'), environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY);
assert.throws(
() => assertProductionExportContainsRevenueCatConfig(outputDir, environment),
/EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/,
);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
});
test('EOAS uses a guarded package runner that receives the resolved production environment', () => {
const eoasEnvironment = buildEoasEnvironment({ ...config.build.production.env, PATH: process.env.PATH });
assert.equal(eoasEnvironment.EOAS_PACKAGE_RUNNER, 'sauci-eoas-npx');
assert.match(eoasEnvironment.PATH, /apps\/mobile\/scripts\/bin/);
assert.equal(eoasEnvironment.SAUCI_EOAS_EXPECTED_EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID, 'Sauci Pro');
const runnerPath = fileURLToPath(new URL('./bin/sauci-eoas-npx', import.meta.url));
const successful = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
env: { ...eoasEnvironment, SAUCI_EOAS_NPX: '/usr/bin/true' },
encoding: 'utf8',
});
assert.equal(successful.status, 0, successful.stderr);
const missingKey = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
env: { ...eoasEnvironment, EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: '', SAUCI_EOAS_NPX: '/usr/bin/true' },
encoding: 'utf8',
});
assert.equal(missingKey.status, 1);
assert.match(missingKey.stderr, /EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/);
assert.doesNotMatch(missingKey.stderr, /goog_test_android_public_key/);
assert.deepEqual(
buildEoasPublishArgs('production', ['--platform', 'all'], true),
['eoas', 'publish', '--branch', 'production', '--packageRunner', 'sauci-eoas-npx', '--platform', 'all'],
);
assert.deepEqual(
buildEoasPublishArgs('staging', ['--platform', 'ios']),
['eoas', 'publish', '--branch', 'staging', '--platform', 'ios'],
);
});
test('staging export requires the designated non-production Auth configuration', () => {
const environment = {
EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
@@ -0,0 +1,68 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
import { useGuestAccountWarning } from "@/hooks/useGuestAccountWarning";
jest.mock("@/lib/guestAccountWarningSeen", () => ({
hasSeenGuestAccountWarning: jest.fn(),
markGuestAccountWarningSeen: jest.fn(),
}));
const options = {
isAuthenticated: true,
isAnonymous: true,
userId: "guest-1",
pathname: "/(app)/pairing",
};
describe("useGuestAccountWarning", () => {
beforeEach(() => {
jest.mocked(hasSeenGuestAccountWarning).mockReset();
jest.mocked(markGuestAccountWarningSeen).mockReset();
});
it("does not reopen after a dismissal while the durable write is pending", async () => {
let finishMarking: (() => void) | undefined;
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
jest.mocked(markGuestAccountWarningSeen).mockImplementation(() => new Promise<void>((resolve) => {
finishMarking = resolve;
}));
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
await waitFor(() => expect(result.current.visible).toBe(true));
let dismissal: Promise<void> | undefined;
act(() => {
dismissal = result.current.dismiss();
});
expect(result.current.visible).toBe(false);
rerender({ ...options, pathname: "/(app)/settings" });
expect(result.current.visible).toBe(false);
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
finishMarking?.();
await act(async () => {
await dismissal;
});
});
it("checks once per anonymous account and can warn a later guest account", async () => {
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
await waitFor(() => expect(result.current.visible).toBe(true));
rerender({ ...options, pathname: "/(app)/settings" });
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.dismiss();
});
rerender({ ...options, userId: "guest-2" });
await waitFor(() => expect(hasSeenGuestAccountWarning).toHaveBeenCalledWith("guest-2"));
await waitFor(() => expect(result.current.visible).toBe(true));
});
});
@@ -12,12 +12,14 @@ describe("coupleApi", () => {
await coupleApi.getState();
await coupleApi.create();
await coupleApi.join("ABCD2345");
await coupleApi.cancelInvite();
await coupleApi.cancel();
expect(request.mock.calls).toEqual([
["/v1/couple"],
["/v1/couple", { method: "POST", body: {} }],
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
["/v1/couple/invite", { method: "DELETE" }],
["/v1/couple", { method: "DELETE" }],
]);
});
@@ -0,0 +1,20 @@
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
import AsyncStorage from "@react-native-async-storage/async-storage";
describe("guestAccountWarningSeen", () => {
beforeEach(() => {
return AsyncStorage.clear();
});
it("persists a dismissal for that anonymous account", async () => {
await markGuestAccountWarningSeen("guest-1");
await expect(hasSeenGuestAccountWarning("guest-1")).resolves.toBe(true);
});
it("does not suppress the recovery warning for a later guest account", async () => {
await markGuestAccountWarningSeen("guest-1");
await expect(hasSeenGuestAccountWarning("guest-2")).resolves.toBe(false);
});
});
@@ -1,22 +1,15 @@
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
describe("buildInviteShareMessage", () => {
it("leads with a plain invite when there are no sealed answers", () => {
const message = buildInviteShareMessage("ABCD1234", 0);
expect(message).toBe(
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
it("keeps a recoverable bare code in a separate sentence", () => {
const message = buildInviteShareMessage("AB12CD34", 3);
expect(message).toContain("https://sauci.app/join/AB12CD34");
expect(message).toContain("enter code AB12CD34 in Sauci");
expect(message).toContain("Vote-style questions show shared outcomes");
expect(message).toContain("open answers can be visible to us both after we each answer");
});
it("leads with earned value and singular wording for exactly one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 1);
expect(message).toBe(
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
);
});
it("pluralises for more than one sealed answer", () => {
const message = buildInviteShareMessage("ABCD1234", 5);
expect(message).toContain("I have already answered 5 questions about us.");
it("does not claim a sealed outcome before answers exist", () => {
expect(buildInviteShareMessage("AB12CD34", 0)).not.toContain("unlock");
});
});
@@ -0,0 +1,18 @@
jest.mock("@react-native-async-storage/async-storage", () => ({
getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(),
}));
import AsyncStorage from "@react-native-async-storage/async-storage";
import { clearPairedUnlockSeen, hasSeenPairedUnlock, markPairedUnlockSeen } from "../../lib/pairedUnlockSeen";
describe("paired unlock state", () => {
it("is scoped to both account and couple", async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue("true");
await hasSeenPairedUnlock("user-a", "couple-a");
await markPairedUnlockSeen("user-a", "couple-a");
await clearPairedUnlockSeen("user-a", "couple-a");
expect(AsyncStorage.getItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
expect(AsyncStorage.setItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a", "true");
expect(AsyncStorage.removeItem).toHaveBeenCalledWith("paired_unlock_seen_user-a_couple-a");
});
});
@@ -0,0 +1,8 @@
import { PAIRING_PRIVACY_SUMMARY } from "../../lib/pairingPrivacyCopy";
describe("PAIRING_PRIVACY_SUMMARY", () => {
it("explains what each response style reveals before a user answers", () => {
expect(PAIRING_PRIVACY_SUMMARY).toContain("Vote-style answers reveal shared outcomes");
expect(PAIRING_PRIVACY_SUMMARY).toContain("Text, audio, photo, and who-likely responses are shown after you both answer");
});
});
@@ -10,20 +10,40 @@ describe("pendingInviteCode", () => {
await AsyncStorage.clear();
});
it("stashes, retrieves, and clears a valid code", async () => {
await stashPendingInviteCode("abcd1234");
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
it("stashes, retrieves, and clears a valid code for its owner", async () => {
await stashPendingInviteCode("abcd1234", "user-a");
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
await clearPendingInviteCode();
await expect(getPendingInviteCode()).resolves.toBeNull();
await clearPendingInviteCode("user-a");
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
});
it("returns null when nothing has been stashed", async () => {
await expect(getPendingInviteCode()).resolves.toBeNull();
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
});
it("ignores a stashed value that no longer matches the invite code shape", async () => {
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
await expect(getPendingInviteCode()).resolves.toBeNull();
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
});
it("does not leak a claimed invite to a later account", async () => {
await stashPendingInviteCode("abcd1234");
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
await expect(getPendingInviteCode("user-b")).resolves.toBeNull();
await clearPendingInviteCode("user-b");
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
});
it("lets only one concurrently arriving account claim a pre-auth invite", async () => {
await stashPendingInviteCode("abcd1234");
const [first, second] = await Promise.all([
getPendingInviteCode("user-a"),
getPendingInviteCode("user-b"),
]);
expect([first, second].filter(Boolean)).toHaveLength(1);
});
});
@@ -8,6 +8,7 @@ import { useMessageStore } from "@/store/messageStore";
import { usePacksStore } from "@/store/packsStore";
import { useSubscriptionStore } from "@/store/subscriptionStore";
import type { Profile } from "@/types";
import AsyncStorage from "@react-native-async-storage/async-storage";
const profile = {
id: "me",
@@ -259,6 +260,7 @@ describe("authStore", () => {
it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
await useAuthStore.getState().signOut();
@@ -267,6 +269,7 @@ describe("authStore", () => {
sealedCount: 0,
isAuthenticated: false,
});
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
});
it("clears sealed answers when the auth listener clears the user", () => {
@@ -281,6 +284,14 @@ describe("authStore", () => {
});
});
it("clears a pending invite when the auth listener clears the user", async () => {
await AsyncStorage.setItem("pending_invite_code", "ABCD1234");
useAuthStore.setState({ user: profile, isAuthenticated: true } as any);
useAuthStore.getState().setUser(null);
await new Promise((resolve) => setTimeout(resolve, 0));
await expect(AsyncStorage.getItem("pending_invite_code")).resolves.toBeNull();
});
it('keeps a pending Apple name scoped to its original subject', () => {
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
@@ -50,10 +50,11 @@ describe('matchStore API behavior', () => {
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const inFlight = useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true, { silent: true });
const overlapping = useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
release({ matches: [], totalCount: 0 });
await inFlight;
await overlapping;
(apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false });
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
expect(apiClient.get).not.toHaveBeenCalled();
});
it('makes overlapping callers await the same failed refresh', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let reject!: (error: Error) => void;
(apiClient.get as jest.Mock).mockImplementationOnce(() => new Promise((_, rejectRequest) => { reject = rejectRequest; }));
const first = useMatchStore.getState().fetchMatches(true);
const second = useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
reject(new Error('network'));
await expect(first).resolves.toBe(false);
await expect(second).resolves.toBe(false);
expect(useMatchStore.getState().error).toBe('Failed to load matches');
});
it('appends and deduplicates later pages', async () => {
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
@@ -21,11 +21,13 @@ export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSe
onPress={onInvitePress}
activeOpacity={0.8}
testID="swipe-sealed-answers-banner"
accessibilityLabel={`Invite your partner to compare ${sealedCount} sealed answers`}
>
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
<Text style={styles.text}>
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
</Text>
<View style={styles.copy}>
<Text style={styles.title}>{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed for your partner</Text>
<Text style={styles.text}>When you both answer, your shared discoveries are ready to compare.</Text>
</View>
<Text style={styles.cta}>Invite</Text>
</TouchableOpacity>
);
@@ -38,11 +40,15 @@ const styles = StyleSheet.create({
gap: spacing.sm,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.full,
borderRadius: radius.lg,
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
copy: { flex: 1, gap: 2 },
title: { ...typography.subhead, color: colors.text, fontWeight: "700" },
text: {
...typography.caption1,
color: colors.textSecondary,
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../lib/guestAccountWarningSeen";
type GuestAccountWarningOptions = {
isAuthenticated: boolean;
isAnonymous: boolean;
userId: string | null | undefined;
pathname: string | null;
};
/**
* Shows the account recovery warning at most once for each anonymous account
* during a mounted app session. A dismissal is applied in memory before the
* durable write completes, so a slow storage operation cannot reopen it.
*/
export function useGuestAccountWarning({
isAuthenticated,
isAnonymous,
userId,
pathname,
}: GuestAccountWarningOptions) {
const [visible, setVisible] = useState(false);
const checkedUserIds = useRef(new Set<string>());
const dismissedUserIds = useRef(new Set<string>());
const isEligible = Boolean(
isAuthenticated
&& isAnonymous
&& userId
&& !pathname?.startsWith("/(auth)"),
);
useEffect(() => {
if (!isEligible || !userId) {
return;
}
if (checkedUserIds.current.has(userId) || dismissedUserIds.current.has(userId)) {
return;
}
checkedUserIds.current.add(userId);
let isCurrent = true;
void hasSeenGuestAccountWarning(userId).then((seen) => {
if (isCurrent && !seen && !dismissedUserIds.current.has(userId)) {
setVisible(true);
}
});
return () => {
isCurrent = false;
};
}, [isEligible, userId]);
const dismiss = useCallback(async () => {
if (!userId) {
setVisible(false);
return;
}
dismissedUserIds.current.add(userId);
setVisible(false);
await markGuestAccountWarningSeen(userId);
}, [userId]);
return { visible: isEligible && visible, dismiss };
}
+12
View File
@@ -281,6 +281,18 @@ export const Events = {
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
logEvent("invite_link_opened", { source }),
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
invitePromptViewed: (surface: "swipe_banner" | "pairing_waiting" | "swipe_no_partner", sealedBucket: string) => logEvent("invite_prompt_viewed", { surface, sealed_bucket: sealedBucket }),
inviteShareOpened: (surface: "pairing_waiting", channel: "system" | "sms" | "whatsapp" | "copy_link" | "copy_code") => logEvent("invite_share_opened", { surface, channel }),
pairingJoinAttempted: (codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_attempted", { code_source: codeSource }),
pairingJoinFailed: (reason: "invalid_shape" | "code_not_found" | "couple_full" | "already_paired" | "network" | "unknown", codeSource: "link" | "stash" | "clipboard" | "manual") => logEvent("pairing_join_failed", { reason, code_source: codeSource }),
pairingCompleted: (role: "inviter" | "joiner", codeSource: "link" | "stash" | "clipboard" | "manual", hadSealed: boolean) => logEvent("pairing_completed", { role, code_source: codeSource, had_sealed: hadSealed }),
pairingUnlockViewed: (role: "inviter" | "joiner", unlockedBucket: string) => logEvent("pairing_unlock_viewed", { role, unlocked_bucket: unlockedBucket }),
pairingUnlockCtaTapped: (role: "inviter" | "joiner", cta: "view_matches" | "start_session" | "dismiss") => logEvent("pairing_unlock_cta_tapped", { role, cta }),
pairingCancelPrompted: (surface: "waiting" | "conflict") => logEvent("pairing_cancel_prompted", { surface }),
clipboardInviteOfferShown: () => logEvent("clipboard_invite_offer_shown"),
clipboardInviteOfferResolved: (outcome: "accepted" | "dismissed") => logEvent("clipboard_invite_offer_resolved", { outcome }),
inviteConflictShown: (state: "already_paired" | "waiting_own_code") => logEvent("invite_conflict_shown", { state }),
inviteConflictResolved: (state: "already_paired" | "waiting_own_code", choice: "cancel_and_join" | "keep_own" | "acknowledged") => logEvent("invite_conflict_resolved", { state, choice }),
// Quiz events
quizStarted: () => logEvent("quiz_started"),
+1
View File
@@ -24,4 +24,5 @@ export const coupleApi = {
body: { invite_code: inviteCode },
}),
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
cancelInvite: () => apiRequest<CoupleMutationResponse>("/v1/couple/invite", { method: "DELETE" }),
};
+13 -29
View File
@@ -1,47 +1,31 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "guest_account_recovery_warning_seen";
const STORAGE_KEY_PREFIX = "guest_account_recovery_warning_seen";
async function getStorage(): Promise<boolean> {
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
return data === "true";
return (await AsyncStorage.getItem(key)) === "true";
} catch (error) {
console.error("Error reading guest warning state:", error);
}
return false;
}
async function setStorage(seen: boolean): Promise<void> {
async function setStorage(key: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
await AsyncStorage.setItem(key, seen ? "true" : "false");
} catch (error) {
console.error("Error saving guest warning state:", error);
}
}
export async function hasSeenGuestAccountWarning(): Promise<boolean> {
return getStorage();
/** The recovery warning is shown at most once for each anonymous account. */
export async function hasSeenGuestAccountWarning(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markGuestAccountWarningSeen(): Promise<void> {
await setStorage(true);
export async function markGuestAccountWarningSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+3 -3
View File
@@ -5,10 +5,10 @@
* the recipient a concrete reason to join right away.
*/
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`;
const link = `https://sauci.app/join/${inviteCode}`;
if (sealedCount > 0) {
const noun = sealedCount === 1 ? "question" : "questions";
return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`;
return `Join me on Sauci! I've saved ${sealedCount} ${noun} for us to compare. Vote-style questions show shared outcomes; open answers can be visible to us both after we each answer. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
}
return `Join me on Sauci! Tap this link to pair up instantly: ${link}`;
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
}
+15
View File
@@ -0,0 +1,15 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const keyFor = (userId: string, coupleId: string) => `paired_unlock_seen_${userId}_${coupleId}`;
export async function hasSeenPairedUnlock(userId: string, coupleId: string): Promise<boolean> {
return (await AsyncStorage.getItem(keyFor(userId, coupleId))) === "true";
}
export async function markPairedUnlockSeen(userId: string, coupleId: string): Promise<void> {
await AsyncStorage.setItem(keyFor(userId, coupleId), "true");
}
export async function clearPairedUnlockSeen(userId: string, coupleId: string | null): Promise<void> {
if (coupleId) await AsyncStorage.removeItem(keyFor(userId, coupleId));
}
@@ -0,0 +1 @@
export const PAIRING_PRIVACY_SUMMARY = "Vote-style answers reveal shared outcomes. Text, audio, photo, and who-likely responses are shown after you both answer.";
+52 -10
View File
@@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
const STORAGE_KEY = "pending_invite_code";
let storageOperation = Promise.resolve();
function withStorageLock<T>(operation: () => Promise<T>): Promise<T> {
const next = storageOperation.then(operation, operation);
storageOperation = next.then(() => undefined, () => undefined);
return next;
}
interface PendingInviteCode {
code: string;
/** The account that received or first claimed this deferred invite. */
userId: string | null;
}
function parsePendingInviteCode(value: string | null): PendingInviteCode | null {
if (!value) return null;
// Treat legacy, unscoped values as a pre-auth hand-off. The first account
// that intentionally enters the pairing flow claims it below.
if (isValidInviteCode(value)) return { code: normalizeInviteCode(value), userId: null };
export async function stashPendingInviteCode(code: string): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
const parsed = JSON.parse(value) as Partial<PendingInviteCode>;
if (typeof parsed.code !== "string" || !isValidInviteCode(parsed.code)) return null;
if (parsed.userId !== null && typeof parsed.userId !== "string") return null;
return { code: normalizeInviteCode(parsed.code), userId: parsed.userId ?? null };
} catch {
return null;
}
}
export async function stashPendingInviteCode(code: string, userId: string | null = null): Promise<void> {
try {
await withStorageLock(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ code: normalizeInviteCode(code), userId } satisfies PendingInviteCode)));
} catch (error) {
console.error("Error stashing pending invite code:", error);
}
}
export async function getPendingInviteCode(): Promise<string | null> {
export async function getPendingInviteCode(userId: string): Promise<string | null> {
try {
const value = await AsyncStorage.getItem(STORAGE_KEY);
if (value && isValidInviteCode(value)) {
return normalizeInviteCode(value);
}
return null;
return await withStorageLock(async () => {
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
if (!pending || (pending.userId && pending.userId !== userId)) return null;
// A hand-off made before sign-in becomes owned by the account that
// reaches pairing. Later account switches cannot consume it.
if (!pending.userId) {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...pending, userId }));
}
return pending.code;
});
} catch (error) {
console.error("Error reading pending invite code:", error);
return null;
}
}
export async function clearPendingInviteCode(): Promise<void> {
export async function clearPendingInviteCode(userId?: string): Promise<void> {
try {
await AsyncStorage.removeItem(STORAGE_KEY);
await withStorageLock(async () => {
if (userId) {
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
if (pending?.userId && pending.userId !== userId) return;
}
await AsyncStorage.removeItem(STORAGE_KEY);
});
} catch (error) {
console.error("Error clearing pending invite code:", error);
}
+17
View File
@@ -5,6 +5,8 @@ import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import { clearPairedUnlockSeen } from "../lib/pairedUnlockSeen";
import { clearPendingInviteCode } from "../lib/pendingInviteCode";
import type { Profile, Couple } from "@/types";
export interface AuthSessionSnapshot {
@@ -201,6 +203,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
signOut: async () => {
Events.signOut();
// The paired-unlock presentation is scoped to this account and current
// relationship. Remove it before local identity state is cleared.
const unlockUserId = get().user?.id;
const unlockCoupleId = get().couple?.id ?? get().user?.couple_id ?? null;
if (unlockUserId) {
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
}
await clearPendingInviteCode(unlockUserId);
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
@@ -236,6 +247,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
setUser: (user) => {
const priorUserId = user === null ? get().user?.id : undefined;
const priorCoupleId = user === null ? (get().couple?.id ?? get().user?.couple_id ?? null) : null;
set({
user,
isAuthenticated: !!user,
@@ -246,6 +259,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
void clearPendingInviteCode(priorUserId);
if (priorUserId) {
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
}
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
+20 -11
View File
@@ -28,6 +28,7 @@ export interface FetchOptions { silent?: boolean }
// account's request for the same key, and a stale generation's `finally` must not
// clear an entry a newer generation just claimed.
const inFlight = new Map<string, number>();
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
@@ -80,7 +81,7 @@ interface MatchState {
*/
generation: number;
// Methods
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
isNudging: false,
generation: 0,
fetchMatches: async (refresh = false, options = {}) => {
fetchMatches: (refresh = false, options = {}) => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
return;
return Promise.resolve(false);
}
const state = get();
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
const activeRequest = activeInFlight.get('active');
if (activeRequest?.generation === myGeneration) return activeRequest.promise;
if (state.isLoadingMore && !refresh) return Promise.resolve(false);
// Nothing on screen yet is the only case that warrants blanking the list
// for a spinner; every other refresh leaves the cached rows in place.
@@ -149,17 +152,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
if (refresh) {
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
if (!state.hasMore) return Promise.resolve(false);
set({ isLoadingMore: true });
}
inFlight.set('active', myGeneration);
try {
const request = (async (): Promise<boolean> => {
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
// A sign-out/account switch that happened while this request was in
// flight must not let its response populate the next account's store.
if (get().generation !== myGeneration) return;
if (get().generation !== myGeneration) return false;
const nonArchivedMatches = result.matches;
const totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds;
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
} else {
set({ isLoadingMore: false, hasMore: false });
}
return;
return true;
}
const sortedData = nonArchivedMatches;
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
loadedAt,
};
});
return true;
} catch (err) {
if (get().generation !== myGeneration) return;
if (get().generation !== myGeneration) return false;
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
return false;
} finally {
// An old generation's cleanup must not delete a newer generation's guard.
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
if (activeInFlight.get('active')?.generation === myGeneration) activeInFlight.delete('active');
}
})();
activeInFlight.set('active', { generation: myGeneration, promise: request });
return request;
},
markAsSeen: async (matchId) => {
@@ -296,6 +304,7 @@ clearMatches: () => {
// A request belonging to the signed-out user must not suppress the next
// one: the guard is a duplicate-request check, not a lock on the data.
inFlight.clear();
activeInFlight.clear();
},
// Archive methods
+76
View File
@@ -0,0 +1,76 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import type { CapturedNetworkRequest } from 'posthog-js'
import { redactInviteNetworkRequest, redactInvitePaths } from './providers'
const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url)
const providerPath = new URL('./providers.tsx', import.meta.url)
describe('PostHog invite privacy boundary', () => {
it('does not send a global page view for invite routes', async () => {
const source = await readFile(pageViewPath, 'utf8')
expect(source).toContain("pathname?.startsWith('/join/')")
expect(source.indexOf("pathname?.startsWith('/join/')")).toBeLessThan(source.indexOf("posthog.capture('$pageview'"))
})
it('disables automatic page-leave capture and redacts join URLs', async () => {
const source = await readFile(providerPath, 'utf8')
expect(source).toContain('capture_pageview: false')
expect(source).toContain('capture_pageleave: false')
expect(source).toContain('get_current_url: redactInviteUrl')
expect(source).toContain('before_send: redactInvitePathsBeforeSend')
expect(source).toContain('maskCapturedNetworkRequestFn: redactInviteNetworkRequest')
expect(source).toContain("url.pathname.startsWith('/join/')")
expect(source).toContain('/join/[invite]')
})
it('removes an invite code from every serialized URL or path property', () => {
const inviteCode = 'A4K9BT2Q'
const event = {
event: 'join_page_viewed',
properties: {
$current_url: `https://sauci.app/join/${inviteCode}`,
$pathname: `/join/${inviteCode}`,
$referrer: `https://sauci.app/join/${inviteCode}?source=message`,
$session_entry_url: `https://sauci.app/join/${inviteCode}`,
nested: { previous_path: `/join/${inviteCode}` },
},
}
const serialized = JSON.stringify(redactInvitePaths(event))
expect(serialized).not.toContain(inviteCode)
expect(serialized).toContain('/join/[invite]')
expect(serialized).toContain('join_page_viewed')
})
it('redacts invite codes from replay network request URLs, headers, and bodies without mutating the capture', () => {
const inviteCode = 'A4K9BT2Q'
const request = {
name: `https://sauci.app/join/${inviteCode}`,
entryType: 'resource',
startTime: 1,
duration: 2,
requestHeaders: {
referer: `https://sauci.app/join/${inviteCode}`,
},
requestBody: JSON.stringify({ invite_path: `/join/${inviteCode}` }),
responseHeaders: {
location: `https://sauci.app/join/${inviteCode}`,
},
responseBody: JSON.stringify({ referrer: `https://sauci.app/join/${inviteCode}` }),
} as CapturedNetworkRequest
const redacted = redactInviteNetworkRequest(request)
const serialized = JSON.stringify(redacted)
expect(redacted).not.toBe(request)
expect(redacted.requestHeaders).not.toBe(request.requestHeaders)
expect(redacted.responseHeaders).not.toBe(request.responseHeaders)
expect(serialized).not.toContain(inviteCode)
expect(serialized).toContain('/join/[invite]')
expect(JSON.stringify(request)).toContain(inviteCode)
})
})
+8 -4
View File
@@ -7,10 +7,14 @@ import { usePostHog } from 'posthog-js/react'
function PostHogPageViewInner() {
const pathname = usePathname()
const searchParams = useSearchParams()
const posthog = usePostHog()
useEffect(() => {
if (pathname && posthog) {
const posthog = usePostHog()
useEffect(() => {
// Invite codes are bearer-like tokens. The join route emits its own
// aggregate-only event, so it must never produce a URL-bearing page view.
if (pathname?.startsWith('/join/')) return
if (pathname && posthog) {
let url = window.origin + pathname
if (searchParams.toString()) {
url = url + '?' + searchParams.toString()
+9 -2
View File
@@ -62,13 +62,20 @@ body {
border-radius: 16px;
}
.glass-light {
.glass-light {
background: rgba(22, 33, 62, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
}
}
/* Solid pairing surface. Join links must not rely on glass or blur. */
.surface-solid {
background: #17171c;
border: 1px solid #303036;
border-radius: 16px;
}
/* Gradient text */
.gradient-text {
+44 -3
View File
@@ -12,12 +12,13 @@ describe('/join/[code] JoinClient', () => {
expect(source).not.toMatch(/fetch\(/)
})
it('attempts the app scheme link and shows the raw code fallback', async () => {
it('only attempts the app scheme link after an explicit Open in Sauci action', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('buildInviteSchemeUrl')
expect(source).toContain('Open in Sauci')
expect(source.indexOf('const handleOpenInApp')).toBeLessThan(source.indexOf('window.location.href = buildInviteSchemeUrl'))
expect(source).toContain('invite-code-display')
expect(source).toContain('Copy code')
})
it('copies only the raw code, not a share message', async () => {
@@ -37,13 +38,53 @@ describe('/join/[code] JoinClient', () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain("posthog?.capture('join_page_viewed'")
expect(source).toContain("posthog?.capture('join_page_code_copied')")
expect(source).toContain("posthog?.capture('join_page_code_copied'")
expect(source).toContain("posthog?.capture('join_page_store_button_clicked'")
expect(source).toContain("posthog?.capture('join_page_open_app_clicked'")
expect(source).toContain("posthog?.capture('join_page_open_app_fallback_shown'")
expect(source).toContain("posthog?.capture('join_page_offline_shown'")
})
it('does not put an invite code in a PostHog payload', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).not.toMatch(/posthog\?\.capture\([^)]*normalizedCode/)
expect(source).not.toMatch(/posthog\?\.capture\([^)]*\bcode\s*:/)
})
it('uses the required recovery and accessibility states', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('Copy this before you install')
expect(source).toContain('setTimeout(() =>')
expect(source).toContain('}, 1500)')
expect(source).toContain("document.visibilityState === 'visible'")
expect(source).toContain("aria-live=\"polite\"")
expect(source).toContain('navigator.onLine')
expect(source).toContain('surface-solid')
expect(source).not.toContain('glass-light')
})
it('describes comparison and open-answer visibility truthfully', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('Vote-style questions reveal shared outcomes')
expect(source).toContain('open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.')
expect(source).not.toContain('Neither of you sees the other’s individual answers.')
})
it('uses PostHog-recognized selectors to exclude the invite-code DOM from capture and replay', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('ph-no-capture ph-mask')
expect(source).not.toContain('data-ph-no-capture')
expect(source).not.toContain('data-ph-mask')
})
it('shows a clear fallback message for a malformed invite code', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('Invite link not recognized')
expect(source).not.toContain('has expired')
})
})
+149 -57
View File
@@ -7,35 +7,66 @@ import { usePostHog } from 'posthog-js/react'
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
type Platform = 'ios' | 'android' | 'desktop' | 'other'
function getPlatform(): Platform {
if (typeof navigator === 'undefined') return 'other'
const userAgent = navigator.userAgent
if (/iPad|iPhone|iPod/i.test(userAgent)) return 'ios'
if (/Android/i.test(userAgent)) return 'android'
if (/Macintosh|Windows|Linux/i.test(userAgent)) return 'desktop'
return 'other'
}
export function JoinClient({ code }: { code: string }) {
const posthog = usePostHog()
const [copied, setCopied] = useState(false)
const hasAttemptedOpen = useRef(false)
const [isOnline, setIsOnline] = useState(true)
const [openAppFallbackVisible, setOpenAppFallbackVisible] = useState(false)
const [platform, setPlatform] = useState<Platform>('other')
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasCopiedCode = useRef(false)
const isValid = isValidInviteCode(code)
const normalizedCode = useMemo(() => (isValid ? normalizeInviteCode(code) : ''), [isValid, code])
// Track the page view once. Fires regardless of code validity so we can
// see broken/expired links in the funnel too.
useEffect(() => {
posthog?.capture('join_page_viewed', { code_valid: isValid })
const detectedPlatform = getPlatform()
setPlatform(detectedPlatform)
posthog?.capture('join_page_viewed', { code_valid: isValid, platform: detectedPlatform })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Attempt to open the app via the custom scheme once. If the app is
// installed and registered for the scheme, this hands off to it; if not,
// nothing visible happens and the fallback UI below remains in view.
useEffect(() => {
if (!isValid || hasAttemptedOpen.current) return
hasAttemptedOpen.current = true
window.location.href = buildInviteSchemeUrl(normalizedCode)
}, [isValid, normalizedCode])
const updateOnlineStatus = () => setIsOnline(navigator.onLine)
const handleCopy = async () => {
updateOnlineStatus()
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
return () => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
}
}, [])
useEffect(() => {
if (!isOnline) posthog?.capture('join_page_offline_shown')
}, [isOnline, posthog])
useEffect(() => () => {
if (fallbackTimer.current) clearTimeout(fallbackTimer.current)
}, [])
const handleCopy = async (trigger: 'code_block' | 'fallback_note') => {
try {
await navigator.clipboard.writeText(normalizedCode)
hasCopiedCode.current = true
setCopied(true)
posthog?.capture('join_page_code_copied')
posthog?.capture('join_page_code_copied', { trigger })
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy invite code:', err)
@@ -43,60 +74,95 @@ export function JoinClient({ code }: { code: string }) {
}
const handleStoreClick = (store: 'app_store' | 'play_store') => {
posthog?.capture('join_page_store_button_clicked', { store })
if (!isOnline) return
posthog?.capture('join_page_store_button_clicked', { store, code_copied_first: hasCopiedCode.current })
}
const handleOpenInApp = () => {
setOpenAppFallbackVisible(false)
posthog?.capture('join_page_open_app_clicked', { platform })
window.location.href = buildInviteSchemeUrl(normalizedCode)
if (fallbackTimer.current) clearTimeout(fallbackTimer.current)
fallbackTimer.current = setTimeout(() => {
if (document.visibilityState === 'visible') {
setOpenAppFallbackVisible(true)
posthog?.capture('join_page_open_app_fallback_shown', { platform })
}
}, 1500)
}
if (!isValid) {
const malformedStoreUrl = platform === 'android' ? PLAY_STORE_URL : APP_STORE_URL
return (
<main className="min-h-screen bg-gradient-to-b from-background-light to-background flex items-center justify-center p-6">
<div className="glass-light p-8 sm:p-12 max-w-md w-full text-center">
<div className="surface-solid p-8 sm:p-12 max-w-md w-full text-center">
<h1 className="text-2xl sm:text-3xl font-bold mb-3">Invite link not recognized</h1>
<p className="text-white/60 mb-8">
This invite link looks incomplete or has expired. Ask your partner to send a new one from the Sauci app.
<p className="text-white/70 mb-8">
This invite link looks incomplete. Ask your partner to send a new one from the Sauci app.
</p>
<Link href="/" className="btn-primary inline-block">
Back to Home
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Link href="/" className="btn-primary inline-block">
Back to Home
</Link>
<a
href={malformedStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary inline-block"
>
Get Sauci
</a>
</div>
</div>
</main>
)
}
const copyAnnouncement = copied ? 'Invite code copied.' : ''
const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.'
const desktopCopy = platform === 'desktop'
? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.'
: 'Join to compare answers together. Vote-style questions reveal shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.'
return (
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
{/* Header */}
<main className="min-h-screen bg-gradient-to-b from-background-light to-background ph-no-capture ph-mask">
<header className="py-6 px-6 border-b border-white/5">
<div className="max-w-4xl mx-auto flex items-center gap-4">
<Link href="/" className="flex items-center gap-2 text-white/60 hover:text-white transition-colors">
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
<span>Back to Home</span>
</Link>
</div>
</header>
{/* Content */}
<div className="max-w-md mx-auto px-6 py-12 sm:py-20 text-center">
<div className="w-20 h-20 rounded-full bg-primary/20 flex items-center justify-center mx-auto mb-8">
<Heart className="w-10 h-10 text-primary" />
{!isOnline && (
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status">
You&apos;re offline. Your code is below, and it still works.
</p>
)}
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center mx-auto mb-8 shadow-[0_8px_28px_rgba(233,69,96,0.28)]">
<Heart className="w-10 h-10 text-white" />
</div>
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to Sauci</h1>
<p className="text-white/60 mb-10">
Open the app to pair up instantly. If it doesn&apos;t open automatically, copy your code below and
enter it after you install Sauci.
</p>
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to compare answers</h1>
<p className="text-white/70 mb-10">{desktopCopy}</p>
<div className="glass-light p-6 sm:p-8 mb-8">
<p className="text-white/60 text-sm mb-3">Your invite code</p>
<section className="surface-solid p-6 sm:p-8 mb-6 ph-no-capture ph-mask" aria-labelledby="invite-code-heading">
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
<p
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
data-testid="invite-code-display"
aria-label={normalizedCode.split('').join(' ')}
>
{normalizedCode}
</p>
<p className="text-white/70 text-sm mb-5">Copy this before you install. You&apos;ll enter it in the app.</p>
<button
type="button"
onClick={handleCopy}
onClick={() => void handleCopy('code_block')}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{copied ? (
@@ -111,33 +177,59 @@ export function JoinClient({ code }: { code: string }) {
</>
)}
</button>
</div>
<span className="sr-only" role="status" aria-live="polite">{copyAnnouncement}</span>
</section>
<p className="text-white/40 text-sm mb-6">Don&apos;t have Sauci yet? Get the app first:</p>
<button type="button" onClick={handleOpenInApp} className="btn-primary w-full mb-2">
Open in Sauci
</button>
<p className="text-white/60 text-sm mb-6">Already installed? This opens the app with your code.</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleStoreClick('app_store')}
className="btn-primary w-full sm:w-auto"
>
Download on the App Store
</a>
<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => handleStoreClick('play_store')}
className="btn-secondary w-full sm:w-auto"
>
Get it on Google Play
</a>
</div>
{openAppFallbackVisible && (
<p className="surface-solid mb-6 px-4 py-3 text-sm text-white/70" role="status" aria-live="polite">
Nothing opened? You probably don&apos;t have Sauci yet.{' '}
<button type="button" className="underline font-medium text-white" onClick={() => void handleCopy('fallback_note')}>
Copy your code
</button>
, then install below.
</p>
)}
<section aria-labelledby="install-heading">
<h2 id="install-heading" className="text-white/70 text-sm font-medium mb-4">Get Sauci</h2>
<div className="flex flex-col gap-3">
<a
href={APP_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => {
if (!isOnline) event.preventDefault()
handleStoreClick('app_store')
}}
aria-disabled={!isOnline}
title={storeUnavailableTitle}
className={`${platform === 'ios' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Get it on the App Store
</a>
<a
href={PLAY_STORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => {
if (!isOnline) event.preventDefault()
handleStoreClick('play_store')
}}
aria-disabled={!isOnline}
title={storeUnavailableTitle}
className={`${platform === 'android' ? 'btn-primary' : 'btn-secondary'} w-full ${!isOnline ? 'opacity-50 cursor-not-allowed' : ''}`}
>
Get it on Google Play
</a>
</div>
</section>
</div>
{/* Footer */}
<footer className="py-8 px-6 border-t border-white/5 mt-auto">
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<span className="text-xl font-bold gradient-text">Sauci</span>
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
import { JoinClient } from './JoinClient'
export const metadata: Metadata = {
title: 'Join your partner on Sauci',
description: 'Open Sauci and pair up instantly with your invite code.',
title: 'Compare answers together on Sauci',
description: 'Copy your invite code, then join Sauci to make shared discoveries together.',
}
export default async function JoinPage({
+16
View File
@@ -0,0 +1,16 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const providerPath = new URL('./providers.tsx', import.meta.url)
describe('PostHogProvider invite privacy', () => {
it('disables DOM autocapture and session recording on invite routes', async () => {
const source = await readFile(providerPath, 'utf8')
expect(source).toContain("pathname?.startsWith('/join/')")
expect(source).toContain("window.location.pathname.startsWith('/join/')")
expect(source).toContain('autocapture: !isInvitePage')
expect(source).toContain('disable_session_recording: isInvitePage')
expect(source).toContain('mask_all_text: isInvitePage')
})
})
+87 -14
View File
@@ -1,20 +1,93 @@
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
person_profiles: 'identified_only',
capture_pageview: false, // We'll capture pageviews manually for better control
capture_pageleave: true,
})
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import type { CapturedNetworkRequest, CaptureResult } from 'posthog-js'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
function redactInviteUrl(defaultUrl: string): string {
const url = new URL(defaultUrl)
if (url.pathname.startsWith('/join/')) {
return `${url.origin}/join/[invite]`
}
return defaultUrl
}
export function redactInvitePaths<T>(value: T): T {
if (typeof value === 'string') {
return value.replace(INVITE_PATH_PATTERN, '/join/[invite]') as T
}
if (Array.isArray(value)) {
return value.map(redactInvitePaths) as T
}
if (value && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactInvitePaths(entry)])) as T
}
return value
}
export function redactInvitePathsBeforeSend(event: CaptureResult | null): CaptureResult | null {
return event ? redactInvitePaths(event) : null
}
export function redactInviteNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
return {
...request,
name: redactInvitePaths(request.name),
requestHeaders: request.requestHeaders ? redactInvitePaths({ ...request.requestHeaders }) : request.requestHeaders,
requestBody: redactInvitePaths(request.requestBody),
responseHeaders: request.responseHeaders ? redactInvitePaths({ ...request.responseHeaders }) : request.responseHeaders,
responseBody: redactInvitePaths(request.responseBody),
}
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
useEffect(() => {
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
const isInvitePage = window.location.pathname.startsWith('/join/')
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
person_profiles: 'identified_only',
capture_pageview: false,
capture_pageleave: false,
get_current_url: redactInviteUrl,
before_send: redactInvitePathsBeforeSend,
session_recording: {
maskCapturedNetworkRequestFn: redactInviteNetworkRequest,
},
// This must be part of initialization, not only the navigation effect
// below, so an initially loaded invite page is protected before its DOM
// can be considered for capture or recording.
autocapture: !isInvitePage,
disable_session_recording: isInvitePage,
mask_all_text: isInvitePage,
})
}
}, [])
}, [])
useEffect(() => {
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
// Invite codes are credentials. Do not let their page enter PostHog's DOM
// autocapture or session-recording pipeline, even though event properties
// and URLs are separately redacted below.
const isInvitePage = pathname?.startsWith('/join/')
posthog.set_config({
autocapture: !isInvitePage,
disable_session_recording: isInvitePage,
mask_all_text: isInvitePage,
})
}, [pathname])
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
return <>{children}</>
+35
View File
@@ -115,6 +115,23 @@ Leaves current couple (sets `couple_id` to null).
- All messages
- All couple_packs settings
### Standalone API invite cancellation (DELETE)
`DELETE /v1/couple/invite` cancels an unjoined invite for the authenticated
creator only. It is intentionally separate from `DELETE /v1/couple`, which
continues to end an existing relationship for legacy clients.
The server locks the authenticated profile and then the couple row in one
transaction. It deletes the invite only when that caller is the sole member.
If another member has joined, it returns `409 invite_already_joined` and makes
no changes. This serializes safely with a concurrent `POST /v1/couple` join:
one operation completes first, while the other observes the resulting state.
```javascript
// Response on a cancelled waiting invite
{ success: true, couple_id: null }
```
## Database Schema
### Couple Record
@@ -178,6 +195,18 @@ This prevents race conditions where multiple users try to join simultaneously.
**Waiting State:**
```
The invite journey leads with the shared outcome, then shows the recoverable
eight-character code. It offers both a bare-code copy and a full
`https://sauci.app/join/{code}` link copy. Cancelling an invite warns that it
removes only an unjoined invite. Sealed answers made before invite creation
remain, while answers made while waiting are deleted with the invite. The
five-second poll surfaces an inline offline state but continues retrying.
When pairing succeeds, both members are routed once per account and couple to
the `/(app)/paired` payoff screen. It reads only `newMatchesCount`: it never
reveals unmatched or individual answers. Existing swipe remains the named
post-pair question entry until Quick Spark is implemented.
┌─────────────────────────────┐
│ ❤️ Partner Code │
│ │
@@ -360,6 +389,11 @@ The code is never applied automatically; the user must accept the offer.
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
(web, PostHog).
The mobile invite funnel also records aggregate-only prompt, share, join,
failure, completion, unlock, cancellation, clipboard, and conflict events.
Counts use fixed buckets. Invite codes, user IDs, partner IDs, questions, and
answers are never sent as analytics properties.
## Cascade Behavior
When a couple is deleted, foreign key cascades clean up:
@@ -381,4 +415,5 @@ Storage (chat-media) is manually cleaned before the cascade.
| Already paired | User tries to create/join while in couple | "You are already in a couple" |
| Invalid code | Invite code doesn't exist | "Invalid invite code" |
| Couple full | Couple already has 2 members | "This couple already has two partners" |
| Invite already joined | Creator tries to cancel after a partner joins | "This invite has already been joined" |
| DB trigger | Race condition caught | "A couple can only have 2 members" |
+30
View File
@@ -75,6 +75,36 @@ notification preview, and `dares.integration.test.ts` asserts no payload leaks i
## History durability
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
## Editorial catalogue seed
Migration `0023_dare_catalogue_150.sql` seeds ten deterministic-ID packs and 150
dares. It is replay-safe only when the existing rows exactly match the seed; a
deterministic-ID collision with changed content aborts the migration. The seed does
not change the dare schema, API, or mobile behaviour.
Only the 60 non-sexual rows in these packs are public and `allowed`:
- Little Things, Make Me Laugh, and Sweet & Romantic: 45 free dares.
- Surprise Me: 15 premium dares.
Flirty Messages (15 premium rows) is non-public and `unreviewed` until an
editorial line review records an allowed decision. Long-Distance Heat, Risky
Photos & Audio, Hands On, After Dark, and Power Play (75 premium rows) are
explicit, non-public, and `unreviewed`. They are private editorial inventory and
cannot be returned by the current fail-closed catalogue queries.
The 60 `allowed` rows carry a fixed migration review timestamp and a matching
`content_reviews` record. `changed_by` is null because a migration has no
authenticated human actor; the audit reason and timestamp identify this as the
system seed decision rather than attributing it to a fabricated profile. Exact
replay verifies this audit provenance too, so a divergent deterministic review ID
blocks the migration rather than being silently accepted.
The sender chooses a duration when sending a dare. Approved catalogue rows may
refer to "until the dare expires", which resolves to that sender-selected deadline.
The two private, unreviewed "within the next hour" rows remain unavailable until
their editorial review also confirms the sending experience communicates that limit.
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
+11 -4
View File
@@ -51,7 +51,7 @@ Channels. The client cannot create them.
`apps/mobile/app.config.js` sets an explicit runtime string:
```js
runtimeVersion: '1.0.7'
runtimeVersion: '1.0.8'
```
The release version script updates this string with `expo.version` and the
@@ -91,7 +91,7 @@ Resolved configuration:
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
| `updates.requestHeaders['xprem-branch']` | `''` |
| `runtimeVersion` | Explicit public version, currently `1.0.7` |
| `runtimeVersion` | Explicit public version, currently `1.0.8` |
`expo-updates` only sends request headers that were declared at build time, so
`xprem-branch` is declared even though it is empty. Removing it would not disable
@@ -176,6 +176,8 @@ dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed.
cd apps/mobile
# Verify the exact export environment without contacting the update server.
# Production preflight fails closed unless the compiled bundle contains both
# RevenueCat platform public keys and the entitlement identifier.
npm run ota:preflight:production
# Staging: consumed by builds made with the EAS `preview` profile.
@@ -189,7 +191,12 @@ The scripts resolve the matching EAS build profile into EOAS's export subprocess
EOAS sets `EXPO_NO_DOTENV=1`, so invoking `eoas publish` directly does not load
profile values from `eas.json`. The wrapper injects the selected profile's public
configuration, including the required API and hosted Auth values, sets the
matching `RELEASE_CHANNEL`, and fixes the target branch. It does not print
matching `RELEASE_CHANNEL`, and fixes the target branch. Before each production
publish, it makes an isolated Expo export and fails unless that compiled bundle
contains the iOS and Android RevenueCat public SDK keys plus the entitlement
identifier. EOAS is also forced through a checked-in package-runner guard, which
receives the resolved profile environment and rejects a missing, malformed, or
changed RevenueCat value before EOAS runs its own Expo export. It does not print
configuration values.
Production public API/Auth values come from the checked-in production profile.
@@ -230,7 +237,7 @@ production deploy and none of the review latency.
curl -sD - "https://ota.apps.jbrunton.co.uk/manifest" \
-H "expo-app-id: <xprem-app-id>" \
-H "expo-channel-name: production" \
-H "expo-runtime-version: 1.0.7" \
-H "expo-runtime-version: 1.0.8" \
-H "expo-platform: ios"
```
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5
View File
@@ -216,6 +216,11 @@ The `get_recommended_questions()` function also filters premium packs:
> `isProUser: false`, and paying users silently appear unsubscribed with no error.
> Confirm the variable is set on every iOS and Android build profile.
Production OTA publishes also fail before upload unless an isolated compiled
Expo bundle contains both platform public SDK keys and this entitlement
identifier. Run `npm run ota:preflight:production` from `apps/mobile`; it
validates presence only and never prints those values.
## Subscription Flow Diagrams
### Purchase Flow
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const launcherPath = fileURLToPath(new URL('./dev-local.sh', import.meta.url));
test('native launcher retains a UTF-8 locale after sanitizing its environment', async () => {
const launcher = await readFile(launcherPath, 'utf8');
const nativeCommand = launcher.slice(launcher.indexOf('cmd_native() {'), launcher.indexOf('\ncase "${1:-up}" in'));
assert.match(nativeCommand, /env -i[\s\S]*LANG=en_US\.UTF-8 LC_ALL=en_US\.UTF-8/);
});
@@ -0,0 +1,117 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '..');
const snapshotPath = resolve(root, 'apps/supabase/catalog-snapshots/production-2026-08-28.json');
const checksumPath = resolve(root, 'apps/supabase/catalog-snapshots/production-2026-08-28.sha256');
const v1Path = resolve(root, 'docs/product/question-drafts/play-safe-spicy-v1.json');
const v2Path = resolve(root, 'docs/product/question-drafts/play-safe-spicy-extension-v2.json');
const markdownPath = resolve(root, 'docs/product/question-drafts/full-live-catalogue-1650.md');
const allowPacks = new Set([
'7e9cb72e-95dd-4140-8d58-17206e6fe153','8da8dcb1-0b9b-4828-9a30-199758b8ade5','e11bdfea-ab07-46a9-952d-5c00c8b4ddbb','cf483ec0-502a-41ea-a2a2-2f223e7ec6ce','877d8a39-5229-4263-bffc-d3043015603f','1d2ed912-1429-4045-a608-35b8db63f9b4','45ff272c-be6d-4faa-a932-a5285a19afe6','d6128ed8-e209-43db-88eb-e9d0115ba8d7','1ec3f726-b495-423b-a5dc-60f5178fdf21','0f2b3400-05d4-40db-b8fa-41c92ab877eb','48150d71-bf95-49b0-924f-e9bcff63fc03','fd6f2a49-1057-4f30-aab4-7cd185764f7e','aea8bb8f-de02-41ad-bd1f-7616780dde30','91dacfed-46ae-462d-aae3-5e540fe4a973'
]);
const excludedId = '8d49681a-4b27-445e-9fda-915a3a7d7f5c';
const existingCategoryIds = new Set(['c1000000-0000-0000-0000-000000000002','fcb36b61-6081-4bf6-9267-1ba9ba75fc08','43106c46-042a-4032-a663-8e8206273927','ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da','ecabc509-9fed-48cb-bd60-c5d830d2491d']);
const taxonomy = {
'flirty-communication': ['Flirting & Anticipation', 'Flirty Communication'], anticipation: ['Flirting & Anticipation', 'Anticipation'],
'clothes-confidence': ['Attraction & Confidence', 'Clothes Confidence'], 'private-performance': ['Attraction & Confidence', 'Private Performance'], 'giving-receiving-attention': ['Attraction & Confidence', 'Giving Receiving Attention'],
'sensory-cues': ['Sensory & Atmosphere', 'Sensory Cues'], 'settings-atmosphere': ['Sensory & Atmosphere', 'Settings Atmosphere'],
'fantasy-exchange': ['Fantasy & Roleplay', 'Fantasy Exchange'], roleplay: ['Fantasy & Roleplay', 'Roleplay'],
'taking-the-lead': ['Connection Dynamics', 'Taking The Lead'], following: ['Connection Dynamics', 'Following'], 'shared-control': ['Connection Dynamics', 'Shared Control'],
'boundaries-check-ins': ['Trust & Aftercare', 'Boundaries Check Ins'], 'aftercare-reconnection': ['Trust & Aftercare', 'Aftercare Reconnection'],
'surprise-novelty': ['Surprise & Play', 'Surprise Novelty'], 'long-distance-desire': ['Long Distance', 'Long Distance Desire']
};
const newPacks = Object.keys(taxonomy);
const typeMatrix = { swipe:[24,50,30], text_answer:[30,75,36], audio:[24,55,33], photo:[26,40,37], who_likely:[10,25,14] };
const titles = {
'flirty-communication':'Flirty Communication', anticipation:'Anticipation', 'clothes-confidence':'Clothes Confidence', 'private-performance':'Private Performance', 'giving-receiving-attention':'Giving Receiving Attention',
'sensory-cues':'Sensory Cues', 'settings-atmosphere':'Settings Atmosphere', 'fantasy-exchange':'Fantasy Exchange', roleplay:'Roleplay', 'taking-the-lead':'Taking The Lead', following:'Following', 'shared-control':'Shared Control', 'boundaries-check-ins':'Boundaries Check Ins', 'aftercare-reconnection':'Aftercare Reconnection', 'surprise-novelty':'Surprise Novelty', 'long-distance-desire':'Long Distance Desire'
};
function uuid(seed) { const h=createHash('sha256').update(seed).digest('hex'); return `${h.slice(0,8)}-${h.slice(8,12)}-4${h.slice(13,16)}-8${h.slice(17,20)}-${h.slice(20,32)}`; }
function norm(s='') { return s.toLowerCase().replace(/[^a-z0-9 ]/g,' ').replace(/\b(your|partner|the|a|an|to|and|with|for|of|in|on)\b/g,' ').replace(/\s+/g,' ').trim(); }
function getExisting() {
const snapshot=JSON.parse(readFileSync(snapshotPath)); const t=snapshot.tables;
const packs=new Map(t.question_packs.map(x=>[x.id,x])); const cats=new Map(t.categories.map(x=>[x.id,x]));
return t.questions.filter(q=>!q.deleted_at && q.id!==excludedId && allowPacks.has(q.pack_id)).map(q=>({...q,status:'EXISTING',pack_slug:packs.get(q.pack_id).name,pack_name:packs.get(q.pack_id).name,category_name:cats.get(packs.get(q.pack_id).category_id)?.name ?? 'Uncategorised'}));
}
const ideas = {
'flirty-communication':['a compliment that would stay with you','a line that makes you feel chosen','a small invitation you would enjoy receiving','a playful message that would make you smile','a secret phrase that signals interest','a kind of praise that feels personal','a question that opens a private conversation','a detail you want your partner to notice'],
anticipation:['the part of a date you most enjoy looking forward to','a small ritual that creates build-up','a plan that makes waiting feel exciting','a clue you would enjoy being given','a way to make an ordinary evening feel charged','a surprise you would like hinted at','a moment worth stretching out','a private promise that would make you curious'],
'clothes-confidence':['an outfit detail that changes your posture','a colour that makes you feel magnetic','a look you want your partner to notice','an accessory that feels like your signature','a style you would like to try for them','a look that makes you feel bold','a detail that makes getting ready fun','an outfit that deserves a second glance'],
'private-performance':['a gesture that makes you feel desired','a look that makes you feel seen','a small performance that feels playful','a way your partner could hold your attention','a private entrance that would delight you','a moment where confidence is attractive','a way to make eye contact feel meaningful','a private flourish you would enjoy'],
'giving-receiving-attention':['a kind of attention that helps you relax','a detail that makes you feel appreciated','a way you like being looked after','a way you enjoy making your partner feel special','a moment of focus that feels generous','a signal that you want more closeness','a gesture that says you are fully present','a way to make affection feel intentional'],
'sensory-cues':['a scent that changes the mood','a sound that makes a room feel private','a texture that feels comforting','a song that creates anticipation','a drink that feels celebratory','a lighting choice that changes the atmosphere','a shared snack that feels indulgent','a detail that wakes up your senses'],
'settings-atmosphere':['a corner of home that could feel more special','a setting that makes conversation easier','a room detail that changes the mood','a place that feels like an escape','a simple way to make privacy feel intentional','a space that invites closeness','a scene you would like to create together','an atmosphere that makes you linger'],
'fantasy-exchange':['a version of a date with no interruptions','a mood you have been curious to explore','a private scenario that feels exciting in theory','a setting where you would feel more daring','a role you would enjoy imagining','a detail that makes a fantasy feel personal','a playful what-if you want to share','a made-up rule that would make a date more interesting'],
roleplay:['a character energy you would enjoy borrowing','a made-up first meeting you could play with','a setting that makes pretending easier','a secret identity you would enjoy inventing','a role that lets you act more confident','a fictional rule you would enjoy following','a scene that could be flirtier with a little acting','a character detail that would make you laugh'],
'taking-the-lead':['a decision you would like to make for your partner','a private plan you want to steer','a moment where you would enjoy setting the pace','a surprise you would like to arrange','a choice you want your partner to trust you with','a way you would guide the mood','a detail you would like to take charge of','a signal that you are ready to lead'],
following:['a decision you would enjoy handing over','a moment where you want your partner to set the pace','a surprise you would like to receive','a choice you would enjoy letting your partner make','a way you could show willing trust','a plan you would rather discover than arrange','a cue that helps you relax into following','a detail you would enjoy being guided through'],
'shared-control':['a decision you want to make together','a rule that would make a date more playful','a way to take turns choosing','a signal for switching the lead','a compromise that could feel exciting','a shared boundary that creates freedom','a plan where each person gets a say','a way to make a mutual choice feel special'],
'boundaries-check-ins':['a limit that helps you feel free to explore','a question that makes consent feel natural','a signal for slowing down','a way to ask for a change without awkwardness','a boundary that deserves appreciation','a check-in that makes you feel cared for','a yes that you want to make more specific','a way to leave room for uncertainty'],
'aftercare-reconnection':['a word that helps you feel grounded','a small kindness you want after closeness','a way to return to ordinary life together','a detail that makes you feel cared for','a moment of quiet you would enjoy sharing','a question that helps you reconnect','a ritual that says the experience mattered','a way to make space for feelings afterward'],
'surprise-novelty':['a new twist that still feels comfortable','a surprise that would make you feel chosen','a different way to begin a date','a small risk that sounds fun','a mystery you would enjoy unwrapping','a routine worth changing','a private challenge that feels playful','a detail that would make a familiar evening fresh'],
'long-distance-desire':['a message that bridges the distance','a detail that makes you feel close from apart','a ritual that helps you miss each other well','a voice note you would save','a shared plan for your next reunion','a small object that reminds you of your partner','a way to make a call feel less ordinary','a promise that makes distance feel temporary']
};
function makeV2() {
const rows=[]; let serial=0; const add=(pack,type,intensity,text,partner_text=null,inverse_of=null,extra={})=>rows.push({id:uuid(`v2-${serial++}-${text}`),pack_slug:pack,text,partner_text,intensity,question_type:type,config:type==='audio'?{max_duration_seconds:60}:{},allowed_couple_genders:null,target_user_genders:null,required_props:null,inverse_of,review_status:'draft',policy_risk:intensity===4?'edge':'low',intended_outcome:extra.outcome ?? 'Both partners create a private, specific result that they can revisit together.'});
// 32 natural role reversals: first 24 L2, then 8 L3; rows subsequently get allocation altered below.
const roles=[['taking-the-lead','Ask your partner to let you choose','Let your partner choose'],['following','Ask your partner to choose','Choose'],['shared-control','Suggest that you each choose one part of','Choose one part of'],['surprise-novelty','Offer to surprise your partner with','Let your partner surprise you with']];
for(let i=0;i<32;i++){const [p,a,b]=roles[i%roles.length], idea=ideas[p][Math.floor(i/4)%8]; const t=`${a} ${idea}.`; const pt=`${b} ${idea} for your partner.`; const id=uuid(`v2-${serial}-${t}`); add(p,'swipe',i<12?2:3,t,pt,null,{outcome:'One partner proposes a clear role while the other receives the complementary invitation.'}); rows[rows.length-1].inverse_of=uuid(`v2-${serial}-${pt}`); add(p,'swipe',i<12?2:3,pt,t,id,{outcome:'One partner receives a clear role while the other proposes the complementary invitation.'}); }
// 40 mutual proposals with specific activities.
for(let i=0;i<40;i++){const p=newPacks[i%newPacks.length], idea=ideas[p][Math.floor(i/16)%8]; add(p,'swipe',i<12?2:i<32?3:4,`Would you both like to make room for ${idea}?`,null,null,{outcome:'Both partners can agree to try one precise shared idea.'});}
const patterns={
text_answer:(idea,p)=>`Write the exact words you would use to tell your partner about ${idea}.`,
audio:(idea,p)=>`Record a voice note describing ${idea}, using the tone you want your partner to hear.`,
photo:(idea,p)=>`Share a photo of a safe, non-personal detail that represents ${idea}.`,
who_likely:(idea,p)=>`Who is more likely to turn ${idea} into a shared plan?`
};
for(const [type,nums] of Object.entries(typeMatrix)){
if(type==='swipe') continue;
const count=nums.reduce((a,b)=>a+b,0);
for(let i=0;i<count;i++){const intensity=i<nums[0]?2:i<nums[0]+nums[1]?3:4; const p=newPacks[(i*3+(type==='audio'?2:type==='photo'?5:type==='who_likely'?7:0))%newPacks.length]; const idea=ideas[p][Math.floor(i/newPacks.length)%8]; let text=patterns[type](idea,p);
if(type==='photo') { const safe=['a colour, fabric, object, note, lighting choice, or room detail','an outfit accessory, place setting, song screen, or handwritten note','a safe scene detail such as a lamp, book, drink, or mood board'][i%3]; text=`Share a photo of ${safe} that represents ${idea}.`; }
if(type==='text_answer') { const approaches=['Write a short invitation about','Write a private note about','Finish the sentence, "I would love more of", with','Write a kind request connected to','Describe in your own words']; text=`${approaches[i%5]} ${idea}.`; }
if(type==='audio') { const cues=['using a warm tone','with a playful pause','as if you were speaking close by','with the confidence you want to borrow','slowly enough for your partner to picture it']; text=`Record a voice note about ${idea}, ${cues[i%5]}.`; }
if(type==='who_likely') { const endings=['into a shared plan','into a thoughtful surprise','into a date detail','into a private ritual','into a brave conversation']; text=`Who is more likely to turn ${idea} ${endings[i%5]}?`; }
add(p,type,intensity,text,null,null,{outcome:type==='audio'?'Each partner leaves a distinct spoken message with voice and tone.':type==='photo'?'Each partner shares a safe visual cue that can start a private conversation.':type==='who_likely'?'Both partners make a comparative choice that opens a light follow-up.':'Each partner provides a private, personally worded detail.'});
}
}
// Correct swipe intensity exact 24/50/30 without affecting natural copy.
const sw=rows.filter(r=>r.question_type==='swipe'); sw.forEach((r,i)=>r.intensity=i<24?2:i<74?3:4);
return {version:'2.0',title:'Play-safe spicy extension v2',status:'source-only-unpublished',questions:rows};
}
function packMeta(snapshot) { const t=snapshot.tables,cats=new Map(t.categories.map(c=>[c.id,c])),packs=new Map(t.question_packs.map(p=>[p.id,p])); return {cats,packs}; }
function displayRow(q) { const p=q.partner_text ? `\n - Partner text: ${q.partner_text}` : ''; const inv=q.inverse_of ? `\n - Inverse: ${q.inverse_of}` : ''; return `- **${q.status}** | Type: \`${q.question_type}\` | Intensity: ${q.intensity} | ID: \`${q.id}\`\n - Text: ${q.text}${p}${inv}`; }
function markdown(existing,v1,v2,snapshot) {
const all=[...existing,...v1.map(q=>({...q,status:'NEW'})),...v2.map(q=>({...q,status:'NEW'}))]; const {cats,packs}=packMeta(snapshot);
const grouped=new Map();
for(const q of all){ let category,pack,status=q.status; if(status==='EXISTING'){const p=packs.get(q.pack_id);category=cats.get(p.category_id).name;pack=p.name;} else {[category,pack]=taxonomy[q.pack_slug];} const key=`${category}\u0000${pack}\u0000${status}`; if(!grouped.has(key))grouped.set(key,[]); grouped.get(key).push(q); }
const lines=['# Full live catalogue, 1,650 rows','', 'Status: source-only planning document. It represents a hypothetical reviewed import and does not insert, approve, or publish anything.','', '## Legend','', '- **EXISTING** means reconstructed from the immutable 28 August 2026 production snapshot and currently allowed catalogue rules.','- **NEW** means a proposed source-only row.','- Type is the existing app interaction type. Partner text appears only for asymmetric swipe rows.','', '## Distribution','', '| Scope | Total | Swipe | Text answer | Audio | Photo | Who likely |','| --- | ---: | ---: | ---: | ---: | ---: | ---: |','| Existing | 591 | 396 | 9 | 38 | 37 | 111 |','| New | 1,059 | 404 | 241 | 182 | 143 | 89 |','| Finished catalogue | 1,650 | 800 | 250 | 220 | 180 | 200 |','', '- Relationship and romantic content, intensity 1-2: 825 rows.','- Adult-leaning private content, intensity 3-4: 825 rows.','', '## Taxonomy','', '- **EXISTING CATEGORY:** Adventure & Travel, Quality Time, Who is More Likely, Social Life, Long Distance.','- **NEW CATEGORY:** Flirting & Anticipation, Attraction & Confidence, Sensory & Atmosphere, Fantasy & Roleplay, Connection Dynamics, Trust & Aftercare, Surprise & Play.',''];
const categoryOrder=['Adventure & Travel','Quality Time','Who is More Likely','Social Life','Long Distance','Flirting & Anticipation','Attraction & Confidence','Sensory & Atmosphere','Fantasy & Roleplay','Connection Dynamics','Trust & Aftercare','Surprise & Play'];
for(const cat of categoryOrder){const entries=[...grouped.entries()].filter(([k])=>k.split('\u0000')[0]===cat); if(!entries.length)continue; const isNew=entries.some(([k])=>k.split('\u0000')[2]==='NEW'); const isExisting=entries.some(([k])=>k.split('\u0000')[2]==='EXISTING'); lines.push(`## ${isNew&&!isExisting?'NEW CATEGORY':isExisting&&!isNew?'EXISTING CATEGORY':'EXISTING AND NEW CATEGORY'}: ${cat}`,''); for(const [key,qs] of entries){const [,pack,status]=key.split('\u0000'); lines.push(`### ${status} PACK: ${pack}`, ''); qs.sort((a,b)=>a.id.localeCompare(b.id)).forEach(q=>lines.push(displayRow(q))); lines.push('');}}
while(lines.at(-1)==='') lines.pop(); return `${lines.join('\n')}\n`;
}
function count(rows,key){return Object.fromEntries([...new Set(rows.map(r=>r[key]))].sort().map(x=>[x,rows.filter(r=>r[key]===x).length]));}
function sameCounts(actual, expected){return Object.keys(expected).every(k=>actual[k]===expected[k])&&Object.keys(actual).length===Object.keys(expected).length;}
function fail(message){throw new Error(message)}
function validate() {
// Git may check this immutable JSON out with CRLF on Windows. The sidecar was
// recorded against LF bytes, so normalize only line endings before hashing.
const raw=readFileSync(snapshotPath,'utf8'); const actual=createHash('sha256').update(raw.replace(/\r\n/g,'\n')).digest('hex'); const expected=readFileSync(checksumPath,'utf8').trim().split(/\s+/)[0]; if(actual!==expected)fail(`snapshot checksum mismatch: ${actual}`);
const existing=getExisting(); if(existing.length!==591)fail(`existing expected 591, got ${existing.length}`);
const v1=JSON.parse(readFileSync(v1Path)).questions; const v2=JSON.parse(readFileSync(v2Path)).questions; const v2Ids=new Set(v2.map(q=>q.id)); if(v1.length!==550||v2.length!==509)fail('new source count mismatch'); const all=[...existing,...v1,...v2]; if(all.length!==1650)fail('total mismatch');
for(const [type, levels] of Object.entries(typeMatrix)){for(const [offset, intensity] of [2,3,4].entries()){const actual=v2.filter(q=>q.question_type===type&&q.intensity===intensity).length;if(actual!==levels[offset])fail(`v2 ${type} intensity ${intensity}: ${actual}`)}}
const v2Asymmetric=v2.filter(q=>q.question_type==='swipe'&&q.partner_text); if(v2Asymmetric.length!==64)fail(`v2 asymmetric swipe rows: ${v2Asymmetric.length}`); if(v2.filter(q=>q.question_type==='swipe'&&!q.partner_text).length!==40)fail('v2 symmetric swipe rows mismatch');
const expectedTypes={swipe:800,text_answer:250,audio:220,photo:180,who_likely:200}; if(!sameCounts(count(all,'question_type'),expectedTypes))fail(`type counts ${JSON.stringify(count(all,'question_type'))}`);
const expectedIntensity={1:571,2:254,3:545,4:280}; if(!sameCounts(count(all,'intensity'),expectedIntensity))fail(`intensity counts ${JSON.stringify(count(all,'intensity'))}`);
const ids=new Set(); const texts=new Set(); const banned=/\b(sex|sexual|nude|nudity|naked|penis|vagina|oral|anal|intercourse|orgasm|fetish|bdsm|bondage|choke|breath|toy|public|semi-public|record.*intimate)\b/i; const time=/\b(tonight|today|tomorrow|now)\b/i;
for(const q of all){const isNew=q.status!=='EXISTING';const isV2=v2Ids.has(q.id);if(ids.has(q.id))fail(`duplicate ID ${q.id}`);ids.add(q.id);if(isV2&&(banned.test(q.text)||time.test(q.text)||/—/.test(q.text)))fail(`unsafe wording ${q.id}`); const n=norm(q.text); if(isNew&&texts.has(n))fail(`duplicate normalized text ${q.text}`);texts.add(n); if(isV2&&q.question_type==='audio'&&!/(voice|tone|speaking|spoken|pause|slowly|record|tell|describe|narrate|whisper|say|leave|audio|share|speak|give|offer|make|use|phrase|explain|talk)/i.test(q.text))fail(`audio lacks voice cue ${q.id}`); if(isV2&&q.question_type==='photo'&&/\\b(body|skin|proof|intimate|bed|selfie)\\b/i.test(q.text))fail(`unsafe photo ${q.id}`); if(isV2&&q.question_type==='who_likely'&&!/^(Who|Which partner)/i.test(q.text))fail(`who form ${q.id}`); if(q.question_type!=='swipe'&&(q.partner_text||q.inverse_of))fail(`non-swipe metadata ${q.id}`);}
const byId=new Map(all.map(q=>[q.id,q])); for(const q of v2.filter(q=>q.question_type==='swipe'&&q.partner_text)){const other=[...v2].find(candidate=>candidate.question_type==='swipe'&&candidate.text===q.partner_text&&candidate.partner_text===q.text);if(!other||!(q.inverse_of===other.id||other.inverse_of===q.id))fail(`reciprocal swipe integrity ${q.id}`)}
const md=readFileSync(markdownPath,'utf8'); for(const q of all){if(!md.includes(`ID: \`${q.id}\``)||!md.includes(`**${q.status??'NEW'}**`))fail(`markdown missing ${q.id}`)}
console.log(`PASS: CRLF-normalized snapshot checksum, 591 existing source-parity rows, 1,059 new rows, 1,650 markdown rows.`); console.log(`Types: ${JSON.stringify(expectedTypes)}. Intensities: ${JSON.stringify(expectedIntensity)}. Extension matrix and 32 inverse concepts pass.`);
}
if(process.argv.includes('--write')) { const v2=JSON.parse(readFileSync(v2Path)); const snapshot=JSON.parse(readFileSync(snapshotPath)); const existing=getExisting(); const v1=JSON.parse(readFileSync(v1Path)).questions; writeFileSync(markdownPath,markdown(existing,v1,v2.questions,snapshot)); }
if(!existsSync(v2Path)||!existsSync(markdownPath)) { console.error('Run with --write to create the source-only extension and catalogue.'); process.exit(1); }
validate();