Compare commits

Author SHA1 Message Date
jjbrunton f38b6f14fa chore(release): promote invite funnel join links to main
Promote invite funnel join links to main
2026-08-30 18:16:36 +01:00
jjbruntonandClaude Fable 5 74c1acfe2d Merge couples quiz with shareable results card into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:11:47 +01:00
jjbrunton 23db4ed951 Merge branch 'staging' into task/quiz-viral 2026-08-30 18:10:24 +01:00
jjbrunton ccd3f03437 Merge invite funnel join links into staging 2026-08-30 18:09:54 +01:00
jjbruntonandClaude Fable 5 6523ddeb1c feat(quiz): couples quiz with shareable watermarked results card
Replaces the notify-me quiz stub with a full loop: quiz sessions per
couple, answer-and-guess flow, dual-direction scoring, and a score-only
share card (no question or answer content) captured via ViewShot with
Sauci branding.

API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12
seeded questions), new quiz domain with session start / current /
answers / result routes, couple isolation, route + integration +
scoring unit tests.

Mobile: src/features/quiz feature (intro, per-question self+guess flow,
waiting-for-partner nudge, results with breakdown), quizStore with
sign-out clearing, QuizShareModal following SharePreviewModal, discovery
QuizTile, analytics events, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:09:19 +01:00
jjbruntonandClaude Fable 5 4f9950f21b feat(invite-funnel): set real Android app signing cert fingerprint
Replace the assetlinks.json placeholder with the Play App Signing key
SHA-256 certificate fingerprint so Android App Links for
https://sauci.app/join/* can verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:06:42 +01:00
jjbruntonandClaude Fable 5 d48b44baa8 feat(invite-funnel): add web join page and mobile deep-link hand-off
Adds a shareable https://sauci.app/join/{code} link as an alternative to
manually typing an 8-character invite code:

- apps/web: /join/[code] page that validates the code shape client-side,
  attempts the app.sauci:// scheme link, and falls back to a large code
  display with copy button plus App Store/Play Store links; PostHog events
  for page view, code copy, and store-button clicks.
- apps/web: apple-app-site-association and assetlinks.json under
  /.well-known so iOS Universal Links and Android App Links can verify
  sauci.app for /join/*. The Android release cert SHA-256 fingerprint is a
  placeholder pending the real value.
- apps/mobile: associated domains (iOS) and an intent filter (Android) for
  https://sauci.app/join/*, alongside the existing app.sauci scheme.
- apps/mobile: parse incoming join links, pre-fill and highlight the code on
  the pairing screen, stash the code for signed-out/mid-onboarding users and
  apply it once they reach pairing, and offer a once-per-session clipboard
  hand-off that is never auto-applied.
- apps/mobile: share message now leads with the join link, keeping the raw
  code visible as a fallback.
- Analytics: invite_link_opened, pairing_code_prefilled (mobile);
  join_page_viewed, join_page_code_copied, join_page_store_button_clicked
  (web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:58:43 +01:00
jjbrunton 5564f9bbab Merge iOS CocoaPods lock sync into staging 2026-08-30 17:54:38 +01:00
jjbrunton c972b1c236 fix(ios): synchronize CocoaPods lock checksums 2026-08-30 17:54:30 +01:00
jjbrunton 30aa7bc386 Merge iOS CocoaPods UTF-8 fix into staging 2026-08-30 17:35:30 +01:00
jjbrunton 8bbb0e8cea fix(dev-local): set UTF-8 for native builds 2026-08-30 17:35:18 +01:00
jjbrunton fbdb2a8fff Merge task/agent-fast-lane into staging 2026-08-30 17:34:04 +01:00
jjbrunton bdfe8b7171 perf(agents): streamline Terra execution
Keep Sol responsible for scope, steering, and final acceptance while assigning one continuous discovery, implementation, testing, and iteration unit to Terra. Route additional reviewers and runtime verification by risk and avoid redundant verification passes.
2026-08-30 17:33:48 +01:00
jjbrunton 2cbe9e9c55 chore(release): promote photo picker fix
Promote the verified Android photo-picker policy fix from staging to main after target-branch and pull-request checks passed.
2026-08-30 17:31:02 +01:00
jjbrunton 8d9a3f4f1c Merge local stack single-command fix into staging 2026-08-30 17:26:21 +01:00
jjbrunton 476e4c845f fix(dev-local): harden one-command startup 2026-08-30 17:25:30 +01:00
jjbrunton 3d53d001b7 Merge task/android-photo-picker into main 2026-08-30 17:16:08 +01:00
jjbrunton 8658fc38fe fix(android): use system photo picker
Block broad image and video read permissions in Expo config and the checked-in Android manifest. Keep picker access scoped to user-selected media and gallery saves write-only. Add regression coverage and document the release-manifest invariant.
2026-08-30 17:15:57 +01:00
jjbrunton 24de9af4cd fix(web): add account deletion page (#16)
Add the public deletion resource and repair the monorepo web container build contract.
2026-08-30 16:23:30 +01:00
jjbrunton 5e581efe51 Merge web container fix into staging 2026-08-30 16:19:20 +01:00
jjbrunton 12c6b8bcc9 fix(web): build from monorepo context 2026-08-30 16:18:55 +01:00
jjbrunton 599be22eb1 Merge task/google-account-deletion into staging 2026-08-30 16:09:59 +01:00
jjbrunton 9514835bf2 fix(web): add account deletion page 2026-08-30 16:07:19 +01:00
jjbrunton 65bd1bbd6b chore(release): record iOS build 45
chore(release): record iOS build 45
2026-08-30 10:51:43 +01:00
jjbrunton e0813ba5a3 docs(release): verify iOS extension versions
docs(release): verify iOS extension versions
2026-08-30 10:43:45 +01:00
jjbrunton f7c686d5cf docs(release): verify iOS extension versions 2026-08-30 10:39:33 +01:00
jjbrunton 314664ecd6 chore(ios): record v1.0.6 build 45
chore(ios): record v1.0.6 build 45
2026-08-30 10:37:43 +01:00
jjbrunton 11f89b1345 chore(ios): record v1.0.6 build 45 2026-08-30 10:33:16 +01:00
jjbrunton 2481956c11 chore(agents): prohibit em dashes 2026-08-30 10:17:37 +01:00
jjbrunton 8804e121b6 chore(release): promote iOS compliance fix
chore(release): promote iOS compliance fix
2026-08-30 10:15:27 +01:00
jjbrunton 7b3de48dbd Merge pull request #11 from jjbrunton/task/ios-export-compliance
chore(ios): declare exempt encryption
2026-08-30 10:07:24 +01:00
jjbrunton 5ebf57bdbf Merge branch 'staging' into task/ios-export-compliance 2026-08-30 10:07:10 +01:00
jjbrunton 089e629021 docs(agents): delegate substantive repository tasks 2026-08-30 10:06:37 +01:00
jjbrunton 58b70710db chore(ios): declare exempt encryption 2026-08-30 09:58:26 +01:00
jjbrunton d645d83c47 chore(ios): record v1.0.6 build 44 2026-08-30 01:29:55 +01:00
jjbrunton 179f912170 chore(android): record v1.0.6 build 48 2026-08-30 01:05:13 +01:00
jjbrunton a604f82110 chore(release): prepare v1.0.6 2026-08-30 00:46:46 +01:00
jjbrunton ae8e72ddb5 Merge pull request #10 from jjbrunton/staging
chore(release): complete mobile release configuration
2026-08-30 00:41:09 +01:00
jjbrunton fd38b303f0 Merge pull request #9 from jjbrunton/staging
fix(api): keep auth sync timestamps monotonic
2026-08-29 23:57:39 +01:00
jjbrunton 387b242aea Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
2026-08-29 23:34:30 +01:00
94 changed files with 4899 additions and 430 deletions

No files matched your search

@@ -0,0 +1,39 @@
---
id: 2026-08-30-ios-extension-version-verification
date: 2026-08-30
status: promoted
scope: iOS release artifact verification
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA
destination: .codex/skills/release-mobile/SKILL.md
review_after: 2027-02-28
---
## Symptom
The signed iOS build logged that the widget extension build number 44 did not
match the containing app build number 45, even though export completed.
## Reproduction
Build iOS version 1.0.6 with EAS auto-increment from build 44 to 45 and retain the
archive log. Extract the resulting IPA with `ditto`, then read the containing app
and every packaged extension `Info.plist` with `PlistBuddy`.
## Cause
The archive warning described an intermediate state and was not authoritative
for the exported package. Direct IPA inspection showed both the app and
`LiveDrawWidget.appex` at version 1.0.6 build 45.
## Remediation
For every iOS release, inspect version and build values in the exported app and
all packaged extensions. Reject or rebuild only when the final IPA values differ;
do not accept or reject a release from archive warnings alone.
## Evidence
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
both packaged plists. `codesign --verify --deep --strict` passed for the app, and
the verified IPA SHA-256 was
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`.
+9 -7
View File
@@ -1,12 +1,14 @@
name = "implementation" name = "implementation"
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known." description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
model = "gpt-5.6-terra" model = "gpt-5.6-terra"
developer_instructions = """ developer_instructions = """
Read the root and nearest scoped AGENTS.md files plus the applicable repository Read the root and nearest scoped AGENTS.md files plus the applicable repository
skill. Implement only the delegated objective and preserve unrelated work. Update skill. Own the continuous execution unit: perform scoped discovery,
maintained contract documentation and add focused tests when the contract changes. implementation, focused tests, and iteration in this handoff. Implement only the
Run the required focused checks and return changed files, exact results, delegated objective and preserve unrelated work. Update maintained contract
assumptions, and blockers. Stop for missing authority or a materially ambiguous documentation and add focused tests when the contract changes. Run the required
product choice. Do not expand scope or perform external, destructive, production, focused checks and return changed files, exact results, assumptions, and blockers.
release, or provider writes unless the task explicitly authorizes that action. Stop for missing authority or a materially ambiguous product choice. Do not expand
scope or perform external, destructive, production, release, or provider writes
unless the task explicitly authorizes that action.
""" """
+6 -2
View File
@@ -54,5 +54,9 @@ additional TestFlight distribution requires another fresh authorization.
Preserve platform signing and local EAS versioning. After a build, verify that Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently tracked changes are limited to the expected version metadata, then independently
read the artifact version and signature. Report the artifact, version/build read the artifact version and signature. For an iOS IPA, inspect every packaged
number, upload result when authorized, and store read-back state. app extension as well as the containing app and require matching
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
warnings as diagnostic evidence, not the final artifact state: decide whether to
reject or rebuild only after reading the exported IPA itself. Report the artifact,
version/build number, upload result when authorized, and store read-back state.
+20 -11
View File
@@ -5,25 +5,34 @@ description: Prove a completed Sauci engineering task works before a PR. Use whe
# Verify Sauci work # Verify Sauci work
Run locally on a non-default branch with the implementation committed. Run locally on a non-default branch with the implementation committed. Use an
independent verifier for user-facing, cross-boundary, or high-risk runtime
changes. Documentation, mechanical configuration, isolated tests, and low-risk
package-local changes may use focused deterministic checks without a verifier.
1. Start the stack once with `scripts/dev-local.sh up`; reset fixtures first when 1. Package-local deterministic checks do not need a running stack. For runtime
the task changes durable state. Web is `http://127.0.0.1:3000`, admin is verification, reuse a compatible running local stack when available; otherwise
`http://127.0.0.1:3001`, and MCP health is `http://127.0.0.1:3002/health`. start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
2. Give a fresh read-only verifier the acceptance criteria. It drives browser first when the task changes durable state. Web is `http://127.0.0.1:3000`,
admin is `http://127.0.0.1:3001`, and MCP health is
`http://127.0.0.1:3002/health`.
2. When independent runtime verification is required, give a fresh read-only
verifier the acceptance criteria. It drives browser
surfaces with the repository browser capability, native surfaces with Maestro surfaces with the repository browser capability, native surfaces with Maestro
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`. described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
3. The verifier writes screenshots, traces, video, or response evidence under 3. The verifier writes screenshots, traces, video, or response evidence under
`evidence/` and returns only expected, observed, evidence paths, and `evidence/` and returns only expected, observed, evidence paths, and
`works|broken`. It never edits code. `works|broken`. It never edits code.
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and 4. If independent verification is broken, fix and use a fresh verifier. Stop
report the unresolved evidence. after three failed rounds and report the unresolved evidence.
5. After the task works, run `npm run verify:fast`, affected E2E, and 5. Run focused checks for the affected change. Run affected E2E when the runtime
`npm run verify:full` when local Supabase/Deno are available. Never weaken an path requires it. Run `npm run verify:full` when its prerequisites are
assertion just to pass. available; it already runs `npm run verify:fast`, so do not run both in
sequence. Never weaken an assertion just to pass.
6. Open a PR only after both task verification and regression checks pass. Upload 6. Open a PR only after both task verification and regression checks pass. Upload
evidence to the established CI artifact or `pr-evidence` prerelease and embed evidence to the established CI artifact or `pr-evidence` prerelease and embed
the screenshot plus a video/trace link in the PR body. the screenshot plus a video/trace link in the PR body.
A green test suite without an independently observed task outcome is incomplete. A green test suite without an independently observed outcome is incomplete when
independent runtime verification is required.
+6
View File
@@ -1,5 +1,7 @@
node_modules node_modules
**/node_modules **/node_modules
.next
**/.next
.git .git
.gitignore .gitignore
*.md *.md
@@ -23,11 +25,15 @@ apps/mcp
apps/mobile apps/mobile
apps/supabase apps/supabase
apps/web apps/web
!apps/web
!apps/web/**
e2e e2e
evidence evidence
etc etc
infra infra
scripts scripts
!scripts
!scripts/fix-hoisting.js
terraform terraform
.agents .agents
.claude .claude
+13 -3
View File
@@ -25,11 +25,21 @@ MCP service in one npm/Turborepo workspace.
and prove the relevant compatibility contracts still hold. Preserve deployed and prove the relevant compatibility contracts still hold. Preserve deployed
interfaces and supported client behavior; repository checks alone do not interfaces and supported client behavior; repository checks alone do not
prove a live change is safe. prove a live change is safe.
10. Scope work to the task. Route bounded implementation through the project 10. The Sol primary agent owns scope, risk classification, acceptance criteria,
`implementation` agent when delegation is useful; the primary agent retains progress steering, final diff and evidence review, and the final answer.
acceptance, review, and final verification. Do not delegate trivial edits. Delegate every substantive repository task to a project agent. Each
substantive implementation must use one Terra `implementation` agent as a
coherent execution unit, normally combining scoped discovery, implementation,
focused tests, and iteration. "Substantive" includes repository exploration,
multi-file reasoning, implementation, test execution, or contract/risk
analysis. Add a `repo_scout`, reviewer, or verifier only for separable work or
the triggers in `docs/agents/routing.md`; do not duplicate the implementation
agent's detailed exploration or checks without a concrete reason. Skip
delegation only for trivial conversation, a single obvious lookup, or a
mechanical one-line edit whose coordination cost exceeds the work.
11. Use high-effort reviewers only for the risk triggers in 11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`. `docs/agents/routing.md`.
12. Agents must not use em dash characters in text they add to this repository.
## Repository map ## Repository map
+65
View File
@@ -0,0 +1,65 @@
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
-- answers every question twice, once about themselves and once guessing their
-- partner, and the session only scores once both partners have a complete set.
CREATE TABLE IF NOT EXISTS "quiz_questions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"prompt_self" text NOT NULL UNIQUE,
"prompt_guess" text NOT NULL,
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
"sort_order" integer NOT NULL DEFAULT 0,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
"question_ids" uuid[] NOT NULL,
"score_percent" integer,
"completed_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
ON "quiz_sessions" ("couple_id", "created_at" DESC);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "quiz_answers" (
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL,
"question_id" uuid NOT NULL,
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("session_id", "user_id", "question_id")
);
--> statement-breakpoint
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
('How do you most like to receive love?', 'How does your partner most like to receive love?',
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
ON CONFLICT ("prompt_self") DO NOTHING;
+7
View File
@@ -134,6 +134,13 @@
"when": 1788081480000, "when": 1788081480000,
"tag": "0018_cutover_source_parity", "tag": "0018_cutover_source_parity",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1788168000000,
"tag": "0019_quiz",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -15,6 +15,8 @@ import type { ChatRepository } from './domains/chat/repository.js';
import { registerChatRoutes } from './domains/chat/routes.js'; import { registerChatRoutes } from './domains/chat/routes.js';
import type { DaresRepository } from './domains/dares/repository.js'; import type { DaresRepository } from './domains/dares/repository.js';
import { registerDareRoutes } from './domains/dares/routes.js'; import { registerDareRoutes } from './domains/dares/routes.js';
import type { QuizRepository } from './domains/quiz/repository.js';
import { registerQuizRoutes } from './domains/quiz/routes.js';
import { registerCoupleRoutes } from './domains/couples/routes.js'; import { registerCoupleRoutes } from './domains/couples/routes.js';
import type { CoupleService } from './domains/couples/service.js'; import type { CoupleService } from './domains/couples/service.js';
import type { PacksRepository } from './domains/packs/repository.js'; import type { PacksRepository } from './domains/packs/repository.js';
@@ -39,6 +41,7 @@ export interface AppDependencies {
answersRepository?: AnswersRepository; answersRepository?: AnswersRepository;
chatRepository?: ChatRepository; chatRepository?: ChatRepository;
daresRepository?: DaresRepository; daresRepository?: DaresRepository;
quizRepository?: QuizRepository;
profileSettingsRepository?: ProfileSettingsRepository; profileSettingsRepository?: ProfileSettingsRepository;
accountOperationsService?: AccountOperationsService; accountOperationsService?: AccountOperationsService;
mediaRepository?: MediaRepository; mediaRepository?: MediaRepository;
@@ -126,6 +129,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository); if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository); if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository); if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
if (deps.quizRepository) registerQuizRoutes(app, deps.quizRepository);
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository); if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService); if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage); if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
+1
View File
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
export * from '../domains/answers/schema.js'; export * from '../domains/answers/schema.js';
export * from '../domains/chat/schema.js'; export * from '../domains/chat/schema.js';
export * from '../domains/app-data/schema.js'; export * from '../domains/app-data/schema.js';
export * from '../domains/quiz/schema.js';
+376
View File
@@ -0,0 +1,376 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { computeQuizScore, type QuizAnswerPair } from './service.js';
import {
QuizError,
type QuizAnswerInput,
type QuizQuestionSummary,
type QuizResultPayload,
type QuizSessionPayload,
type QuizSessionStatus,
} from './types.js';
interface QuizQuestionRow extends QueryResultRow {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
interface QuizSessionRow extends QueryResultRow {
id: string;
couple_id: string;
status: QuizSessionStatus;
question_ids: string[];
score_percent: number | null;
completed_at: Date | null;
created_at: Date;
}
interface QuizAnswerRow extends QueryResultRow {
user_id: string;
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizRepository {
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
currentSession(userId: string): Promise<QuizSessionPayload | null>;
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
close(): Promise<void>;
}
const QUESTIONS_PER_SESSION = 10;
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await work(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
}
export class PostgresQuizRepository implements QuizRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
const result = await client.query<{ couple_id: string | null }>(
'select couple_id from profiles where id = $1',
[userId],
);
return result.rows[0]?.couple_id ?? null;
}
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
const result = await client.query<{ id: string }>(
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
[coupleId, userId],
);
return result.rows[0]?.id ?? null;
}
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
const result = await client.query<QuizQuestionRow>(
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
[questionIds],
);
return orderByIds(result.rows, questionIds);
}
private async buildPayload(
client: Pool | PoolClient,
session: QuizSessionRow,
userId: string,
): Promise<QuizSessionPayload> {
const questions = await this.questionsFor(client, session.question_ids);
const [myAnswers, counts] = await Promise.all([
client.query<QuizAnswerRow>(
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
[session.id, userId],
),
client.query<{ user_id: string; count: string }>(
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
[session.id],
),
]);
const coupleId = session.couple_id;
const partnerId = await this.partnerOf(client, coupleId, userId);
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
const questionCount = session.question_ids.length;
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
const orderedAnswers: QuizAnswerInput[] = session.question_ids
.map((questionId) => answersByQuestion.get(questionId))
.filter((row): row is QuizAnswerRow => row !== undefined)
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
id: row.id,
prompt_self: row.prompt_self,
prompt_guess: row.prompt_guess,
options: row.options,
}));
return {
id: session.id,
status: session.status,
created_at: session.created_at.toISOString(),
completed_at: session.completed_at?.toISOString() ?? null,
score_percent: session.score_percent,
questions: questionSummaries,
my_answers: orderedAnswers,
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
};
}
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
return transaction(this.pool, async (client) => {
const coupleId = await this.coupleOf(client, userId);
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
const members = await client.query<{ count: string }>(
'select count(*)::text as count from profiles where couple_id = $1',
[coupleId],
);
if (Number(members.rows[0]?.count ?? 0) < 2) {
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
}
const existing = await client.query<QuizSessionRow>(
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
[coupleId],
);
if (existing.rows[0]) {
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
}
const questions = await client.query<{ id: string }>(
'select id from quiz_questions where is_active = true order by sort_order, id limit $1',
[QUESTIONS_PER_SESSION],
);
const questionIds = questions.rows.map((row) => row.id);
// The partial unique index (one active session per couple) is the source of
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
// lets the loser fall through to reading the winner's row instead of erroring.
const inserted = await client.query<QuizSessionRow>(
`insert into quiz_sessions (couple_id, question_ids)
values ($1, $2::uuid[])
on conflict (couple_id) where status = 'active' do nothing
returning *`,
[coupleId, questionIds],
);
let sessionRow = inserted.rows[0];
let created = true;
if (!sessionRow) {
created = false;
const raced = await client.query<QuizSessionRow>(
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
[coupleId],
);
sessionRow = raced.rows[0]!;
}
return { session: await this.buildPayload(client, sessionRow, userId), created };
});
}
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
const coupleId = await this.coupleOf(this.pool, userId);
if (!coupleId) return null;
const result = await this.pool.query<QuizSessionRow>(
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
[coupleId],
);
const row = result.rows[0];
if (!row) return null;
return this.buildPayload(this.pool, row, userId);
}
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
return transaction(this.pool, async (client) => {
const sessionResult = await client.query<QuizSessionRow>(
'select * from quiz_sessions where id = $1 for update',
[sessionId],
);
const session = sessionResult.rows[0];
const coupleId = await this.coupleOf(client, userId);
if (!session || !coupleId || session.couple_id !== coupleId) {
throw new QuizError('session_not_found', 'Quiz session not found', 404);
}
if (session.status !== 'active') {
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
}
const questionIds = new Set(session.question_ids);
const answeredIds = new Set(answers.map((answer) => answer.question_id));
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
for (const id of answeredIds) {
if (!questionIds.has(id)) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
}
const questions = await this.questionsFor(client, session.question_ids);
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
for (const answer of answers) {
const options = optionsByQuestion.get(answer.question_id);
if (!options) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
if (
answer.self_index < 0 || answer.self_index >= options.length ||
answer.guess_index < 0 || answer.guess_index >= options.length
) {
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
}
}
for (const answer of answers) {
await client.query(
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
values ($1, $2, $3, $4, $5)
on conflict (session_id, user_id, question_id)
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
);
}
const partnerId = await this.partnerOf(client, coupleId, userId);
let updatedSession = session;
if (partnerId) {
const counts = await client.query<{ user_id: string; count: string }>(
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
[sessionId],
);
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
const questionCount = session.question_ids.length;
const bothComplete =
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
if (bothComplete) {
const allAnswers = await client.query<QuizAnswerRow>(
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
[sessionId],
);
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
for (const row of allAnswers.rows) {
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
byUser.get(row.user_id)!.set(row.question_id, row);
}
const mine = byUser.get(userId)!;
const theirs = byUser.get(partnerId)!;
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
questionId,
aSelfIndex: mine.get(questionId)!.self_index,
aGuessIndex: mine.get(questionId)!.guess_index,
bSelfIndex: theirs.get(questionId)!.self_index,
bGuessIndex: theirs.get(questionId)!.guess_index,
}));
const { scorePercent } = computeQuizScore(pairs);
const completed = await client.query<QuizSessionRow>(
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
where id = $1
returning *`,
[sessionId, scorePercent],
);
updatedSession = completed.rows[0]!;
}
}
return this.buildPayload(client, updatedSession, userId);
});
}
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
const sessionResult = await this.pool.query<QuizSessionRow>(
'select * from quiz_sessions where id = $1',
[sessionId],
);
const session = sessionResult.rows[0];
const coupleId = await this.coupleOf(this.pool, userId);
if (!session || !coupleId || session.couple_id !== coupleId) {
throw new QuizError('session_not_found', 'Quiz session not found', 404);
}
if (session.status !== 'completed') {
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
}
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
const [questions, allAnswers] = await Promise.all([
this.questionsFor(this.pool, session.question_ids),
this.pool.query<QuizAnswerRow>(
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
[sessionId],
),
]);
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
for (const row of allAnswers.rows) {
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
byUser.get(row.user_id)!.set(row.question_id, row);
}
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
const questionsPayload = questions.map((question) => {
const my = mine.get(question.id);
const partner = theirs.get(question.id);
const mySelf = my?.self_index ?? 0;
const myGuess = my?.guess_index ?? 0;
const partnerSelf = partner?.self_index ?? 0;
const partnerGuess = partner?.guess_index ?? 0;
return {
question_id: question.id,
prompt_self: question.prompt_self,
prompt_guess: question.prompt_guess,
options: question.options,
my_self_index: mySelf,
my_guess_index: myGuess,
partner_self_index: partnerSelf,
partner_guess_index: partnerGuess,
i_guessed_right: myGuess === partnerSelf,
partner_guessed_right: partnerGuess === mySelf,
};
});
return {
score_percent: session.score_percent ?? 0,
completed_at: session.completed_at?.toISOString() ?? null,
questions: questionsPayload,
};
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { QuizError, type QuizAnswerInput } from './types.js';
import type { QuizRepository } from './repository.js';
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const answerEntry = z
.object({
question_id: id,
self_index: z.number().int().min(0),
guess_index: z.number().int().min(0),
})
.strict();
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof QuizError) return { ok: false as const, cause };
throw cause;
}
}
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
app.post('/v1/quiz/sessions', async (c) => {
const response = await result(() => repository.startSession(c.get('identity').id));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
});
app.get('/v1/quiz/sessions/current', async (c) => {
const response = await result(() => repository.currentSession(c.get('identity').id));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value });
});
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
const sessionId = id.safeParse(c.req.param('sessionId'));
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
const answers: QuizAnswerInput[] = body.data.answers;
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value });
});
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
const sessionId = id.safeParse(c.req.param('sessionId'));
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json(response.value);
});
}
+51
View File
@@ -0,0 +1,51 @@
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { couples } from '../couples/schema.js';
/**
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
* same question so a session can ask each partner about themselves and about the
* other without a second row.
*/
export const quizQuestions = pgTable('quiz_questions', {
id: uuid('id').primaryKey().defaultRandom(),
promptSelf: text('prompt_self').notNull().unique(),
promptGuess: text('prompt_guess').notNull(),
options: text('options').array().notNull(),
sortOrder: integer('sort_order').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
]);
export const quizSessions = pgTable('quiz_sessions', {
id: uuid('id').primaryKey().defaultRandom(),
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('active'),
questionIds: uuid('question_ids').array().notNull(),
scorePercent: integer('score_percent'),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
]);
export const quizAnswers = pgTable('quiz_answers', {
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
questionId: uuid('question_id').notNull(),
selfIndex: integer('self_index').notNull(),
guessIndex: integer('guess_index').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
]);
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
export type QuizSessionRow = typeof quizSessions.$inferSelect;
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
+41
View File
@@ -0,0 +1,41 @@
/**
* One partner's guess of the other's self-answer, for a single question. Scoring
* always runs both directions: A guessing B and B guessing A each count separately,
* so a couple who only ever gets half the guesses right lands at 50%, not 100%.
*/
export interface QuizAnswerPair {
questionId: string;
aSelfIndex: number;
aGuessIndex: number;
bSelfIndex: number;
bGuessIndex: number;
}
export interface QuizScoreQuestionResult extends QuizAnswerPair {
aGuessedRight: boolean;
bGuessedRight: boolean;
}
export interface QuizScoreResult {
scorePercent: number;
perQuestion: QuizScoreQuestionResult[];
}
/**
* Pure scoring function for a completed quiz session. Each question contributes
* up to two hits (partner A's guess about B, and B's guess about A), so the
* denominator is twice the question count.
*/
export function computeQuizScore(pairs: QuizAnswerPair[]): QuizScoreResult {
const perQuestion = pairs.map((pair) => ({
...pair,
aGuessedRight: pair.aGuessIndex === pair.bSelfIndex,
bGuessedRight: pair.bGuessIndex === pair.aSelfIndex,
}));
const hits = perQuestion.reduce(
(sum, question) => sum + (question.aGuessedRight ? 1 : 0) + (question.bGuessedRight ? 1 : 0),
0,
);
const scorePercent = pairs.length === 0 ? 0 : Math.round((100 * hits) / (2 * pairs.length));
return { scorePercent, perQuestion };
}
+64
View File
@@ -0,0 +1,64 @@
export type QuizSessionStatus = 'active' | 'completed';
export type QuizErrorCode =
| 'no_couple'
| 'partner_required'
| 'session_not_found'
| 'session_completed'
| 'invalid_answers'
| 'session_not_completed';
export class QuizError extends Error {
constructor(
readonly code: QuizErrorCode,
message: string,
readonly status: 400 | 404 | 409,
) {
super(message);
this.name = 'QuizError';
}
}
export interface QuizAnswerInput {
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizQuestionSummary {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
export interface QuizSessionPayload {
id: string;
status: QuizSessionStatus;
created_at: string;
completed_at: string | null;
score_percent: number | null;
questions: QuizQuestionSummary[];
my_answers: QuizAnswerInput[];
partner_completed: boolean;
i_completed: boolean;
}
export interface QuizResultQuestion {
question_id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
my_self_index: number;
my_guess_index: number;
partner_self_index: number;
partner_guess_index: number;
i_guessed_right: boolean;
partner_guessed_right: boolean;
}
export interface QuizResultPayload {
score_percent: number;
completed_at: string | null;
questions: QuizResultQuestion[];
}
+3
View File
@@ -20,6 +20,7 @@ import {
import { PostgresAnswersRepository } from './domains/answers/repository.js'; import { PostgresAnswersRepository } from './domains/answers/repository.js';
import { PostgresChatRepository } from './domains/chat/repository.js'; import { PostgresChatRepository } from './domains/chat/repository.js';
import { PostgresDaresRepository } from './domains/dares/repository.js'; import { PostgresDaresRepository } from './domains/dares/repository.js';
import { PostgresQuizRepository } from './domains/quiz/repository.js';
import { PostgresCoupleRepository } from './domains/couples/repository.js'; import { PostgresCoupleRepository } from './domains/couples/repository.js';
import { CoupleService } from './domains/couples/service.js'; import { CoupleService } from './domains/couples/service.js';
import { PostgresPacksRepository } from './domains/packs/repository.js'; import { PostgresPacksRepository } from './domains/packs/repository.js';
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
const answersRepository = new PostgresAnswersRepository(pool); const answersRepository = new PostgresAnswersRepository(pool);
const chatRepository = new PostgresChatRepository(pool); const chatRepository = new PostgresChatRepository(pool);
const daresRepository = new PostgresDaresRepository(pool); const daresRepository = new PostgresDaresRepository(pool);
const quizRepository = new PostgresQuizRepository(pool);
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool); const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool); const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
const accountOperationsService = new AccountOperationsService( const accountOperationsService = new AccountOperationsService(
@@ -68,6 +70,7 @@ const app = createApp({
answersRepository, answersRepository,
chatRepository, chatRepository,
daresRepository, daresRepository,
quizRepository,
profileSettingsRepository, profileSettingsRepository,
accountOperationsService, accountOperationsService,
mediaRepository, mediaRepository,
+209
View File
@@ -0,0 +1,209 @@
import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { PostgresQuizRepository } from '../src/domains/quiz/repository.js';
const url = process.env.DATABASE_URL;
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
if (url && !local) throw new Error('Quiz integration tests only permit localhost');
describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const admin = new Pool({ connectionString: url });
const schema = `quiz_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repo: PostgresQuizRepository;
const userA = '11111111-1111-4111-8111-111111111111';
const userB = '22222222-2222-4222-8222-222222222222';
const soloUser = '33333333-3333-4333-8333-333333333333';
const outsider = '44444444-4444-4444-8444-444444444444';
const couple = '55555555-5555-4555-8555-555555555555';
const soloCouple = '66666666-6666-4666-8666-666666666666';
const otherCouple = '77777777-7777-4777-8777-777777777777';
beforeAll(async () => {
await admin.query(`create schema "${schema}"`);
const isolated = new URL(url!);
isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() });
repo = new PostgresQuizRepository(isolated.toString());
const dir = new URL('../drizzle/', import.meta.url);
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
for (const name of files) {
const sql = await readFile(new URL(name, dir), 'utf8');
for (const statement of sql.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
}
await pool.query(
'insert into couples(id,invite_code) values($1,$2),($3,$4),($5,$6)',
[couple, 'QUIZAAA1', soloCouple, 'QUIZAAA2', otherCouple, 'QUIZAAA3'],
);
await pool.query(
`insert into profiles(id,couple_id) values
($1,$5),($2,$5),($3,$6),($4,$7)`,
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple],
);
});
beforeEach(async () => {
await pool.query('delete from quiz_answers');
await pool.query('delete from quiz_sessions');
});
afterAll(async () => {
await repo.close();
await pool.end();
await admin.query(`drop schema "${schema}" cascade`);
await admin.end();
});
it('refuses to start a quiz without a couple or without a partner', async () => {
await expect(repo.startSession(randomUUID())).rejects.toMatchObject({ code: 'no_couple' });
await expect(repo.startSession(soloUser)).rejects.toMatchObject({ code: 'partner_required' });
});
it('starts a session with ten active questions and returns it idempotently', async () => {
const first = await repo.startSession(userA);
expect(first.created).toBe(true);
expect(first.session.status).toBe('active');
expect(first.session.questions).toHaveLength(10);
expect(first.session.score_percent).toBeNull();
expect(first.session.my_answers).toEqual([]);
expect(first.session.i_completed).toBe(false);
expect(first.session.partner_completed).toBe(false);
const second = await repo.startSession(userA);
expect(second.created).toBe(false);
expect(second.session.id).toBe(first.session.id);
// The partner starting also resolves to the same active session.
const partnerView = await repo.startSession(userB);
expect(partnerView.created).toBe(false);
expect(partnerView.session.id).toBe(first.session.id);
});
it('walks the full happy path: both partners answer, session completes, score is exact', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
expect(questionIds).toHaveLength(10);
// Self-answers alternate 0/1 per question so a guess cannot accidentally match
// by coincidence. A guesses B's self-answer correctly on the first 6 questions
// and wrong on the rest; B guesses A's self-answer correctly on the first 3
// questions and wrong on the rest.
const bSelf = questionIds.map((_, index) => index % 2);
const aSelf = questionIds.map((_, index) => 1 - (index % 2));
const aGuess = bSelf.map((value, index) => (index < 6 ? value : 1 - value));
const bGuess = aSelf.map((value, index) => (index < 3 ? value : 1 - value));
const aAnswers = questionIds.map((questionId, index) => ({
question_id: questionId,
self_index: aSelf[index]!,
guess_index: aGuess[index]!,
}));
const bAnswers = questionIds.map((questionId, index) => ({
question_id: questionId,
self_index: bSelf[index]!,
guess_index: bGuess[index]!,
}));
const afterA = await repo.submitAnswers(userA, session.id, aAnswers);
expect(afterA.status).toBe('active');
expect(afterA.i_completed).toBe(true);
expect(afterA.partner_completed).toBe(false);
expect(afterA.my_answers).toHaveLength(10);
const afterB = await repo.submitAnswers(userB, session.id, bAnswers);
expect(afterB.status).toBe('completed');
expect(afterB.completed_at).not.toBeNull();
expect(afterB.i_completed).toBe(true);
expect(afterB.partner_completed).toBe(true);
// A guessed right (guess=1 matches B's self=1) on questions 0-5: 6 hits.
// B guessed right (guess=0 matches A's self=0) on questions 0-2: 3 hits.
// Total 9 hits out of 20 possible = 45%.
expect(afterB.score_percent).toBe(45);
const result = await repo.result(userA, session.id);
expect(result.score_percent).toBe(45);
expect(result.completed_at).not.toBeNull();
expect(result.questions).toHaveLength(10);
expect(result.questions[0]).toMatchObject({
my_self_index: aSelf[0],
my_guess_index: aGuess[0],
partner_self_index: bSelf[0],
partner_guess_index: bGuess[0],
i_guessed_right: true,
partner_guessed_right: true,
});
expect(result.questions[9]).toMatchObject({
i_guessed_right: false,
partner_guessed_right: false,
});
// The partner's result view mirrors the same score with roles swapped.
const partnerResult = await repo.result(userB, session.id);
expect(partnerResult.score_percent).toBe(45);
});
it('rejects an incomplete or mismatched answer set', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
await expect(
repo.submitAnswers(userA, session.id, [
{ question_id: questionIds[0]!, self_index: 0, guess_index: 0 },
]),
).rejects.toMatchObject({ code: 'invalid_answers' });
await expect(
repo.submitAnswers(userA, session.id, [
...questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })),
{ question_id: randomUUID(), self_index: 0, guess_index: 0 },
]),
).rejects.toMatchObject({ code: 'invalid_answers' });
const options = session.questions[0]!.options;
await expect(
repo.submitAnswers(userA, session.id, questionIds.map((id) => ({
question_id: id,
self_index: options.length,
guess_index: 0,
}))),
).rejects.toMatchObject({ code: 'invalid_answers' });
});
it('isolates sessions to their own couple', async () => {
const { session } = await repo.startSession(userA);
await expect(repo.submitAnswers(outsider, session.id, [])).rejects.toMatchObject({ code: 'session_not_found' });
await expect(repo.result(outsider, session.id)).rejects.toMatchObject({ code: 'session_not_found' });
expect(await repo.currentSession(outsider)).toBeNull();
});
it('blocks the answers endpoint once the session has completed', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
const answers = questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, session.id, answers);
const completed = await repo.submitAnswers(userB, session.id, answers);
expect(completed.status).toBe('completed');
await expect(repo.submitAnswers(userA, session.id, answers)).rejects.toMatchObject({ code: 'session_completed' });
});
it('blocks the result endpoint before the session has completed', async () => {
const { session } = await repo.startSession(userA);
await expect(repo.result(userA, session.id)).rejects.toMatchObject({ code: 'session_not_completed' });
});
it('returns the couple\'s most recent session from currentSession', async () => {
expect(await repo.currentSession(userA)).toBeNull();
const { session } = await repo.startSession(userA);
const current = await repo.currentSession(userB);
expect(current?.id).toBe(session.id);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { QuizRepository } from '../src/domains/quiz/repository.js';
import { registerQuizRoutes } from '../src/domains/quiz/routes.js';
import { QuizError, type QuizSessionPayload } from '../src/domains/quiz/types.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const sessionId = '22222222-2222-4222-8222-222222222222';
const questionId = '33333333-3333-4333-8333-333333333333';
const session: QuizSessionPayload = {
id: sessionId,
status: 'active',
created_at: '2026-08-27T00:00:00.000Z',
completed_at: null,
score_percent: null,
questions: [
{ id: questionId, prompt_self: 'Self prompt', prompt_guess: 'Guess prompt', options: ['A', 'B'] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
};
function setup(overrides: Partial<QuizRepository> = {}) {
const repository: QuizRepository = {
startSession: vi.fn(async () => ({ session, created: true })),
currentSession: vi.fn(async () => session),
submitAnswers: vi.fn(async () => session),
result: vi.fn(async () => ({
score_percent: 100,
completed_at: '2026-08-27T00:00:00.000Z',
questions: [],
})),
close: vi.fn(async () => undefined),
...overrides,
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerQuizRoutes(app, repository);
return { app, repository };
}
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('quiz routes', () => {
it('starts a session and returns 201 when a new one is created', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ session });
expect(repository.startSession).toHaveBeenCalledWith(identity.id);
});
it('returns 200 when starting resolves to an existing active session', async () => {
const { app } = setup({ startSession: vi.fn(async () => ({ session, created: false })) });
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(200);
});
it('maps no_couple and partner_required to 409', async () => {
const { app } = setup({
startSession: vi.fn(async () => { throw new QuizError('no_couple', 'Join a couple first', 409); }),
});
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: 'no_couple' } });
});
it('returns the current session, including null when none exists', async () => {
const { app } = setup({ currentSession: vi.fn(async () => null) });
const response = await app.request('/v1/quiz/sessions/current');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ session: null });
});
it('rejects a malformed session id on the answers route', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/quiz/sessions/not-a-uuid/answers', {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
});
expect(response.status).toBe(404);
expect(repository.submitAnswers).not.toHaveBeenCalled();
});
it('rejects an empty or malformed answer body', async () => {
const { app, repository } = setup();
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers: [] })).status).toBe(400);
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {})).status).toBe(400);
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1, extra: true }],
})).status).toBe(400);
expect(repository.submitAnswers).not.toHaveBeenCalled();
});
it('submits valid answers and surfaces the updated session', async () => {
const { app, repository } = setup();
const answers = [{ question_id: questionId, self_index: 0, guess_index: 1 }];
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers });
expect(response.status).toBe(200);
expect(repository.submitAnswers).toHaveBeenCalledWith(identity.id, sessionId, answers);
});
it('maps session errors to their status codes', async () => {
const { app } = setup({
submitAnswers: vi.fn(async () => { throw new QuizError('session_completed', 'Already finished', 409); }),
});
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: 'session_completed' } });
});
it('returns the result payload for a completed session', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ score_percent: 100 });
expect(repository.result).toHaveBeenCalledWith(identity.id, sessionId);
});
it('maps session_not_completed to 409 on the result route', async () => {
const { app } = setup({
result: vi.fn(async () => { throw new QuizError('session_not_completed', 'Not finished yet', 409); }),
});
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
expect(response.status).toBe(409);
});
it('rejects a malformed session id on the result route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/quiz/sessions/not-a-uuid/result');
expect(response.status).toBe(404);
expect(repository.result).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { computeQuizScore, type QuizAnswerPair } from '../src/domains/quiz/service.js';
function pair(overrides: Partial<QuizAnswerPair> = {}): QuizAnswerPair {
return {
questionId: 'q1',
aSelfIndex: 0,
aGuessIndex: 0,
bSelfIndex: 0,
bGuessIndex: 0,
...overrides,
};
}
describe('computeQuizScore', () => {
it('scores 100% when every guess matches the partner self-answer in both directions', () => {
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 1 }),
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 3, bGuessIndex: 0 }),
];
const { scorePercent } = computeQuizScore(pairs);
expect(scorePercent).toBe(100);
});
it('scores 0% when nobody guesses their partner correctly', () => {
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 2, bGuessIndex: 3 }),
];
expect(computeQuizScore(pairs).scorePercent).toBe(0);
});
it('counts each direction independently, so a half-right couple lands at 50%', () => {
// A guesses B correctly, but B guesses A incorrectly: one hit out of two possible.
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 0 }),
];
const result = computeQuizScore(pairs);
expect(result.scorePercent).toBe(50);
expect(result.perQuestion[0]).toMatchObject({ aGuessedRight: true, bGuessedRight: false });
});
it('rounds to the nearest whole percent', () => {
// 1 hit out of 6 possible (3 questions * 2 directions) = 16.67%, rounds to 17.
const pairs: QuizAnswerPair[] = [
// aGuess (0) matches bSelf (0): one hit. bGuess (3) does not match aSelf (1).
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 0, bGuessIndex: 3 }),
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
pair({ questionId: 'q3', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
];
expect(computeQuizScore(pairs).scorePercent).toBe(17);
});
it('returns 0% for an empty question set instead of dividing by zero', () => {
expect(computeQuizScore([]).scorePercent).toBe(0);
});
});
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 47 versionCode 48
versionName "1.0.5" versionName "1.0.6"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
signingConfigs { signingConfigs {
@@ -3,8 +3,8 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
@@ -52,4 +52,4 @@
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/>
</receiver> </receiver>
</application> </application>
</manifest> </manifest>
+30 -3
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Sauci", "name": "Sauci",
"slug": "sauci", "slug": "sauci",
"version": "1.0.5", "version": "1.0.6",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"scheme": "app.sauci", "scheme": "app.sauci",
@@ -16,12 +16,18 @@
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.sauci.app", "bundleIdentifier": "com.sauci.app",
"associatedDomains": [
"applinks:sauci.app"
],
"config": {
"usesNonExemptEncryption": false
},
"entitlements": { "entitlements": {
"com.apple.security.application-groups": [ "com.apple.security.application-groups": [
"group.com.sauci.app" "group.com.sauci.app"
] ]
}, },
"buildNumber": "43", "buildNumber": "45",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist", "googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": { "infoPlist": {
@@ -183,7 +189,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 47, "versionCode": 48,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
@@ -191,6 +197,23 @@
"android.permission.USE_BIOMETRIC", "android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT", "android.permission.USE_FINGERPRINT",
"com.android.vending.BILLING" "com.android.vending.BILLING"
],
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "sauci.app",
"pathPrefix": "/join"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
] ]
}, },
"web": { "web": {
@@ -208,6 +231,10 @@
"buildToolsVersion": "36.0.0", "buildToolsVersion": "36.0.0",
"compileSdkVersion": 36, "compileSdkVersion": 36,
"targetSdkVersion": 36, "targetSdkVersion": 36,
"blockedPermissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
],
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }", "extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
"useLegacyPackaging": false, "useLegacyPackaging": false,
"manifestPlaceholders": { "manifestPlaceholders": {
+4 -1
View File
@@ -8,7 +8,7 @@ import * as Haptics from 'expo-haptics';
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui'; import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall'; import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery'; import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard, QuizTile } from '../../src/components/discovery';
import { StreakDisplay } from '../../src/components/StreakDisplay'; import { StreakDisplay } from '../../src/components/StreakDisplay';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme'; import { colors, spacing, typography } from '../../src/theme';
@@ -166,6 +166,9 @@ export default function DiscoveryScreen() {
{/* Dares */} {/* Dares */}
<DaresTile delay={200} /> <DaresTile delay={200} />
{/* Quiz */}
<QuizTile delay={225} />
{/* Content Rows by Category */} {/* Content Rows by Category */}
{packsByCategory.map((group, index) => ( {packsByCategory.map((group, index) => (
<ContentRow <ContentRow
+168 -17
View File
@@ -17,17 +17,24 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useAuthStore } from "../../src/store"; import { useAuthStore } from "../../src/store";
import { coupleApi } from "../../src/lib/coupleApi"; import { coupleApi } from "../../src/lib/coupleApi";
import { getPairingError } from "../../src/lib/errors"; import { getPairingError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
import { router } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; 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";
export default function PairingScreen() { export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore(); const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>();
const [inviteCode, setInviteCode] = useState(""); const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [wasPrefilled, setWasPrefilled] = useState(false);
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false);
// Redirect if already paired // Redirect if already paired
useEffect(() => { useEffect(() => {
@@ -36,25 +43,83 @@ export default function PairingScreen() {
} }
}, [couple, partner]); }, [couple, partner]);
// Poll the standalone API while waiting for a partner. Realtime delivery can // One-time prefill: apply a code carried by a join link (route param), then
// be added later without coupling product data back to Supabase. // a stashed code from before sign-in, and only otherwise offer a clipboard
useEffect(() => { // match. Never auto-submits; the user still taps "Join Partner".
if (!couple || partner) return; useEffect(() => {
if (couple) return; // only relevant to the unpaired join flow
if (prefillAttempted) return;
let cancelled = false;
const applyPrefill = async (code: string) => {
if (cancelled) return;
setInviteCode(code);
setWasPrefilled(true);
Events.pairingCodePrefilled();
};
(async () => {
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
if (isValidInviteCode(routeCode)) {
await applyPrefill(routeCode);
await clearPendingInviteCode();
if (!cancelled) setPrefillAttempted(true);
return;
}
const stashedCode = await getPendingInviteCode();
if (stashedCode) {
await applyPrefill(stashedCode);
await clearPendingInviteCode();
if (!cancelled) setPrefillAttempted(true);
return;
}
const clipboardCode = await checkClipboardForInviteCode();
if (clipboardCode && !cancelled) {
setClipboardOfferCode(clipboardCode);
}
if (!cancelled) setPrefillAttempted(true);
})();
return () => {
cancelled = true;
};
}, [couple, params.code, prefillAttempted]);
const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return;
setInviteCode(clipboardOfferCode);
setWasPrefilled(true);
Events.inviteLinkOpened("clipboard");
Events.pairingCodePrefilled();
setClipboardOfferCode(null);
};
const dismissClipboardOffer = () => {
setClipboardOfferCode(null);
};
// Poll the standalone API while waiting for a partner. Realtime delivery can
// be added later without coupling product data back to Supabase.
useEffect(() => {
if (!couple || partner) return;
// Poll every 5 seconds as fallback // Poll every 5 seconds as fallback
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
fetchCouple(); fetchCouple();
}, 5000); }, 5000);
return () => { return () => {
clearInterval(pollInterval); clearInterval(pollInterval);
}; };
}, [couple, partner, fetchCouple]); }, [couple, partner, fetchCouple]);
const handleCreateCouple = async () => { const handleCreateCouple = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await coupleApi.create(); await coupleApi.create();
await fetchUser(); // Refresh user to get couple_id await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data await fetchCouple(); // Fetch couple data
@@ -76,7 +141,7 @@ export default function PairingScreen() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await coupleApi.join(sanitizedCode); await coupleApi.join(sanitizedCode);
await fetchUser(); // Refresh user to get couple_id await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data await fetchCouple(); // Fetch couple data
@@ -100,8 +165,13 @@ export default function PairingScreen() {
} }
}; };
const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false);
setInviteCode(value);
};
const shareMessage = couple?.invite_code const shareMessage = couple?.invite_code
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}` ? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)`
: ""; : "";
const shareCode = async () => { const shareCode = async () => {
@@ -144,7 +214,7 @@ export default function PairingScreen() {
onPress: async () => { onPress: async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await coupleApi.cancel(); await coupleApi.cancel();
await fetchUser(); await fetchUser();
await fetchCouple(); await fetchCouple();
@@ -338,6 +408,37 @@ export default function PairingScreen() {
</Text> </Text>
</Animated.View> </Animated.View>
{/* Clipboard invite code offer */}
{clipboardOfferCode && (
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.section}
>
<View style={styles.clipboardOffer} testID="clipboard-invite-offer">
<Ionicons name="clipboard-outline" size={18} color={colors.primary} />
<Text style={styles.clipboardOfferText}>
Use code {clipboardOfferCode} from your clipboard?
</Text>
<View style={styles.clipboardOfferActions}>
<TouchableOpacity
onPress={dismissClipboardOffer}
style={styles.clipboardOfferButton}
testID="clipboard-invite-offer-dismiss"
>
<Text style={styles.clipboardOfferDismissText}>Not now</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={acceptClipboardOffer}
style={[styles.clipboardOfferButton, styles.clipboardOfferButtonPrimary]}
testID="clipboard-invite-offer-accept"
>
<Text style={styles.clipboardOfferAcceptText}>Use code</Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
)}
{/* Join Card */} {/* Join Card */}
<Animated.View <Animated.View
entering={FadeInDown.delay(300).duration(500)} entering={FadeInDown.delay(300).duration(500)}
@@ -346,14 +447,20 @@ export default function PairingScreen() {
<Text style={styles.sectionTitle}>Have a code?</Text> <Text style={styles.sectionTitle}>Have a code?</Text>
<GlassCard> <GlassCard>
<TextInput <TextInput
style={styles.input} style={[styles.input, wasPrefilled && styles.inputPrefilled]}
placeholder="Enter invite code" placeholder="Enter invite code"
placeholderTextColor={colors.textTertiary} placeholderTextColor={colors.textTertiary}
value={inviteCode} value={inviteCode}
onChangeText={setInviteCode} onChangeText={handleInviteCodeChange}
autoCapitalize="characters" autoCapitalize="characters"
maxLength={8} maxLength={8}
testID="invite-code-input"
/> />
{wasPrefilled && (
<Text style={styles.prefilledCaption}>
Code applied from your invite link
</Text>
)}
<GlassButton <GlassButton
onPress={handleJoinCouple} onPress={handleJoinCouple}
disabled={isSubmitting} disabled={isSubmitting}
@@ -436,6 +543,50 @@ const styles = StyleSheet.create({
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
inputPrefilled: {
borderColor: colors.primary,
},
prefilledCaption: {
...typography.caption1,
color: colors.primary,
textAlign: "center",
marginTop: -spacing.sm,
marginBottom: spacing.md,
},
clipboardOffer: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
gap: spacing.sm,
},
clipboardOfferText: {
...typography.subhead,
color: colors.text,
},
clipboardOfferActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: spacing.sm,
},
clipboardOfferButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
clipboardOfferButtonPrimary: {
backgroundColor: colors.primaryLight,
},
clipboardOfferDismissText: {
...typography.subhead,
color: colors.textTertiary,
},
clipboardOfferAcceptText: {
...typography.subhead,
color: colors.primary,
fontWeight: "600",
},
// ... // ...
dividerLine: { dividerLine: {
flex: 1, flex: 1,
+5 -232
View File
@@ -1,232 +1,5 @@
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native"; import { QuizScreen } from "../../src/features/quiz";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; export default function QuizRoute() {
import { GradientBackground } from "../../src/components/ui"; return <QuizScreen />;
import { colors, spacing, radius, typography, animations } from "../../src/theme"; }
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
const ACCENT_COLOR = colors.premium.rose;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export default function QuizScreen() {
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
useFeatureInterest("quiz");
const scale = useSharedValue(1);
const animatedButtonStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>Quiz</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Learn what makes each other tick</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Deepen your bond</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Track your journey</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text>
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
+25 -6
View File
@@ -43,9 +43,12 @@ const GestureHandlerRootView = Platform.OS === "web"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { authClient } from "../src/lib/authClient"; import { authClient } from "../src/lib/authClient";
import { colors, spacing, radius, typography } from "../src/theme"; import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen"; import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
import { parseInviteLinkCode } from "../src/lib/inviteLink";
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
import { needsOnboarding } from "../src/constants/onboarding";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -117,6 +120,22 @@ export default function RootLayout() {
console.log('[DeepLink] handleDeepLink called with URL:', url); console.log('[DeepLink] handleDeepLink called with URL:', url);
if (!url) return; if (!url) return;
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
// are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
console.log('[DeepLink] Invite link detected:', 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 } });
}
return;
}
try { try {
// Parse the URL to extract auth parameters // Parse the URL to extract auth parameters
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
@@ -148,7 +167,7 @@ export default function RootLayout() {
if (accessToken && refreshToken) { if (accessToken && refreshToken) {
// OAuth flow - set session directly // OAuth flow - set session directly
console.log('[DeepLink] Setting session with tokens'); console.log('[DeepLink] Setting session with tokens');
const { data, error } = await authClient.auth.setSession({ const { data, error } = await authClient.auth.setSession({
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
}); });
@@ -160,7 +179,7 @@ export default function RootLayout() {
} else if (tokenHash && type) { } else if (tokenHash && type) {
// Magic link / email verification flow - verify the OTP // Magic link / email verification flow - verify the OTP
console.log('[DeepLink] Verifying OTP with token_hash and type:', type); console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
const { data, error } = await authClient.auth.verifyOtp({ const { data, error } = await authClient.auth.verifyOtp({
token_hash: tokenHash, token_hash: tokenHash,
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email', type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
}); });
@@ -174,12 +193,12 @@ export default function RootLayout() {
} else { } else {
// Fallback for web or other scenarios // Fallback for web or other scenarios
console.log('[DeepLink] No auth params found, falling back to getSession'); console.log('[DeepLink] No auth params found, falling back to getSession');
await authClient.auth.getSession(); await authClient.auth.getSession();
} }
} catch (error) { } catch (error) {
console.error('[DeepLink] Error handling deep link:', error); console.error('[DeepLink] Error handling deep link:', error);
// Still try to get session as fallback // Still try to get session as fallback
await authClient.auth.getSession(); await authClient.auth.getSession();
} }
}; };
@@ -206,7 +225,7 @@ export default function RootLayout() {
}); });
// Listen for auth changes // Listen for auth changes
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange( const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
async (event, session) => { async (event, session) => {
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id); console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") { if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
+43
View File
@@ -1,10 +1,33 @@
import { useEffect, useState } from "react";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native"; import { View, ActivityIndicator, StyleSheet } from "react-native";
import { needsOnboarding } from "../src/constants/onboarding"; import { needsOnboarding } from "../src/constants/onboarding";
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
export default function Index() { export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore(); const { isAuthenticated, isLoading, user } = useAuthStore();
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
const [hasPendingInvite, setHasPendingInvite] = useState(false);
// 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).
useEffect(() => {
if (isLoading || !isAuthenticated || user?.couple_id) {
return;
}
let cancelled = false;
getPendingInviteCode().then((code) => {
if (cancelled) return;
setHasPendingInvite(!!code);
setPendingCodeChecked(true);
});
return () => {
cancelled = true;
};
}, [isLoading, isAuthenticated, user?.couple_id]);
// Show loading state while auth is being determined // Show loading state while auth is being determined
if (isLoading) { if (isLoading) {
@@ -25,6 +48,26 @@ export default function Index() {
return <Redirect href="/(app)/onboarding" />; return <Redirect href="/(app)/onboarding" />;
} }
// A couple already exists for this user; no invite hand-off is relevant.
if (user?.couple_id) {
return <Redirect href="/(app)" />;
}
// 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) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
if (hasPendingInvite) {
return <Redirect href="/(app)/pairing" />;
}
// Go to home/dashboard - users can pair from within the app // Go to home/dashboard - users can pair from within the app
// No forced redirect to pairing for users without a couple // No forced redirect to pairing for users without a couple
return <Redirect href="/(app)" />; return <Redirect href="/(app)" />;
+4 -4
View File
@@ -3804,8 +3804,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 boost: 1dca942403ed9342f98334bf4c3621f011aa7946
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d EXConstants: 18ac927605517285307ea8f7ffe6fe7ca538eb5d
@@ -3851,8 +3851,8 @@ SPEC CHECKSUMS:
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0 FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41 FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41
FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17 GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
@@ -570,7 +570,7 @@
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -599,7 +599,7 @@
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -772,7 +772,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 44;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -782,7 +782,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.6;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
@@ -808,7 +808,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 43; CURRENT_PROJECT_VERSION = 44;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -818,7 +818,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
+4 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.5</string> <string>1.0.6</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -47,9 +47,11 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>43</string> <string>45</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key> <key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/> <false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker';
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn(),
}));
jest.mock('expo-image-picker', () => ({
requestCameraPermissionsAsync: jest.fn(),
requestMediaLibraryPermissionsAsync: jest.fn(),
launchCameraAsync: jest.fn(),
launchImageLibraryAsync: jest.fn(),
}));
describe('useAvatarPicker', () => {
const originalPlatform = Platform.OS;
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
});
afterEach(() => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: originalPlatform });
});
it('uses Android system picker without requesting broad media access', async () => {
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const chooseFromLibrary = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Choose from Library'
);
await act(async () => chooseFromLibrary.onPress());
expect(ImagePicker.requestMediaLibraryPermissionsAsync).not.toHaveBeenCalled();
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
});
it('keeps the iOS library permission request before opening the picker', async () => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' });
(ImagePicker.requestMediaLibraryPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const onSelect = (ActionSheetIOS.showActionSheetWithOptions as jest.Mock).mock.calls[0][1];
await act(async () => onSelect(2));
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { renderHook, act, waitFor } from '@testing-library/react-native'; import { renderHook, act } from '@testing-library/react-native';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
@@ -41,12 +41,13 @@ describe('useMediaSaver', () => {
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined); (FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
}); });
it('initializes with saving as false', () => { it('initializes with saving as false', () => {
const { result } = renderHook(() => useMediaSaver()); const { result } = renderHook(() => useMediaSaver());
expect(result.current.saving).toBe(false); expect(result.current.saving).toBe(false);
expect(typeof result.current.saveMedia).toBe('function'); expect(typeof result.current.saveMedia).toBe('function');
}); expect(MediaLibrary.usePermissions).toHaveBeenCalledWith({ writeOnly: true });
});
it('saves local image successfully', async () => { it('saves local image successfully', async () => {
const { result } = renderHook(() => useMediaSaver()); const { result } = renderHook(() => useMediaSaver());
@@ -179,10 +180,8 @@ describe('useMediaSaver', () => {
expect(result.current.saving).toBe(false); expect(result.current.saving).toBe(false);
}); });
it('sets saving to true during operation', async () => { it('sets saving to true during operation', async () => {
let savingDuringOperation = false; (MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
// This would capture the saving state during the async operation // This would capture the saving state during the async operation
// but since we can't access result.current here synchronously, // but since we can't access result.current here synchronously,
// we'll test this differently // we'll test this differently
@@ -0,0 +1,54 @@
import * as Clipboard from "expo-clipboard";
import {
checkClipboardForInviteCode,
__resetClipboardInviteOfferForTests,
} from "../../lib/clipboardInviteOffer";
jest.mock("expo-clipboard", () => ({
hasStringAsync: jest.fn(),
getStringAsync: jest.fn(),
}));
const hasStringAsync = Clipboard.hasStringAsync as jest.Mock;
const getStringAsync = Clipboard.getStringAsync as jest.Mock;
describe("checkClipboardForInviteCode", () => {
beforeEach(() => {
__resetClipboardInviteOfferForTests();
hasStringAsync.mockReset();
getStringAsync.mockReset();
});
it("returns a normalized code when the clipboard holds a validly-shaped code", async () => {
hasStringAsync.mockResolvedValue(true);
getStringAsync.mockResolvedValue("abcd1234");
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
});
it("returns null when the clipboard is empty", async () => {
hasStringAsync.mockResolvedValue(false);
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
expect(getStringAsync).not.toHaveBeenCalled();
});
it("returns null when the clipboard string does not match the invite code shape", async () => {
hasStringAsync.mockResolvedValue(true);
getStringAsync.mockResolvedValue("hello world");
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
});
it("only checks the clipboard once per session", async () => {
hasStringAsync.mockResolvedValue(true);
getStringAsync.mockResolvedValue("abcd1234");
await expect(checkClipboardForInviteCode()).resolves.toBe("ABCD1234");
expect(hasStringAsync).toHaveBeenCalledTimes(1);
// A second call in the same session should not re-check the clipboard.
await expect(checkClipboardForInviteCode()).resolves.toBeNull();
expect(hasStringAsync).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,69 @@
import { isValidInviteCode, normalizeInviteCode, parseInviteLinkCode } from "../../lib/inviteLink";
describe("isValidInviteCode", () => {
it("accepts 8-character alphanumeric codes case-insensitively", () => {
expect(isValidInviteCode("ABCD1234")).toBe(true);
expect(isValidInviteCode("abcd1234")).toBe(true);
});
it("rejects codes with the wrong length or characters", () => {
expect(isValidInviteCode("ABC123")).toBe(false); // too short
expect(isValidInviteCode("ABCD12345")).toBe(false); // too long
expect(isValidInviteCode("ABCD-123")).toBe(false); // invalid character
expect(isValidInviteCode("")).toBe(false);
expect(isValidInviteCode(null)).toBe(false);
expect(isValidInviteCode(undefined)).toBe(false);
});
});
describe("normalizeInviteCode", () => {
it("trims and uppercases", () => {
expect(normalizeInviteCode(" abcd1234 ")).toBe("ABCD1234");
});
});
describe("parseInviteLinkCode", () => {
it("parses a universal link", () => {
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234")).toEqual({
code: "ABCD1234",
source: "universal_link",
});
});
it("parses a universal link with a trailing slash", () => {
expect(parseInviteLinkCode("https://sauci.app/join/abcd1234/")).toEqual({
code: "ABCD1234",
source: "universal_link",
});
});
it("parses a custom scheme link", () => {
expect(parseInviteLinkCode("app.sauci://join?code=abcd1234")).toEqual({
code: "ABCD1234",
source: "scheme",
});
});
it("returns null for an unrelated https URL", () => {
expect(parseInviteLinkCode("https://sauci.app/redeem")).toBeNull();
});
it("returns null for an unrelated host", () => {
expect(parseInviteLinkCode("https://example.com/join/abcd1234")).toBeNull();
});
it("returns null for a custom scheme link with a badly-shaped code", () => {
expect(parseInviteLinkCode("app.sauci://join?code=123")).toBeNull();
});
it("returns null for a custom scheme link missing the join host", () => {
expect(parseInviteLinkCode("app.sauci://login?token=abc")).toBeNull();
});
it("returns null for garbage input", () => {
expect(parseInviteLinkCode("not a url")).toBeNull();
expect(parseInviteLinkCode(null)).toBeNull();
expect(parseInviteLinkCode(undefined)).toBeNull();
expect(parseInviteLinkCode("")).toBeNull();
});
});
@@ -0,0 +1,29 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
stashPendingInviteCode,
getPendingInviteCode,
clearPendingInviteCode,
} from "../../lib/pendingInviteCode";
describe("pendingInviteCode", () => {
beforeEach(async () => {
await AsyncStorage.clear();
});
it("stashes, retrieves, and clears a valid code", async () => {
await stashPendingInviteCode("abcd1234");
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
await clearPendingInviteCode();
await expect(getPendingInviteCode()).resolves.toBeNull();
});
it("returns null when nothing has been stashed", async () => {
await expect(getPendingInviteCode()).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();
});
});
@@ -0,0 +1,126 @@
import { ApiError } from "@/lib/apiClient";
import { quizApi } from "@/features/quiz/services/quizApi";
import { useQuizStore } from "@/store/quizStore";
import type { QuizSession } from "@/features/quiz/types";
jest.mock("@/features/quiz/services/quizApi", () => ({
quizApi: {
startSession: jest.fn(),
getCurrentSession: jest.fn(),
submitAnswers: jest.fn(),
getResult: jest.fn(),
},
getQuizErrorCode: (error: unknown) => {
const { ApiError: RealApiError } = jest.requireActual("@/lib/apiClient");
if (!(error instanceof RealApiError)) return null;
const details = (error as { details?: unknown }).details as { error?: { code?: string } } | undefined;
return details?.error?.code ?? null;
},
}));
function session(overrides: Partial<QuizSession> = {}): QuizSession {
return {
id: "session-1",
status: "active",
created_at: "2026-08-01T00:00:00.000Z",
completed_at: null,
score_percent: null,
questions: [
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
{ id: "q2", prompt_self: "Self 2", prompt_guess: "Guess 2", options: ["A", "B"] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
...overrides,
};
}
const resetStore = () =>
useQuizStore.setState({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
generation: 0,
});
describe("quizStore", () => {
beforeEach(() => {
jest.clearAllMocks();
resetStore();
});
it("starts a new session and resets local answers/result", async () => {
(quizApi.startSession as jest.Mock).mockResolvedValue({ session: session() });
await useQuizStore.getState().start();
expect(useQuizStore.getState().session?.id).toBe("session-1");
expect(useQuizStore.getState().localAnswers).toEqual({});
expect(useQuizStore.getState().isStarting).toBe(false);
});
it("records local self and guess answers per question without submitting early", () => {
useQuizStore.setState({ session: session() });
useQuizStore.getState().setSelfAnswer("q1", 0);
useQuizStore.getState().setGuessAnswer("q1", 1);
expect(useQuizStore.getState().localAnswers.q1).toEqual({ self_index: 0, guess_index: 1 });
expect(quizApi.submitAnswers).not.toHaveBeenCalled();
});
it("submits the full answer set built from local answers and stores the returned session", async () => {
useQuizStore.setState({
session: session(),
localAnswers: {
q1: { self_index: 0, guess_index: 1 },
q2: { self_index: 1, guess_index: 0 },
},
});
const updated = session({ i_completed: true });
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({ session: updated });
const ok = await useQuizStore.getState().submit();
expect(ok).toBe(true);
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
{ question_id: "q1", self_index: 0, guess_index: 1 },
{ question_id: "q2", self_index: 1, guess_index: 0 },
]);
expect(useQuizStore.getState().session?.i_completed).toBe(true);
expect(useQuizStore.getState().localAnswers).toEqual({});
});
it("surfaces a pairing error code instead of a generic message", async () => {
(quizApi.getCurrentSession as jest.Mock).mockRejectedValue(
new ApiError("no_couple", 409, { error: { code: "no_couple" } }),
);
await useQuizStore.getState().load();
expect(useQuizStore.getState().errorCode).toBe("no_couple");
expect(useQuizStore.getState().error).toBeNull();
});
it("clearQuiz resets all state and invalidates any in-flight request (sign-out)", async () => {
let resolve!: (value: unknown) => void;
(quizApi.getCurrentSession as jest.Mock).mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const staleLoad = useQuizStore.getState().load();
useQuizStore.getState().clearQuiz();
resolve({ session: session({ id: "stale-session" }) });
await staleLoad;
expect(useQuizStore.getState().session).toBeNull();
expect(useQuizStore.getState().generation).toBe(1);
});
});
@@ -0,0 +1,98 @@
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Haptics from 'expo-haptics';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, radius, typography, shadows, featureColors } from '../../theme';
import { useAuthStore } from '../../store';
const HORIZONTAL_PADDING = spacing.lg;
const TILE_HEIGHT = 100;
const ACCENT = featureColors.quiz.accent;
interface QuizTileProps {
delay?: number;
}
export function QuizTile({ delay = 0 }: QuizTileProps) {
const { user } = useAuthStore();
const paired = Boolean(user?.couple_id);
if (!paired) return null;
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.push('/(app)/quiz' as never);
};
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500).springify()}
style={styles.container}
>
<TouchableOpacity onPress={handlePress} activeOpacity={0.85} style={styles.tileWrapper}>
<View style={[styles.tile, shadows.md]}>
<View style={styles.iconContainer}>
<Ionicons name="help-circle" size={24} color="rgba(255, 255, 255, 0.9)" />
</View>
<View style={styles.content}>
<Text style={styles.title}>Quiz</Text>
<Text style={styles.subtitle}>How well do you know each other?</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
<View style={styles.overlay} />
</View>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
tileWrapper: {
paddingHorizontal: HORIZONTAL_PADDING,
},
tile: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: ACCENT,
borderRadius: radius.lg,
padding: spacing.md,
height: TILE_HEIGHT,
overflow: 'hidden',
position: 'relative',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
content: {
flex: 1,
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '700',
},
subtitle: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: radius.lg,
pointerEvents: 'none',
},
});
@@ -4,4 +4,5 @@ export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress'; export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile'; export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile'; export { DaresTile } from './DaresTile';
export { QuizTile } from './QuizTile';
export { PendingQuestionsCard } from './PendingQuestionsCard'; export { PendingQuestionsCard } from './PendingQuestionsCard';
@@ -30,6 +30,7 @@ interface GlassButtonProps {
textStyle?: TextStyle; textStyle?: TextStyle;
haptic?: boolean; haptic?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
testID?: string;
} }
const AnimatedPressable = Animated.createAnimatedComponent(Pressable); const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
@@ -46,6 +47,7 @@ export function GlassButton({
textStyle, textStyle,
haptic = true, haptic = true,
icon, icon,
testID,
}: GlassButtonProps) { }: GlassButtonProps) {
const scale = useSharedValue(1); const scale = useSharedValue(1);
@@ -104,6 +106,7 @@ export function GlassButton({
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPressOut={handlePressOut} onPressOut={handlePressOut}
disabled={disabled || loading} disabled={disabled || loading}
testID={testID}
style={[ style={[
animatedStyle, animatedStyle,
fullWidth && styles.fullWidth, fullWidth && styles.fullWidth,
@@ -0,0 +1,46 @@
import { act, renderHook } from '@testing-library/react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system/legacy';
import { useCanvasCapture } from './useCanvasCapture';
jest.mock('expo-media-library', () => ({
requestPermissionsAsync: jest.fn(),
saveToLibraryAsync: jest.fn(),
}));
jest.mock('expo-file-system/legacy', () => ({
cacheDirectory: 'file:///cache/',
writeAsStringAsync: jest.fn(),
EncodingType: { Base64: 'base64' },
}));
describe('useCanvasCapture', () => {
beforeEach(() => {
jest.clearAllMocks();
(MediaLibrary.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(MediaLibrary.saveToLibraryAsync as jest.Mock).mockResolvedValue(undefined);
});
it('requests add-only permission before saving the captured image', async () => {
const { result } = renderHook(() => useCanvasCapture());
result.current.makeSnapshot.current = () => ({
encodeToBase64: () => 'encoded-image',
}) as never;
let saved = false;
await act(async () => {
saved = await result.current.saveToGallery();
});
expect(saved).toBe(true);
expect(MediaLibrary.requestPermissionsAsync).toHaveBeenCalledWith(true);
expect(MediaLibrary.saveToLibraryAsync).toHaveBeenCalledWith(
expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/)
);
expect(FileSystem.writeAsStringAsync).toHaveBeenCalledWith(
expect.stringMatching(/^file:\/\/\/cache\/livedraw_\d+\.png$/),
'encoded-image',
{ encoding: 'base64' }
);
});
});
@@ -39,7 +39,9 @@ export function useCanvasCapture(): UseCanvasCaptureReturn {
const fileUri = await captureToFile(); const fileUri = await captureToFile();
if (!fileUri) return false; if (!fileUri) return false;
const { status } = await MediaLibrary.requestPermissionsAsync(); // The capture is created by Sauci, so request add-only access rather than
// broad photo-library read access.
const { status } = await MediaLibrary.requestPermissionsAsync(true);
if (status !== 'granted') return false; if (status !== 'granted') return false;
await MediaLibrary.saveToLibraryAsync(fileUri); await MediaLibrary.saveToLibraryAsync(fileUri);
@@ -0,0 +1,106 @@
import { ActivityIndicator, View, StyleSheet } from "react-native";
import { GradientBackground } from "../../components/ui";
import { featureColors } from "../../theme";
import { SwipeNoPartnerState } from "../swipe/components/SwipeNoPartnerState";
import { useQuizScreen } from "./hooks/useQuizScreen";
import {
QuizIntro,
QuizQuestionCard,
QuizResults,
QuizShareModal,
QuizWaitingState,
} from "./components";
export function QuizScreen() {
const {
screenState,
session,
result,
currentStep,
totalQuestions,
isStarting,
isSubmitting,
isLoadingResult,
shareModalVisible,
handleSelectOption,
handleStart,
handleNewQuiz,
handleRefresh,
handlePairPress,
handleNudgePartner,
openShareModal,
closeShareModal,
} = useQuizScreen();
if (screenState === "not_paired") {
return <SwipeNoPartnerState hasCouple={false} onPairPress={handlePairPress} />;
}
if (screenState === "loading") {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
</View>
</GradientBackground>
);
}
if (screenState === "intro") {
return <QuizIntro isStarting={isStarting} onStart={handleStart} />;
}
if (screenState === "waiting") {
return <QuizWaitingState onNudgePartner={handleNudgePartner} onRefresh={handleRefresh} />;
}
if (screenState === "results") {
return (
<>
<QuizResults
result={result}
isLoadingResult={isLoadingResult}
isStarting={isStarting}
onNewQuiz={handleNewQuiz}
onShare={openShareModal}
/>
<QuizShareModal
visible={shareModalVisible}
onClose={closeShareModal}
scorePercent={result?.score_percent ?? 0}
/>
</>
);
}
// screenState === "answering"
if (!session || !currentStep || isSubmitting) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
</View>
</GradientBackground>
);
}
return (
<QuizQuestionCard
question={currentStep.question}
step={currentStep.step}
questionNumber={currentStep.questionNumber}
totalQuestions={totalQuestions}
onSelectOption={handleSelectOption}
/>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
export default QuizScreen;
@@ -0,0 +1,118 @@
import { StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassButton, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, shadows, spacing, typography } from "../../../theme";
const ACCENT = featureColors.quiz.accent;
interface QuizIntroProps {
isStarting: boolean;
onStart: () => void;
}
export function QuizIntro({ isStarting, onStart }: QuizIntroProps) {
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View entering={FadeInDown.duration(600).springify()} style={styles.content}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="help-circle" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>How well do you know each other?</Text>
<Text style={styles.description}>
Answer a set of questions about yourself, then guess how your partner
answered. See how well you really know each other, and share your score.
</Text>
<View style={styles.badge}>
<Ionicons name="list-outline" size={14} color={ACCENT} />
<Text style={styles.badgeText}>10 questions</Text>
</View>
<GlassButton
onPress={onStart}
loading={isStarting}
disabled={isStarting}
fullWidth
size="lg"
style={styles.startButton}
testID="quiz-start"
>
Start the quiz
</GlassButton>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.lg,
},
content: {
width: "100%",
alignItems: "center",
},
iconGradient: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
...shadows.lg,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: ACCENT,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.md,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.lg,
},
badge: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
badgeText: {
...typography.caption1,
fontWeight: "600",
color: ACCENT,
},
startButton: {
marginTop: spacing.md,
},
});
@@ -0,0 +1,118 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassCard, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { QuizQuestion } from "../types";
import type { QuizAnswerStep } from "../hooks/useQuizScreen";
const ACCENT = featureColors.quiz.accent;
interface QuizQuestionCardProps {
question: QuizQuestion;
step: QuizAnswerStep;
questionNumber: number;
totalQuestions: number;
onSelectOption: (index: number) => void;
}
export function QuizQuestionCard({
question,
step,
questionNumber,
totalQuestions,
onSelectOption,
}: QuizQuestionCardProps) {
const prompt = step === "self" ? question.prompt_self : question.prompt_guess;
return (
<GradientBackground>
<View style={styles.container}>
<Text style={styles.progress} testID="quiz-progress">
{questionNumber} of {totalQuestions}
</Text>
<View style={styles.stepBadge}>
<Text style={styles.stepBadgeText}>{step === "self" ? "YOU" : "YOUR PARTNER"}</Text>
</View>
<Animated.View entering={FadeInDown.duration(400).springify()} key={`${question.id}-${step}`}>
<GlassCard variant="elevated" style={styles.card}>
<Text style={styles.prompt}>{prompt}</Text>
</GlassCard>
<View style={styles.options}>
{question.options.map((option, index) => (
<Pressable
key={`${question.id}-${index}`}
onPress={() => onSelectOption(index)}
style={({ pressed }) => [styles.option, pressed && styles.optionPressed]}
testID={`quiz-option-${index}`}
>
<Text style={styles.optionText}>{option}</Text>
</Pressable>
))}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
},
progress: {
...typography.caption1,
color: colors.textTertiary,
textAlign: "center",
marginBottom: spacing.md,
},
stepBadge: {
alignSelf: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.lg,
},
stepBadgeText: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 2,
color: ACCENT,
},
card: {
marginBottom: spacing.xl,
},
prompt: {
...typography.title2,
color: colors.text,
textAlign: "center",
},
options: {
gap: spacing.md,
},
option: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
},
optionPressed: {
borderColor: ACCENT,
backgroundColor: colors.background,
},
optionText: {
...typography.body,
color: colors.text,
textAlign: "center",
fontWeight: "600",
},
});
@@ -0,0 +1,173 @@
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassButton, GlassCard, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { QuizResult } from "../types";
interface QuizResultsProps {
result: QuizResult | null;
isLoadingResult: boolean;
isStarting: boolean;
onNewQuiz: () => void;
onShare: () => void;
}
export function QuizResults({ result, isLoadingResult, isStarting, onNewQuiz, onShare }: QuizResultsProps) {
if (!result) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
<Text style={styles.loadingText}>
{isLoadingResult ? "Tallying your answers..." : "Loading your results..."}
</Text>
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<Animated.View entering={FadeInDown.duration(500).springify()}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.hero}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.heroLabel}>YOU MATCHED ON</Text>
<Text style={styles.heroScore} testID="quiz-score">
{result.score_percent}%
</Text>
<Text style={styles.heroTagline}>How well do you know each other?</Text>
</LinearGradient>
</Animated.View>
<View style={styles.breakdown}>
{result.questions.map((question) => (
<GlassCard key={question.question_id} style={styles.questionCard}>
<Text style={styles.questionPrompt}>{question.prompt_self}</Text>
<View style={styles.resultRow}>
<Ionicons
name={question.i_guessed_right ? "checkmark-circle" : "close-circle"}
size={18}
color={question.i_guessed_right ? colors.success : colors.error}
/>
<Text style={styles.resultText}>
{question.i_guessed_right ? "You guessed right" : "You guessed wrong"}
</Text>
</View>
<View style={styles.resultRow}>
<Ionicons
name={question.partner_guessed_right ? "checkmark-circle" : "close-circle"}
size={18}
color={question.partner_guessed_right ? colors.success : colors.error}
/>
<Text style={styles.resultText}>
{question.partner_guessed_right
? "Your partner guessed right"
: "Your partner guessed wrong"}
</Text>
</View>
</GlassCard>
))}
</View>
<GlassButton
onPress={onShare}
fullWidth
size="lg"
style={styles.shareButton}
icon={<Ionicons name="share-outline" size={20} color={colors.text} />}
testID="quiz-share"
>
Share your score
</GlassButton>
<GlassButton
variant="secondary"
onPress={onNewQuiz}
loading={isStarting}
disabled={isStarting}
fullWidth
style={styles.newQuizButton}
>
Take another quiz
</GlassButton>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
container: {
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: spacing.xxl,
},
hero: {
borderRadius: radius.xl,
paddingVertical: spacing.xl,
paddingHorizontal: spacing.lg,
alignItems: "center",
marginBottom: spacing.lg,
},
heroLabel: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: "rgba(255, 255, 255, 0.85)",
marginBottom: spacing.xs,
},
heroScore: {
...typography.largeTitle,
fontSize: 56,
lineHeight: 64,
color: colors.text,
fontWeight: "800",
},
heroTagline: {
...typography.subhead,
color: "rgba(255, 255, 255, 0.85)",
marginTop: spacing.xs,
},
breakdown: {
gap: spacing.sm,
marginBottom: spacing.xl,
},
questionCard: {
gap: spacing.xs,
},
questionPrompt: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
marginBottom: spacing.xs,
},
resultRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
resultText: {
...typography.caption1,
color: colors.textSecondary,
},
shareButton: {
marginBottom: spacing.md,
},
newQuizButton: {},
});
@@ -0,0 +1,15 @@
import { render } from "@/test/test-utils";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
describe("QuizResultsShareCard", () => {
it("renders the score and Sauci watermark without any question content", () => {
const { getByText, queryByText } = render(
<QuizResultsShareCard scorePercent={84} cardWidth={300} />,
);
expect(getByText("84%")).toBeTruthy();
expect(getByText("How well do you know each other?")).toBeTruthy();
expect(getByText("sauci.app")).toBeTruthy();
expect(queryByText(/prompt/i)).toBeNull();
});
});
@@ -0,0 +1,98 @@
import { Image, StyleSheet, Text, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { featureColors, radius, spacing, typography } from "../../../theme";
const logo = require("../../../../assets/logo.png");
interface QuizResultsShareCardProps {
scorePercent: number;
cardWidth: number;
}
/**
* The card captured for sharing. Deliberately carries only the score, never the
* question text or answers, so a screenshot cannot leak either partner's content.
*/
export function QuizResultsShareCard({ scorePercent, cardWidth }: QuizResultsShareCardProps) {
return (
<View style={[styles.card, { width: cardWidth }]}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.overlay} />
<View style={styles.content}>
<Text style={styles.tagline}>How well do you know each other?</Text>
<Text style={styles.label}>WE MATCHED ON</Text>
<Text style={styles.score}>{scorePercent}%</Text>
</View>
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
card: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: "hidden",
},
gradient: {
flex: 1,
padding: spacing.lg,
justifyContent: "space-between",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.08)",
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
tagline: {
...typography.subhead,
color: "rgba(255, 255, 255, 0.85)",
textAlign: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: "rgba(255, 255, 255, 0.85)",
marginBottom: spacing.xs,
},
score: {
...typography.largeTitle,
fontSize: 64,
lineHeight: 72,
fontWeight: "800",
color: "#ffffff",
},
branding: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: "contain",
},
brandingText: {
...typography.subhead,
color: "#ffffff",
fontWeight: "600",
},
});
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from "react";
import {
Linking,
Modal,
Platform,
Share,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from "react-native";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system/legacy";
import { colors, spacing, typography, blur } from "../../../theme";
import { Events } from "../../../lib/analytics";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
const MAX_CARD_WIDTH = 340;
interface QuizShareModalProps {
visible: boolean;
onClose: () => void;
scorePercent: number;
}
export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModalProps) {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
const shareViewRef = useRef<ViewShot>(null);
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const shareMessage = `We matched on ${scorePercent}% on our couple quiz! Discover more on sauci.app`;
useEffect(() => {
if (visible && shareViewRef.current) {
setIsCapturing(true);
const timer = setTimeout(async () => {
try {
const uri = await shareViewRef.current?.capture?.();
if (uri) setCapturedUri(uri);
} catch (error) {
console.error("Error capturing quiz share image:", error);
} finally {
setIsCapturing(false);
}
}, 100);
return () => clearTimeout(timer);
}
}, [visible]);
useEffect(() => {
if (!visible) setCapturedUri(null);
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
const instagramUrl = "instagram://app";
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === "ios") {
await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
const instagramStoriesUrl = "instagram-stories://share?source_application=com.sauci.app";
await Linking.openURL(instagramStoriesUrl);
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Instagram share error:", error);
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
await Share.share({ message: shareMessage });
Events.quizShared();
return;
}
try {
if (Platform.OS === "ios") {
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", UTI: "public.png" });
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Messages share error:", error);
await Share.share({ message: shareMessage });
Events.quizShared();
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({ message: shareMessage });
Events.quizShared();
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", dialogTitle: "Share your quiz score" });
} else {
await Share.share({ message: shareMessage });
}
Events.quizShared();
} catch (error) {
console.error("Share error:", error);
}
};
const useBlur = Platform.OS === "ios";
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<View style={styles.overlay}>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
{useBlur ? (
<BlurView intensity={blur.heavy} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={styles.backdropFallback} />
)}
</Animated.View>
<Animated.View
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
style={[styles.content, { paddingTop: insets.top }]}
>
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
testID="quiz-share-close"
>
<Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Share your score</Text>
<View style={styles.headerSpacer} />
</View>
<View style={styles.previewContainer}>
<ViewShot ref={shareViewRef} options={{ format: "png", quality: 1, result: "tmpfile" }}>
<QuizResultsShareCard scorePercent={scorePercent} cardWidth={cardWidth} />
</ViewShot>
</View>
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
onPress={handleMoreShare}
disabled={isCapturing}
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
onPress,
disabled,
}: {
icon: string;
label: string;
onPress: () => void;
disabled?: boolean;
}) {
return (
<TouchableOpacity
style={styles.actionButton}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
testID={`quiz-share-${icon}`}
>
<View style={styles.actionIconContainer}>
<Ionicons name={icon as any} size={28} color={colors.text} />
</View>
<Text style={styles.actionLabel}>{label}</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropFallback: {
flex: 1,
backgroundColor: "rgba(14, 14, 17, 0.95)",
},
content: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
closeButton: {
width: 44,
height: 44,
justifyContent: "center",
alignItems: "center",
},
title: {
...typography.headline,
color: colors.text,
fontWeight: "600",
},
headerSpacer: {
width: 44,
},
previewContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.lg,
},
actionsContainer: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.xl,
paddingTop: spacing.xl,
paddingHorizontal: spacing.lg,
},
actionButton: {
alignItems: "center",
gap: spacing.sm,
},
actionIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
actionLabel: {
...typography.caption1,
color: colors.text,
fontWeight: "500",
},
});
@@ -0,0 +1,27 @@
import { SwipeInfoStateLayout } from "../../swipe/components/SwipeInfoStateLayout";
import { colors } from "../../../theme";
interface QuizWaitingStateProps {
onNudgePartner: () => void;
onRefresh: () => void;
}
export function QuizWaitingState({ onNudgePartner, onRefresh }: QuizWaitingStateProps) {
return (
<SwipeInfoStateLayout
accentColor={colors.premium.rose}
icon="checkmark-circle"
label="NICE WORK"
title="Your answers are in"
badgeText="WAITING ON YOUR PARTNER"
description="Nudge your partner to finish the quiz so you can both see how well you know each other."
features={[
{ icon: "hourglass-outline", text: "Results unlock once you both finish" },
{ icon: "heart-outline", text: "Share your match score together" },
]}
teaser="Almost there"
action={{ label: "Nudge your partner", onPress: onNudgePartner }}
secondaryAction={{ label: "Refresh", onPress: onRefresh, icon: "refresh-outline" }}
/>
);
}
@@ -0,0 +1,6 @@
export { QuizIntro } from "./QuizIntro";
export { QuizQuestionCard } from "./QuizQuestionCard";
export { QuizWaitingState } from "./QuizWaitingState";
export { QuizResults } from "./QuizResults";
export { QuizResultsShareCard } from "./QuizResultsShareCard";
export { QuizShareModal } from "./QuizShareModal";
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useQuizScreen } from "./useQuizScreen";
import { useQuizStore } from "../../../store/quizStore";
import { quizApi } from "../services/quizApi";
import type { QuizSession } from "../types";
const mockRouterPush = jest.fn();
jest.mock("expo-router", () => {
const React = jest.requireActual("react");
return {
useRouter: () => ({ push: mockRouterPush }),
// Mirrors react-navigation's real semantics closely enough for this hook:
// runs once per stable callback identity, not on every render.
useFocusEffect: (callback: () => void) => React.useEffect(callback, [callback]),
};
});
let mockAuth = { user: { couple_id: null as string | null }, partner: null as { id: string } | null };
jest.mock("../../../store", () => ({
useAuthStore: () => mockAuth,
}));
jest.mock("../services/quizApi", () => ({
quizApi: {
startSession: jest.fn(),
getCurrentSession: jest.fn(),
submitAnswers: jest.fn(),
getResult: jest.fn(),
},
getQuizErrorCode: () => null,
}));
function session(overrides: Partial<QuizSession> = {}): QuizSession {
return {
id: "session-1",
status: "active",
created_at: "2026-08-01T00:00:00.000Z",
completed_at: null,
score_percent: null,
questions: [
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
...overrides,
};
}
describe("useQuizScreen", () => {
beforeEach(() => {
jest.clearAllMocks();
useQuizStore.setState({
session: null, result: null, localAnswers: {},
isLoading: false, isStarting: false, isSubmitting: false, isLoadingResult: false,
errorCode: null, error: null, generation: 0,
});
mockAuth = { user: { couple_id: null }, partner: null };
});
it("shows the not_paired state when the user has no couple or partner", async () => {
const { result } = renderHook(() => useQuizScreen());
expect(result.current.screenState).toBe("not_paired");
act(() => result.current.handlePairPress());
expect(mockRouterPush).toHaveBeenCalledWith("/pairing");
});
it("moves to intro once paired and no active session exists", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: null });
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("intro"));
});
it("walks self then guess for each question and submits once all are answered", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: session() });
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({
session: session({ i_completed: true }),
});
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("answering"));
expect(result.current.currentStep?.step).toBe("self");
await act(async () => {
await result.current.handleSelectOption(0);
});
expect(result.current.currentStep?.step).toBe("guess");
await act(async () => {
await result.current.handleSelectOption(1);
});
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
{ question_id: "q1", self_index: 0, guess_index: 1 },
]);
await waitFor(() => expect(result.current.screenState).toBe("waiting"));
});
it("shows results once the session and both partners have completed", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({
session: session({ status: "completed", i_completed: true, partner_completed: true, score_percent: 80 }),
});
(quizApi.getResult as jest.Mock).mockResolvedValue({
score_percent: 80, completed_at: "now", questions: [],
});
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("results"));
await waitFor(() => expect(result.current.result?.score_percent).toBe(80));
});
});
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Share } from "react-native";
import { useFocusEffect, useRouter } from "expo-router";
import { useAuthStore } from "../../../store";
import { useQuizStore } from "../../../store/quizStore";
import { Events } from "../../../lib/analytics";
import type { QuizQuestion } from "../types";
export type QuizScreenState = "not_paired" | "loading" | "intro" | "answering" | "waiting" | "results";
export type QuizAnswerStep = "self" | "guess";
export interface CurrentQuizStep {
question: QuizQuestion;
step: QuizAnswerStep;
questionNumber: number;
}
export function useQuizScreen() {
const router = useRouter();
const { user, partner } = useAuthStore();
const {
session,
result,
localAnswers,
isLoading,
isStarting,
isSubmitting,
isLoadingResult,
errorCode,
load,
start,
setSelfAnswer,
setGuessAnswer,
submit,
loadResult,
} = useQuizStore();
const [shareModalVisible, setShareModalVisible] = useState(false);
const hasCouple = Boolean(user?.couple_id);
const hasPartner = Boolean(partner);
const paired = hasCouple && hasPartner;
useFocusEffect(
useCallback(() => {
if (paired) void load();
}, [paired, load]),
);
const isCompleted = session?.status === "completed";
const shouldShowResults = isCompleted || (session?.i_completed && session?.partner_completed);
useEffect(() => {
if (shouldShowResults && session && !result && !isLoadingResult) {
void loadResult();
}
}, [shouldShowResults, session, result, isLoadingResult, loadResult]);
const screenState: QuizScreenState = useMemo(() => {
if (!paired) return "not_paired";
if (isLoading && !session) return "loading";
if (!session) return "intro";
if (shouldShowResults) return "results";
if (session.i_completed && !session.partner_completed) return "waiting";
return "answering";
}, [paired, isLoading, session, shouldShowResults]);
// The first unanswered step across the question set, in order.
const currentStep: CurrentQuizStep | null = useMemo(() => {
if (!session) return null;
for (let i = 0; i < session.questions.length; i += 1) {
const question = session.questions[i];
const answer = localAnswers[question.id];
if (answer?.self_index === undefined) {
return { question, step: "self", questionNumber: i + 1 };
}
if (answer?.guess_index === undefined) {
return { question, step: "guess", questionNumber: i + 1 };
}
}
return null;
}, [session, localAnswers]);
const totalQuestions = session?.questions.length ?? 0;
const handleSelectOption = useCallback(
async (index: number) => {
if (!currentStep) return;
const { question, step } = currentStep;
if (step === "self") {
setSelfAnswer(question.id, index);
return;
}
setGuessAnswer(question.id, index);
// The guess just recorded may be the last one needed; submit as soon as
// every question has both a self and a guess answer.
const answers = useQuizStore.getState().localAnswers;
const isLastQuestion = totalQuestions > 0 && currentStep.questionNumber === totalQuestions;
if (isLastQuestion) {
const allAnswered = session!.questions.every((q) => {
const a = q.id === question.id ? { ...answers[q.id], guess_index: index } : answers[q.id];
return a?.self_index !== undefined && a?.guess_index !== undefined;
});
if (allAnswered) {
await submit();
}
}
},
[currentStep, setSelfAnswer, setGuessAnswer, submit, totalQuestions, session],
);
const handleStart = useCallback(() => {
void start();
}, [start]);
const handleNewQuiz = useCallback(() => {
void start();
}, [start]);
const handleRefresh = useCallback(() => {
void load();
}, [load]);
const handlePairPress = useCallback(() => {
router.push("/pairing");
}, [router]);
const handleNudgePartner = useCallback(async () => {
try {
await Share.share({
message: "I just finished our couple quiz on Sauci, your turn!",
});
Events.quizPartnerNudged();
} catch (err) {
console.error("Error nudging partner:", err);
}
}, []);
const openShareModal = useCallback(() => setShareModalVisible(true), []);
const closeShareModal = useCallback(() => setShareModalVisible(false), []);
return {
screenState,
session,
result,
currentStep,
totalQuestions,
isStarting,
isSubmitting,
isLoadingResult,
isLoading,
errorCode,
hasCouple,
shareModalVisible,
handleSelectOption,
handleStart,
handleNewQuiz,
handleRefresh,
handlePairPress,
handleNudgePartner,
openShareModal,
closeShareModal,
};
}
+4
View File
@@ -0,0 +1,4 @@
export { QuizScreen, default } from "./QuizScreen";
export { useQuizScreen } from "./hooks/useQuizScreen";
export { quizApi, getQuizErrorCode } from "./services/quizApi";
export * from "./types";
@@ -0,0 +1,57 @@
import { apiClient, ApiError } from "../../../lib/apiClient";
import { getQuizErrorCode, quizApi } from "./quizApi";
jest.mock("../../../lib/apiClient", () => {
const actual = jest.requireActual("../../../lib/apiClient");
return {
...actual,
apiClient: { get: jest.fn(), post: jest.fn() },
};
});
const get = apiClient.get as jest.Mock;
const post = apiClient.post as jest.Mock;
describe("quizApi", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("starts a session against the couple quiz session endpoint", async () => {
post.mockResolvedValue({ session: { id: "s1" } });
await quizApi.startSession();
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions");
});
it("reads the current session", async () => {
get.mockResolvedValue({ session: null });
await quizApi.getCurrentSession();
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/current");
});
it("submits the full answer set for a session", async () => {
post.mockResolvedValue({ session: { id: "s1" } });
const answers = [{ question_id: "q1", self_index: 0, guess_index: 1 }];
await quizApi.submitAnswers("s1", answers);
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions/s1/answers", { answers });
});
it("encodes the session id when reading a result", async () => {
get.mockResolvedValue({ score_percent: 80, completed_at: "now", questions: [] });
await quizApi.getResult("s1/weird");
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/s1%2Fweird/result");
});
});
describe("getQuizErrorCode", () => {
it("reads the contract's { error: { code } } body off an ApiError", () => {
const error = new ApiError("no_couple", 409, { error: { code: "no_couple" } });
expect(getQuizErrorCode(error)).toBe("no_couple");
});
it("returns null for a non-ApiError or a body without a code", () => {
expect(getQuizErrorCode(new Error("boom"))).toBeNull();
expect(getQuizErrorCode(new ApiError("oops", 500, {}))).toBeNull();
expect(getQuizErrorCode(new ApiError("oops", 500, undefined))).toBeNull();
});
});
@@ -0,0 +1,23 @@
import { apiClient, ApiError } from "../../../lib/apiClient";
import type { QuizAnswer, QuizErrorCode, QuizResult, QuizSession } from "../types";
const sessionPath = (sessionId: string) => `/v1/quiz/sessions/${encodeURIComponent(sessionId)}`;
export const quizApi = {
startSession: () => apiClient.post<{ session: QuizSession }>("/v1/quiz/sessions"),
getCurrentSession: () => apiClient.get<{ session: QuizSession | null }>("/v1/quiz/sessions/current"),
submitAnswers: (sessionId: string, answers: QuizAnswer[]) =>
apiClient.post<{ session: QuizSession }>(`${sessionPath(sessionId)}/answers`, { answers }),
getResult: (sessionId: string) => apiClient.get<QuizResult>(`${sessionPath(sessionId)}/result`),
};
/**
* Reads the API's `{ error: { code } }` body out of an `ApiError`, so callers can
* branch on the contract's error codes instead of matching on message text.
*/
export function getQuizErrorCode(error: unknown): QuizErrorCode | null {
if (!(error instanceof ApiError)) return null;
const body = error.details as { error?: { code?: string } } | undefined;
const code = body?.error?.code;
return typeof code === "string" ? (code as QuizErrorCode) : null;
}
+58
View File
@@ -0,0 +1,58 @@
export interface QuizQuestion {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
export interface QuizAnswer {
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizSession {
id: string;
status: "active" | "completed";
created_at: string;
completed_at: string | null;
score_percent: number | null;
questions: QuizQuestion[];
my_answers: QuizAnswer[];
partner_completed: boolean;
i_completed: boolean;
}
export interface QuizResultQuestion {
question_id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
my_self_index: number;
my_guess_index: number;
partner_self_index: number;
partner_guess_index: number;
i_guessed_right: boolean;
partner_guessed_right: boolean;
}
export interface QuizResult {
score_percent: number;
completed_at: string;
questions: QuizResultQuestion[];
}
/** Known error codes the quiz API can surface as `{ error: { code } }`. */
export type QuizErrorCode =
| "no_couple"
| "partner_required"
| "session_not_found"
| "session_completed"
| "invalid_answers"
| "session_not_completed";
/** In-progress local answer for a question, before the full set is submitted. */
export interface QuizLocalAnswer {
self_index?: number;
guess_index?: number;
}
+11 -7
View File
@@ -100,13 +100,17 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
quality: 0.8, quality: 0.8,
exif: false, exif: false,
}); });
} else { } else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); // Android's system photo picker grants the selected file directly, so
if (status !== 'granted') { // selecting an avatar does not need broad media-library access.
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.'); if (Platform.OS !== 'android') {
return; const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
} if (status !== 'granted') {
result = await ImagePicker.launchImageLibraryAsync({ Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'], mediaTypes: ['images'],
allowsEditing: true, allowsEditing: true,
aspect: [1, 1], aspect: [1, 1],
+3 -1
View File
@@ -5,7 +5,9 @@ import * as FileSystem from 'expo-file-system/legacy';
export function useMediaSaver() { export function useMediaSaver() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions(); // Saving app-created/downloaded media only needs add-only access. Passing
// writeOnly prevents this path from requesting broad library read access.
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({ writeOnly: true });
const saveMedia = async (uri: string, type: 'image' | 'video') => { const saveMedia = async (uri: string, type: 'image' | 'video') => {
try { try {
+9
View File
@@ -272,6 +272,15 @@ export const Events = {
// Invite events // Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"), inviteCodeCopied: () => logEvent("invite_code_copied"),
inviteLinkOpened: (source: "universal_link" | "scheme" | "clipboard") =>
logEvent("invite_link_opened", { source }),
pairingCodePrefilled: () => logEvent("pairing_code_prefilled"),
// Quiz events
quizStarted: () => logEvent("quiz_started"),
quizCompleted: (scorePercent: number) => logEvent("quiz_completed", { score_percent: scorePercent }),
quizShared: () => logEvent("quiz_shared"),
quizPartnerNudged: () => logEvent("quiz_partner_nudged"),
// Tutorial events // Tutorial events
tutorialStarted: (tutorial: "swipe" | "matches") => tutorialStarted: (tutorial: "swipe" | "matches") =>
@@ -0,0 +1,39 @@
// Deferred hand-off fallback: when a user copies an invite code on another
// device (e.g. from the web join page) then opens the app, offer to use the
// code found on the clipboard. Never auto-applies the code; the caller must
// present a confirmation before using the result. Only checks once per app
// session so the user is not repeatedly prompted.
import * as Clipboard from "expo-clipboard";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
let hasCheckedThisSession = false;
/**
* Checks the clipboard once per session for a string matching the invite
* code shape. Returns the normalized code, or null when there is nothing
* usable (or the check already ran this session).
*/
export async function checkClipboardForInviteCode(): Promise<string | null> {
if (hasCheckedThisSession) return null;
hasCheckedThisSession = true;
try {
const hasString = await Clipboard.hasStringAsync();
if (!hasString) return null;
const value = await Clipboard.getStringAsync();
if (value && isValidInviteCode(value)) {
return normalizeInviteCode(value);
}
} catch (error) {
console.error("Error checking clipboard for invite code:", error);
}
return null;
}
/** Test-only helper to reset the per-session gating between test cases. */
export function __resetClipboardInviteOfferForTests(): void {
hasCheckedThisSession = false;
}
+76
View File
@@ -0,0 +1,76 @@
// Shared invite code shape and deep-link parsing for the invite funnel.
//
// Two link forms carry an invite code into the app:
// - Universal/App Link: https://sauci.app/join/{code}
// - Custom scheme link: app.sauci://join?code={code}
//
// Both forms are handled by the root layout's deep link listener and by the
// pairing screen when it receives a `code` route param.
export const INVITE_CODE_LENGTH = 8;
const INVITE_CODE_PATTERN = /^[A-Z0-9]{8}$/;
export type InviteLinkSource = "universal_link" | "scheme";
export interface ParsedInviteLink {
code: string;
source: InviteLinkSource;
}
/**
* Returns true when `code` (case-insensitive) matches the 8-character
* alphanumeric invite code shape used across the app.
*/
export function isValidInviteCode(code: string | null | undefined): boolean {
if (!code) return false;
return INVITE_CODE_PATTERN.test(code.trim().toUpperCase());
}
/**
* Normalizes a raw invite code string to the canonical uppercase form used
* for display, storage, and API calls.
*/
export function normalizeInviteCode(code: string): string {
return code.trim().toUpperCase();
}
/**
* Parses an incoming URL for an invite code, supporting both the universal
* link form (https://sauci.app/join/{code}) and the custom scheme form
* (app.sauci://join?code={code}). Returns null when the URL does not carry a
* recognizable, validly-shaped invite code.
*/
export function parseInviteLinkCode(url: string | null | undefined): ParsedInviteLink | null {
if (!url) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
let rawCode: string | null = null;
let source: InviteLinkSource | null = null;
if (parsed.protocol === "https:" && parsed.hostname === "sauci.app") {
const match = parsed.pathname.match(/^\/join\/([^/]+)\/?$/);
if (match) {
rawCode = match[1];
source = "universal_link";
}
} else if (parsed.protocol === "app.sauci:") {
const host = parsed.hostname || parsed.pathname.replace(/^\/+/, "");
if (host === "join") {
rawCode = parsed.searchParams.get("code");
source = "scheme";
}
}
if (!rawCode || !source) return null;
const code = normalizeInviteCode(rawCode);
if (!isValidInviteCode(code)) return null;
return { code, source };
}
+37
View File
@@ -0,0 +1,37 @@
// Deferred hand-off storage for an invite code received before the user is
// signed in or has finished onboarding. The code is applied once the user
// reaches the pairing screen (see app/(app)/pairing.tsx and app/index.tsx).
import AsyncStorage from "@react-native-async-storage/async-storage";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
const STORAGE_KEY = "pending_invite_code";
export async function stashPendingInviteCode(code: string): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
} catch (error) {
console.error("Error stashing pending invite code:", error);
}
}
export async function getPendingInviteCode(): Promise<string | null> {
try {
const value = await AsyncStorage.getItem(STORAGE_KEY);
if (value && isValidInviteCode(value)) {
return normalizeInviteCode(value);
}
return null;
} catch (error) {
console.error("Error reading pending invite code:", error);
return null;
}
}
export async function clearPendingInviteCode(): Promise<void> {
try {
await AsyncStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error("Error clearing pending invite code:", error);
}
}
+8 -4
View File
@@ -35,7 +35,8 @@ const getOtherStores = () => {
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore"); const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore"); const { useStreakStore } = require("./streakStore");
const { useResponsesStore } = require("./responsesStore"); const { useResponsesStore } = require("./responsesStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore }; const { useQuizStore } = require("./quizStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore };
}; };
@@ -88,7 +89,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out"); console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages(); useMessageStore.getState().clearMessages();
@@ -96,6 +97,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences(); useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak(); useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses(); useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
await authClient.auth.signOut(); await authClient.auth.signOut();
return; return;
} }
@@ -144,7 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: false, isLoading: false,
}); });
// Clear other stores // Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages(); useMessageStore.getState().clearMessages();
@@ -152,6 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences(); useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak(); useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses(); useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
// Clear badge on sign out // Clear badge on sign out
const { clearBadge } = require("../lib/badge"); const { clearBadge } = require("../lib/badge");
@@ -176,7 +179,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
// Clear other stores when user signs out // Clear other stores when user signs out
if (user === null) { if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages(); useMessageStore.getState().clearMessages();
@@ -184,6 +187,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences(); useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak(); useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses(); useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
// Clear badge on sign out // Clear badge on sign out
const { clearBadge } = require("../lib/badge"); const { clearBadge } = require("../lib/badge");
clearBadge(); clearBadge();
+1
View File
@@ -11,3 +11,4 @@ export { useNotificationPreferencesStore } from "./notificationPreferencesStore"
export type { NotificationPreferences } from "./notificationPreferencesStore"; export type { NotificationPreferences } from "./notificationPreferencesStore";
export { useStreakStore } from "./streakStore"; export { useStreakStore } from "./streakStore";
export type { CoupleStreak } from "./streakStore"; export type { CoupleStreak } from "./streakStore";
export { useQuizStore } from "./quizStore";
+164
View File
@@ -0,0 +1,164 @@
import { create } from "zustand";
import { Events } from "../lib/analytics";
import { getQuizErrorCode, quizApi } from "../features/quiz/services/quizApi";
import type { QuizAnswer, QuizErrorCode, QuizLocalAnswer, QuizResult, QuizSession } from "../features/quiz/types";
interface QuizState {
session: QuizSession | null;
result: QuizResult | null;
/** In-progress answers for the active session, keyed by question id, not yet submitted. */
localAnswers: Record<string, QuizLocalAnswer>;
isLoading: boolean;
isStarting: boolean;
isSubmitting: boolean;
isLoadingResult: boolean;
/** Set when the couple/partner state blocks the quiz (`no_couple`, `partner_required`). */
errorCode: QuizErrorCode | null;
error: string | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
load: () => Promise<void>;
start: () => Promise<void>;
setSelfAnswer: (questionId: string, index: number) => void;
setGuessAnswer: (questionId: string, index: number) => void;
submit: () => Promise<boolean>;
loadResult: () => Promise<void>;
reset: () => void;
clearQuiz: () => void;
}
export const useQuizStore = create<QuizState>((set, get) => ({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
generation: 0,
load: async () => {
const myGeneration = get().generation;
set({ isLoading: true, error: null, errorCode: null });
try {
const { session } = await quizApi.getCurrentSession();
if (get().generation !== myGeneration) return;
set({ session, isLoading: false, localAnswers: {} });
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error loading quiz session:", cause);
set({ isLoading: false, errorCode: code, error: code ? null : "Could not load your quiz" });
}
},
start: async () => {
const myGeneration = get().generation;
set({ isStarting: true, error: null, errorCode: null });
try {
const { session } = await quizApi.startSession();
if (get().generation !== myGeneration) return;
set({ session, result: null, localAnswers: {}, isStarting: false });
Events.quizStarted();
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error starting quiz:", cause);
set({ isStarting: false, errorCode: code, error: code ? null : "Could not start the quiz" });
}
},
setSelfAnswer: (questionId, index) => {
set((state) => ({
localAnswers: {
...state.localAnswers,
[questionId]: { ...state.localAnswers[questionId], self_index: index },
},
}));
},
setGuessAnswer: (questionId, index) => {
set((state) => ({
localAnswers: {
...state.localAnswers,
[questionId]: { ...state.localAnswers[questionId], guess_index: index },
},
}));
},
submit: async () => {
const state = get();
const session = state.session;
if (!session) return false;
const answers: QuizAnswer[] = session.questions.map((question) => {
const local = state.localAnswers[question.id] ?? {};
return {
question_id: question.id,
self_index: local.self_index ?? -1,
guess_index: local.guess_index ?? -1,
};
});
const myGeneration = state.generation;
set({ isSubmitting: true, error: null, errorCode: null });
try {
const { session: updated } = await quizApi.submitAnswers(session.id, answers);
if (get().generation !== myGeneration) return false;
set({ session: updated, isSubmitting: false, localAnswers: {} });
if (updated.score_percent !== null) {
Events.quizCompleted(updated.score_percent);
}
return true;
} catch (cause) {
if (get().generation !== myGeneration) return false;
const code = getQuizErrorCode(cause);
console.error("Error submitting quiz answers:", cause);
set({ isSubmitting: false, errorCode: code, error: code ? null : "Could not submit your answers" });
return false;
}
},
loadResult: async () => {
const session = get().session;
if (!session) return;
const myGeneration = get().generation;
set({ isLoadingResult: true, error: null });
try {
const result = await quizApi.getResult(session.id);
if (get().generation !== myGeneration) return;
set({ result, isLoadingResult: false });
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error loading quiz result:", cause);
set({ isLoadingResult: false, errorCode: code, error: code ? null : "Could not load your results" });
}
},
/** Clears the in-memory quiz state without bumping the generation, e.g. after starting a new quiz. */
reset: () => {
set({ session: null, result: null, localAnswers: {}, error: null, errorCode: null });
},
clearQuiz: () => {
set((state) => ({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
// Invalidates any request already in flight: its generation check will
// fail and it will not be able to write into the next account's store.
generation: state.generation + 1,
}));
},
}));
+3 -3
View File
@@ -8,8 +8,8 @@
<string>com.apple.widgetkit-extension</string> <string>com.apple.widgetkit-extension</string>
</dict> </dict>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>43</string> <string>45</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.5</string> <string>1.0.6</string>
</dict> </dict>
</plist> </plist>
+12 -14
View File
@@ -12,15 +12,12 @@ ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Install dependencies # Install dependencies from the committed monorepo lockfile.
COPY package*.json ./ COPY . .
RUN npm ci RUN npm ci
# Copy source code # Build the web workspace from the repository root.
COPY . . RUN npm run build -w @sauci/web
# Build the application
RUN npm run build
# Production stage # Production stage
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
@@ -37,10 +34,11 @@ RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copy built assets from builder # Next preserves the workspace path in standalone output. Keep that layout so
COPY --from=builder /app/public ./public # the generated server can resolve its .next directory and public assets.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
USER nextjs USER nextjs
@@ -49,4 +47,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "apps/web/server.js"]
+19
View File
@@ -0,0 +1,19 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const pagePath = new URL('./page.tsx', import.meta.url)
describe('/delete-account public deletion resource', () => {
it('provides an accessible request route and the required deletion details', async () => {
const page = await readFile(pagePath, 'utf8')
expect(page).toContain('Delete your Sauci account')
expect(page).toContain('mailto:privacy@sauci.app')
expect(page).toContain('without reinstalling the app')
expect(page).toContain('Open Sauci and go to your Profile.')
expect(page).toContain('Choose Delete Account and follow the confirmation steps.')
expect(page).toContain('within 30 days')
expect(page).toContain('required for legal purposes')
expect(page).toContain('relationship data, question responses, match data, chat messages and photos')
})
})
+89
View File
@@ -0,0 +1,89 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Delete your Sauci account',
description: 'How to delete your Sauci account and associated data.',
}
export default function DeleteAccountPage() {
return (
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
<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">
<ArrowLeft className="w-5 h-5" />
<span>Back to Home</span>
</Link>
</div>
</header>
<div className="max-w-4xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold mb-4">Delete your Sauci account</h1>
<p className="text-white/70 leading-relaxed text-lg">
You can request deletion of your Sauci account and associated data without reinstalling the app.
</p>
<div className="glass p-6 my-8">
<h2 className="text-2xl font-semibold text-white mb-3">Request deletion by email</h2>
<p className="text-white/70 leading-relaxed mb-4">
Email us from the address associated with your Sauci account and ask us to delete your account.
</p>
<a
href="mailto:privacy@sauci.app?subject=Sauci%20account%20deletion%20request"
className="inline-flex rounded-lg bg-primary px-5 py-3 font-semibold text-white hover:opacity-90 transition-opacity"
>
Request account deletion
</a>
<p className="text-white/60 mt-4">
Or email <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
</p>
</div>
<div className="prose prose-invert prose-lg max-w-none space-y-8">
<section>
<h2 className="text-2xl font-semibold text-white mb-4">Delete your account in the Sauci app</h2>
<ol className="list-decimal pl-6 text-white/70 space-y-2">
<li>Open Sauci and go to your Profile.</li>
<li>Select Account.</li>
<li>Choose Delete Account and follow the confirmation steps.</li>
</ol>
</section>
<section>
<h2 className="text-2xl font-semibold text-white mb-4">What deletion covers</h2>
<p className="text-white/70 leading-relaxed">
Deleting an account permanently removes the account and profile, relationship data, question responses, match data, chat messages and photos, encryption keys, and other cloud-stored data associated with that account.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-white mb-4">Timing and limited retention</h2>
<p className="text-white/70 leading-relaxed">
We will delete or anonymize your personal information within 30 days. We may retain information where required for legal purposes.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold text-white mb-4">Need help?</h2>
<p className="text-white/70 leading-relaxed">
For questions about deletion or your data, contact <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
</p>
</section>
</div>
</div>
<footer className="py-8 px-6 border-t border-white/5">
<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>
<div className="flex gap-6 text-sm text-white/40">
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
<Link href="/" className="hover:text-white transition-colors">Home</Link>
</div>
</div>
</footer>
</main>
)
}
@@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const componentPath = new URL('./JoinClient.tsx', import.meta.url)
describe('/join/[code] JoinClient', () => {
it('validates the code shape client-side without calling a private API', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain("isValidInviteCode(code)")
expect(source).not.toMatch(/\/public\/v1\//)
expect(source).not.toMatch(/fetch\(/)
})
it('attempts the app scheme link and shows the raw code fallback', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('buildInviteSchemeUrl')
expect(source).toContain('invite-code-display')
expect(source).toContain('Copy code')
})
it('copies only the raw code, not a share message', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('navigator.clipboard.writeText(normalizedCode)')
})
it('links to both app stores', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('APP_STORE_URL')
expect(source).toContain('PLAY_STORE_URL')
})
it('instruments the funnel with PostHog page view, copy, and store-click events', async () => {
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_store_button_clicked'")
})
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')
})
})
+153
View File
@@ -0,0 +1,153 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { ArrowLeft, Check, Copy, Heart } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
import { isValidInviteCode, normalizeInviteCode } from '../../../lib/inviteCode'
import { APP_STORE_URL, PLAY_STORE_URL, buildInviteSchemeUrl } from '../../../lib/appLinks'
export function JoinClient({ code }: { code: string }) {
const posthog = usePostHog()
const [copied, setCopied] = useState(false)
const hasAttemptedOpen = 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 })
// 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 handleCopy = async () => {
try {
await navigator.clipboard.writeText(normalizedCode)
setCopied(true)
posthog?.capture('join_page_code_copied')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy invite code:', err)
}
}
const handleStoreClick = (store: 'app_store' | 'play_store') => {
posthog?.capture('join_page_store_button_clicked', { store })
}
if (!isValid) {
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">
<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>
<Link href="/" className="btn-primary inline-block">
Back to Home
</Link>
</div>
</main>
)
}
return (
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
{/* Header */}
<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">
<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" />
</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>
<div className="glass-light p-6 sm:p-8 mb-8">
<p className="text-white/60 text-sm mb-3">Your invite code</p>
<p
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
data-testid="invite-code-display"
>
{normalizedCode}
</p>
<button
type="button"
onClick={handleCopy}
className="btn-secondary w-full flex items-center justify-center gap-2"
>
{copied ? (
<>
<Check className="w-5 h-5" />
Copied
</>
) : (
<>
<Copy className="w-5 h-5" />
Copy code
</>
)}
</button>
</div>
<p className="text-white/40 text-sm mb-6">Don&apos;t have Sauci yet? Get the app first:</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>
</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>
<div className="flex gap-6 text-sm text-white/40">
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
<Link href="/" className="hover:text-white transition-colors">Home</Link>
</div>
</div>
</footer>
</main>
)
}
+11
View File
@@ -0,0 +1,11 @@
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.',
}
export default function JoinPage({ params }: { params: { code: string } }) {
return <JoinClient code={params.code} />
}
+10 -6
View File
@@ -129,9 +129,12 @@ export default function PrivacyPolicy() {
<li><strong>Portability:</strong> Request a copy of your data in a portable format.</li> <li><strong>Portability:</strong> Request a copy of your data in a portable format.</li>
<li><strong>Opt-out:</strong> Disable push notifications through your device settings.</li> <li><strong>Opt-out:</strong> Disable push notifications through your device settings.</li>
</ul> </ul>
<p className="text-white/70 leading-relaxed mt-4"> <p className="text-white/70 leading-relaxed mt-4">
To exercise these rights, please contact us at <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>. To exercise these rights, please contact us at <a href="mailto:privacy@sauci.app" className="text-primary hover:underline">privacy@sauci.app</a>.
</p> </p>
<p className="text-white/70 leading-relaxed mt-4">
For account deletion instructions and an email deletion request, visit our <Link href="/delete-account" className="text-primary hover:underline">account deletion page</Link>.
</p>
</section> </section>
<section> <section>
@@ -174,9 +177,10 @@ export default function PrivacyPolicy() {
<footer className="py-8 px-6 border-t border-white/5"> <footer className="py-8 px-6 border-t border-white/5">
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4"> <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> <span className="text-xl font-bold gradient-text">Sauci</span>
<div className="flex gap-6 text-sm text-white/40"> <div className="flex gap-6 text-sm text-white/40">
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link> <Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link> <Link href="/delete-account" className="hover:text-white transition-colors">Delete Account</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
<Link href="/" className="hover:text-white transition-colors">Home</Link> <Link href="/" className="hover:text-white transition-colors">Home</Link>
</div> </div>
</div> </div>
+4 -3
View File
@@ -173,9 +173,10 @@ export default function RedeemPage() {
<footer className="py-8 px-6 border-t border-white/5 mt-auto"> <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"> <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> <span className="text-xl font-bold gradient-text">Sauci</span>
<div className="flex gap-6 text-sm text-white/40"> <div className="flex gap-6 text-sm text-white/40">
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link> <Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link> <Link href="/delete-account" className="hover:text-white transition-colors">Delete Account</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
<Link href="/" className="hover:text-white transition-colors">Home</Link> <Link href="/" className="hover:text-white transition-colors">Home</Link>
</div> </div>
</div> </div>
+4 -3
View File
@@ -235,9 +235,10 @@ export default function TermsAndConditions() {
<footer className="py-8 px-6 border-t border-white/5"> <footer className="py-8 px-6 border-t border-white/5">
<div className="max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4"> <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> <span className="text-xl font-bold gradient-text">Sauci</span>
<div className="flex gap-6 text-sm text-white/40"> <div className="flex gap-6 text-sm text-white/40">
<Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link> <Link href="/privacy" className="hover:text-white transition-colors">Privacy</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link> <Link href="/delete-account" className="hover:text-white transition-colors">Delete Account</Link>
<Link href="/terms" className="hover:text-white transition-colors">Terms</Link>
<Link href="/" className="hover:text-white transition-colors">Home</Link> <Link href="/" className="hover:text-white transition-colors">Home</Link>
</div> </div>
</div> </div>
+9 -6
View File
@@ -24,12 +24,15 @@ export default function Footer() {
{/* Links */} {/* Links */}
<div className="flex flex-wrap items-center justify-center gap-6"> <div className="flex flex-wrap items-center justify-center gap-6">
<Link href="/privacy" className="text-white/60 hover:text-white transition-colors text-sm"> <Link href="/privacy" className="text-white/60 hover:text-white transition-colors text-sm">
Privacy Policy Privacy Policy
</Link> </Link>
<Link href="/terms" className="text-white/60 hover:text-white transition-colors text-sm"> <Link href="/delete-account" className="text-white/60 hover:text-white transition-colors text-sm">
Terms & Conditions Delete Account
</Link> </Link>
<Link href="/terms" className="text-white/60 hover:text-white transition-colors text-sm">
Terms & Conditions
</Link>
<a href="mailto:support@sauci.app" className="text-white/60 hover:text-white transition-colors text-sm"> <a href="mailto:support@sauci.app" className="text-white/60 hover:text-white transition-colors text-sm">
Contact Contact
</a> </a>
+3 -3
View File
@@ -2,9 +2,9 @@ version: '3.8'
services: services:
web: web:
build: build:
context: . context: ../..
dockerfile: Dockerfile dockerfile: apps/web/Dockerfile
args: args:
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com}
+15
View File
@@ -0,0 +1,15 @@
// App store and deep-link destinations used by the invite join page
// (apps/web/app/join/[code]/page.tsx). These are the real, live listing URLs
// already used by components/Hero.tsx, components/CTA.tsx,
// components/Navbar.tsx, and components/Pricing.tsx.
export const APP_STORE_URL = 'https://apps.apple.com/gb/app/sauci-couple-games-romance/id6757159885'
export const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.sauci.app'
export function buildInviteSchemeUrl(code: string): string {
return `app.sauci://join?code=${encodeURIComponent(code)}`
}
export function buildInviteUniversalUrl(code: string): string {
return `https://sauci.app/join/${encodeURIComponent(code)}`
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isValidInviteCode, normalizeInviteCode } from './inviteCode'
describe('isValidInviteCode', () => {
it('accepts 8-character alphanumeric codes', () => {
expect(isValidInviteCode('ABCD1234')).toBe(true)
expect(isValidInviteCode('abcd1234')).toBe(true)
})
it('rejects codes with the wrong length or characters', () => {
expect(isValidInviteCode('ABC123')).toBe(false)
expect(isValidInviteCode('ABCD12345')).toBe(false)
expect(isValidInviteCode('ABCD-123')).toBe(false)
expect(isValidInviteCode('')).toBe(false)
expect(isValidInviteCode(null)).toBe(false)
expect(isValidInviteCode(undefined)).toBe(false)
})
})
describe('normalizeInviteCode', () => {
it('trims and uppercases', () => {
expect(normalizeInviteCode(' abcd1234 ')).toBe('ABCD1234')
})
})
+15
View File
@@ -0,0 +1,15 @@
// Shared invite code shape validation for the web join page. Mirrors the
// mobile app's invite code shape (apps/mobile/src/lib/inviteLink.ts) and the
// standalone API's invite_code column. This module only validates the shape
// client-side; it never calls a private API.
const INVITE_CODE_PATTERN = /^[A-Za-z0-9]{8}$/
export function isValidInviteCode(code: string | null | undefined): boolean {
if (!code) return false
return INVITE_CODE_PATTERN.test(code.trim())
}
export function normalizeInviteCode(code: string): string {
return code.trim().toUpperCase()
}
+18
View File
@@ -1,6 +1,24 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
async headers() {
return [
{
// No file extension by design; iOS requires this exact path and
// rejects a response that isn't served as application/json.
source: '/.well-known/apple-app-site-association',
headers: [
{ key: 'Content-Type', value: 'application/json' },
],
},
{
source: '/.well-known/assetlinks.json',
headers: [
{ key: 'Content-Type', value: 'application/json' },
],
},
]
},
} }
module.exports = nextConfig module.exports = nextConfig
@@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "BXVPBATDZY.com.sauci.app",
"paths": ["/join/*"]
}
]
}
}
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.sauci.app",
"sha256_cert_fingerprints": [
"65:2F:E7:60:3F:2E:D2:54:8B:4A:EF:0E:F1:AC:35:3E:DD:13:13:EF:35:E7:90:A7:01:1F:89:0F:4F:71:E2:21"
]
}
}
]
@@ -0,0 +1,30 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const aasaPath = new URL('./apple-app-site-association', import.meta.url)
const assetlinksPath = new URL('./assetlinks.json', import.meta.url)
describe('apple-app-site-association', () => {
it('is valid JSON that covers /join/*', async () => {
const raw = await readFile(aasaPath, 'utf8')
const data = JSON.parse(raw)
expect(data.applinks.details).toHaveLength(1)
expect(data.applinks.details[0].paths).toContain('/join/*')
// com.sauci.app matches apps/mobile/app.json ios.bundleIdentifier.
expect(data.applinks.details[0].appID).toMatch(/\.com\.sauci\.app$/)
})
})
describe('assetlinks.json', () => {
it('is valid JSON that targets the Android package', async () => {
const raw = await readFile(assetlinksPath, 'utf8')
const data = JSON.parse(raw)
expect(data[0].target.package_name).toBe('com.sauci.app')
expect(data[0].target.namespace).toBe('android_app')
// Must be the Play App Signing key certificate fingerprint (Play Console >
// Test and release > Setup > App signing), colon-separated uppercase hex.
expect(data[0].target.sha256_cert_fingerprints[0]).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/)
})
})
+30 -9
View File
@@ -1,9 +1,17 @@
# Agent routing and effort # Agent routing and effort
The primary agent owns scope, acceptance criteria, task decomposition, and the Sol is the primary agent and owns scope, risk classification, acceptance
final answer. Use one implementation agent when a bounded handoff will keep noisy criteria, task decomposition, progress steering, final diff and evidence review,
execution out of the primary context. Do not pay the coordination cost for a and the final answer. Every substantive repository task must delegate to a
trivial one-file edit. Add other agents only for separable work or a risk trigger. project agent. Each substantive implementation must delegate one coherent
execution unit to the Terra `implementation` agent. That handoff normally
combines scoped discovery, implementation, focused tests, and iteration in one
continuous task. Add a `repo_scout`, reviewer, test writer, or verifier only for
separable work or the triggers below. The primary reviews the returned diff and
evidence, but does not repeat the implementation agent's detailed exploration or
checks without a concrete reason. Skip delegation only for trivial conversation,
a single obvious lookup, or a mechanical one-line edit whose coordination cost
exceeds the work.
## Implementation and integration ## Implementation and integration
@@ -41,10 +49,10 @@ Project Codex defaults live in `.codex/config.toml`; task profiles live in
| Role | Codex model | Default effort | Trigger | Do not use for | | Role | Codex model | Default effort | Trigger | Do not use for |
|---|---|---:|---|---| |---|---|---:|---|---|
| `repo_scout` | Luna | low | locate ownership, callers, contracts | editing or design decisions | | `repo_scout` | Luna | low | separable read-only ownership, caller, or contract lookup | editing or design decisions |
| `implementation` | Terra | medium, overridable | bounded product or harness change | broad architecture review | | `implementation` | Terra | medium, overridable | one continuous scoped discovery, implementation, test, and iteration unit | broad architecture review |
| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output | | `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output |
| `verifier` | Terra | medium | independently drive implemented behavior | editing implementation | | `verifier` | Terra | medium | user-facing, cross-boundary, or high-risk runtime behavior | editing implementation or low-risk package-local changes |
| `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes | | `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes |
| `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change | | `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors | | `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors |
@@ -68,13 +76,26 @@ the implementation agent into a premium generalist. Add the matching read-only
Sol reviewer and keep implementation on Terra unless the user explicitly asks for Sol reviewer and keep implementation on Terra unless the user explicitly asks for
a different model. a different model.
## Verification routing
Use a fresh Terra `verifier` after implementation when the change has
user-facing runtime behavior, crosses an application, API, data, authentication,
or provider boundary, or is otherwise high risk. The verifier independently
drives the acceptance criteria and returns evidence only. It is not required for
documentation, mechanical configuration, isolated tests, or low-risk
package-local changes with focused deterministic checks. A verifier may still be
added when independent observation would materially increase confidence.
## Handoff contract ## Handoff contract
Every delegated task includes the objective, owned files or surface, acceptance Every delegated task includes the objective, owned files or surface, acceptance
criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks, criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks,
and the evidence to return. The agent must report changed files, commands and and the evidence to return. The agent must report changed files, commands and
results, assumptions, and blockers. The primary agent independently reviews the results, assumptions, and blockers. The primary independently reviews the final
diff and decides whether acceptance has been met. diff and evidence and decides whether acceptance has been met. It should steer
the active implementation agent with targeted feedback rather than restart the
same discovery or verification in another agent unless an independent judgment,
separable workstream, or the verification-routing trigger requires it.
Parallel agents are for independent read-heavy work or non-overlapping ownership. Parallel agents are for independent read-heavy work or non-overlapping ownership.
Avoid concurrent edits to the same files, shared database state, fixed local Avoid concurrent edits to the same files, shared database state, fixed local
+49
View File
@@ -263,6 +263,55 @@ DELETE delete-relationship
Both users: Unpaired Both users: Unpaired
``` ```
## Invite Funnel (Join Links)
The invite code can also be delivered as a tappable link instead of requiring
the recipient to type it in manually.
**Link forms:**
- Universal/App Link: `https://sauci.app/join/{code}` (served by `apps/web`,
opens the app directly on a device that has it installed and verified).
- Custom scheme: `app.sauci://join?code={code}` (used by the web join page's
automatic hand-off attempt, and remains supported for any existing client
behavior).
**Web fallback (`apps/web/app/join/[code]/page.tsx`):** validates the code
shape client-side only (8-character alphanumeric; never calls a private API),
attempts the custom scheme link, and otherwise shows the code with a copy
button plus App Store / Play Store links. Copying only copies the raw code.
**Association files:** `apps/web/public/.well-known/apple-app-site-association`
and `apps/web/public/.well-known/assetlinks.json` authorize `sauci.app` to open
the app for `/join/*`. The Apple Team ID (`BXVPBATDZY`, from
`apps/mobile/app.json` and `apps/mobile/eas.json`) and bundle/package id
(`com.sauci.app`) are real values already used elsewhere in the repo. The
SHA-256 fingerprint in `assetlinks.json` is the Play App Signing key
certificate fingerprint from the Play Console (Test and release > Setup >
App signing); if the app signing key ever rotates, this file must be updated
or Android App Links will stop verifying.
**Mobile link handling:** `apps/mobile/app.json` declares
`associatedDomains: ["applinks:sauci.app"]` (iOS) and an intent filter for
`https://sauci.app/join/*` (Android), in addition to the existing `app.sauci`
scheme. `apps/mobile/app/_layout.tsx` parses incoming URLs
(`src/lib/inviteLink.ts`) and either routes a signed-in, onboarded user
straight to the pairing screen with the code pre-filled, or stashes the code
(`src/lib/pendingInviteCode.ts`) for signed-out/mid-onboarding users. The
stashed code is applied once the user reaches the pairing screen or the app's
initial route (`apps/mobile/app/index.tsx`) after sign-in/onboarding
completes.
**Deferred clipboard hand-off:** if an unpaired user opens the pairing screen
with no code from a link or stash, the app checks the clipboard once per
session (`src/lib/clipboardInviteOffer.ts`) for a string matching the invite
code shape and offers to use it ("Use code XXXXXXXX from your clipboard?").
The code is never applied automatically; the user must accept the offer.
**Analytics:** `invite_link_opened` (`source`: `universal_link` | `scheme` |
`clipboard`), `pairing_code_prefilled` (mobile, Firebase Analytics), and
`join_page_viewed` / `join_page_code_copied` / `join_page_store_button_clicked`
(web, PostHog).
## Cascade Behavior ## Cascade Behavior
When a couple is deleted, foreign key cascades clean up: When a couple is deleted, foreign key cascades clean up:
+68 -22
View File
@@ -16,6 +16,22 @@ described by their environment examples.
| MCP | Hono | 3002 | | MCP | Hono | 3002 |
| Expo Metro | Expo | 8081 | | Expo Metro | Expo | 8081 |
If another local project owns one of the application ports, add a different,
unique value to the ignored root `.env.local` before running `up`:
```text
SAUCI_WEB_PORT=3100
SAUCI_ADMIN_PORT=3101
SAUCI_MCP_PORT=3102
SAUCI_METRO_PORT=8181
```
`SAUCI_POSTGRES_PORT` and `SAUCI_API_PORT` remain available for their existing
local overrides. Every configured port must be a unique integer from 1 through
65535. Before launching managed services, the launcher checks both IPv4 and IPv6
listeners and fails closed with the owning PID, command, and working directory;
it never stops another project's process.
PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup, PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup,
and stores data in a named Docker volume. The local connection string is: and stores data in a named Docker volume. The local connection string is:
@@ -26,28 +42,56 @@ postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
Those credentials are for local development only. Deployed environments must Those credentials are for local development only. Deployed environments must
inject unique secrets; they must never consume `infra/backend/.env.example`. inject unique secrets; they must never consume `infra/backend/.env.example`.
## First run ## Start the stack
1. Use Node from `.nvmrc` and run `npm ci`. One command starts the local database, applies migrations and fixtures, and
2. Install Docker Desktop or Rancher Desktop and tmux. launches the application services:
3. Configure the hosted Supabase Auth public URL/key for the mobile application
and the hosted Auth issuer/JWKS settings for the API. Do not configure any
local command with production credentials. Authenticated mobile E2E requires
the designated hosted non-production Auth project and a disposable,
onboarding-complete user whose subject has a matching local profile. Pass
its credentials as `E2E_EMAIL` and `E2E_PASSWORD` only to the Maestro process;
do not store them in an Expo environment file.
To exercise MCP tools, also set a local `ADMIN_API_SERVICE_TOKEN`, its
fixture `ADMIN_API_SERVICE_USER_ID`, and a separate `SAUCI_MCP_API_KEY`.
`scripts/dev-local.sh` passes the service token to MCP as
`SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
credential.
4. Run `scripts/dev-local.sh reset` to recreate only the dedicated local Docker
volume, apply API migrations, and create configured fixtures.
5. Run `scripts/dev-local.sh up`.
`up` is idempotent. `down` stops tmux application processes and leaves the ```bash
database available. `down --all` also stops PostgreSQL but retains its volume. scripts/dev-local.sh up
```
Before the first use, install Docker Desktop or Rancher Desktop, tmux, and the
repository dependencies with `npm ci`. The launcher reads `.nvmrc` itself. When
the active Node version differs, it loads an installed Homebrew `nvm` and selects
the exact version automatically. If that version has not been installed yet, run
the command the launcher prints once, then rerun `up`.
Create the ignored root `.env.local` with the public Auth settings for the
designated non-production Supabase project (`itbzhrvlgvdmzbnhzhyx`). Set
`SUPABASE_AUTH_URL` (or `EXPO_PUBLIC_SUPABASE_URL`) and
`EXPO_PUBLIC_SUPABASE_ANON_KEY`; the launcher uses the URL for the API and both
values for Expo. It accepts only this designated hosted project or loopback Auth,
and explicitly rejects the production project and every other hosted project.
Environment variables exported in the shell take precedence over `.env.local`.
The launcher treats `.env.local` as data, never shell code: use unquoted
`KEY=VALUE` lines for only the documented launcher keys. Never place production
credentials or Auth issuer/JWKS, Auth service-role, RevenueCat, or other provider
secrets in `.env.local`; the launcher removes those inherited values from its
local API and worker processes. Each `up` replaces its managed tmux windows so
stale application processes cannot retain earlier configuration, while leaving
unrelated windows intact. Credentials are passed only to the local process that
needs them: MCP receives its local API token/key but never `DATABASE_URL`.
Before replacing a managed window, the launcher verifies and stops only its
current descendant processes. If those descendants do not exit promptly, it
leaves that window in place and fails rather than broadening process cleanup.
This includes a previously captured descendant that has left the pane lineage:
the launcher will not signal it again or replace the window until that captured
process identity is gone.
Authenticated mobile E2E requires a disposable, onboarding-complete user whose
subject has a matching local profile. Pass its credentials as `E2E_EMAIL` and
`E2E_PASSWORD` only to the Maestro process; do not store them in an Expo
environment file. To exercise MCP tools, also set a local
`ADMIN_API_SERVICE_TOKEN`, its fixture `ADMIN_API_SERVICE_USER_ID`, and a
separate `SAUCI_MCP_API_KEY`. `scripts/dev-local.sh` passes the service token to
MCP as `SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
credential.
`up` is idempotent. `down` stops only the launcher's managed tmux application
windows and leaves unrelated windows and the database available. `down --all`
also stops PostgreSQL but retains its volume, and runs only after managed
application cleanup succeeds.
Use `status`, `logs <service>`, `restart <service>`, and `attach` for diagnosis. Use `status`, `logs <service>`, `restart <service>`, and `attach` for diagnosis.
`logs postgres` reads Compose logs; other names read their tmux panes. `logs postgres` reads Compose logs; other names read their tmux panes.
The `worker` pane owns scheduled jobs, notifications, moderation classification, The `worker` pane owns scheduled jobs, notifications, moderation classification,
@@ -61,8 +105,10 @@ The API seed is a no-op unless its documented local fixture identity is set.
The Expo window defaults to Metro with The Expo window defaults to Metro with
`EXPO_PUBLIC_API_URL=http://127.0.0.1:3003`. Use `scripts/dev-local.sh ios` or `EXPO_PUBLIC_API_URL=http://127.0.0.1:3003`. Use `scripts/dev-local.sh ios` or
`android` to launch the native application against the same API. Physical `android` to launch the native application against the same API. The native
devices require a reachable host URL rather than loopback. launcher supplies a UTF-8 locale inside its sanitized environment so CocoaPods
does not depend on the calling shell's locale. Physical devices require a
reachable host URL rather than loopback.
`e2e/maestro/login-screen-smoke.yaml` verifies the native build and Auth shell `e2e/maestro/login-screen-smoke.yaml` verifies the native build and Auth shell
without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated
+2
View File
@@ -11,6 +11,7 @@ historical notes. Agents should load only the section relevant to their task.
- [Testing strategy](testing/strategy.md) - [Testing strategy](testing/strategy.md)
- [Database migration safety](operations/database-migrations.md) - [Database migration safety](operations/database-migrations.md)
- [Backend deployment and branching](operations/backend-deployments.md) - [Backend deployment and branching](operations/backend-deployments.md)
- [Website deployment](operations/web-deployments.md)
- [Agent routing and effort](agents/routing.md) - [Agent routing and effort](agents/routing.md)
- [Learning promotion](agents/learnings.md) - [Learning promotion](agents/learnings.md)
- [Authentication](authentication.md) - [Authentication](authentication.md)
@@ -23,6 +24,7 @@ historical notes. Agents should load only the section relevant to their task.
- [Redemption codes](product/redemption.md) - [Redemption codes](product/redemption.md)
- [Mobile release process](releasing.md) - [Mobile release process](releasing.md)
- [Dares loop](dares-loop.md) - [Dares loop](dares-loop.md)
- [Couples quiz](quiz.md)
## Feature and implementation references ## Feature and implementation references
+26
View File
@@ -0,0 +1,26 @@
# Website deployment
Dokploy deploys the public Sauci website from the `main` branch using
`apps/web/docker-compose.prod.yml`. The Compose file builds from the repository
root so the Docker build can use the committed workspace lockfile. Its Dockerfile
builds `@sauci/web` and runs the Next standalone server from its workspace path.
## Required configuration
`NEXT_PUBLIC_API_URL` is required at build time and must be the intended public
standalone API URL. `NEXT_PUBLIC_POSTHOG_KEY` is optional. When it is set,
`NEXT_PUBLIC_POSTHOG_HOST` defaults to `https://eu.i.posthog.com` unless Dokploy
provides a different value. These public values are inlined into the Next build;
do not place server credentials in them.
## Promotion and acceptance
Promote the verified website commit to `main` before allowing Dokploy to deploy
it. After the deployment reports success, verify valid TLS and HTTP 200 for:
- `https://sauci.app/`
- `https://sauci.app/delete-account`
The second check is required for the Google Play account-deletion resource. A
successful Dokploy deployment alone is not sufficient evidence that either URL
is publicly reachable.
+100
View File
@@ -0,0 +1,100 @@
# Couples quiz
Maintained reference for the shareable "how well do you know each other?"
quiz: a couple works through the same set of questions, each partner answers
about themselves and guesses about the other, and the pair only sees a score
once both have finished.
## Ownership
| Concern | Location |
|---|---|
| Schema and seed content | `apps/api/drizzle/0019_quiz.sql` |
| HTTP contract | `apps/api/src/domains/quiz/routes.ts` |
| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` |
| Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) |
## Session lifecycle
```
POST /v1/quiz/sessions ──► active session (10 questions, question order fixed at creation)
│
├── each partner submits a full answer set (self + guess per question)
│
└── once BOTH partners have a complete set ──► session completes, score is computed
```
Only one active session exists per couple at a time (`quiz_sessions_one_active_per_couple_idx`,
a partial unique index on `couple_id` where `status = 'active'`). Starting a
quiz while one is already active returns that same session (`200`) instead of
creating a second one; two simultaneous starts race safely because the
partial unique index, not application locking, is the source of truth.
Answers are submitted as one complete batch per call (`quiz_answers`, upsert
on `(session_id, user_id, question_id)`). A submission must cover every
question in the session exactly once, with every index inside that
question's option list, or it is rejected with `400 invalid_answers`. A
session that has already completed rejects further submissions with
`409 session_completed`.
## Scoring
Each question contributes up to two hits: partner A's guess about B counts if
it matches B's self-answer, and separately B's guess about A counts if it
matches A's self-answer. `score_percent = round(100 * hits / (2 *
question_count))`. `computeQuizScore` in `service.ts` is the pure,
unit-tested implementation; the repository only assembles the per-question
pairs and persists the result.
## Couple isolation
Every session and result lookup is scoped to the caller's own couple. A
session ID that belongs to another couple returns `404 session_not_found`,
never a hint that it exists elsewhere. Starting a quiz requires both a couple
(`409 no_couple`) and a paired partner (`409 partner_required`).
## HTTP contract
| Method | Path | Notes |
|---|---|---|
| POST | `/v1/quiz/sessions` | `201` on a new session, `200` when an active one already exists |
| GET | `/v1/quiz/sessions/current` | `{ session: SessionPayload \| null }`, most recent session for the couple |
| POST | `/v1/quiz/sessions/:sessionId/answers` | `{ answers: [{ question_id, self_index, guess_index }] }` |
| GET | `/v1/quiz/sessions/:sessionId/result` | `409 session_not_completed` before both partners finish |
`SessionPayload` never includes the partner's raw answers before completion;
per-question partner detail (`partner_self_index`, `partner_guess_index`,
`partner_guessed_right`) is only exposed by the `/result` endpoint of a
completed session.
## Tests
- `apps/api/test/quiz.service.test.ts`: unit tests for `computeQuizScore`
- `apps/api/test/quiz.routes.test.ts`: HTTP contract and error mapping
- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full
happy path with a hand-computed score, idempotent session start, couple
isolation, incomplete answer set rejection, and result blocked before
completion
## Mobile client
| Concern | Location |
|---|---|
| Feature screens, hooks, API client | `apps/mobile/src/features/quiz/` |
| Zustand store (generation-counter pattern, cleared on sign-out) | `apps/mobile/src/store/quizStore.ts` |
| Discovery entry point | `apps/mobile/src/components/discovery/QuizTile.tsx` |
| Route wrapper | `apps/mobile/app/(app)/quiz.tsx` |
The screen walks not-paired, intro, answering (one question at a time, self
then guess), waiting-on-partner, and results states off `useQuizScreen`.
Answers are held client-side per question until every question in the
session has both a self and a guess index, then submitted as the one batch
the API expects; the store never calls the answers endpoint more than once
per session. Results render a shareable card (`QuizResultsShareCard` /
`QuizShareModal`, following the `SharePreviewModal` capture pattern) carrying
only the match score and Sauci branding, never question text or answers, so a
screenshot cannot leak either partner's content.
## Not in this version
Question authoring tooling in the admin app is out of scope for this change.
+10
View File
@@ -1,5 +1,15 @@
# Sauci Mobile Release Notes # Sauci Mobile Release Notes
## v1.0.6 — 30 August 2026
_iOS build 44 · Android build 48_
A little more clarity, momentum, and playfulness for your shared check-ins.
- **Your turn at a glance** — A private Home prompt now shows when questions from your partner are waiting, without revealing their answers.
- **New ways to play** — Browse, send, and respond to Dares together from a dedicated shared space.
- **Streaks that fit your day** — Shared streaks now follow your local day, with clearer progress and reminder controls.
- **Smoother shared moments** — Leaner syncing keeps questions, matches, chat, and Live Draw feeling current with less unnecessary refreshing.
## v1.0.1 (Build 30) - January 2025 ## v1.0.1 (Build 30) - January 2025
### New Features ### New Features
+10 -1
View File
@@ -85,6 +85,15 @@ may change: `app.json`, the platform native version file, and the iOS widget
`Info.plist` for an iOS build. Any widget implementation, resource, or Xcode `Info.plist` for an iOS build. Any widget implementation, resource, or Xcode
project rewrite means the source was regenerated and the artifact must be rebuilt project rewrite means the source was regenerated and the artifact must be rebuilt
from the clean tracked sources. from the clean tracked sources.
### Android media permission policy
Sauci uses the Android system photo picker for user-selected images and videos.
Android release manifests must not contain `READ_MEDIA_IMAGES` or
`READ_MEDIA_VIDEO`. Gallery save flows request add-only access and must not add
broad read access. After any Expo, media-library, image-picker, or native Android
change, run `:app:processReleaseMainManifest` and inspect the merged release
manifest before building an artifact for Google Play.
### Building an APK (for Testing) ### Building an APK (for Testing)
@@ -258,7 +267,7 @@ To verify you're using the correct keystore:
keytool -list -v -keystore android/sauci-release.keystore -storepass <password> keytool -list -v -keystore android/sauci-release.keystore -storepass <password>
``` ```
Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43` Expected EAS production upload-key SHA1: `F0:60:26:C7:54:B0:46:F2:80:FE:AC:43:8A:69:10:BD:9A:62:B4:59`
--- ---
+409 -37
View File
@@ -5,8 +5,98 @@ SESSION="sauci-dev"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$ROOT/infra/backend/compose.yaml" COMPOSE_FILE="$ROOT/infra/backend/compose.yaml"
COMPOSE_PROJECT="sauci-local" COMPOSE_PROJECT="sauci-local"
DESCENDANT_SHUTDOWN_ATTEMPTS=30
DESCENDANT_SHUTDOWN_DELAY_SECONDS=0.1
load_env_local() {
local env_file="$ROOT/.env.local" line line_number=0 key value seen_keys=' '
local safe_value_pattern='^[A-Za-z0-9._:/?&=+%@,#-]+$'
[ -f "$env_file" ] || return 0
# This is deliberately a data parser, not `source`: .env.local is ignored
# developer input and must never execute in the launcher's shell.
while IFS= read -r line || [ -n "$line" ]; do
line_number=$((line_number + 1))
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ ! "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]]; then
printf '✗ %s:%s must be KEY=VALUE with no quoting or shell syntax.\n' "$env_file" "$line_number" >&2
exit 1
fi
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
case " $key " in
" SAUCI_POSTGRES_PORT "|" SAUCI_API_PORT "|" SAUCI_WEB_PORT "|" SAUCI_ADMIN_PORT "|\
" SAUCI_MCP_PORT "|" SAUCI_METRO_PORT "|" SAUCI_MOBILE_API_URL "|\
" SUPABASE_AUTH_URL "|" EXPO_PUBLIC_SUPABASE_URL "|" EXPO_PUBLIC_SUPABASE_ANON_KEY "|\
" ADMIN_API_SERVICE_USER_ID "|" ADMIN_API_SERVICE_TOKEN "|" SAUCI_MCP_API_KEY ") ;;
*)
printf '✗ %s:%s contains unsupported launcher key %s.\n' "$env_file" "$line_number" "$key" >&2
exit 1
;;
esac
case "$seen_keys" in
*" $key "*)
printf '✗ %s:%s duplicates launcher key %s.\n' "$env_file" "$line_number" "$key" >&2
exit 1
;;
esac
if [[ -z "$value" || ! "$value" =~ $safe_value_pattern ]]; then
printf '✗ %s:%s contains an unsafe or empty value.\n' "$env_file" "$line_number" >&2
exit 1
fi
# Explicit caller exports are authoritative over ignored local defaults.
printenv "$key" >/dev/null 2>&1 || export "$key=$value"
seen_keys+="$key "
done <"$env_file"
}
load_nvm() {
command -v nvm >/dev/null 2>&1 && return
local nvm_dir="${NVM_DIR:-$HOME/.nvm}" brew_nvm_prefix
local -a candidates=("$nvm_dir/nvm.sh")
if command -v brew >/dev/null 2>&1; then
brew_nvm_prefix="$(brew --prefix nvm 2>/dev/null || true)"
[ -n "$brew_nvm_prefix" ] && candidates+=("$brew_nvm_prefix/nvm.sh")
fi
local candidate
for candidate in "${candidates[@]}"; do
if [ -s "$candidate" ]; then
# shellcheck disable=SC1090
. "$candidate"
command -v nvm >/dev/null 2>&1 && return
fi
done
return 1
}
activate_required_node() {
local required_node current_node
required_node="$(tr -d '[:space:]' <"$ROOT/.nvmrc")"
[ -n "$required_node" ] || die ".nvmrc must specify a Node version."
if command -v node >/dev/null 2>&1; then
current_node="$(node -p 'process.versions.node')"
[ "$current_node" = "$required_node" ] && return
fi
load_nvm || die "Node $required_node is required. Install Homebrew nvm, then run: nvm install $required_node"
nvm use --silent "$required_node" >/dev/null 2>&1 || die "Node $required_node is required. Install it once with: nvm install $required_node"
current_node="$(node -p 'process.versions.node')"
[ "$current_node" = "$required_node" ] || die "Node $required_node is required; nvm selected $current_node instead."
}
# Read the ignored developer configuration before computing launch settings.
# Explicit environment variables take precedence over .env.local values.
load_env_local
POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}" POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}"
API_PORT="${SAUCI_API_PORT:-3003}" API_PORT="${SAUCI_API_PORT:-3003}"
WEB_PORT="${SAUCI_WEB_PORT:-3000}"
ADMIN_PORT="${SAUCI_ADMIN_PORT:-3001}"
MCP_PORT="${SAUCI_MCP_PORT:-3002}"
METRO_PORT="${SAUCI_METRO_PORT:-8081}"
LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci" LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci"
LOCAL_API_URL="http://127.0.0.1:${API_PORT}" LOCAL_API_URL="http://127.0.0.1:${API_PORT}"
MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}" MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}"
@@ -17,21 +107,21 @@ LOCAL_ADMIN_SERVICE_TOKEN="${ADMIN_API_SERVICE_TOKEN:-local-sauci-admin-service-
LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}" LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}"
SERVERS=( SERVERS=(
"api|env PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL SUPABASE_AUTH_URL=$AUTH_URL ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api" "api|PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api"
"worker|env DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api" "worker|DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api"
"web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port 3000" "web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port $WEB_PORT"
"admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001" "admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port $ADMIN_PORT"
"mcp|env PORT=3002 SAUCI_ADMIN_API_URL=$LOCAL_API_URL SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY npm run dev -w sauci-mcp-server" "mcp|env PORT=$MCP_PORT npm run dev -w sauci-mcp-server"
"mobile|env EXPO_PUBLIC_API_URL=$MOBILE_API_URL EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY npm run dev -w @sauci/mobile -- --port 8081 --localhost" "mobile|npm run dev -w @sauci/mobile -- --port $METRO_PORT --localhost"
) )
PORTS=( PORTS=(
"postgres:$POSTGRES_PORT" "postgres:$POSTGRES_PORT"
"api:$API_PORT" "api:$API_PORT"
"web:3000" "web:$WEB_PORT"
"admin:3001" "admin:$ADMIN_PORT"
"mcp:3002" "mcp:$MCP_PORT"
"metro:8081" "metro:$METRO_PORT"
) )
c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_red=$'\033[31m'; c_cyn=$'\033[36m' c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_red=$'\033[31m'; c_cyn=$'\033[36m'
@@ -46,15 +136,21 @@ compose() {
} }
preflight() { preflight() {
activate_required_node
if ! node - "$POSTGRES_PORT" "$API_PORT" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT" "$METRO_PORT" <<'NODE'
const ports = process.argv.slice(2);
const valid = ports.every((port) => /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535);
if (!valid || new Set(ports.map(Number)).size !== ports.length) process.exitCode = 1;
NODE
then
die "Configured ports must be unique integers from 1 to 65535."
fi
command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux" command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux"
command -v npm >/dev/null 2>&1 || die "npm not found. Install Node $(cat "$ROOT/.nvmrc")." command -v npm >/dev/null 2>&1 || die "npm not found after activating Node $(cat "$ROOT/.nvmrc")."
command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found." command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found."
docker info >/dev/null 2>&1 || die "Container runtime is not running." docker info >/dev/null 2>&1 || die "Container runtime is not running."
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required." docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required."
[ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci" [ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci"
local node_major
node_major="$(node -p 'process.versions.node.split(`.`)[0]')"
[ "$node_major" = "20" ] || die "Node 20 is required. Activate .nvmrc before starting the stack."
} }
assert_local_database() { assert_local_database() {
@@ -65,10 +161,212 @@ assert_local_database() {
[ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320." [ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320."
} }
assert_local_auth() { assert_allowed_auth() {
case "$AUTH_URL" in local verdict
http://127.0.0.1:*|http://localhost:*|http://\[::1\]:*) ;; verdict="$(node - "$AUTH_URL" <<'NODE'
*) die "Refusing local development against non-local Supabase Auth: $AUTH_URL" ;; const raw = process.argv[2];
try {
const url = new URL(raw);
if (url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) throw new Error('shape');
const isCanonicalRoot = raw === url.origin || raw === `${url.origin}/`;
const isLoopback = isCanonicalRoot && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
const isNonProduction = isCanonicalRoot && url.protocol === 'https:' && url.origin === 'https://itbzhrvlgvdmzbnhzhyx.supabase.co';
if (isLoopback || isNonProduction) process.stdout.write('allowed');
else if (url.hostname === 'ckjcrkjpmhqhiucifukx.supabase.co') process.stdout.write('production');
else process.stdout.write('unapproved');
} catch {
process.stdout.write('invalid');
}
NODE
)"
case "$verdict" in
allowed) ;;
production) die "Refusing local development against the production Supabase Auth project." ;;
invalid) die "SUPABASE_AUTH_URL must be a valid root Auth URL without credentials, a path, query, or fragment." ;;
*) die "Refusing local development against an unapproved Supabase Auth URL." ;;
esac
}
prepare_session() {
BOOTSTRAP_CREATED=false
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300'
BOOTSTRAP_CREATED=true
elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -qx '_bootstrap'; then
tmux new-window -d -t "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300'
BOOTSTRAP_CREATED=true
fi
}
restart_managed_windows() {
local name failed=false
for name in api worker web admin mcp mobile; do
stop_managed_window "$name" || failed=true
done
[ "$failed" = false ]
}
is_numeric_pid() {
[[ "$1" =~ ^[1-9][0-9]*$ ]]
}
pane_pid_for_window() {
local name="$1" pane_pid
pane_pid="$(tmux display-message -p -t "$SESSION:$name" '#{pane_pid}' 2>/dev/null || true)"
is_numeric_pid "$pane_pid" && printf '%s\n' "$pane_pid"
}
process_start_time() {
local pid="$1" start_time
is_numeric_pid "$pid" || return 1
start_time="$(ps -p "$pid" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[ -n "$start_time" ] && printf '%s\n' "$start_time"
}
has_pane_ancestry() {
local pid="$1" pane_pid="$2" parent_pid hops=0
is_numeric_pid "$pid" && is_numeric_pid "$pane_pid" || return 1
while [ "$hops" -lt 64 ]; do
parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d '[:space:]')"
is_numeric_pid "$parent_pid" || return 1
[ "$parent_pid" = "$pane_pid" ] && return 0
[ "$parent_pid" -gt 1 ] || return 1
pid="$parent_pid"
hops=$((hops + 1))
done
return 1
}
capture_descendants() {
local parent_pid="$1" child_pid
is_numeric_pid "$parent_pid" || return 0
while IFS= read -r child_pid; do
is_numeric_pid "$child_pid" || continue
capture_descendants "$child_pid"
local start_time
start_time="$(process_start_time "$child_pid" || true)"
[ -n "$start_time" ] || continue
MANAGED_DESCENDANT_PIDS+=("$child_pid")
MANAGED_DESCENDANT_STARTS+=("$start_time")
record_captured_identity "$child_pid" "$start_time"
done < <(pgrep -P "$parent_pid" 2>/dev/null || true)
}
record_captured_identity() {
local pid="$1" start_time="$2" index
for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do
[ "${CAPTURED_DESCENDANT_PIDS[$index]}" = "$pid" ] &&
[ "${CAPTURED_DESCENDANT_STARTS[$index]}" = "$start_time" ] && return 0
done
CAPTURED_DESCENDANT_PIDS+=("$pid")
CAPTURED_DESCENDANT_STARTS+=("$start_time")
}
captured_identity_remains() {
local index pid start_time
for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do
pid="${CAPTURED_DESCENDANT_PIDS[$index]}"
start_time="${CAPTURED_DESCENDANT_STARTS[$index]}"
is_numeric_pid "$pid" || continue
[ "$(process_start_time "$pid" || true)" = "$start_time" ] && return 0
done
return 1
}
signal_verified_descendants() {
local name="$1" expected_pane_pid="$2" index pid start_time current_pane_pid
for index in "${!MANAGED_DESCENDANT_PIDS[@]}"; do
pid="${MANAGED_DESCENDANT_PIDS[$index]}"
start_time="${MANAGED_DESCENDANT_STARTS[$index]}"
is_numeric_pid "$pid" || continue
current_pane_pid="$(pane_pid_for_window "$name" || true)"
[ "$current_pane_pid" = "$expected_pane_pid" ] || continue
[ "$(process_start_time "$pid" || true)" = "$start_time" ] || continue
has_pane_ancestry "$pid" "$expected_pane_pid" || continue
kill -TERM "$pid" 2>/dev/null || true
done
}
descendants_remain() {
local pane_pid="$1" child_pid
is_numeric_pid "$pane_pid" || return 1
child_pid="$(pgrep -P "$pane_pid" 2>/dev/null | head -1 || true)"
[ -n "$child_pid" ]
}
stop_managed_window() {
local name="$1" pane_pid attempt=1
case "$name" in api|worker|web|admin|mcp|mobile) ;; *) die "unknown managed window '$name'" ;; esac
tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -qx "$name" || return 0
CAPTURED_DESCENDANT_PIDS=("")
CAPTURED_DESCENDANT_STARTS=("")
while [ "$attempt" -le "$DESCENDANT_SHUTDOWN_ATTEMPTS" ]; do
pane_pid="$(pane_pid_for_window "$name" || true)"
if ! is_numeric_pid "$pane_pid"; then
captured_identity_remains && return 1
return 0
fi
if ! descendants_remain "$pane_pid"; then
captured_identity_remains && {
warn "window '$name' has a captured descendant outside its pane lineage; leaving it running"
return 1
}
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
return 0
fi
# Bash 3.2 plus nounset requires non-empty sentinels for these arrays.
MANAGED_DESCENDANT_PIDS=("")
MANAGED_DESCENDANT_STARTS=("")
capture_descendants "$pane_pid"
signal_verified_descendants "$name" "$pane_pid"
sleep "$DESCENDANT_SHUTDOWN_DELAY_SECONDS"
attempt=$((attempt + 1))
done
pane_pid="$(pane_pid_for_window "$name" || true)"
if captured_identity_remains; then
warn "window '$name' still has captured descendants; leaving it running"
return 1
fi
if is_numeric_pid "$pane_pid" && descendants_remain "$pane_pid"; then
warn "window '$name' still has managed descendants; leaving it running"
return 1
fi
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
}
window_environment() {
local name="$1"
# Bash 3.2 with nounset treats an empty array expansion as unbound. Keep a
# sentinel and skip it when building tmux's optional -e arguments.
WINDOW_ENV=("")
WINDOW_FORWARD=''
case "$name" in
api)
WINDOW_ENV=(
"SUPABASE_AUTH_URL=$AUTH_URL"
"ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID"
"ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN"
)
WINDOW_FORWARD='SUPABASE_AUTH_URL="$SUPABASE_AUTH_URL" ADMIN_API_SERVICE_USER_ID="$ADMIN_API_SERVICE_USER_ID" ADMIN_API_SERVICE_TOKEN="$ADMIN_API_SERVICE_TOKEN"'
;;
mcp)
WINDOW_ENV=(
"SAUCI_ADMIN_API_URL=$LOCAL_API_URL"
"SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN"
"SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY"
)
WINDOW_FORWARD='SAUCI_ADMIN_API_URL="$SAUCI_ADMIN_API_URL" SAUCI_ADMIN_API_TOKEN="$SAUCI_ADMIN_API_TOKEN" SAUCI_MCP_API_KEY="$SAUCI_MCP_API_KEY"'
;;
mobile)
WINDOW_ENV=(
"EXPO_PUBLIC_API_URL=$MOBILE_API_URL"
"EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL"
"EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY"
)
WINDOW_FORWARD='EXPO_PUBLIC_API_URL="$EXPO_PUBLIC_API_URL" EXPO_PUBLIC_SUPABASE_URL="$EXPO_PUBLIC_SUPABASE_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$EXPO_PUBLIC_SUPABASE_ANON_KEY"'
;;
esac esac
} }
@@ -85,7 +383,8 @@ api_workspace_exists() {
run_api_script() { run_api_script() {
local script="$1" local script="$1"
api_workspace_exists || die "apps/api is not present yet; cannot run db:$script" api_workspace_exists || die "apps/api is not present yet; cannot run db:$script"
(cd "$ROOT" && env DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api) (cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \
DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api)
} }
start_window() { start_window() {
@@ -98,8 +397,17 @@ start_window() {
warn "apps/api is not present yet; skipping API window" warn "apps/api is not present yet; skipping API window"
return return
fi fi
tmux new-window -t "$SESSION" -n "$name" -c "$ROOT" window_environment "$name"
tmux send-keys -t "$SESSION:$name" "$cmd" C-m local entry escaped_path escaped_home escaped_tmpdir
printf -v escaped_path '%q' "$PATH"
printf -v escaped_home '%q' "$HOME"
printf -v escaped_tmpdir '%q' "${TMPDIR:-/tmp}"
set -- tmux new-window -d -t "$SESSION" -n "$name" -c "$ROOT"
for entry in "${WINDOW_ENV[@]}"; do
[ -n "$entry" ] && set -- "$@" -e "$entry"
done
set -- "$@" "env -i PATH=$escaped_path HOME=$escaped_home TMPDIR=$escaped_tmpdir $WINDOW_FORWARD $cmd"
"$@"
} }
port_check() { port_check() {
@@ -115,20 +423,57 @@ port_check() {
fi fi
} }
report_port_owner() {
local port="$1" pid command cwd raw_command
while IFS= read -r pid; do
[ -n "$pid" ] || continue
raw_command="$(ps -p "$pid" -o command= 2>/dev/null)"
command="$(printf '%s' "$raw_command" | LC_ALL=C tr -d '[:cntrl:]')"
command="${command#"${command%%[![:space:]]*}"}"
command="${command%%[[:space:]]*}"
[ -n "$command" ] && command="$(basename "$command")"
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
cwd="$(printf '%s' "$cwd" | LC_ALL=C tr -d '[:cntrl:]')"
printf ' PID %s\n command: %s\n cwd: %s\n' "$pid" "${command:-unknown}" "${cwd:-unknown}" >&2
done < <(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u)
}
assert_app_ports_available() {
local entry name port occupied=false
local -a app_ports=("api:$API_PORT" "web:$WEB_PORT" "admin:$ADMIN_PORT" "mcp:$MCP_PORT" "metro:$METRO_PORT")
for entry in "${app_ports[@]}"; do
name="${entry%%:*}"; port="${entry##*:}"
if port_up "$port"; then
printf '✗ Port %s for %s is already listening. Refusing to replace its owner.\n' "$port" "$name" >&2
report_port_owner "$port"
occupied=true
fi
done
[ "$occupied" = false ] || return 1
}
cmd_up() { cmd_up() {
preflight preflight
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification." [ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification."
assert_local_auth assert_allowed_auth
prepare_session
if ! restart_managed_windows; then
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
die "Refusing to restart while a managed window still has descendants."
fi
if ! assert_app_ports_available; then
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
die "Refusing to start while a configured application port is occupied."
fi
ensure_infra ensure_infra
run_api_script migrate run_api_script migrate
run_api_script seed run_api_script seed
tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT"
local service local service
for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done
tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true [ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
ok "Stack starting in tmux session '$SESSION'" ok "Stack starting in tmux session '$SESSION'"
port_check port_check
printf "\n API: %s/health/live\n Web: http://127.0.0.1:3000\n Admin: http://127.0.0.1:3001\n MCP: http://127.0.0.1:3002/health\n" "$LOCAL_API_URL" printf "\n API: %s/health/live\n Web: http://127.0.0.1:%s\n Admin: http://127.0.0.1:%s\n MCP: http://127.0.0.1:%s/health\n" "$LOCAL_API_URL" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT"
} }
cmd_status() { cmd_status() {
@@ -152,26 +497,52 @@ cmd_logs() {
} }
cmd_restart() { cmd_restart() {
local name="${1:?usage: restart <service>}" service local name="${1:?usage: restart <service>}" service="" candidate
if [ "$name" = "postgres" ]; then if [ "$name" = "postgres" ]; then
compose restart postgres compose restart postgres
ok "restarted postgres" ok "restarted postgres"
return return
fi fi
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running" for candidate in "${SERVERS[@]}"; do
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true if [ "${candidate%%|*}" = "$name" ]; then
for service in "${SERVERS[@]}"; do service="$candidate"
if [ "${service%%|*}" = "$name" ]; then break
start_window "$name" "${service#*|}"
ok "restarted $name"
return
fi fi
done done
die "unknown service '$name'" [ -n "$service" ] || die "unknown service '$name'"
preflight
case "$name" in
api|mobile)
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
assert_allowed_auth
;;
esac
[ "$name" != "mobile" ] || [ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
prepare_session
if ! stop_managed_window "$name"; then
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
die "Refusing to restart '$name' while its managed descendants remain."
fi
start_window "$name" "${service#*|}"
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
ok "restarted $name"
} }
cmd_down() { cmd_down() {
tmux kill-session -t "$SESSION" 2>/dev/null && ok "application services stopped" || warn "no session '$SESSION'" if tmux has-session -t "$SESSION" 2>/dev/null; then
prepare_session
if ! restart_managed_windows; then
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
die "Refusing to stop infrastructure while a managed window still has descendants."
fi
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
ok "managed application services stopped"
else
warn "no session '$SESSION'"
fi
if [ "${1:-}" = "--all" ]; then if [ "${1:-}" = "--all" ]; then
compose down compose down
ok "standalone backend infrastructure stopped (data retained)" ok "standalone backend infrastructure stopped (data retained)"
@@ -203,9 +574,10 @@ cmd_native() {
preflight preflight
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required." [ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
[ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client." [ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
assert_local_auth assert_allowed_auth
ensure_infra ensure_infra
(cd "$ROOT" && env EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \ (cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \
EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \ EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \
npm run "$platform" -w @sauci/mobile) npm run "$platform" -w @sauci/mobile)
} }
+7
View File
@@ -40,6 +40,13 @@ for (const file of allFiles) {
} }
if (!scoped) { if (!scoped) {
const agentsFile = path.join(root, 'AGENTS.md');
const agents = await readFile(agentsFile, 'utf8');
const noEmDashRule = 'Agents must not use em dash characters in text they add to this repository.';
if (!agents.includes(noEmDashRule)) {
fail(agentsFile, 'agent writing rule against em dash characters is missing', `restore: ${noEmDashRule}`);
}
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
const codexConfigFile = path.join(root, '.codex', 'config.toml'); const codexConfigFile = path.join(root, '.codex', 'config.toml');
const codexConfig = await readFile(codexConfigFile, 'utf8'); const codexConfig = await readFile(codexConfigFile, 'utf8');