From 9957fa43f2d8145c145c0aff42c8981c8f035b68 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 18:29:29 +0100 Subject: [PATCH 1/8] feat(agents): add Claude delegation skill Add a repository-owned Codex skill and helper for bounded Claude Code execution, model and effort selection, resumable follow-ups, and independent acceptance checks. --- .codex/skills/delegate-to-claude/SKILL.md | 73 +++++++++++++++++++ .../delegate-to-claude/agents/openai.yaml | 4 + .../scripts/claude_delegate.sh | 70 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 .codex/skills/delegate-to-claude/SKILL.md create mode 100644 .codex/skills/delegate-to-claude/agents/openai.yaml create mode 100755 .codex/skills/delegate-to-claude/scripts/claude_delegate.sh diff --git a/.codex/skills/delegate-to-claude/SKILL.md b/.codex/skills/delegate-to-claude/SKILL.md new file mode 100644 index 0000000..acec94a --- /dev/null +++ b/.codex/skills/delegate-to-claude/SKILL.md @@ -0,0 +1,73 @@ +--- +name: delegate-to-claude +description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review. +--- + +# Delegate to Claude Code + +Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance. + +## Preconditions + +1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory. +2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order. +3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has. + +## Work order + +Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include: + +- role: executor or read-only reviewer; +- exact objective and boundaries; +- working directory and relevant files; +- repository instructions Claude must read; +- allowed side effects and forbidden actions; +- required checks and expected evidence; +- requested return format: findings or changed files, commands run, results, and blockers. + +The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient. + +## Run + +From the target repository directory: + +```bash +/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json +``` + +The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode: + +```bash +CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json +``` + +Optional environment variables: + +- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`. +- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`. +- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode. +- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary. + +The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access. + +Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox. + +For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run: + +```bash +scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json +``` + +If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate. + +## Accept or iterate + +Claude's summary is evidence to inspect, not proof. Codex must: + +1. inspect the actual diff or artifacts; +2. run proportionate independent checks; +3. compare the result with the original scope and repository rules; +4. send a focused follow-up with concrete failures when iteration is needed; +5. report Claude's contribution separately from Codex's verification. + +Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change. diff --git a/.codex/skills/delegate-to-claude/agents/openai.yaml b/.codex/skills/delegate-to-claude/agents/openai.yaml new file mode 100644 index 0000000..6922829 --- /dev/null +++ b/.codex/skills/delegate-to-claude/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Delegate to Claude Code" + short_description: "Delegate scoped work to Claude Code" + default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result." diff --git a/.codex/skills/delegate-to-claude/scripts/claude_delegate.sh b/.codex/skills/delegate-to-claude/scripts/claude_delegate.sh new file mode 100755 index 0000000..cb3b551 --- /dev/null +++ b/.codex/skills/delegate-to-claude/scripts/claude_delegate.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'claude_delegate: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +usage: + claude_delegate.sh check + claude_delegate.sh run [result-json] + claude_delegate.sh resume [result-json] +EOF + exit 2 +} + +command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH" + +if [[ "${1:-}" == "check" ]]; then + claude --version + auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status" + [[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated" + printf 'authentication: logged in\n' + exit 0 +fi + +mode="${1:-}" +case "$mode" in + run) + [[ $# -ge 2 && $# -le 3 ]] || usage + prompt_file="$2" + result_file="${3:-}" + resume_id="" + ;; + resume) + [[ $# -ge 3 && $# -le 4 ]] || usage + resume_id="$2" + prompt_file="$3" + result_file="${4:-}" + [[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID" + ;; + *) usage ;; +esac + +[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file" +[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file" + +prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")" + +effort="${CLAUDE_DELEGATE_EFFORT:-medium}" +permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}" +case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac +case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;; + *) die "invalid permission mode: $permission_mode" ;; +esac + +model="${CLAUDE_DELEGATE_MODEL:-sonnet}" +args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome) +[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD") +[[ -n "$resume_id" ]] && args+=(--resume "$resume_id") + +if [[ -n "$result_file" ]]; then + result_dir="$(dirname "$result_file")" + [[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir" + claude "${args[@]}" "$prompt" | tee "$result_file" +else + claude "${args[@]}" "$prompt" +fi From c7ee8a4851d265074ea3c9d25532bd5b244d67fa Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 21:47:19 +0100 Subject: [PATCH 2/8] perf(app): reduce redundant data fetching Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware. Add focused regression coverage, client freshness documentation, and repository agent role configuration. --- .codex/agents/architecture-reviewer.toml | 11 + .codex/agents/database-reviewer.toml | 12 + .codex/agents/implementation.toml | 12 + .codex/agents/repo-scout.toml | 11 + .codex/agents/security-reviewer.toml | 12 + .codex/agents/test-writer.toml | 11 + .codex/agents/verifier.toml | 11 + .codex/config.toml | 5 + AGENTS.md | 5 +- apps/api/.env.example | 8 + apps/api/src/config.ts | 9 + apps/api/src/db/pool.ts | 50 + apps/api/src/db/repository.ts | 24 +- .../domains/account-operations/repository.ts | 10 +- apps/api/src/domains/admin/repository.ts | 8 +- apps/api/src/domains/answers/repository.ts | 27 +- apps/api/src/domains/app-data/repository.ts | 131 ++- apps/api/src/domains/app-data/routes.ts | 5 + apps/api/src/domains/billing/repository.ts | 10 +- apps/api/src/domains/chat/repository.ts | 136 ++- apps/api/src/domains/chat/routes.ts | 14 +- apps/api/src/domains/couples/repository.ts | 10 +- apps/api/src/domains/dares/repository.ts | 10 +- apps/api/src/domains/media/repository.ts | 6 +- apps/api/src/domains/operations/repository.ts | 6 +- apps/api/src/domains/packs/repository.ts | 10 +- .../domains/profile-settings/repository.ts | 10 +- apps/api/src/index.ts | 46 +- apps/api/test/answers.integration.test.ts | 67 +- apps/api/test/answers.routes.test.ts | 12 +- apps/api/test/api.integration.test.ts | 25 + apps/api/test/app-data.integration.test.ts | 93 ++ apps/api/test/app-data.routes.test.ts | 6 + apps/api/test/chat.integration.test.ts | 102 +- apps/api/test/chat.routes.test.ts | 25 +- apps/api/test/db.pool.test.ts | 49 + apps/mobile/app/(app)/_layout.tsx | 976 +++++++++--------- apps/mobile/app/(app)/index.tsx | 14 +- apps/mobile/app/(app)/matches.tsx | 26 +- apps/mobile/app/(app)/my-answers.tsx | 11 +- apps/mobile/app/pack/[id].tsx | 6 +- .../features/swipe/useSwipeScreen.test.ts | 16 +- .../src/__tests__/hooks/useCoupleSync.test.ts | 313 ++++++ .../src/__tests__/hooks/useDares.test.ts | 78 ++ .../__tests__/hooks/useDrawingSync.test.ts | 54 + .../hooks/useMessageSubscription.test.ts | 194 +++- .../src/__tests__/hooks/usePolling.test.ts | 141 +++ .../hooks/useTypingIndicator.test.ts | 18 + .../src/__tests__/lib/appForeground.test.ts | 36 + apps/mobile/src/__tests__/lib/chatApi.test.ts | 12 + .../src/__tests__/stores/matchStore.test.ts | 250 ++++- .../src/__tests__/stores/messageStore.test.ts | 50 +- .../src/__tests__/stores/packsStore.test.ts | 65 ++ .../__tests__/stores/responsesStore.test.ts | 47 + .../src/__tests__/stores/streakStore.test.ts | 58 ++ apps/mobile/src/components/StreakDisplay.tsx | 454 ++++---- apps/mobile/src/features/chat/ChatScreen.tsx | 33 +- .../mobile/src/features/dares/DaresScreen.tsx | 4 +- .../src/features/dares/hooks/useDares.ts | 83 +- .../src/features/live-draw/LiveDrawScreen.tsx | 3 + .../live-draw/hooks/useDrawingSync.ts | 44 +- .../features/swipe/hooks/useSwipeScreen.ts | 8 +- apps/mobile/src/hooks/index.ts | 3 + apps/mobile/src/hooks/useCoupleSync.ts | 116 +++ .../src/hooks/useMessageSubscription.ts | 129 ++- apps/mobile/src/hooks/usePolling.ts | 92 ++ apps/mobile/src/hooks/useTypingIndicator.ts | 57 +- apps/mobile/src/lib/appDataApi.ts | 29 + apps/mobile/src/lib/appForeground.ts | 21 + apps/mobile/src/lib/chatApi.ts | 36 +- apps/mobile/src/store/authStore.ts | 346 ++++--- apps/mobile/src/store/matchStore.ts | 201 +++- apps/mobile/src/store/messageStore.ts | 187 ++-- apps/mobile/src/store/packsStore.ts | 74 +- apps/mobile/src/store/responsesStore.ts | 70 +- apps/mobile/src/store/streakStore.ts | 170 +-- docs/agents/routing.md | 69 +- docs/architecture/client-data-freshness.md | 195 ++++ docs/dares-loop.md | 12 +- docs/index.md | 1 + scripts/lint-harness.mjs | 55 + 81 files changed, 4456 insertions(+), 1400 deletions(-) create mode 100644 .codex/agents/architecture-reviewer.toml create mode 100644 .codex/agents/database-reviewer.toml create mode 100644 .codex/agents/implementation.toml create mode 100644 .codex/agents/repo-scout.toml create mode 100644 .codex/agents/security-reviewer.toml create mode 100644 .codex/agents/test-writer.toml create mode 100644 .codex/agents/verifier.toml create mode 100644 .codex/config.toml create mode 100644 apps/api/src/db/pool.ts create mode 100644 apps/api/test/db.pool.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/usePolling.test.ts create mode 100644 apps/mobile/src/__tests__/lib/appForeground.test.ts create mode 100644 apps/mobile/src/__tests__/stores/streakStore.test.ts create mode 100644 apps/mobile/src/hooks/useCoupleSync.ts create mode 100644 apps/mobile/src/hooks/usePolling.ts create mode 100644 apps/mobile/src/lib/appForeground.ts create mode 100644 docs/architecture/client-data-freshness.md diff --git a/.codex/agents/architecture-reviewer.toml b/.codex/agents/architecture-reviewer.toml new file mode 100644 index 0000000..d1f3eb1 --- /dev/null +++ b/.codex/agents/architecture-reviewer.toml @@ -0,0 +1,11 @@ +name = "architecture_reviewer" +description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Evaluate the delegated decision against current system boundaries, data flow, +migration path, operability, reversibility, and verification. Prefer the smallest +coherent change. Return explicit trade-offs, risks, and a recommendation with +file-backed evidence. Do not edit files or review unrelated local refactors. +""" diff --git a/.codex/agents/database-reviewer.toml b/.codex/agents/database-reviewer.toml new file mode 100644 index 0000000..2bf90b4 --- /dev/null +++ b/.codex/agents/database-reviewer.toml @@ -0,0 +1,12 @@ +name = "database_reviewer" +description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Read the applicable AGENTS.md, database migration skill, and migration safety +documentation. Review only the delegated diff for migration tracking, +idempotency, authorization and tenant isolation, portability, rollback impact, +types, and integration coverage. Never mutate a database or edit files. Return +prioritized line-specific findings with concrete evidence and counterevidence. +""" diff --git a/.codex/agents/implementation.toml b/.codex/agents/implementation.toml new file mode 100644 index 0000000..ce5b776 --- /dev/null +++ b/.codex/agents/implementation.toml @@ -0,0 +1,12 @@ +name = "implementation" +description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known." +model = "gpt-5.6-terra" +developer_instructions = """ +Read the root and nearest scoped AGENTS.md files plus the applicable repository +skill. Implement only the delegated objective and preserve unrelated work. Update +maintained contract documentation and add focused tests when the contract changes. +Run the required focused checks and return changed files, exact results, +assumptions, and blockers. 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. +""" diff --git a/.codex/agents/repo-scout.toml b/.codex/agents/repo-scout.toml new file mode 100644 index 0000000..6c7c945 --- /dev/null +++ b/.codex/agents/repo-scout.toml @@ -0,0 +1,11 @@ +name = "repo_scout" +description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns." +model = "gpt-5.6-luna" +model_reasoning_effort = "low" +sandbox_mode = "read-only" +developer_instructions = """ +Read the root and nearest scoped AGENTS.md files. Map only the requested surface. +Return evidence-backed file paths, symbols, current behavior, dependencies, tests, +and uncertainties. Do not edit files, make design decisions, or propose broad +refactors. Do not claim runtime behavior you did not observe. +""" diff --git a/.codex/agents/security-reviewer.toml b/.codex/agents/security-reviewer.toml new file mode 100644 index 0000000..953f377 --- /dev/null +++ b/.codex/agents/security-reviewer.toml @@ -0,0 +1,12 @@ +name = "security_reviewer" +description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Perform a scoped threat-boundary review of the delegated change. Trace callers +through authentication and authorization to the data sink. Prioritize exploitable +correctness and security risks, include counterevidence, and cite exact files and +lines. Do not edit files or expand into a repository-wide audit unless explicitly +requested. +""" diff --git a/.codex/agents/test-writer.toml b/.codex/agents/test-writer.toml new file mode 100644 index 0000000..ba96cc8 --- /dev/null +++ b/.codex/agents/test-writer.toml @@ -0,0 +1,11 @@ +name = "test_writer" +description = "Focused Sauci regression and contract-test author for already-defined behavior." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" +developer_instructions = """ +Read the applicable AGENTS.md and testing documentation. Test the intended +observable contract at the lowest useful layer while preserving real auth, +authorization, tenant, and couple boundaries. Do not weaken assertions to +accommodate a failure. Run the focused test and return files changed, exact +commands, results, and any product defect exposed by the test. +""" diff --git a/.codex/agents/verifier.toml b/.codex/agents/verifier.toml new file mode 100644 index 0000000..f2752c4 --- /dev/null +++ b/.codex/agents/verifier.toml @@ -0,0 +1,11 @@ +name = "verifier" +description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" +developer_instructions = """ +Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently +exercise the delegated acceptance criteria against the running non-production +product. You may write generated proof only under evidence/. Do not edit source, +tests, configuration, or acceptance criteria. Return expected behavior, observed +behavior, evidence paths, environment limitations, and a works or broken verdict. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..7c89319 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,5 @@ +[agents] +enabled = true +max_concurrent_threads_per_session = 3 +default_subagent_model = "gpt-5.6-terra" +default_subagent_reasoning_effort = "medium" diff --git a/AGENTS.md b/AGENTS.md index a1f0b73..baab081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,10 @@ MCP service in one npm/Turborepo workspace. 5. Read `apps/mobile/DESIGN.md` before mobile UI work. 6. Keep shared package exports type-only and route them through `src/index.ts`. 7. Do not commit secrets, generated evidence, local logs, or runtime state. -8. Scope work to the task. Use high-effort reviewers only for the risk triggers in +8. Scope work to the task. Route bounded implementation through the project + `implementation` agent when delegation is useful; the primary agent retains + acceptance, review, and final verification. Do not delegate trivial edits. +9. Use high-effort reviewers only for the risk triggers in `docs/agents/routing.md`. ## Repository map diff --git a/apps/api/.env.example b/apps/api/.env.example index 08b0374..a923cd8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,6 +3,14 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci SUPABASE_AUTH_URL=https://your-project.supabase.co SUPABASE_AUTH_AUDIENCE=authenticated +# Optional connection-pool bounds. The API process opens one pool and shares it +# across every repository, so these are the process-wide limits, not per-domain +# ones. Raising DATABASE_POOL_MAX raises the server connections this instance can +# hold; keep the total across instances inside the database's own limit. +# DATABASE_POOL_MAX=10 +# DATABASE_POOL_IDLE_TIMEOUT_MS=30000 +# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000 + # Server-only hosted Auth Admin credential. Used for account deletion and for # enriching admin user lists; it is never exposed to the browser or used as a # Supabase database client. diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 72ed9b0..b35a5f2 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -7,6 +7,9 @@ const baseSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(3003), DATABASE_URL: z.string().url(), + DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10), + DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000), + DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000), SUPABASE_AUTH_URL: z.string().url().optional(), SUPABASE_AUTH_ISSUER: z.string().url().optional(), SUPABASE_AUTH_JWKS_URL: z.string().url().optional(), @@ -28,6 +31,7 @@ export interface AppConfig { nodeEnv: 'development' | 'test' | 'production'; port: number; databaseUrl: string; + databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }; authIssuer: string; authJwksUrl?: string; authAudience: string; @@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { nodeEnv: parsed.NODE_ENV, port: parsed.PORT, databaseUrl: parsed.DATABASE_URL, + databasePool: { + max: parsed.DATABASE_POOL_MAX, + idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS, + connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS, + }, authIssuer, authJwksUrl, authAudience: parsed.SUPABASE_AUTH_AUDIENCE, diff --git a/apps/api/src/db/pool.ts b/apps/api/src/db/pool.ts new file mode 100644 index 0000000..0db68bd --- /dev/null +++ b/apps/api/src/db/pool.ts @@ -0,0 +1,50 @@ +import { Pool } from 'pg'; + +/** + * A repository is constructed either from a connection string, in which case it + * owns the pool it creates and must end it, or from a pool the caller owns. The + * API process takes the second path so every domain shares one bounded pool + * instead of opening a default-sized pool per repository; tests keep taking the + * first so each suite stays independently closable. + */ +export type DatabaseConnection = string | Pool; + +export interface DatabasePoolOptions { + /** Server-wide ceiling on concurrent PostgreSQL connections. */ + max?: number; + idleTimeoutMillis?: number; + connectionTimeoutMillis?: number; +} + +/** + * `pg` defaults to ten connections per pool, which multiplied by one pool per + * repository. One shared pool of the same size keeps the same headroom for a + * single API process while bounding what a self-hosted PostgreSQL must accept. + */ +export const DEFAULT_POOL_MAX = 10; + +export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}): Pool { + return new Pool({ + connectionString: databaseUrl, + max: options.max ?? DEFAULT_POOL_MAX, + ...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }), + ...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }), + }); +} + +export interface ResolvedPool { + pool: Pool; + /** True when this repository created the pool and is responsible for ending it. */ + owned: boolean; +} + +export function resolvePool(connection: DatabaseConnection): ResolvedPool { + return typeof connection === 'string' + ? { pool: new Pool({ connectionString: connection }), owned: true } + : { pool: connection, owned: false }; +} + +/** Ends the pool only when this repository created it, so shared pools survive. */ +export async function closeResolvedPool(pool: Pool, owned: boolean): Promise { + if (owned) await pool.end(); +} diff --git a/apps/api/src/db/repository.ts b/apps/api/src/db/repository.ts index e4a8514..477bf2e 100644 --- a/apps/api/src/db/repository.ts +++ b/apps/api/src/db/repository.ts @@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared'; import { and, eq, sql } from 'drizzle-orm'; import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js'; import type { AuthIdentity } from '../auth.js'; import { featureInterests, profiles, type ProfileRow } from './schema.js'; @@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile { export class PostgresRepository implements ApiRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; private readonly db: NodePgDatabase; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; this.db = drizzle(this.pool); } @@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository { await this.db.select({ id: profiles.id }).from(profiles).limit(1); } + /** + * Idempotent for the common case. `GET /v1/me` runs on every app start and on + * every session refresh, and the previous unconditional upsert made each of + * those a write that advanced `auth_synced_at` and produced dead row versions + * for autovacuum. The only fields this sync can actually change are the ones + * the conflict clause sets, so a profile that already carries the identity's + * email is returned as read and nothing is written. First-use creation and + * email enrichment keep their existing behaviour. + */ async upsertProfile(identity: AuthIdentity): Promise { + const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1); + if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) { + return toProfile(existing); + } + const [row] = await this.db.insert(profiles).values({ id: identity.id, name: identity.name, @@ -93,6 +111,6 @@ export class PostgresRepository implements ApiRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/account-operations/repository.ts b/apps/api/src/domains/account-operations/repository.ts index aac28d2..f31044e 100644 --- a/apps/api/src/domains/account-operations/repository.ts +++ b/apps/api/src/domains/account-operations/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { AccountOperationError } from './types.js'; interface ProfileOperationRecord extends QueryResultRow { @@ -63,9 +64,12 @@ async function lockCoupleAndPartner( export class PostgresAccountOperationsRepository implements AccountOperationsRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async deleteRelationship(userId: string): Promise { @@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/admin/repository.ts b/apps/api/src/domains/admin/repository.ts index 99af77f..563e37a 100644 --- a/apps/api/src/domains/admin/repository.ts +++ b/apps/api/src/domains/admin/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { readFile } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js'; @@ -139,9 +140,10 @@ export interface AdminRepository { export class PostgresAdminRepository implements AdminRepository { private readonly pool: Pool; - constructor(databaseUrl: string, private readonly options: { + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection, private readonly options: { adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory; - } = {}) { this.pool = new Pool({ connectionString: databaseUrl }); } + } = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } async principal(userId: string): Promise { const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>( @@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository { await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]); } - async close(): Promise { await this.pool.end(); } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/answers/repository.ts b/apps/api/src/domains/answers/repository.ts index 443970c..ce39082 100644 --- a/apps/api/src/domains/answers/repository.ts +++ b/apps/api/src/domains/answers/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js'; interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean } @@ -44,7 +45,7 @@ export interface AnswersRepository { submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>; update(userId: string, input: UpdateInput): Promise>; responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>; - matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>; + matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>; markSeen(userId: string, ids: string[]): Promise; archive(userId: string, matchId: string, archived: boolean): Promise; streak(userId: string): Promise; @@ -53,8 +54,9 @@ export interface AnswersRepository { export class PostgresAnswersRepository implements AnswersRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; private zones: Promise> | null = null; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } private async context(client: Pool | PoolClient, userId: string): Promise { const result = await client.query('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]); @@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository { return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0}; } + /** + * Reading one row past the page decides `hasMore` exactly, and the total — which + * the client only records when it refreshes — is counted on the first page only + * instead of on every scroll. Unread is aggregated once per couple rather than as + * a correlated count per row: the sort key depends on it, so the old subquery + * already ran for every candidate match, not just the twenty that were returned. + */ async matches(userId:string,page:number,limit:number,archived:boolean){ const ctx=await this.context(this.pool,userId); const offset=page*limit; - const params=[userId,ctx.couple_id,archived,limit,offset]; - const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`; - const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]); - return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0}; + const params=[userId,ctx.couple_id,archived,limit+1,offset]; + const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`; + const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`; + const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`; + const [rows,count]=await Promise.all([ + this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params), + page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]); + return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit}; } async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);} async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);} @@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository { } private async tx(fn:(client:PoolClient)=>Promise):Promise{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}} private dates>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;} - async close(){await this.pool.end();} + async close(){await closeResolvedPool(this.pool,this.ownsPool);} } diff --git a/apps/api/src/domains/app-data/repository.ts b/apps/api/src/domains/app-data/repository.ts index e4d6a94..746f1f9 100644 --- a/apps/api/src/domains/app-data/repository.ts +++ b/apps/api/src/domains/app-data/repository.ts @@ -1,4 +1,5 @@ import { Pool, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export interface PackContext { id: string; name: string; icon: string | null } export interface AppQuestion { @@ -19,6 +20,50 @@ export interface StrokeSegment { } export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null } +/** + * A change summary rather than a payload. The mobile app used to re-fetch profile, + * couple, matches, both pending directions and enabled packs every five seconds to + * notice partner activity; it now compares this summary with the last one it saw and + * refreshes only the domains whose marker actually moved. Every field is a marker, + * not display data, so the response stays a few hundred bytes whatever the couple's + * history looks like. + */ +export interface SyncSummary { + server_time: string; + couple_id: string | null; + profile_updated_at: string | null; + partner_id: string | null; + partner_updated_at: string | null; + /** Matches visible to this member: archiving one of them moves the count. */ + match_count: number; + new_match_count: number; + latest_match_at: string | null; + /** Partner answered, this member has not. */ + pending_yours: number; + /** This member answered, partner has not. */ + pending_theirs: number; + unread_total: number; + /** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */ + enabled_packs_fingerprint: string; + /** + * Digest of each visible match's type and response summary. `match_count` / + * `new_match_count` / `latest_match_at` only move when a match is created, + * archived or unarchived — not when a partner edits an existing response and + * changes its `match_type` or `response_summary` — so this fingerprint is + * what actually notices that edit. + */ + match_state_fingerprint: string; + /** + * Digest of each visible match's unread count. `unread_total` can stay equal + * while unread messages redistribute between matches (one read, another + * receives a message), which reorders the unread-first list without moving + * the total; this fingerprint is what notices that. + */ + match_unread_fingerprint: string; + /** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */ + streak_updated_at: string | null; +} + export class AppDataError extends Error { constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record) { super(message); } } @@ -31,6 +76,7 @@ export interface AppDataRepository { markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>; nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>; sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>; + syncSummary(userId: string): Promise; getLiveDraw(userId: string): Promise; putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise; close(): Promise; @@ -38,11 +84,22 @@ export interface AppDataRepository { interface QuestionRow extends QueryResultRow, Omit { created_at: Date } +interface SyncRow extends QueryResultRow { + server_time: Date; couple_id: string | null; profile_updated_at: Date | null; + partner_id: string | null; partner_updated_at: Date | null; + match_count: number | null; new_match_count: number | null; latest_match_at: Date | null; + pending_yours: number | null; pending_theirs: number | null; unread_total: number | null; + enabled_packs_fingerprint: string | null; + match_state_fingerprint: string | null; match_unread_fingerprint: string | null; + streak_updated_at: Date | null; +} + function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; } export class PostgresAppDataRepository implements AppDataRepository { private readonly pool: Pool; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } private async visiblePack(userId: string, packId: string): Promise { const result = await this.pool.query(` @@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository { } return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by }; } - async close(): Promise { await this.pool.end(); } + /** + * One round trip, all scalar subqueries, nothing that grows with the couple's + * history. An unpaired member's couple-scoped markers all collapse to zero or null + * because `me.couple_id` is null, so the endpoint stays valid before pairing. + */ + async syncSummary(userId: string): Promise { + const result = await this.pool.query(` + with me as (select id, couple_id, updated_at from profiles where id = $1), + visible as ( + select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me + where m.couple_id = me.couple_id + and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id) + ), + visible_unread as ( + select v.id as match_id, count(msg.id)::int as c + from visible v + left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me) + and msg.read_at is null and msg.deleted_at is null + and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me)) + group by v.id + ) + select + now() as server_time, + me.couple_id, + me.updated_at as profile_updated_at, + (select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id, + (select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at, + (select count(*)::int from visible) as match_count, + (select count(*)::int from visible where is_new) as new_match_count, + (select max(created_at) from visible) as latest_match_at, + (select count(*)::int from responses r join questions q on q.id = r.question_id + where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id + and not exists (select 1 from responses o + where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours, + (select count(*)::int from responses r join questions q on q.id = r.question_id + where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id + and not exists (select 1 from responses o + where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs, + (select count(*)::int from messages msg join matches ma on ma.id = msg.match_id + where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null + and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total, + (select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), '')) + from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint, + (select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), '')) + from visible v) as match_state_fingerprint, + (select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), '')) + from visible_unread vu) as match_unread_fingerprint, + (select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at + from me`, [userId]); + const row = result.rows[0]; + if (!row) throw new AppDataError('not_found', 'Profile not found', 404); + return { + server_time: row.server_time.toISOString(), + couple_id: row.couple_id, + profile_updated_at: row.profile_updated_at?.toISOString() ?? null, + partner_id: row.partner_id, + partner_updated_at: row.partner_updated_at?.toISOString() ?? null, + match_count: row.match_count ?? 0, + new_match_count: row.new_match_count ?? 0, + latest_match_at: row.latest_match_at?.toISOString() ?? null, + pending_yours: row.pending_yours ?? 0, + pending_theirs: row.pending_theirs ?? 0, + unread_total: row.unread_total ?? 0, + enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '', + match_state_fingerprint: row.match_state_fingerprint ?? '', + match_unread_fingerprint: row.match_unread_fingerprint ?? '', + streak_updated_at: row.streak_updated_at?.toISOString() ?? null, + }; + } + + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/app-data/routes.ts b/apps/api/src/domains/app-data/routes.ts index af2755f..4eaf7eb 100644 --- a/apps/api/src/domains/app-data/routes.ts +++ b/apps/api/src/domains/app-data/routes.ts @@ -44,6 +44,11 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository): const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at)); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); }); + // The client polls this instead of re-fetching every domain; see SyncSummary. + app.get('/v1/me/sync', async c => { + const result = await run(() => repository.syncSummary(c.get('identity').id)); + return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); + }); app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id))); app.post('/v1/me/nudge', async c => { const result = await run(() => repository.sendNudge(c.get('identity').id)); diff --git a/apps/api/src/domains/billing/repository.ts b/apps/api/src/domains/billing/repository.ts index 2286dd9..aabe999 100644 --- a/apps/api/src/domains/billing/repository.ts +++ b/apps/api/src/domains/billing/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js'; interface ProfileRecord extends QueryResultRow { @@ -43,9 +44,12 @@ async function transaction(pool: Pool, operation: (client: PoolClient) => Pro export class PostgresBillingRepository implements BillingRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async processRevenueCatEvent( @@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/chat/repository.ts b/apps/api/src/domains/chat/repository.ts index c6916b0..5f6aad5 100644 --- a/apps/api/src/domains/chat/repository.ts +++ b/apps/api/src/domains/chat/repository.ts @@ -1,4 +1,5 @@ import { Pool, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export type ChatMediaType = 'image' | 'video' | null; export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other'; @@ -45,8 +46,37 @@ export class ChatError extends Error { } } +/** + * The reply to a message poll. A request without a cursor is a full snapshot; a + * request with one carries only what changed, so a quiet chat costs an empty + * array rather than the whole backlog. + */ +export interface MessagesPage { + /** Newest first. Messages created or changed since the cursor, or the snapshot. */ + messages: ChatMessage[]; + /** Messages the caller deleted for themselves since the cursor. */ + removed_ids: string[]; + /** Cursor to send as `since` on the next poll. */ + server_time: string; + /** True when `messages` is a complete snapshot the client may replace its cache with. */ + complete: boolean; + /** Present only when the caller asked for it, folding the typing poll into this one. */ + typing?: PartnerTyping; +} + +export interface PartnerTyping { + typing: boolean; + expires_at: string | null; +} + +export interface ListMessagesOptions { + /** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */ + since?: string; + includeTyping?: boolean; +} + export interface ChatRepository { - listMessages(userId: string, matchId: string): Promise; + listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise; sendText(userId: string, matchId: string, content: string): Promise; unreadCounts(userId: string): Promise<{ total: number; by_match: Record }>; markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>; @@ -55,13 +85,21 @@ export interface ChatRepository { deleteForEveryone(userId: string, messageId: string): Promise; report(userId: string, messageId: string, reason: ReportReason): Promise; setTyping(userId: string, matchId: string, ttlMs: number): Promise; - getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>; + getPartnerTyping(userId: string, matchId: string): Promise; close(): Promise; } +// `media_expired` is derived rather than read. Nothing ever writes the stored +// column — it is inserted `false` and never updated — so a video that reached +// `media_expires_at` still arrived flagged as playable, while the media itself +// was already refused (see `accessible` in the media repository, which treats +// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the +// same rule the media side already applies, and it is what makes the client's +// "Video expired" state reachable at all. const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at, m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at, - m.media_expired, m.media_viewed_at, m.version, m.encrypted_content, + (m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired, + m.media_viewed_at, m.version, m.encrypted_content, m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`; function iso(value: Date | null): string | null { @@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage { export class PostgresChatRepository implements ChatRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } private async assertMatchAccess(userId: string, matchId: string): Promise { @@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository { return row; } - async listMessages(userId: string, matchId: string): Promise { + /** + * Without `since` this is the previous behaviour: the newest 500 visible messages. + * With `since` it returns only rows whose visible state moved after that instant — + * new messages, existing ones whose delivery, read, deletion or media-view + * timestamp advanced, and videos whose media expired inside the window — plus the + * ids the caller has since deleted for themselves. + * + * Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not + * covered: the classifier and the admin tool rewrite those columns without + * advancing any timestamp, so a delta cannot see them move. No client renders + * them today, so nothing user-visible goes stale; giving them a cursor would + * mean adding a column to `messages`. + * Rows are returned whole rather than as patches so the client merges by id and + * needs no second shape to interpret. + * + * The returned cursor is deliberately a couple of seconds behind the server clock: + * a transaction that started before this read but committed after it would + * otherwise fall into the gap between two polls. The overlap re-delivers a handful + * of rows the client already has, which its merge-by-id absorbs. + */ + async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise { await this.assertMatchAccess(userId, matchId); - const result = await this.pool.query( - `select ${columns} - from messages m - where m.match_id = $1 - and not exists ( - select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2 - ) - order by m.created_at desc, m.id desc - limit 500`, - [matchId, userId], - ); - return result.rows.map(toMessage); + const since = options.since ?? null; + const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`; + // Expiry is the one visible change no row write announces: the clock crosses + // `media_expires_at` and nothing is updated, so no timestamp moves past the + // cursor. Comparing the deadline against the window instead emits the row in + // the single poll whose `(since, now()]` contains it, which is exactly when + // the derived `media_expired` above flips. + const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3 + or m.deleted_at > $3 or m.media_viewed_at > $3 + or (m.media_expires_at > $3 and m.media_expires_at <= now()))`; + + const [clock, messages, removed, typing] = await Promise.all([ + this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`), + this.pool.query( + `select ${columns} + from messages m + where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`} + order by m.created_at desc, m.id desc + limit 500`, + since === null ? [matchId, userId] : [matchId, userId, since], + ), + since === null + ? null + : this.pool.query<{ message_id: string }>( + `select d.message_id + from message_deletions d + join messages m on m.id = d.message_id + where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`, + [matchId, userId, since], + ), + options.includeTyping ? this.partnerTyping(userId, matchId) : null, + ]); + + return { + messages: messages.rows.map(toMessage), + removed_ids: removed?.rows.map((row) => row.message_id) ?? [], + server_time: clock.rows[0]!.cursor.toISOString(), + complete: since === null, + ...(typing ? { typing } : {}), + }; } async sendText(userId: string, matchId: string, content: string): Promise { @@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository { ); } - async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> { - await this.assertMatchAccess(userId, matchId); + /** Access already asserted by the caller, so the message poll can fold this in for free. */ + private async partnerTyping(userId: string, matchId: string): Promise { const result = await this.pool.query<{ expires_at: Date }>( `select expires_at from chat_typing_states where match_id = $1 and user_id <> $2 and expires_at > now() @@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository { return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) }; } + async getPartnerTyping(userId: string, matchId: string): Promise { + await this.assertMatchAccess(userId, matchId); + return this.partnerTyping(userId, matchId); + } + async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/chat/routes.ts b/apps/api/src/domains/chat/routes.ts index a09104a..b2f0e19 100644 --- a/apps/api/src/domains/chat/routes.ts +++ b/apps/api/src/domains/chat/routes.ts @@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>; const id = z.string().uuid(); const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict(); const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict(); +const since = z.string().datetime({ offset: true }); function error(code: string, message: string): ApiErrorResponse { return { error: { code, message } }; @@ -25,11 +26,20 @@ async function result(operation: () => Promise) { export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void { app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id))); + // `since` turns this into a delta poll and `typing=true` folds the typing check + // into the same request. Both are optional: a call with neither still answers with + // the full snapshot under the `messages` key it always used. app.get('/v1/matches/:matchId/messages', async (c) => { const parsedId = id.safeParse(c.req.param('matchId')); if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400); - const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data)); - return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status); + const rawSince = c.req.query('since'); + const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince); + if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400); + const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, { + ...(parsedSince?.success ? { since: parsedSince.data } : {}), + includeTyping: c.req.query('typing') === 'true', + })); + return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status); }); app.post('/v1/matches/:matchId/messages', async (c) => { diff --git a/apps/api/src/domains/couples/repository.ts b/apps/api/src/domains/couples/repository.ts index ca4852f..97b850c 100644 --- a/apps/api/src/domains/couples/repository.ts +++ b/apps/api/src/domains/couples/repository.ts @@ -1,5 +1,6 @@ import type { Couple, Profile } from '@sauci/shared'; import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { CoupleError, type CoupleStateResponse } from './types.js'; interface ProfileRecord extends QueryResultRow { @@ -93,9 +94,12 @@ async function transaction(pool: Pool, work: (client: PoolClient) => Promise< export class PostgresCoupleRepository implements CoupleRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async getState(userId: string): Promise { @@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/dares/repository.ts b/apps/api/src/domains/dares/repository.ts index c3b04ab..5902a2d 100644 --- a/apps/api/src/domains/dares/repository.ts +++ b/apps/api/src/domains/dares/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export type DareStatus = | 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled'; @@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare { export class PostgresDaresRepository implements DaresRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } /** @@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/media/repository.ts b/apps/api/src/domains/media/repository.ts index 91c6222..52af5cc 100644 --- a/apps/api/src/domains/media/repository.ts +++ b/apps/api/src/domains/media/repository.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import type { MediaKind, MediaObject, MediaUploadContext } from './types.js'; interface MediaRow extends QueryResultRow { @@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject { export class PostgresMediaRepository implements MediaRepository { private readonly pool: Pool; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) { const client = await this.pool.connect(); @@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository { async acknowledgeDeletion(storageKey: string): Promise { await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]); } - async close() { await this.pool.end(); } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index 61baa58..8792c5b 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js'; interface OutboxRow extends QueryResultRow { @@ -31,7 +32,8 @@ async function transaction(pool: Pool, work: (client: PoolClient) => Promise< export class PostgresOperationsRepository implements OperationsRepository { private readonly pool: Pool; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } async produce(now: Date, limit=100): Promise { return transaction(this.pool, async (client) => { @@ -312,7 +314,7 @@ export class PostgresOperationsRepository implements OperationsRepository { async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) { await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]); } - async close() { await this.pool.end(); } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } function streakCopy(days:number) { diff --git a/apps/api/src/domains/packs/repository.ts b/apps/api/src/domains/packs/repository.ts index 910a7af..bfd9c17 100644 --- a/apps/api/src/domains/packs/repository.ts +++ b/apps/api/src/domains/packs/repository.ts @@ -1,4 +1,5 @@ import { Pool } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export interface PackCategory { id: string; @@ -69,9 +70,12 @@ interface ProfilePreferencesRow { export class PostgresPacksRepository implements PacksRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } private async profile(userId: string): Promise { @@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/profile-settings/repository.ts b/apps/api/src/domains/profile-settings/repository.ts index 81dd59e..54d78d3 100644 --- a/apps/api/src/domains/profile-settings/repository.ts +++ b/apps/api/src/domains/profile-settings/repository.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { Pool, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import type { FeedbackSubmission, NotificationPreferenceKey, @@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void { export class PostgresProfileSettingsRepository implements ProfileSettingsRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async updateProfile(userId: string, update: ProfileUpdate): Promise { @@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d85eb2f..0121673 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,6 +2,7 @@ import { serve } from '@hono/node-server'; import { createApp } from './app.js'; import { createAuthVerifier } from './auth.js'; import { loadConfig } from './config.js'; +import { createDatabasePool } from './db/pool.js'; import { PostgresRepository } from './db/repository.js'; import { PostgresAppDataRepository } from './domains/app-data/repository.js'; import { AdminRequestAuth } from './domains/admin/auth.js'; @@ -26,30 +27,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media const config = loadConfig(); const authVerifier = createAuthVerifier(config); -const repository = new PostgresRepository(config.databaseUrl); -const coupleRepository = new PostgresCoupleRepository(config.databaseUrl); -const packsRepository = new PostgresPacksRepository(config.databaseUrl); -const answersRepository = new PostgresAnswersRepository(config.databaseUrl); -const chatRepository = new PostgresChatRepository(config.databaseUrl); -const daresRepository = new PostgresDaresRepository(config.databaseUrl); -const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl); -const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl); +// One deliberately bounded pool for the whole process. Repositories used to open +// a default-sized pool each, so a single API instance could hold twelve times the +// connections a self-hosted PostgreSQL was sized for. +const pool = createDatabasePool(config.databaseUrl, config.databasePool); +const repository = new PostgresRepository(pool); +const coupleRepository = new PostgresCoupleRepository(pool); +const packsRepository = new PostgresPacksRepository(pool); +const answersRepository = new PostgresAnswersRepository(pool); +const chatRepository = new PostgresChatRepository(pool); +const daresRepository = new PostgresDaresRepository(pool); +const profileSettingsRepository = new PostgresProfileSettingsRepository(pool); +const accountOperationsRepository = new PostgresAccountOperationsRepository(pool); const accountOperationsService = new AccountOperationsService( accountOperationsRepository, new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey), new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId), new ExpoPartnerNotifier(), ); -const mediaRepository = new PostgresMediaRepository(config.databaseUrl); +const mediaRepository = new PostgresMediaRepository(pool); const mediaStorage = new FilesystemMediaStorage( config.mediaRoot, config.mediaSigningSecret, config.mediaPublicBaseUrl, ); -const appDataRepository = new PostgresAppDataRepository(config.databaseUrl); -const billingRepository = new PostgresBillingRepository(config.databaseUrl); +const appDataRepository = new PostgresAppDataRepository(pool); +const billingRepository = new PostgresBillingRepository(pool); const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret); -const adminRepository = new PostgresAdminRepository(config.databaseUrl, { +const adminRepository = new PostgresAdminRepository(pool, { adminPrivateKeyJwk: config.adminPrivateKeyJwk, mediaRoot: config.mediaRoot, authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey), @@ -94,20 +99,9 @@ async function shutdown(signal: string): Promise { server.close(async () => { try { - await Promise.all([ - repository.close(), - coupleRepository.close(), - packsRepository.close(), - answersRepository.close(), - chatRepository.close(), - daresRepository.close(), - profileSettingsRepository.close(), - accountOperationsRepository.close(), - mediaRepository.close(), - appDataRepository.close(), - billingRepository.close(), - adminRepository.close(), - ]); + // Repositories share the process pool and no longer own a connection each, + // so ending the pool once is the whole of the database shutdown. + await pool.end(); process.exit(0); } catch (error) { console.error('Failed to close database connections', error); diff --git a/apps/api/test/answers.integration.test.ts b/apps/api/test/answers.integration.test.ts index 9530f29..f32b728 100644 --- a/apps/api/test/answers.integration.test.ts +++ b/apps/api/test/answers.integration.test.ts @@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/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('Answer integration tests only permit localhost'); describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository; const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; -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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); +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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);}); it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});}); it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);}); @@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0}); expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});}); +describe('match pagination',()=>{ + const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`); + const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`); + const otherMatch='e0000000-0000-4000-8000-000000000000'; + beforeAll(async()=>{ + await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches'); + // One question per match: a couple may only match a given question once. + for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]); + // Distinct creation times so the ordering assertion is about the query, not tie-breaking. + for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]); + // A match belonging to another couple must never appear, whatever the page. + await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]); + // Two unread from the partner on the oldest match; one message of our own, which is never unread. + await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]); + }); + + it('counts once on the first page, never again, and still reports hasMore',async()=>{ + const first=await repo.matches(u1,0,2,false); + expect(first.totalCount).toBe(5); + expect(first.hasMore).toBe(true); + expect(first.matches).toHaveLength(2); + + // Later pages skip the count entirely: the client already has it and paging + // is decided by hasMore, which comes from reading limit + 1 rows. + const second=await repo.matches(u1,1,2,false); + expect(second.totalCount).toBeNull(); + expect(second.hasMore).toBe(true); + const last=await repo.matches(u1,2,2,false); + expect(last.totalCount).toBeNull(); + expect(last.hasMore).toBe(false); + expect(last.matches).toHaveLength(1); + + // An exactly-full final page must not claim another page exists. + expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5}); + }); + + it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{ + const page=await repo.matches(u1,0,5,false); + // The unread match sorts ahead of newer ones; the rest stay newest-first. + expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]); + expect(page.matches[0]).toMatchObject({unreadCount:2}); + // Our own message is not unread to us, and everything else is zero. + expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true); + // Unread is per viewer: the partner's own two messages are not unread to them, + // but ours is, so the same thread reports a different count on each side. + expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1); + // The other couple's match is invisible here, and their member sees only theirs. + expect(page.matches.some(item=>item.id===otherMatch)).toBe(false); + expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]); + }); + + it('pages the archived view separately and per user',async()=>{ + await repo.archive(u1,paged[0],true); + const active=await repo.matches(u1,0,5,false); + expect(active.matches.some(item=>item.id===paged[0])).toBe(false); + expect(active.totalCount).toBe(4); + const archived=await repo.matches(u1,0,5,true); + expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]); + expect(archived).toMatchObject({totalCount:1,hasMore:false}); + // Archiving is per user, so the partner still sees it in their active list. + expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true); + await repo.archive(u1,paged[0],false); + }); +}); + it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']); await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]); await repo.submit(u1,{questionId:q,answer:'yes'}); diff --git a/apps/api/test/answers.routes.test.ts b/apps/api/test/answers.routes.test.ts index 7a98c43..1c8e5e6 100644 --- a/apps/api/test/answers.routes.test.ts +++ b/apps/api/test/answers.routes.test.ts @@ -10,7 +10,7 @@ const questionId='22222222-2222-4222-8222-222222222222'; function repo():AnswersRepository{return { recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})), dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})), - submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};} + submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};} function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;} describe('answer routes',()=>{ @@ -18,6 +18,16 @@ describe('answer routes',()=>{ it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429); // The client renders the limit screen straight from this body, so its shape is a contract. expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});}); + it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r); + // Later pages skip the count entirely, so `hasMore` — not a client-side + // comparison against totalCount — is what decides whether to keep paging. + vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true}); + const later=await a.request('/v1/matches?page=2&limit=20'); + expect(later.status).toBe(200); + expect(await later.json()).toMatchObject({totalCount:null,hasMore:true}); + expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false); + await a.request('/v1/matches?page=0&limit=20&archived=true'); + expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);}); it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);}); }); diff --git a/apps/api/test/api.integration.test.ts b/apps/api/test/api.integration.test.ts index 78e3e7c..1ed83fb 100644 --- a/apps/api/test/api.integration.test.ts +++ b/apps/api/test/api.integration.test.ts @@ -75,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', }, }); + // Reading the profile again must not rewrite it. Every authenticated request the + // app makes starts here, so an unconditional upsert turned a read into a write + // on the busiest path in the API. + const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>( + 'select auth_synced_at, updated_at from profiles where id = $1', + ['33333333-3333-4333-8333-333333333333'], + )).rows[0]; + const created = await stamps(); + expect(created.auth_synced_at).not.toBeNull(); + + for (let attempt = 0; attempt < 3; attempt += 1) { + expect((await app.request('/v1/me', { headers })).status).toBe(200); + } + const unchanged = await stamps(); + expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime()); + expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime()); + + // A genuine identity change still writes through, so first use and a changed + // email are both recorded rather than skipped. + await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']); + expect((await app.request('/v1/me', { headers })).status).toBe(200); + const resynced = await stamps(); + expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime()); + expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } }); + expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200); expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json()) .toEqual({ feature: 'better-chat', interested: true }); diff --git a/apps/api/test/app-data.integration.test.ts b/apps/api/test/app-data.integration.test.ts index 384e982..ece1dd3 100644 --- a/apps/api/test/app-data.integration.test.ts +++ b/apps/api/test/app-data.integration.test.ts @@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () = await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]); }); afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); + it('summarises change markers per couple without leaking another couple state',async()=>{ + // This one small read replaced a five-second refresh of profile, couple, matches, + // pending questions, packs and unread counts, so its markers have to move for + // exactly the changes those refreshes existed to notice. + const before=await repo.syncSummary(alice); + expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0}); + expect(before.profile_updated_at).not.toBeNull(); + expect(Date.parse(before.server_time)).not.toBeNaN(); + + // The partner's own message is unread to them and not to us. + expect((await repo.syncSummary(bob)).unread_total).toBe(1); + + // A partner profile edit moves the partner marker, which is what tells the + // client to re-read the couple rather than everything. + await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]); + const afterPartner=await repo.syncSummary(alice); + expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!)); + expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at); + + // couple_packs carries no timestamp, so equal-and-opposite toggles have to be + // caught by a digest rather than a max(updated_at). + const emptyDigest=before.enabled_packs_fingerprint; + expect(emptyDigest).toEqual(expect.any(String)); + await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]); + const enabled=await repo.syncSummary(alice); + expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest); + await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]); + expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest); + // Toggling back must land on the same digest, so the client stops refetching. + await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]); + expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint); + + // An outsider's summary is about their own couple and never ours. + const other=await repo.syncSummary(outsider); + expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0}); + }); + + it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{ + // Editing an existing match's type/response summary (response editing) creates + // no new match and touches no created_at, so match_count/new_match_count/ + // latest_match_at all hold steady; only the state fingerprint must move. + const before=await repo.syncSummary(alice); + await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]); + const after=await repo.syncSummary(alice); + expect(after.match_count).toBe(before.match_count); + expect(after.new_match_count).toBe(before.new_match_count); + expect(after.latest_match_at).toBe(before.latest_match_at); + expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint); + // Reverting lands back on the same digest, so the client stops refetching. + await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]); + expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint); + }); + + it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{ + const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const secondUnread='11111111-2222-4333-8444-555555555555'; + await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]); + await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]); + // Alice's own message ('message', from beforeAll) is never unread to her, so + // give the original match a message from her partner to actually redistribute. + await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]); + await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]); + + const before=await repo.syncSummary(alice); + expect(before.unread_total).toBe(2); + + // Read the original match's unread message while a new one lands in the + // second match: unread_total holds steady at 2, but which match owns it moves. + await pool.query('update messages set read_at=now() where id=$1',[originalUnread]); + const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff'; + await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]); + + const after=await repo.syncSummary(alice); + expect(after.unread_total).toBe(before.unread_total); + expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint); + + // Clean up so later assertions in this suite see the original single-match state. + await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]); + await pool.query('delete from matches where id=$1',[secondMatch]); + await pool.query('delete from questions where id=$1',[secondQuestion]); + }); + + it('moves the streak marker from couple_streaks.updated_at',async()=>{ + await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]); + const before=await repo.syncSummary(alice); + expect(before.streak_updated_at).toEqual(expect.any(String)); + await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]); + const after=await repo.syncSummary(alice); + expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!)); + }); + it('returns only public catalog data and couple-owned match context',async()=>{ expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question'); expect((await repo.matchContext(bob,match)).responses).toHaveLength(2); diff --git a/apps/api/test/app-data.routes.test.ts b/apps/api/test/app-data.routes.test.ts index 7235f1f..7f3fec1 100644 --- a/apps/api/test/app-data.routes.test.ts +++ b/apps/api/test/app-data.routes.test.ts @@ -16,6 +16,12 @@ function repository(): AppDataRepository { markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })), nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })), sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })), + syncSummary: vi.fn(async () => ({ + server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null, + match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0, + unread_total: 0, enabled_packs_fingerprint: '', + match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null, + })), getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })), putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })), close: vi.fn(async () => undefined), diff --git a/apps/api/test/chat.integration.test.ts b/apps/api/test/chat.integration.test.ts index ebd5cac..3d5ae10 100644 --- a/apps/api/test/chat.integration.test.ts +++ b/apps/api/test/chat.integration.test.ts @@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () => it('sends, lists, counts and reads text messages for couple members only', async () => { const sent = await repository.sendText(alice, match, 'Hello'); - expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello'); + expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello'); expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } }); expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull(); expect((await repository.markMatchRead(bob, match)).updated).toBe(1); @@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () => it('enforces per-user and author-only deletions plus reports', async () => { const sent = await repository.sendText(alice, match, 'Moderate me'); await repository.deleteForSelf(bob, sent.id); - expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false); - expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true); + expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false); + expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true); await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' }); expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull(); await repository.report(bob, sent.id, 'harassment'); await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError); }); + it('answers a delta with only what changed, and the same rows a full read would give', async () => { + const before = await repository.listMessages(bob, match); + const cursor = before.server_time; + expect(before.complete).toBe(true); + + // Nothing has happened since the cursor, so the delta carries no row the client + // has not already seen. The cursor is deliberately held a couple of seconds + // behind wall clock so a write racing the read is repeated rather than lost; + // that repetition is absorbed by merging on id, never by blanking the thread. + const known = new Set(before.messages.map(item => item.id)); + const quiet = await repository.listMessages(bob, match, { since: cursor }); + expect(quiet.complete).toBe(false); + expect(quiet.messages.every(item => known.has(item.id))).toBe(true); + expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true); + + const fresh = await repository.sendText(alice, match, 'Delta arrival'); + const delta = await repository.listMessages(bob, match, { since: cursor }); + expect(delta.messages.map(item => item.id)).toContain(fresh.id); + // Media and moderation columns must survive the narrower query. + expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({ + content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null, + }); + + // A status change with no new row still reaches the sender through the delta. + const afterSend = await repository.listMessages(alice, match); + await repository.markMatchRead(bob, match); + const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time }); + expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull(); + + // So does a deletion, which arrives as a removal rather than a silent gap. + const doomed = await repository.sendText(alice, match, 'Delete me'); + const beforeDelete = await repository.listMessages(bob, match); + await repository.deleteForSelf(bob, doomed.id); + const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time }); + expect(removal.removed_ids).toContain(doomed.id); + + // Ordering is the contract the screen renders against: newest first, both ways. + const full = await repository.listMessages(bob, match); + const timestamps = full.messages.map(item => Date.parse(item.created_at)); + expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps); + + // Access control is unchanged on the delta path. + await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' }); + }); + + it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => { + // Expiry is the one visible change no write announces: the deadline simply + // passes. Backdated so neither `created_at` nor `media_viewed_at` can be what + // puts the row in a delta — only the deadline crossing may. + const video = await repository.sendText(alice, match, 'Sent a video'); + await pool.query( + `update messages + set media_path = 'media:expiring', media_type = 'video', + moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv', + created_at = now() - interval '1 hour', + media_viewed_at = now() - interval '1 hour', + media_expires_at = now() + interval '1 hour' + where id = $1`, + [video.id], + ); + + const before = await repository.listMessages(bob, match); + expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false }); + + // A deadline still in the future is not a change, so the row is not re-sent on + // every poll until it arrives. + const quiet = await repository.listMessages(bob, match, { since: before.server_time }); + expect(quiet.messages.some(item => item.id === video.id)).toBe(false); + + // The deadline now falls inside this poll's window. + await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]); + const delta = await repository.listMessages(bob, match, { since: before.server_time }); + const expired = delta.messages.find(item => item.id === video.id); + // Media, encryption and moderation columns still arrive whole on the delta path. + expect(expired).toMatchObject({ + media_expired: true, media_type: 'video', media_path: 'media:expiring', + moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv', + }); + expect(expired?.media_expires_at).not.toBeNull(); + expect(expired?.media_viewed_at).not.toBeNull(); + + // A full read agrees, so leaving and re-entering the chat shows the same thing. + expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id)) + .toMatchObject({ media_expired: true }); + }); + + it('folds the partner typing check into the message read when asked', async () => { + await repository.setTyping(alice, match, 4_000); + const withTyping = await repository.listMessages(bob, match, { includeTyping: true }); + expect(withTyping.typing?.typing).toBe(true); + // Own typing is never reflected back, so the sender does not see themselves. + expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false); + // And it stays absent unless requested, so the default read costs no extra work. + expect((await repository.listMessages(bob, match)).typing).toBeUndefined(); + }); + it('stores typing state with a short expiry and exposes only the partner state', async () => { await repository.setTyping(alice, match, 4_000); expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true); diff --git a/apps/api/test/chat.routes.test.ts b/apps/api/test/chat.routes.test.ts index 9dc0867..548f019 100644 --- a/apps/api/test/chat.routes.test.ts +++ b/apps/api/test/chat.routes.test.ts @@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten function setup() { const repository: ChatRepository = { - listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message), + listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })), + sendText: vi.fn(async () => message), unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })), markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })), markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined), @@ -43,6 +44,28 @@ describe('chat routes', () => { expect(repository.sendText).not.toHaveBeenCalled(); }); + it('passes a delta cursor and folded typing check through to the repository', async () => { + const { app, repository } = setup(); + const since = '2026-08-28T10:00:00.000Z'; + const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`); + expect(delta.status).toBe(200); + expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true }); + + // Omitting both keeps the full-snapshot contract the client had before. + const snapshot = await app.request(`/v1/matches/${matchId}/messages`); + expect(snapshot.status).toBe(200); + expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] }); + expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false }); + }); + + it('refuses a malformed cursor rather than silently reading everything', async () => { + const { app, repository } = setup(); + const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } }); + expect(repository.listMessages).not.toHaveBeenCalled(); + }); + it('supports unread, typing, deletion and reporting actions', async () => { const { app, repository } = setup(); expect((await app.request('/v1/chat/unread')).status).toBe(200); diff --git a/apps/api/test/db.pool.test.ts b/apps/api/test/db.pool.test.ts new file mode 100644 index 0000000..9410765 --- /dev/null +++ b/apps/api/test/db.pool.test.ts @@ -0,0 +1,49 @@ +import { Pool } from 'pg'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_POOL_MAX, + closeResolvedPool, + createDatabasePool, + resolvePool, +} from '../src/db/pool.js'; + +const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test'; + +afterEach(() => vi.restoreAllMocks()); + +describe('database pool', () => { + it('bounds the shared pool and only forwards timeouts that were configured', () => { + // A process-wide pool is the whole point of the change: an unbounded default + // multiplied by one pool per repository is what the self-hosted database had + // to absorb before. + const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 }); + expect(bounded.options.max).toBe(4); + expect(bounded.options.idleTimeoutMillis).toBe(1_000); + expect(bounded.options.connectionTimeoutMillis).toBe(2_000); + + const defaults = createDatabasePool(url); + expect(defaults.options.max).toBe(DEFAULT_POOL_MAX); + + return Promise.all([bounded.end(), defaults.end()]); + }); + + it('owns a pool it built from a URL and never ends one it was handed', async () => { + // Repositories in the API process share one pool, so closing any single + // repository must not take the connections out from under the others. Tests + // still construct from a URL and must be able to close what they created. + const owned = resolvePool(url); + expect(owned.owned).toBe(true); + const ownedEnd = vi.spyOn(owned.pool, 'end'); + await closeResolvedPool(owned.pool, owned.owned); + expect(ownedEnd).toHaveBeenCalledTimes(1); + + const shared = new Pool({ connectionString: url }); + const borrowed = resolvePool(shared); + expect(borrowed.owned).toBe(false); + expect(borrowed.pool).toBe(shared); + const sharedEnd = vi.spyOn(shared, 'end'); + await closeResolvedPool(borrowed.pool, borrowed.owned); + expect(sharedEnd).not.toHaveBeenCalled(); + await shared.end(); + }); +}); diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 980af67..f583d17 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -1,509 +1,495 @@ -import { Tabs, useRouter, useSegments } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; -import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native"; -import { BlurView } from "expo-blur"; -import { useEffect, useState, useCallback, useRef } from "react"; -import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store"; -import { colors, radius, spacing, typography, shadows } from "../../src/theme"; +import { Tabs, useRouter, useSegments } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native"; +import { BlurView } from "expo-blur"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store"; +import { colors, radius, spacing, typography, shadows } from "../../src/theme"; import { authClient } from "../../src/lib/authClient"; -import { isBiometricEnabled } from "../../src/lib/biometricAuth"; -import { checkAndRegisterPushToken } from "../../src/lib/notifications"; -import { syncBadgeCount } from "../../src/lib/badge"; -import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; -import { Events } from "../../src/lib/analytics"; -import { needsOnboarding } from "../../src/constants/onboarding"; -import type { Database } from "../../src/types/supabase"; - -type Message = Database["public"]["Tables"]["messages"]["Row"]; - -function MessageToastPreview({ - message, -}: { - message: Message; -}) { - if (message.media_path) { - const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image'; - return ( - - {fallback} - - ); - } - - return ( - - {message.content ?? 'New message'} - - ); -} - -function TabBarBackground() { - // Only use BlurView on iOS - Android experimental blur causes white overlay artifacts - // The solid rgba background in tabBarStyle provides the visual effect on Android - if (Platform.OS === 'ios') { - return ( - <> - - - - ); - } - return null; -} - -export default function AppLayout() { - const router = useRouter(); - const segments = useSegments(); - const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore(); - const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore(); - const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore(); - const { fetchEnabledPacks } = usePacksStore(); +import { isBiometricEnabled } from "../../src/lib/biometricAuth"; +import { checkAndRegisterPushToken } from "../../src/lib/notifications"; +import { useCoupleSync } from "../../src/hooks/useCoupleSync"; +import { syncBadgeCount } from "../../src/lib/badge"; +import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; +import { Events } from "../../src/lib/analytics"; +import { needsOnboarding } from "../../src/constants/onboarding"; +import type { Database } from "../../src/types/supabase"; + +type Message = Database["public"]["Tables"]["messages"]["Row"]; + +function MessageToastPreview({ + message, +}: { + message: Message; +}) { + if (message.media_path) { + const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image'; + return ( + + {fallback} + + ); + } + + return ( + + {message.content ?? 'New message'} + + ); +} + +function TabBarBackground() { + // Only use BlurView on iOS - Android experimental blur causes white overlay artifacts + // The solid rgba background in tabBarStyle provides the visual effect on Android + if (Platform.OS === 'ios') { + return ( + <> + + + + ); + } + return null; +} + +export default function AppLayout() { + const router = useRouter(); + const segments = useSegments(); + const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore(); + const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore(); + const { unreadCount, lastMessage, clearLastMessage } = useMessageStore(); const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore(); - const { fetchStreak } = useStreakStore(); - const messageToastAnim = useRef(new Animated.Value(-100)).current; + const { fetchStreak } = useStreakStore(); + const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageTimeoutRef = useRef | null>(null); - const [isLocked, setIsLocked] = useState(false); - const hasCheckedInitialBiometric = useRef(false); - const wentToBackgroundAt = useRef(null); + const [isLocked, setIsLocked] = useState(false); + const hasCheckedInitialBiometric = useRef(false); + const wentToBackgroundAt = useRef(null); const knownMatchIdsRef = useRef>(new Set()); const matchPollInitializedRef = useRef(false); - - // Check if we're on screens that should hide the tab bar - const segmentStrings = segments as string[]; - const isOnOnboarding = segmentStrings.includes("onboarding"); - const isOnPairing = segmentStrings.includes("pairing"); - const isOnChat = segmentStrings.includes("chat"); - const isOnLiveDraw = segmentStrings.includes("live-draw"); - const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; - const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen; - - // Redirect to login when not authenticated, or to onboarding if not completed/outdated - useEffect(() => { - if (!isLoading && !isAuthenticated) { - router.replace("/(auth)/login"); - } else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) { - router.replace("/(app)/onboarding"); - } - }, [isLoading, isAuthenticated, user, router, isOnOnboarding]); - - // Check session validity on mount and when app comes to foreground - useEffect(() => { - const checkSession = async () => { + + // Check if we're on screens that should hide the tab bar + const segmentStrings = segments as string[]; + const isOnOnboarding = segmentStrings.includes("onboarding"); + const isOnPairing = segmentStrings.includes("pairing"); + const isOnChat = segmentStrings.includes("chat"); + const isOnLiveDraw = segmentStrings.includes("live-draw"); + const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; + const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen; + + // Redirect to login when not authenticated, or to onboarding if not completed/outdated + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace("/(auth)/login"); + } else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) { + router.replace("/(app)/onboarding"); + } + }, [isLoading, isAuthenticated, user, router, isOnOnboarding]); + + // Check session validity on mount and when app comes to foreground + useEffect(() => { + const checkSession = async () => { const { data: { session } } = await authClient.auth.getSession(); - if (!session) { - // No valid session - sign out to clear local state - signOut(); - } - }; - - // Check immediately - checkSession(); - - // Update last active timestamp on mount - updateLastActive(); - - // Check when app comes to foreground - const subscription = AppState.addEventListener('change', (nextAppState) => { - if (nextAppState === 'active') { - checkSession(); - // Update last active timestamp when app comes to foreground - updateLastActive(); + if (!session) { + // No valid session - sign out to clear local state + signOut(); + } + }; + + // Check immediately + checkSession(); + + // Update last active timestamp on mount + updateLastActive(); + + // Check when app comes to foreground + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'active') { + checkSession(); + // Update last active timestamp when app comes to foreground + updateLastActive(); if (user?.id && Platform.OS !== "web") { void refreshSubscriptionStatus(); } - } - }); - - return () => { - subscription.remove(); - }; + } + }); + + return () => { + subscription.remove(); + }; }, [signOut, updateLastActive, user?.id, refreshSubscriptionStatus]); - - // Handle biometric lock when app goes to background/foreground - useEffect(() => { - // Check if biometric should be shown on initial mount - const checkInitialBiometric = async () => { - if (hasCheckedInitialBiometric.current) return; - hasCheckedInitialBiometric.current = true; - - const enabled = await isBiometricEnabled(); - if (enabled && isAuthenticated) { - setIsLocked(true); - } - }; - - if (isAuthenticated && !isLoading) { - checkInitialBiometric(); - } - - const subscription = AppState.addEventListener('change', async (nextAppState) => { - // Track when app goes to background (not just inactive) - if (nextAppState === 'background') { - wentToBackgroundAt.current = Date.now(); - } - - // When app becomes active, check if we should lock - if (nextAppState === 'active' && isAuthenticated && !isLocked) { - // Only lock if we actually went to background (not just inactive from modal/control center) - // and we were in background for at least 1 second - if (wentToBackgroundAt.current) { - const timeInBackground = Date.now() - wentToBackgroundAt.current; - wentToBackgroundAt.current = null; - - if (timeInBackground > 1000) { - const enabled = await isBiometricEnabled(); - if (enabled) { - setIsLocked(true); - } - } - } - } - }); - - return () => { - subscription.remove(); - }; - }, [isAuthenticated, isLoading, isLocked]); - - const handleBiometricUnlock = useCallback(() => { - setIsLocked(false); - }, []); - - // Fetch unread message count on mount - useEffect(() => { - if (user?.id) { - fetchUnreadCount(); - } - }, [user?.id, fetchUnreadCount]); - - // Fetch streak data when user is in a couple - useEffect(() => { - if (user?.couple_id) { - fetchStreak(); - } - }, [user?.couple_id, fetchStreak]); - - // Fetch pending questions (Your Turn) when user is in a couple - useEffect(() => { - if (user?.couple_id) { - fetchPendingQuestions(); - } - }, [user?.couple_id, fetchPendingQuestions]); - - // Sync app icon badge count whenever matches, messages, or pending questions change - useEffect(() => { - syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount); - }, [newMatchesCount, unreadCount, pendingQuestions.length]); - - // Initialize RevenueCat for subscription management (iOS and Android only) - useEffect(() => { - if (user?.id && Platform.OS !== "web") { - initializeRevenueCat(user.id); - } - }, [user?.id, initializeRevenueCat]); - - // Check and register push token for this device - // This handles the case where user onboarded on a different device - useEffect(() => { - if (user?.id && user?.onboarding_completed) { - checkAndRegisterPushToken(user.id); - } - }, [user?.id, user?.onboarding_completed]); - - // Poll the self-hosted API for profile, match, pending-question and pack changes. - // This replaces the former Supabase Postgres change subscriptions. - useEffect(() => { - if (!user?.id) return; - let cancelled = false; + + // Handle biometric lock when app goes to background/foreground + useEffect(() => { + // Check if biometric should be shown on initial mount + const checkInitialBiometric = async () => { + if (hasCheckedInitialBiometric.current) return; + hasCheckedInitialBiometric.current = true; + + const enabled = await isBiometricEnabled(); + if (enabled && isAuthenticated) { + setIsLocked(true); + } + }; + + if (isAuthenticated && !isLoading) { + checkInitialBiometric(); + } + + const subscription = AppState.addEventListener('change', async (nextAppState) => { + // Track when app goes to background (not just inactive) + if (nextAppState === 'background') { + wentToBackgroundAt.current = Date.now(); + } + + // When app becomes active, check if we should lock + if (nextAppState === 'active' && isAuthenticated && !isLocked) { + // Only lock if we actually went to background (not just inactive from modal/control center) + // and we were in background for at least 1 second + if (wentToBackgroundAt.current) { + const timeInBackground = Date.now() - wentToBackgroundAt.current; + wentToBackgroundAt.current = null; + + if (timeInBackground > 1000) { + const enabled = await isBiometricEnabled(); + if (enabled) { + setIsLocked(true); + } + } + } + } + }); + + return () => { + subscription.remove(); + }; + }, [isAuthenticated, isLoading, isLocked]); + + const handleBiometricUnlock = useCallback(() => { + setIsLocked(false); + }, []); + + // The unread badge arrives with the first sync summary, so it needs no request + // of its own here. + + // Fetch streak data when user is in a couple + useEffect(() => { + if (user?.couple_id) { + fetchStreak(); + } + }, [user?.couple_id, fetchStreak]); + + // Fetch pending questions (Your Turn) and matches when user is in a couple. + // Both feed tab badges, so they load once here and are kept current by the + // sync summary rather than by re-fetching on a timer. + useEffect(() => { + if (user?.couple_id) { + void fetchPendingQuestions(); + void fetchMatches(true); + } + }, [user?.couple_id, fetchPendingQuestions, fetchMatches]); + + // Sync app icon badge count whenever matches, messages, or pending questions change + useEffect(() => { + syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount); + }, [newMatchesCount, unreadCount, pendingQuestions.length]); + + // Initialize RevenueCat for subscription management (iOS and Android only) + useEffect(() => { + if (user?.id && Platform.OS !== "web") { + initializeRevenueCat(user.id); + } + }, [user?.id, initializeRevenueCat]); + + // Check and register push token for this device + // This handles the case where user onboarded on a different device + useEffect(() => { + if (user?.id && user?.onboarding_completed) { + checkAndRegisterPushToken(user.id); + } + }, [user?.id, user?.onboarding_completed]); + + // One small change summary drives every domain refresh. It replaces a + // five-second refetch of profile, matches, pending questions and packs plus a + // three-second unread poll, and only asks a store to reload when the server + // reports that its data actually moved. + useCoupleSync(user?.id, user?.couple_id); + + // Match analytics are derived from the store rather than from a poll response, + // so they fire once per genuinely new match however it arrived. + useEffect(() => { matchPollInitializedRef.current = false; knownMatchIdsRef.current = new Set(); + }, [user?.id, user?.couple_id]); - const poll = async () => { - await Promise.allSettled([ - useAuthStore.getState().fetchUser(), - ...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []), - ]); - if (cancelled || !user.couple_id) return; - const refreshed = useMatchStore.getState().matches; - if (matchPollInitializedRef.current) { - const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id)); - for (const item of added) Events.matchCreated(item.match_type || "unknown"); - const previousCount = knownMatchIdsRef.current.size; - if (previousCount === 0 && added.length > 0) Events.firstMatch(); - else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length); + useEffect(() => { + if (!user?.couple_id) return; + const known = knownMatchIdsRef.current; + const added = matches.filter(item => !known.has(item.id)); + if (matchPollInitializedRef.current && added.length > 0) { + for (const item of added) Events.matchCreated(item.match_type || "unknown"); + if (known.size === 0) Events.firstMatch(); + else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length); + } + knownMatchIdsRef.current = new Set(matches.map(item => item.id)); + matchPollInitializedRef.current = true; + }, [matches, user?.couple_id]); + + // Animate message toast when lastMessage changes + useEffect(() => { + if (lastMessage) { + // Clear any existing timeout + if (messageTimeoutRef.current) { + clearTimeout(messageTimeoutRef.current); } - knownMatchIdsRef.current = new Set(refreshed.map(item => item.id)); - matchPollInitializedRef.current = true; - }; - - void poll(); - const timer = setInterval(() => void poll(), 5_000); - return () => { cancelled = true; clearInterval(timer); }; - }, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]); - - // Poll the self-hosted API for unread chat state. Chat screens perform their - // own message polling; this keeps the global badge current without Supabase Realtime. - useEffect(() => { - if (!user?.couple_id) return; - const timer = setInterval(() => void fetchUnreadCount(), 3_000); - return () => clearInterval(timer); - }, [user?.couple_id, fetchUnreadCount]); - - // Animate message toast when lastMessage changes - useEffect(() => { - if (lastMessage) { - // Clear any existing timeout - if (messageTimeoutRef.current) { - clearTimeout(messageTimeoutRef.current); - } - - // Slide in - Animated.spring(messageToastAnim, { - toValue: 0, - useNativeDriver: true, - tension: 80, - friction: 10, - }).start(); - - // Auto-dismiss after 4 seconds - messageTimeoutRef.current = setTimeout(() => { - dismissMessageToast(); - }, 4000); - } - - return () => { - if (messageTimeoutRef.current) { - clearTimeout(messageTimeoutRef.current); - } - }; - }, [lastMessage]); - - const dismissMessageToast = useCallback(() => { - Animated.timing(messageToastAnim, { - toValue: -100, - duration: 200, - useNativeDriver: true, - }).start(() => { - clearLastMessage(); - }); - }, [messageToastAnim, clearLastMessage]); - - const handleMessageToastPress = useCallback(() => { - if (lastMessage?.match_id) { - dismissMessageToast(); - router.push(`/(app)/chat/${lastMessage.match_id}`); - } - }, [lastMessage, dismissMessageToast, router]); - - // Show loading state while checking authentication or redirecting to onboarding - if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) { - return ( - - - - ); - } - - return ( - <> - , - tabBarStyle: shouldHideTabBar ? { display: 'none' } : { - position: 'absolute', - backgroundColor: 'rgba(14, 14, 17, 0.85)', - borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color - borderTopWidth: 1, - paddingTop: 8, - paddingBottom: Platform.OS === 'ios' ? 28 : 8, - height: Platform.OS === 'ios' ? 88 : 64, - elevation: 0, - }, - tabBarActiveTintColor: colors.primary, - tabBarInactiveTintColor: colors.textSecondary, - tabBarLabelStyle: { - fontSize: 10, - fontWeight: "500", - letterSpacing: 0.5, - }, - }} - > - ( - - ), - }} - /> - - ( - - ), - tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined, - tabBarBadgeStyle: { - backgroundColor: colors.premium.rose, - fontSize: 9, - fontWeight: '600', - minWidth: 16, - height: 16, - borderRadius: 8, - }, - }} - /> - ( - - ), - }} - /> - - - - - - - - - - - {/* Message Toast Notification */} - {lastMessage && ( - - - - - - - - New message - - - - - - - - - )} - - {/* Biometric Lock Screen */} - - - ); -} - -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: colors.background, - }, - messageToast: { - position: 'absolute', - top: Platform.OS === 'ios' ? 50 : 20, - left: spacing.md, - right: spacing.md, - zIndex: 1000, - }, - messageToastContent: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.backgroundLight, - borderRadius: radius.lg, - padding: spacing.md, - borderWidth: 1, - borderColor: colors.glass.border, - ...shadows.lg, - }, - messageToastIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: colors.primaryLight, - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.md, - }, - messageToastText: { - flex: 1, - marginRight: spacing.sm, - }, - messageToastTitle: { - ...typography.subhead, - color: colors.text, - fontWeight: '600', - }, - messageToastBody: { - ...typography.caption1, - color: colors.textSecondary, - marginTop: 2, - }, -}); + + // Slide in + Animated.spring(messageToastAnim, { + toValue: 0, + useNativeDriver: true, + tension: 80, + friction: 10, + }).start(); + + // Auto-dismiss after 4 seconds + messageTimeoutRef.current = setTimeout(() => { + dismissMessageToast(); + }, 4000); + } + + return () => { + if (messageTimeoutRef.current) { + clearTimeout(messageTimeoutRef.current); + } + }; + }, [lastMessage]); + + const dismissMessageToast = useCallback(() => { + Animated.timing(messageToastAnim, { + toValue: -100, + duration: 200, + useNativeDriver: true, + }).start(() => { + clearLastMessage(); + }); + }, [messageToastAnim, clearLastMessage]); + + const handleMessageToastPress = useCallback(() => { + if (lastMessage?.match_id) { + dismissMessageToast(); + router.push(`/(app)/chat/${lastMessage.match_id}`); + } + }, [lastMessage, dismissMessageToast, router]); + + // Show loading state while checking authentication or redirecting to onboarding + if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) { + return ( + + + + ); + } + + return ( + <> + , + tabBarStyle: shouldHideTabBar ? { display: 'none' } : { + position: 'absolute', + backgroundColor: 'rgba(14, 14, 17, 0.85)', + borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color + borderTopWidth: 1, + paddingTop: 8, + paddingBottom: Platform.OS === 'ios' ? 28 : 8, + height: Platform.OS === 'ios' ? 88 : 64, + elevation: 0, + }, + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: colors.textSecondary, + tabBarLabelStyle: { + fontSize: 10, + fontWeight: "500", + letterSpacing: 0.5, + }, + }} + > + ( + + ), + }} + /> + + ( + + ), + tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined, + tabBarBadgeStyle: { + backgroundColor: colors.premium.rose, + fontSize: 9, + fontWeight: '600', + minWidth: 16, + height: 16, + borderRadius: 8, + }, + }} + /> + ( + + ), + }} + /> + + + + + + + + + + + {/* Message Toast Notification */} + {lastMessage && ( + + + + + + + + New message + + + + + + + + + )} + + {/* Biometric Lock Screen */} + + + ); +} + +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: colors.background, + }, + messageToast: { + position: 'absolute', + top: Platform.OS === 'ios' ? 50 : 20, + left: spacing.md, + right: spacing.md, + zIndex: 1000, + }, + messageToastContent: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.backgroundLight, + borderRadius: radius.lg, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.glass.border, + ...shadows.lg, + }, + messageToastIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primaryLight, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + messageToastText: { + flex: 1, + marginRight: spacing.sm, + }, + messageToastTitle: { + ...typography.subhead, + color: colors.text, + fontWeight: '600', + }, + messageToastBody: { + ...typography.caption1, + color: colors.textSecondary, + marginTop: 2, + }, +}); diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index ccdc392..e28fcc6 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store'; +import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; @@ -68,8 +68,7 @@ function groupPacksByCategory( export default function DiscoveryScreen() { const { user, partner, couple } = useAuthStore(); - const { packs, categories, fetchPacks, getPackProgress } = usePacksStore(); - const { fetchStreak } = useStreakStore(); + const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore(); const { subscription } = useSubscriptionStore(); const { width } = useWindowDimensions(); const [showPaywall, setShowPaywall] = useState(false); @@ -79,12 +78,13 @@ export default function DiscoveryScreen() { const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser; const isWideScreen = width > MAX_CONTENT_WIDTH; - // Refresh data when screen gains focus + // Load the catalog once. Returning to this tab shows what is cached; answering a + // question invalidates it, and StreakDisplay owns the streak, so neither needs a + // request here. useFocusEffect( useCallback(() => { - fetchPacks(); - fetchStreak(); - }, [fetchStreak]) + void ensurePacksLoaded(); + }, [ensurePacksLoaded]) ); // Group packs by category diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index bcc9037..527d82e 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -39,6 +39,7 @@ export default function MatchesScreen() { fetchMatches, markAllAsSeen, isLoading, + isRefreshing, hasMore, isLoadingMore, totalCount, @@ -58,7 +59,9 @@ export default function MatchesScreen() { fetchTheirTurnQuestions, currentView, setCurrentView, + ensureViewLoaded, fetchPendingQuestions, + fetchArchivedMatches, // Nudge state and methods nudgeCooldownUntil, isNudging, @@ -118,14 +121,12 @@ export default function MatchesScreen() { setShowTutorial(false); }; + // Load whichever view is showing, once. Returning to this tab reuses what is + // already cached; the sync poll refreshes it when the server reports a change, + // and pull-to-refresh remains the explicit way to force one. useFocusEffect( useCallback(() => { - fetchMatches(true); - // Also refresh pending questions when focused - fetchPendingQuestions(); - fetchTheirTurnQuestions(); - // Check nudge cooldown - checkNudgeCooldown(); + ensureViewLoaded(); // Cleanup: mark matches as seen when leaving the screen (if on Complete tab) return () => { @@ -134,9 +135,15 @@ export default function MatchesScreen() { state.markAllAsSeen(); } }; - }, []) + }, [ensureViewLoaded]) ); + // The nudge cooldown only moves when this device sends a nudge, and sendNudge + // already records the new cooldown, so one read on mount is enough. + useEffect(() => { + void checkNudgeCooldown(); + }, [checkNudgeCooldown]); + // Format cooldown remaining time const formatCooldownTime = (cooldownUntil: Date): string => { const now = new Date(); @@ -633,13 +640,16 @@ export default function MatchesScreen() { refreshing={ currentView === 'pending' ? isLoadingPending : currentView === 'their_turn' ? isLoadingTheirTurn : - isLoading + currentView === 'archived' ? isLoadingArchived : + isRefreshing } onRefresh={() => { if (currentView === 'pending') { fetchPendingQuestions(); } else if (currentView === 'their_turn') { fetchTheirTurnQuestions(); + } else if (currentView === 'archived') { + fetchArchivedMatches(); } else { fetchMatches(true); } diff --git a/apps/mobile/app/(app)/my-answers.tsx b/apps/mobile/app/(app)/my-answers.tsx index 0b849a0..ad265ad 100644 --- a/apps/mobile/app/(app)/my-answers.tsx +++ b/apps/mobile/app/(app)/my-answers.tsx @@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList); type GroupByOption = "pack" | "date" | "answer"; export default function MyAnswersScreen() { - const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore(); + const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore(); const { user, partner } = useAuthStore(); const router = useRouter(); const params = useLocalSearchParams(); @@ -110,11 +110,12 @@ export default function MyAnswersScreen() { // Ambient orb breathing animations const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); - // Fetch responses on focus + // Load once, then keep showing what is cached. Answering or editing a response + // invalidates it, and pull-to-refresh is always available. useFocusEffect( useCallback(() => { - fetchResponses(true); - }, []) + void ensureResponsesLoaded(); + }, [ensureResponsesLoaded]) ); const handleLoadMore = useCallback(() => { @@ -271,7 +272,7 @@ export default function MyAnswersScreen() { scrollEventThrottle={16} refreshControl={ fetchResponses(true)} tintColor={colors.primary} colors={[colors.primary]} diff --git a/apps/mobile/app/pack/[id].tsx b/apps/mobile/app/pack/[id].tsx index 0c93815..2389670 100644 --- a/apps/mobile/app/pack/[id].tsx +++ b/apps/mobile/app/pack/[id].tsx @@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); export default function PackDetailsScreen() { const { id } = useLocalSearchParams(); const router = useRouter(); - const { packs, fetchPacks } = usePacksStore(); + const { packs, ensurePacksLoaded } = usePacksStore(); const { subscription } = useSubscriptionStore(); const { user, partner } = useAuthStore(); const [questions, setQuestions] = useState([]); @@ -61,9 +61,7 @@ export default function PackDetailsScreen() { const category = categories.find(c => c.id === pack?.category_id); useEffect(() => { - if (packs.length === 0) { - fetchPacks(); - } + void ensurePacksLoaded(); fetchPackDetails(); }, [id]); diff --git a/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts index 989dd0e..d3e612c 100644 --- a/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts +++ b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts @@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({ jest.mock("@/store", () => { const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } }; const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] }; + const packState = { ...packs, invalidatePacks: jest.fn() }; + const responses = { invalidateResponses: jest.fn() }; const useAuthStore = () => auth; useAuthStore.getState = () => auth.user; + const usePacksStore = () => packs; + usePacksStore.getState = () => packState; + const useResponsesStore = () => responses; + useResponsesStore.getState = () => responses; // Read lazily: the factory runs before this module's own consts are initialised. const streak = { fetchStreak: () => mockFetchStreak() }; const useStreakStore = () => streak; useStreakStore.getState = () => streak; - return { useAuthStore, usePacksStore: () => packs, useStreakStore }; + return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore }; }); jest.mock("@/lib/skippedQuestions", () => ({ @@ -45,17 +51,19 @@ const questions = [ { id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" }, ]; +const futureResetAt = "2099-08-29T00:00:00.000Z"; + const openLimit = { responses_today: 4, limit_value: 10, remaining: 6, - reset_at: "2026-08-29T00:00:00.000Z", + reset_at: futureResetAt, is_blocked: false, }; const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, { error: { code: "daily_limit", message: "Daily response limit reached" }, - details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" }, + details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt }, }); // clearMocks wipes implementations between tests, so re-arm the whole surface each time. @@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => { responses_today: 10, limit_value: 10, remaining: 0, - reset_at: "2026-08-29T00:00:00.000Z", + reset_at: futureResetAt, is_blocked: true, }); }); diff --git a/apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts b/apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts new file mode 100644 index 0000000..6eb62ba --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts @@ -0,0 +1,313 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync'; +import { appDataApi, type SyncSummary } from '@/lib/appDataApi'; +import { useAuthStore } from '@/store/authStore'; +import { useMatchStore } from '@/store/matchStore'; +import { useMessageStore } from '@/store/messageStore'; +import { usePacksStore } from '@/store/packsStore'; +import { useStreakStore } from '@/store/streakStore'; + +jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } })); + +const mockForeground = { value: true }; +const mockListeners = new Set<(foreground: boolean) => void>(); +jest.mock('@/lib/appForeground', () => ({ + isForeground: () => mockForeground.value, + subscribeToForeground: (listener: (foreground: boolean) => void) => { + mockListeners.add(listener); + return () => { mockListeners.delete(listener); }; + }, +})); + +const baseline: SyncSummary = { + server_time: '2026-08-27T12:00:00.000Z', + couple_id: 'couple', + profile_updated_at: '2026-08-01T00:00:00.000Z', + partner_id: 'partner', + partner_updated_at: '2026-08-01T00:00:00.000Z', + match_count: 3, + new_match_count: 0, + latest_match_at: '2026-08-20T00:00:00.000Z', + pending_yours: 1, + pending_theirs: 2, + unread_total: 0, + enabled_packs_fingerprint: 'digest-a', + match_state_fingerprint: 'state-a', + match_unread_fingerprint: 'unread-a', + streak_updated_at: '2026-08-01T00:00:00.000Z', +}; + +const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; +const tick = async () => { + await flush(); + await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); }); + await flush(); +}; + +/** Replaces every domain refresh the sync can dispatch with a counter. */ +function stubRefreshers() { + const fetchUser = jest.fn(async () => undefined); + const fetchCouple = jest.fn(async () => undefined); + const fetchMatches = jest.fn(async () => undefined); + const fetchPendingQuestions = jest.fn(async () => undefined); + const fetchTheirTurnQuestions = jest.fn(async () => undefined); + const fetchEnabledPacks = jest.fn(async () => undefined); + const refreshStreak = jest.fn(async () => undefined); + useAuthStore.setState({ fetchUser, fetchCouple } as never); + useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never); + usePacksStore.setState({ fetchEnabledPacks } as never); + useStreakStore.setState({ refreshStreak } as never); + return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak }; +} + +describe('useCoupleSync', () => { + let refreshers: ReturnType; + const originals = { + auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple }, + match: { + fetchMatches: useMatchStore.getState().fetchMatches, + fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions, + fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions, + }, + packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks }, + streak: { refreshStreak: useStreakStore.getState().refreshStreak }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockForeground.value = true; + mockListeners.clear(); + useMessageStore.setState({ unreadCount: 0 }); + // Every view starts loaded: this hook must refresh only what is on screen, + // and the "not loaded" case is asserted separately below. + useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } }); + usePacksStore.setState({ enabledPacksLoadedAt: Date.now() }); + refreshers = stubRefreshers(); + }); + afterEach(() => { + jest.useRealTimers(); + useAuthStore.setState(originals.auth as never); + useMatchStore.setState({ ...originals.match, loadedAt: {} } as never); + usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never); + useStreakStore.setState(originals.streak as never); + }); + + const answer = (...summaries: SyncSummary[]) => { + const mock = appDataApi.syncSummary as jest.Mock; + mock.mockReset(); + for (const summary of summaries) mock.mockResolvedValueOnce(summary); + mock.mockResolvedValue(summaries[summaries.length - 1]); + }; + + it('spends one request per interval and refreshes nothing while the markers hold still', async () => { + answer(baseline); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + + await tick(); + await tick(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3); + // Three polls, three requests in total: the baseline poll must not trigger + // the refresh of five domains that the old five-second sweep performed. + for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled(); + }); + + it('applies the unread total from the summary without a second request', async () => { + answer(baseline, { ...baseline, unread_total: 4 }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(useMessageStore.getState().unreadCount).toBe(4); + }); + + it('refreshes only the domain whose marker moved', async () => { + answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true }); + expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled(); + expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled(); + expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled(); + expect(refreshers.fetchUser).not.toHaveBeenCalled(); + expect(refreshers.fetchCouple).not.toHaveBeenCalled(); + }); + + it('routes each remaining marker to its own domain', async () => { + answer(baseline, { + ...baseline, + pending_yours: 2, + pending_theirs: 3, + enabled_packs_fingerprint: 'digest-b', + partner_updated_at: '2026-08-29T00:00:00.000Z', + streak_updated_at: '2026-08-29T00:00:00.000Z', + }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true }); + expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true }); + expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1); + expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1); + // A partner profile change is a couple-level read; the whole user is only + // re-read when the user's own profile or couple membership moved. + expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1); + expect(refreshers.fetchUser).not.toHaveBeenCalled(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + }); + + it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => { + // Counts and latest-created-at do not move for an in-place match_type/ + // response_summary edit; only the state fingerprint does. + answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true }); + }); + + it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => { + // One match is read while another receives a message: unread_total holds + // steady but the per-match fingerprint (and unread-first ordering) moves. + answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true }); + }); + + it('does not refresh enabled packs when that domain has never been opened', async () => { + usePacksStore.setState({ enabledPacksLoadedAt: null }); + answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled(); + }); + + it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => { + answer({ ...baseline, match_count: 3 }); + const { rerender } = renderHook( + ({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId), + { initialProps: { userId: 'alice', coupleId: 'couple-a' } }, + ); + await flush(); + await tick(); // establishes alice's baseline (match_count: 3) + + // Switch accounts: bob's couple has a completely different match_count. + // Without a baseline reset this would look like a 12-count jump and + // trigger a refresh purely from comparing across accounts. + answer({ ...baseline, couple_id: 'couple-b', match_count: 15 }); + rerender({ userId: 'bob', coupleId: 'couple-b' }); + await flush(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + + await tick(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + }); + + it('re-reads the user once when the profile moves, instead of the couple as well', async () => { + answer(baseline, { + ...baseline, + profile_updated_at: '2026-08-29T00:00:00.000Z', + partner_updated_at: '2026-08-29T00:00:00.000Z', + }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchUser).toHaveBeenCalledTimes(1); + expect(refreshers.fetchCouple).not.toHaveBeenCalled(); + }); + + it('leaves views the user has never opened unloaded', async () => { + useMatchStore.setState({ loadedAt: {} }); + answer(baseline, { + ...baseline, + match_count: 9, + latest_match_at: '2026-08-29T00:00:00.000Z', + pending_yours: 7, + pending_theirs: 8, + }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled(); + expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled(); + }); + + it('stops entirely for a signed-out user and while the app is backgrounded', async () => { + answer(baseline); + const { unmount } = renderHook(() => useCoupleSync(undefined, null)); + await flush(); + await tick(); + expect(appDataApi.syncSummary).not.toHaveBeenCalled(); + unmount(); + + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + + await act(async () => { + mockForeground.value = false; + for (const listener of [...mockListeners]) listener(false); + }); + await tick(); + await tick(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + }); + + it('discards a summary that lands after the account it belongs to was switched away', async () => { + // Alice's poll is still open when Bob signs in. Its answer describes a + // couple Bob is not in, so none of it may land: not his badge, not the + // baseline his next poll is diffed against, not a refresh decision. + let answerAlice: (summary: SyncSummary) => void = () => undefined; + const mock = appDataApi.syncSummary as jest.Mock; + mock.mockReset(); + mock.mockReturnValueOnce(new Promise(resolve => { answerAlice = resolve; })); + + const { rerender } = renderHook( + ({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId), + { initialProps: { userId: 'alice', coupleId: 'couple-a' } }, + ); + await flush(); + expect(mock).toHaveBeenCalledTimes(1); + + const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 }; + mock.mockResolvedValue(bobBaseline); + rerender({ userId: 'bob', coupleId: 'couple-b' }); + await flush(); + expect(useMessageStore.getState().unreadCount).toBe(1); + + // Alice's request finally answers, long after she stopped being the user. + await act(async () => { + answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 }); + await Promise.resolve(); + }); + await flush(); + expect(useMessageStore.getState().unreadCount).toBe(1); + + // Bob's baseline is still Bob's: a poll identical to his own first one has + // nothing to refresh. Had Alice's answer been recorded as the baseline, + // this would read as a 40-match jump and refresh the Matches view. + await tick(); + expect(useMessageStore.getState().unreadCount).toBe(1); + for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled(); + }); + + it('never runs two summaries at once', async () => { + let release: (summary: SyncSummary) => void = () => undefined; + (appDataApi.syncSummary as jest.Mock).mockReset(); + (appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + (appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); }); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + await act(async () => { release(baseline); await Promise.resolve(); }); + await tick(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useDares.test.ts b/apps/mobile/src/__tests__/hooks/useDares.test.ts index fb4b961..a5db683 100644 --- a/apps/mobile/src/__tests__/hooks/useDares.test.ts +++ b/apps/mobile/src/__tests__/hooks/useDares.test.ts @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native"; import { useDares } from "@/features/dares/hooks/useDares"; import { ApiError, apiClient } from "@/lib/apiClient"; +import type { UseDaresReturn } from "@/features/dares/hooks/useDares"; import type { SentDare } from "@/features/dares/types"; const incoming: SentDare = { @@ -90,3 +91,80 @@ describe("useDares", () => { expect(result.current.error).toBe("offline"); }); }); + +describe("useDares polling cost", () => { + /** Counts requests per endpoint and lets the active payload be swapped mid-test. */ + function mockCounting(active: () => SentDare[]) { + const calls = { active: 0, history: 0, catalog: 0, stats: 0 }; + jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => { + if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; } + if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; } + if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; } + if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; } + throw new Error(`unexpected path ${path}`); + }); + return calls; + } + + const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; + const tick = async () => { + await flush(); + await act(async () => { jest.advanceTimersByTime(5_000); }); + await flush(); + }; + + beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); }); + afterEach(() => jest.useRealTimers()); + + it("polls the active list alone and re-reads the static endpoints only when it changes", async () => { + let current: SentDare[] = [incoming]; + const calls = mockCounting(() => current); + const { result } = renderHook(() => useDares({ isFocused: true })); + await flush(); + expect(result.current.loading).toBe(false); + // The first pass is a full load: nothing is cached yet. + expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 }); + + await tick(); + await tick(); + // Two quiet polls cost one request each, not four. The catalogue and the + // stats cannot move while the active set is byte-for-byte identical. + expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 }); + + current = [{ ...incoming, status: "active" }]; + await tick(); + expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 }); + expect(result.current.active[0]?.status).toBe("active"); + + await tick(); + expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 }); + }); + + it("issues nothing at all while the screen is not on top", async () => { + const calls = mockCounting(() => [incoming]); + const { rerender } = renderHook( + ({ isFocused }) => useDares({ isFocused }), + { initialProps: { isFocused: false } }, + ); + await tick(); + await tick(); + expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 }); + + rerender({ isFocused: true }); + await flush(); + expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 }); + }); + + it("never overlaps two polls", async () => { + let release: (payload: { dares: SentDare[] }) => void = () => undefined; + jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => { + if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never; + return { dares: [], ...catalog, ...stats } as never; + }); + renderHook(() => useDares({ isFocused: true })); + await flush(); + await act(async () => { jest.advanceTimersByTime(20_000); }); + expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1); + await act(async () => { release({ dares: [] }); await Promise.resolve(); }); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts b/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts index dcc4cbe..01fcfa3 100644 --- a/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts +++ b/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts @@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => { }); }); +describe('useDrawingSync focus gating', () => { + const handlers = { + onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(), + onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(), + }; + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); }); + afterEach(() => jest.useRealTimers()); + + it('reads the canvas once on mount and then polls only while the screen is on top', async () => { + (appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId }); + const onLoadStrokes = jest.fn(); + const { rerender } = renderHook(({ isFocused }) => useDrawingSync({ + coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers, + }), { initialProps: { isFocused: false } }); + + await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1)); + // A canvas nobody is looking at costs nothing beyond the state it loaded with. + await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1); + + rerender({ isFocused: true }); + await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2); + + rerender({ isFocused: false }); + await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2); + }); + + it('never polls over its own write in flight', async () => { + (appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId }); + let release: (state: unknown) => void = () => undefined; + (appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; })); + const onLoadStrokes = jest.fn(); + const { result } = renderHook(() => useDrawingSync({ + coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers, + })); + await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1)); + + const pending = result.current.persistStrokes([stroke]); + await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); }); + // The write returns the authoritative state; reading over the top of it would + // only race a revision we are about to replace. + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1); + + await act(async () => { + release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId }); + await pending; + }); + await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2); + }); +}); + describe('useDrawingSync stroke deduplication', () => { const at = (id: string, points: number, userId = 'u1') => ({ id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })), diff --git a/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts b/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts index f0f90c7..8a733d6 100644 --- a/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts +++ b/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts @@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi'; jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } })); -const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any); +const message = (id: string, user_id = 'partner', overrides: Record = {}) => ({ + id, match_id: 'match1', user_id, content: id, + created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides, +} as any); -describe('useMessageSubscription API polling', () => { +const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true }); +const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({ + messages, + removed_ids: options.removed ?? [], + server_time: options.server_time ?? 'cursor-2', + complete: false, + ...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }), +}); + +const listMessages = chatApi.listMessages as jest.Mock; +const markRead = chatApi.markRead as jest.Mock; + +describe('useMessageSubscription incremental polling', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - (chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] }); - (chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' }); + listMessages.mockResolvedValue(snapshot([])); + markRead.mockResolvedValue({ updated: 0, read_at: 'now' }); }); afterEach(() => jest.useRealTimers()); - it('fails closed without both a match and authenticated user', async () => { - const { result, rerender } = renderHook, { matchId: string | undefined; userId: string | undefined }>( + const mount = (config: Partial[0]> = {}) => + renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config })); + + it('fails closed without both match and authenticated user', () => { + const { rerender } = renderHook, { matchId: string | undefined; userId: string | undefined }>( ({ matchId, userId }) => useMessageSubscription({ matchId, userId }), { initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } }, ); - expect(result.current.loading).toBe(false); - expect(chatApi.listMessages).not.toHaveBeenCalled(); + expect(listMessages).not.toHaveBeenCalled(); rerender({ matchId: 'match1', userId: undefined }); - expect(chatApi.listMessages).not.toHaveBeenCalled(); + expect(listMessages).not.toHaveBeenCalled(); }); - it('loads initial messages without treating them as newly arrived', async () => { + it('loads a snapshot first, then requests only changes since the server cursor', async () => { + listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1')); + const { result } = mount(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true }); + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true }); + }); + + it('keeps cached messages, and their identity, when a delta carries nothing', async () => { + listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })])); + listMessages.mockResolvedValue(delta([])); + const { result } = mount(); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + const cached = result.current.messages; + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + expect(result.current.messages).toBe(cached); + expect(result.current.loading).toBe(false); + }); + + it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => { + listMessages.mockResolvedValueOnce(snapshot([ + message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }), + message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }), + ])); + listMessages.mockResolvedValueOnce(delta([ + message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }), + message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }), + ], { removed: ['m2'] })); + const { result } = mount(); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']); + expect(result.current.messages[1]?.deleted_at).toBe('gone'); + }); + + it('marks read only when the response holds an unread partner message', async () => { + listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')])); + listMessages.mockResolvedValueOnce(delta([message('m2', 'me')])); + listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })])); + const { result } = mount(); + await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1)); + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3)); + expect(markRead).toHaveBeenCalledTimes(1); + expect(result.current.messages).toHaveLength(2); + }); + + it('issues no request at all while the chat is not focused', async () => { + const { rerender } = renderHook, { focused: boolean }>( + ({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }), + { initialProps: { focused: false } }, + ); + await act(async () => { jest.advanceTimersByTime(2_000); }); + expect(listMessages).not.toHaveBeenCalled(); + expect(markRead).not.toHaveBeenCalled(); + + rerender({ focused: true }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1)); + + rerender({ focused: false }); + const callsAtBlur = listMessages.mock.calls.length; + await act(async () => { jest.advanceTimersByTime(2_000); }); + expect(listMessages).toHaveBeenCalledTimes(callsAtBlur); + }); + + it('reports partner arrivals once, and never for the initial load or own messages', async () => { const onNewMessage = jest.fn(); - (chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] }); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage })); + listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })])); + listMessages.mockResolvedValueOnce(delta([message('mine', 'me')])); + listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')])); + listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })])); + const { result } = mount({ onNewMessage }); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.messages).toEqual([message('m1')]); expect(onNewMessage).not.toHaveBeenCalled(); - expect(chatApi.markRead).not.toHaveBeenCalled(); - }); - it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => { - const onNewMessage = jest.fn(); - (chatApi.listMessages as jest.Mock) - .mockResolvedValueOnce({ messages: [message('m1')] }) - .mockResolvedValueOnce({ messages: [message('m2'), message('m1')] }); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 })); - await waitFor(() => expect(result.current.loading).toBe(false)); - act(() => { result.current.isFocusedRef.current = true; }); - await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); }); - await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2')); - expect(chatApi.markRead).toHaveBeenCalledWith('match1'); + for (const expected of [2, 3, 4]) { + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected)); + } expect(onNewMessage).toHaveBeenCalledTimes(1); }); - it('does not notify for the current user own newly polled message', async () => { - const onNewMessage = jest.fn(); - (chatApi.listMessages as jest.Mock) - .mockResolvedValueOnce({ messages: [] }) - .mockResolvedValueOnce({ messages: [message('mine', 'me')] }); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 })); - await waitFor(() => expect(result.current.loading).toBe(false)); - await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); }); - expect(onNewMessage).not.toHaveBeenCalled(); + it('reads partner typing state out of the message response', async () => { + listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } }); + const { result } = mount(); + await waitFor(() => expect(result.current.partnerTyping).toBe(true)); + + listMessages.mockResolvedValueOnce(delta([], { typing: false })); + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(result.current.partnerTyping).toBe(false)); }); it('surfaces loading completion and preserves a usable empty state after an API error', async () => { - (chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden')); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' })); + listMessages.mockRejectedValueOnce(new Error('forbidden')); + const { result } = mount(); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.messages).toEqual([]); }); - it('prevents overlapping polls and clears the interval on unmount', async () => { - let resolveFirst!: (value: { messages: any[] }) => void; - (chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; })); - const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 })); - expect(chatApi.listMessages).toHaveBeenCalledTimes(1); - act(() => { jest.advanceTimersByTime(1_500); }); - expect(chatApi.listMessages).toHaveBeenCalledTimes(1); - await act(async () => resolveFirst({ messages: [] })); + it('prevents overlapping polls and stops on unmount', async () => { + let resolveFirst!: (value: unknown) => void; + listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; })); + const { unmount } = mount(); + expect(listMessages).toHaveBeenCalledTimes(1); + await act(async () => { jest.advanceTimersByTime(1_500); }); + expect(listMessages).toHaveBeenCalledTimes(1); + + await act(async () => { resolveFirst(snapshot([])); }); unmount(); - act(() => { jest.advanceTimersByTime(2_000); }); - expect(chatApi.listMessages).toHaveBeenCalledTimes(1); + await act(async () => { jest.advanceTimersByTime(2_000); }); + expect(listMessages).toHaveBeenCalledTimes(1); + }); + + it('restarts immediately, and without a stale cursor, for a different match', async () => { + listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1')); + const { result, rerender } = renderHook, { matchId: string }>( + ({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }), + { initialProps: { matchId: 'match1' } }, + ); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + rerender({ matchId: 'match2' }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true }); }); it('supports optimistic external updates', async () => { - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' })); + const { result } = mount(); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => result.current.setMessages([message('optimistic', 'me')])); expect(result.current.messages[0]?.id).toBe('optimistic'); diff --git a/apps/mobile/src/__tests__/hooks/usePolling.test.ts b/apps/mobile/src/__tests__/hooks/usePolling.test.ts new file mode 100644 index 0000000..0f47257 --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/usePolling.test.ts @@ -0,0 +1,141 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { usePolling } from '@/hooks/usePolling'; + +const mockForeground = { value: true }; +const mockListeners = new Set<(foreground: boolean) => void>(); + +jest.mock('@/lib/appForeground', () => ({ + isForeground: () => mockForeground.value, + subscribeToForeground: (listener: (foreground: boolean) => void) => { + mockListeners.add(listener); + return () => { mockListeners.delete(listener); }; + }, +})); + +const setForeground = async (value: boolean) => { + await act(async () => { + mockForeground.value = value; + for (const listener of [...mockListeners]) listener(value); + }); +}; +// The next run is scheduled once the previous one settles, so let pending +// promises land before and after moving the clock. +const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; +const tick = async (ms: number) => { + await flush(); + await act(async () => { jest.advanceTimersByTime(ms); }); + await flush(); +}; + +describe('usePolling', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockForeground.value = true; + mockListeners.clear(); + }); + afterEach(() => jest.useRealTimers()); + + it('runs once immediately and then on the interval', async () => { + const task = jest.fn(async () => undefined); + renderHook(() => usePolling(task, { intervalMs: 100 })); + expect(task).toHaveBeenCalledTimes(1); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + await tick(100); + expect(task).toHaveBeenCalledTimes(3); + }); + + it('skips the leading run when asked to', async () => { + const task = jest.fn(async () => undefined); + renderHook(() => usePolling(task, { intervalMs: 100, leading: false })); + expect(task).not.toHaveBeenCalled(); + await tick(100); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('issues nothing while disabled and starts once enabled', async () => { + const task = jest.fn(async () => undefined); + const { rerender } = renderHook(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), { + initialProps: { enabled: false }, + }); + await tick(1_000); + expect(task).not.toHaveBeenCalled(); + rerender({ enabled: true }); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('stops while the app is backgrounded and catches up on return', async () => { + const task = jest.fn(async () => undefined); + renderHook(() => usePolling(task, { intervalMs: 100 })); + expect(task).toHaveBeenCalledTimes(1); + + await setForeground(false); + await tick(1_000); + expect(task).toHaveBeenCalledTimes(1); + + await setForeground(true); + expect(task).toHaveBeenCalledTimes(2); + await tick(100); + expect(task).toHaveBeenCalledTimes(3); + }); + + it('never runs two tasks concurrently', async () => { + let release!: () => void; + const task = jest.fn(() => new Promise(resolve => { release = resolve; })); + renderHook(() => usePolling(task, { intervalMs: 100 })); + expect(task).toHaveBeenCalledTimes(1); + + await tick(500); + expect(task).toHaveBeenCalledTimes(1); + + await act(async () => { release(); }); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + }); + + it('backs off after consecutive failures and recovers after a success', async () => { + const task = jest.fn(async (): Promise => { throw new Error('offline'); }); + renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 })); + expect(task).toHaveBeenCalledTimes(1); + + await tick(100); + expect(task).toHaveBeenCalledTimes(1); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + + await tick(399); + expect(task).toHaveBeenCalledTimes(2); + await tick(1); + expect(task).toHaveBeenCalledTimes(3); + + task.mockImplementation(async () => undefined); + await tick(400); + expect(task).toHaveBeenCalledTimes(4); + await tick(100); + expect(task).toHaveBeenCalledTimes(5); + }); + + it('restarts immediately when the reset key changes', async () => { + const task = jest.fn(async () => undefined); + const { rerender } = renderHook(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), { + initialProps: { resetKey: 'a' }, + }); + expect(task).toHaveBeenCalledTimes(1); + rerender({ resetKey: 'b' }); + expect(task).toHaveBeenCalledTimes(2); + await tick(50); + expect(task).toHaveBeenCalledTimes(2); + }); + + it('stops and unsubscribes on unmount', async () => { + const task = jest.fn(async () => undefined); + const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 })); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + unmount(); + expect(mockListeners.size).toBe(0); + await tick(1_000); + expect(task).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts b/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts index 6feb5b8..367147d 100644 --- a/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts +++ b/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts @@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => { act(() => { jest.advanceTimersByTime(2_000); }); expect(chatApi.getTyping).toHaveBeenCalledTimes(calls); }); + it('stops polling and mirrors typing state supplied by the message poll', async () => { + const { result, rerender } = renderHook, { externalTyping: boolean | undefined }>( + ({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }), + { initialProps: { externalTyping: undefined as boolean | undefined } }, + ); + await act(async () => { await Promise.resolve(); }); + const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length; + + rerender({ externalTyping: true }); + expect(result.current.partnerTyping).toBe(true); + await act(async () => { jest.advanceTimersByTime(5_000); }); + expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding); + + act(() => result.current.clearTypingIndicator()); + expect(result.current.partnerTyping).toBe(false); + rerender({ externalTyping: false }); + expect(result.current.partnerTyping).toBe(false); + }); }); diff --git a/apps/mobile/src/__tests__/lib/appForeground.test.ts b/apps/mobile/src/__tests__/lib/appForeground.test.ts new file mode 100644 index 0000000..a56e3b4 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/appForeground.test.ts @@ -0,0 +1,36 @@ +import { AppState } from 'react-native'; +import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground'; + +describe('appForeground', () => { + afterEach(() => jest.restoreAllMocks()); + + it('treats only background and inactive as away', () => { + expect(isForegroundState('active')).toBe(true); + expect(isForegroundState('background')).toBe(false); + expect(isForegroundState('inactive')).toBe(false); + // Unknown states — including whatever a test harness leaves behind — must + // fail towards running, never towards disabling recurring work forever. + expect(isForegroundState(undefined)).toBe(true); + expect(isForegroundState('extension')).toBe(true); + }); + + it('reads the current state and reports transitions until unsubscribed', () => { + let emit: (state: string) => void = () => undefined; + const remove = jest.fn(); + jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => { + emit = handler; + return { remove } as never; + }) as never); + + expect(isForeground()).toBe(isForegroundState(AppState.currentState)); + + const seen: boolean[] = []; + const unsubscribe = subscribeToForeground(value => seen.push(value)); + emit('background'); + emit('active'); + expect(seen).toEqual([false, true]); + + unsubscribe(); + expect(remove).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/chatApi.test.ts b/apps/mobile/src/__tests__/lib/chatApi.test.ts index 474b2c3..8ebf294 100644 --- a/apps/mobile/src/__tests__/lib/chatApi.test.ts +++ b/apps/mobile/src/__tests__/lib/chatApi.test.ts @@ -24,6 +24,18 @@ describe('chatApi', () => { expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing'); }); + it('encodes the delta cursor and folded typing request into the message query', async () => { + (apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false }); + + await chatApi.listMessages('match1', { typing: true }); + await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' }); + await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true }); + + expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true'); + expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00'); + expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true'); + }); + it('uses authenticated unread, receipt, deletion and reporting routes', async () => { (apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} }); (apiClient.post as jest.Mock).mockResolvedValue({}); diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts index 0c8fe74..9514648 100644 --- a/apps/mobile/src/__tests__/stores/matchStore.test.ts +++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts @@ -45,9 +45,18 @@ describe('matchStore API behavior', () => { }); it('guards concurrent loads and exhausted pagination', async () => { - useMatchStore.setState({ isLoading: true }); - await useMatchStore.getState().fetchMatches(true); - useMatchStore.setState({ isLoading: false, hasMore: false }); + // Overlap protection cannot key off the loading flags any more: a silent + // refresh raises none of them and still must not issue a second request. + let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const inFlight = useMatchStore.getState().fetchMatches(true); + await useMatchStore.getState().fetchMatches(true, { silent: true }); + expect(apiClient.get).toHaveBeenCalledTimes(1); + release({ matches: [], totalCount: 0 }); + await inFlight; + + (apiClient.get as jest.Mock).mockClear(); + useMatchStore.setState({ hasMore: false }); await useMatchStore.getState().fetchMatches(false); expect(apiClient.get).not.toHaveBeenCalled(); }); @@ -147,7 +156,10 @@ describe('matchStore API behavior', () => { it('toggles archived visibility and triggers an initial fetch only', async () => { (apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 }); useMatchStore.getState().toggleShowArchived(); - await Promise.resolve(); + // Let the floating fetch settle: its in-flight guard is module-scoped and + // is only released in a finally, so a half-run request would leak into + // the next test and silently suppress its archived fetch. + await jest.runOnlyPendingTimersAsync(); expect(useMatchStore.getState().showArchived).toBe(true); expect(apiClient.get).toHaveBeenCalledTimes(1); useMatchStore.getState().toggleShowArchived(); @@ -178,11 +190,19 @@ describe('matchStore API behavior', () => { }); it('updates current view and delegates an empty view fetch', () => { + const real = useMatchStore.getState().fetchArchivedMatches; const fetchArchivedMatches = jest.fn(async () => undefined); useMatchStore.setState({ fetchArchivedMatches }); - useMatchStore.getState().setCurrentView('archived'); - expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true }); - expect(fetchArchivedMatches).toHaveBeenCalled(); + try { + useMatchStore.getState().setCurrentView('archived'); + expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true }); + expect(fetchArchivedMatches).toHaveBeenCalled(); + } finally { + // Actions live in the same store as the data, and clearMatches does not + // reset them: leaving the stub in place would silence every later test's + // archived fetch. + useMatchStore.setState({ fetchArchivedMatches: real }); + } }); }); @@ -225,6 +245,222 @@ describe('matchStore API behavior', () => { }); }); + describe('view switching and cache freshness', () => { + /** Answers each of the four view endpoints with an empty payload. */ + const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => { + if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 }; + if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false }; + return { questions: [] }; + }); + + it('switches between loaded views without a single extra request, even when every view is empty', async () => { + emptyViews(); + const views = ['pending', 'their_turn', 'active', 'archived'] as const; + + // First visit to each view loads it once. + for (const view of views) { + useMatchStore.getState().setCurrentView(view); + await jest.runOnlyPendingTimersAsync(); + } + expect(apiClient.get).toHaveBeenCalledTimes(views.length); + expect(useMatchStore.getState()).toMatchObject({ + matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [], + }); + + // Every subsequent switch is served from cache. An empty view is loaded, + // not unloaded: this is the bug where `array.length === 0` meant "never + // fetched" and made every visit pay for the same empty answer. + (apiClient.get as jest.Mock).mockClear(); + for (const view of [...views, ...views].reverse()) { + useMatchStore.getState().setCurrentView(view); + await jest.runOnlyPendingTimersAsync(); + } + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => { + (apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false }); + await useMatchStore.getState().fetchMatches(true); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']); + + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true }); + + // Mid-flight: the user still sees the rows they had, and neither the + // full-screen loader nor the pull-to-refresh spinner is showing. + expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false }); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']); + + release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false }); + await refreshing; + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']); + }); + + it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => { + (apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false }); + await useMatchStore.getState().fetchMatches(true); + + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const refreshing = useMatchStore.getState().fetchMatches(true); + expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true }); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']); + release({ matches: [], totalCount: 0, hasMore: false }); + await refreshing; + expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false }); + }); + + it('keeps a silent list refresh out of the pending and their-turn loaders', async () => { + (apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] }); + await useMatchStore.getState().fetchPendingQuestions(); + await useMatchStore.getState().fetchTheirTurnQuestions(); + + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; })); + const quiet = Promise.all([ + useMatchStore.getState().fetchPendingQuestions({ silent: true }), + useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }), + ]); + expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false }); + expect(useMatchStore.getState().pendingQuestions).toHaveLength(1); + release({ questions: [] }); + await quiet; + }); + }); + + describe('account isolation (generation tokens)', () => { + it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = useMatchStore.getState().fetchMatches(true); + + // Simulates sign-out/account switch while the request is still in flight. + useMatchStore.getState().clearMatches(); + release({ matches: [match('stale')], totalCount: 1, hasMore: false }); + await staleFetch; + + expect(useMatchStore.getState().matches).toEqual([]); + + (apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false }); + await useMatchStore.getState().fetchMatches(true); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']); + }); + + it('does not let a stale seen-marker write over the next account\'s list', async () => { + useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 }); + let release: () => void = () => undefined; + (apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = () => resolve(); })); + const stale = useMatchStore.getState().markAsSeen('theirs'); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 }); + const untouched = useMatchStore.getState().matches; + release(); + await stale; + + // Not merely "the same ids": the previous account's response must not + // rewrite the array at all, because the recount it derives is only true + // of the rows that account had loaded. + expect(useMatchStore.getState().matches).toBe(untouched); + expect(useMatchStore.getState().newMatchesCount).toBe(1); + }); + + it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => { + useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 }); + let release: () => void = () => undefined; + (apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = () => resolve(); })); + const stale = useMatchStore.getState().markAllAsSeen(); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 }); + release(); + await stale; + + expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true); + expect(useMatchStore.getState().newMatchesCount).toBe(2); + }); + + it('does not roll a failed archive or unarchive back into the next account', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + let fail: (error: Error) => void = () => undefined; + (apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set(), totalCount: 1 }); + const staleArchive = useMatchStore.getState().archiveMatch('theirs'); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ matches: [match('mine')], totalCount: 1 }); + fail(new Error('forbidden')); + await staleArchive; + + // The revert would otherwise put one couple's match into another's list. + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']); + expect(useMatchStore.getState().totalCount).toBe(1); + + (apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) }); + const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived'); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 }); + fail(new Error('forbidden')); + await staleUnarchive; + + expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']); + expect(useMatchStore.getState().totalCount).toBe(0); + }); + + it('does not carry a nudge cooldown across an account switch', async () => { + let release: (data: unknown) => void = () => undefined; + (appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleSend = useMatchStore.getState().sendNudge(); + + useMatchStore.getState().clearMatches(); + release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' }); + await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false }); + expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull(); + + // The 429 branch writes a cooldown too, and must be gated the same way. + let fail: (error: Error) => void = () => undefined; + (appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + const staleLimit = useMatchStore.getState().sendNudge(); + useMatchStore.getState().clearMatches(); + fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 })); + await staleLimit; + expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull(); + }); + + it('does not let a stale cooldown read clear the next account\'s cooldown', async () => { + let release: (profile: unknown) => void = () => undefined; + (appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const stale = useMatchStore.getState().checkNudgeCooldown(); + + useMatchStore.getState().clearMatches(); + const mine = new Date('2026-08-27T18:00:00Z'); + useMatchStore.setState({ nudgeCooldownUntil: mine }); + // The previous account not being in cooldown says nothing about this one. + release({ last_nudge_sent_at: null }); + await stale; + expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine); + }); + + it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = useMatchStore.getState().fetchPendingQuestions(); + + useMatchStore.getState().clearMatches(); + release({ questions: [pending('stale')] }); + await staleFetch; + + expect(useMatchStore.getState().pendingQuestions).toEqual([]); + + (apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] }); + await useMatchStore.getState().fetchPendingQuestions(); + expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']); + }); + }); + it('clearMatches resets all user-scoped state', () => { useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true }); useMatchStore.getState().clearMatches(); diff --git a/apps/mobile/src/__tests__/stores/messageStore.test.ts b/apps/mobile/src/__tests__/stores/messageStore.test.ts index d9be140..a7a6444 100644 --- a/apps/mobile/src/__tests__/stores/messageStore.test.ts +++ b/apps/mobile/src/__tests__/stores/messageStore.test.ts @@ -47,15 +47,25 @@ describe('messageStore API state', () => { expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } }); }); - it('marks partner messages read then reconciles global and per-match counts', async () => { + it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => { + useMessageStore.setState({ unreadCount: 3 }); (chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' }); - (chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } }); await useMessageStore.getState().markMatchMessagesAsRead('m1'); expect(chatApi.markRead).toHaveBeenCalledWith('m1'); + expect(chatApi.unread).not.toHaveBeenCalled(); expect(useMessageStore.getState().unreadCount).toBe(1); expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1); }); + it('spends no request and touches no count when the read API cleared nothing', async () => { + useMessageStore.setState({ unreadCount: 3 }); + (chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null }); + await useMessageStore.getState().markMatchMessagesAsRead('m1'); + expect(chatApi.unread).not.toHaveBeenCalled(); + expect(useMessageStore.getState().unreadCount).toBe(3); + expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3); + }); + it('does not mark read without an authenticated user', async () => { useAuthStore.setState({ user: null } as any); await useMessageStore.getState().markMatchMessagesAsRead('m1'); @@ -69,6 +79,42 @@ describe('messageStore API state', () => { expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3); }); + describe('account isolation (generation tokens)', () => { + it('does not let a stale unread total become the next account\'s badge', async () => { + let release: (value: unknown) => void = () => undefined; + (chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = useMessageStore.getState().fetchUnreadCount(); + + // Sign-out or leaving a couple, while the request is still open. + useMessageStore.getState().clearMessages(); + useMessageStore.setState({ unreadCount: 2 }); + + release({ total: 9, by_match: { m1: 9 } }); + await staleFetch; + + expect(useMessageStore.getState().unreadCount).toBe(2); + }); + + it('does not subtract a stale read receipt from the next account\'s badge', async () => { + useMessageStore.setState({ unreadCount: 3 }); + let release: (value: unknown) => void = () => undefined; + (chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1'); + + useMessageStore.getState().clearMessages(); + useMessageStore.setState({ unreadCount: 2 }); + useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] }); + + release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' }); + await staleRead; + + // The badge is wrong whatever the match id; reusing 'm1' also makes the + // second half of the damage — the per-match count — visible. + expect(useMessageStore.getState().unreadCount).toBe(2); + expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2); + }); + }); + it('clears transient message state', () => { useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' }); useMessageStore.getState().clearLastMessage(); diff --git a/apps/mobile/src/__tests__/stores/packsStore.test.ts b/apps/mobile/src/__tests__/stores/packsStore.test.ts index d62cfda..efa8ce5 100644 --- a/apps/mobile/src/__tests__/stores/packsStore.test.ts +++ b/apps/mobile/src/__tests__/stores/packsStore.test.ts @@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => { expect(getSpy).not.toHaveBeenCalled(); expect(usePacksStore.getState().enabledPackIds).toEqual([]); }); + + describe('account isolation (generation tokens)', () => { + it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = usePacksStore.getState().fetchEnabledPacks(); + + // Simulates sign-out/account switch while the request is still in flight. + usePacksStore.getState().clearPacks(); + release({ enabledPackIds: ['stale-pack'] }); + await staleFetch; + + expect(usePacksStore.getState().enabledPackIds).toEqual([]); + + getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] }); + await usePacksStore.getState().fetchEnabledPacks(); + expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']); + }); + + it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = usePacksStore.getState().fetchPackProgress(); + + usePacksStore.getState().clearPacks(); + release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] }); + await staleFetch; + + expect(usePacksStore.getState().packProgress.size).toBe(0); + + getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] }); + await usePacksStore.getState().fetchPackProgress(); + expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 }); + }); + + it('does not let a stale toggle write the previous couple\'s pack selection', async () => { + usePacksStore.setState({ enabledPackIds: [] } as any); + let release: (value: unknown) => void = () => undefined; + jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleToggle = usePacksStore.getState().togglePack('pack1'); + + usePacksStore.getState().clearPacks(); + usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any); + + release({ enabledPackIds: ['pack1'] }); + await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' }); + expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']); + }); + + it('does not roll a failed toggle back into the next couple\'s selection', async () => { + usePacksStore.setState({ enabledPackIds: ['pack1'] } as any); + let reject: (reason: unknown) => void = () => undefined; + jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const staleToggle = usePacksStore.getState().togglePack('pack1'); + + usePacksStore.getState().clearPacks(); + usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any); + + reject(new Error('offline')); + await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' }); + // The rollback would have re-added pack1, which this couple never enabled. + expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']); + }); + }); }); diff --git a/apps/mobile/src/__tests__/stores/responsesStore.test.ts b/apps/mobile/src/__tests__/stores/responsesStore.test.ts index 707789e..df71fd2 100644 --- a/apps/mobile/src/__tests__/stores/responsesStore.test.ts +++ b/apps/mobile/src/__tests__/stores/responsesStore.test.ts @@ -265,6 +265,53 @@ describe('responsesStore', () => { }); }); + describe('account isolation (generation tokens)', () => { + it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => { + let resolve!: (value: unknown) => void; + apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const staleFetch = useResponsesStore.getState().fetchResponses(true); + + // Simulates sign-out/account switch while the request is still in flight. + useResponsesStore.getState().clearResponses(); + resolve({ responses: [response({ id: 'stale' })], totalCount: 1 }); + await staleFetch; + + expect(useResponsesStore.getState().responses).toEqual([]); + + apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 }); + await useResponsesStore.getState().fetchResponses(true); + expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']); + }); + + it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => { + // Question ids come from the shared catalogue, so the same id names a real + // row in both accounts' lists: a stale edit would land on one of them. + const fetchMatches = jest.fn(); + useMatchStore.setState({ fetchMatches } as any); + useResponsesStore.setState({ + responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })], + }); + + let resolve!: (value: unknown) => void; + apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes'); + + useResponsesStore.getState().clearResponses(); + const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })]; + useResponsesStore.setState({ responses: next }); + + resolve({ success: true, new_match: { id: 'm2' } }); + // The caller still hears what the server did for the account that asked. + await expect(staleEdit).resolves.toMatchObject({ success: true }); + + expect(useResponsesStore.getState().responses).toBe(next); + expect(useResponsesStore.getState().responses[0]).toMatchObject({ + id: 'bob', answer: 'no', has_match: false, + }); + expect(fetchMatches).not.toHaveBeenCalled(); + }); + }); + describe('state controls', () => { it('sets and toggles grouping options', () => { useResponsesStore.getState().setGroupBy('pack'); diff --git a/apps/mobile/src/__tests__/stores/streakStore.test.ts b/apps/mobile/src/__tests__/stores/streakStore.test.ts new file mode 100644 index 0000000..4a95415 --- /dev/null +++ b/apps/mobile/src/__tests__/stores/streakStore.test.ts @@ -0,0 +1,58 @@ +import { apiClient } from '@/lib/apiClient'; +import { useAuthStore } from '@/store/authStore'; +import { type CoupleStreak, useStreakStore } from '@/store/streakStore'; + +jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } })); + +const apiGet = apiClient.get as jest.Mock; + +function streak(overrides: Partial = {}): CoupleStreak { + return { + couple_id: 'couple1', current_streak: 1, longest_streak: 1, + last_active_date: '2026-08-27', last_completed_date: '2026-08-27', + you_answered_today: true, partner_answered_today: false, + partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0, + created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z', + ...overrides, + }; +} + +describe('streakStore account isolation (generation tokens)', () => { + beforeEach(() => { + jest.clearAllMocks(); + useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 }); + useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any); + }); + + it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => { + let resolve!: (value: unknown) => void; + apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const staleFetch = useStreakStore.getState().fetchStreak(); + + // Simulates sign-out/account switch while the request is still in flight. + useStreakStore.getState().clearStreak(); + resolve({ streak: streak({ couple_id: 'stale-couple' }) }); + await staleFetch; + + expect(useStreakStore.getState().streak).toBeNull(); + expect(useStreakStore.getState().loadedAt).toBeNull(); + + apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) }); + await useStreakStore.getState().fetchStreak(); + expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple'); + expect(useStreakStore.getState().loadedAt).not.toBeNull(); + }); + + it('refreshStreak silently reloads only an already-loaded streak', async () => { + await useStreakStore.getState().refreshStreak(); + expect(apiGet).not.toHaveBeenCalled(); + + apiGet.mockResolvedValueOnce({ streak: streak() }); + await useStreakStore.getState().fetchStreak(); + expect(useStreakStore.getState().loadedAt).not.toBeNull(); + + apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) }); + await useStreakStore.getState().refreshStreak(); + expect(useStreakStore.getState().streak?.current_streak).toBe(2); + }); +}); diff --git a/apps/mobile/src/components/StreakDisplay.tsx b/apps/mobile/src/components/StreakDisplay.tsx index fd2a63d..88c8b1f 100644 --- a/apps/mobile/src/components/StreakDisplay.tsx +++ b/apps/mobile/src/components/StreakDisplay.tsx @@ -1,227 +1,227 @@ -import React, { useEffect } from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated'; -import { useStreakStore } from '../store'; -import type { CoupleStreak } from '../store'; -import { colors, spacing, radius, typography, shadows } from '../theme'; - -interface StreakDisplayProps { - /** Whether to show the "longest streak" indicator */ - showLongest?: boolean; - /** Animation delay for entry animation */ - delay?: number; - /** Compact mode for smaller displays */ - compact?: boolean; -} - -/** - * The count is deliberately framed as a couple's, not a personal score: a shared streak - * is the thing worth keeping. The status line is the working half of that framing - it - * says who the day is waiting on - and the copy stays an invitation, because guilt - * between partners is the failure mode for this mechanic rather than the goal. - */ -export function streakStatus(streak: CoupleStreak): { headline: string; status: string } { - const named = streak.partner_name?.trim() || null; - // The same fallback reads wrong in both positions, so keep a sentence-start form. - const subject = named ?? 'Your partner'; - const object = named ?? 'your partner'; - const days = streak.current_streak; - const both = streak.you_answered_today && streak.partner_answered_today; - - const headline = days > 0 - ? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row` - : `You and ${object}`; - - if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' }; - if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` }; - if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` }; - return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' }; -} - -/** - * Displays the couple's shared streak. The flame pulses while the streak is alive. - */ -export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) { - const { streak, fetchStreak } = useStreakStore(); - - // Fetch streak on mount - useEffect(() => { - fetchStreak(); - }, [fetchStreak]); - - // Flame pulse animation - const scale = useSharedValue(1); - const hasStreak = (streak?.current_streak ?? 0) > 0; - - useEffect(() => { - if (hasStreak) { - scale.value = withRepeat( - withSequence( - withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), - withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }) - ), - -1, - true - ); - } else { - scale.value = 1; - } - }, [hasStreak, scale]); - - const flameStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - if (!streak) { - return null; - } - - // A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply - // have not played yet reads as a scolding for a streak they never started. - if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) { - return null; - } - - const { headline, status } = streakStatus(streak); - const currentStreak = streak.current_streak; - const longestStreak = streak.longest_streak; - // The partner having moved first is the one state the couple can still act on today. - const awaitingYou = streak.partner_answered_today && !streak.you_answered_today; - - if (compact) { - return ( - - - - - - - {currentStreak} - - - - ); - } - - return ( - - - - - - - - - - - {headline} - - - {status} - - - - - {showLongest && longestStreak > 0 && ( - - - - Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''} - - - )} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - marginVertical: spacing.sm, - }, - card: { - backgroundColor: colors.surface, - borderRadius: radius.lg, - padding: spacing.md, - borderWidth: 1, - borderColor: colors.border, - }, - cardAwaiting: { - borderColor: colors.warning, - }, - content: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - iconContainer: { - width: 44, - height: 44, - borderRadius: radius.full, - backgroundColor: colors.warningLight, - justifyContent: 'center', - alignItems: 'center', - }, - info: { - flex: 1, - }, - headline: { - ...typography.headline, - color: colors.text, - }, - status: { - ...typography.subhead, - color: colors.textSecondary, - marginTop: 2, - }, - statusAwaiting: { - color: colors.warning, - }, - inactiveText: { - color: colors.textTertiary, - }, - longest: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - marginTop: spacing.sm, - paddingTop: spacing.sm, - borderTopWidth: 1, - borderTopColor: colors.border, - }, - longestText: { - ...typography.caption1, - color: colors.textTertiary, - }, - // Compact styles - compactContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - backgroundColor: colors.warningLight, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: radius.full, - }, - compactAwaiting: { - borderWidth: 1, - borderColor: colors.warning, - }, - compactCount: { - ...typography.caption1, - fontWeight: '600', - color: colors.warning, - }, -}); - -export default StreakDisplay; +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated'; +import { useStreakStore } from '../store'; +import type { CoupleStreak } from '../store'; +import { colors, spacing, radius, typography, shadows } from '../theme'; + +interface StreakDisplayProps { + /** Whether to show the "longest streak" indicator */ + showLongest?: boolean; + /** Animation delay for entry animation */ + delay?: number; + /** Compact mode for smaller displays */ + compact?: boolean; +} + +/** + * The count is deliberately framed as a couple's, not a personal score: a shared streak + * is the thing worth keeping. The status line is the working half of that framing - it + * says who the day is waiting on - and the copy stays an invitation, because guilt + * between partners is the failure mode for this mechanic rather than the goal. + */ +export function streakStatus(streak: CoupleStreak): { headline: string; status: string } { + const named = streak.partner_name?.trim() || null; + // The same fallback reads wrong in both positions, so keep a sentence-start form. + const subject = named ?? 'Your partner'; + const object = named ?? 'your partner'; + const days = streak.current_streak; + const both = streak.you_answered_today && streak.partner_answered_today; + + const headline = days > 0 + ? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row` + : `You and ${object}`; + + if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' }; + if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` }; + if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` }; + return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' }; +} + +/** + * Displays the couple's shared streak. The flame pulses while the streak is alive. + */ +export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) { + const { streak, ensureStreakLoaded } = useStreakStore(); + + // Fetch streak on mount + useEffect(() => { + void ensureStreakLoaded(); + }, [ensureStreakLoaded]); + + // Flame pulse animation + const scale = useSharedValue(1); + const hasStreak = (streak?.current_streak ?? 0) > 0; + + useEffect(() => { + if (hasStreak) { + scale.value = withRepeat( + withSequence( + withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }) + ), + -1, + true + ); + } else { + scale.value = 1; + } + }, [hasStreak, scale]); + + const flameStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + if (!streak) { + return null; + } + + // A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply + // have not played yet reads as a scolding for a streak they never started. + if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) { + return null; + } + + const { headline, status } = streakStatus(streak); + const currentStreak = streak.current_streak; + const longestStreak = streak.longest_streak; + // The partner having moved first is the one state the couple can still act on today. + const awaitingYou = streak.partner_answered_today && !streak.you_answered_today; + + if (compact) { + return ( + + + + + + + {currentStreak} + + + + ); + } + + return ( + + + + + + + + + + + {headline} + + + {status} + + + + + {showLongest && longestStreak > 0 && ( + + + + Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginVertical: spacing.sm, + }, + card: { + backgroundColor: colors.surface, + borderRadius: radius.lg, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + }, + cardAwaiting: { + borderColor: colors.warning, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + iconContainer: { + width: 44, + height: 44, + borderRadius: radius.full, + backgroundColor: colors.warningLight, + justifyContent: 'center', + alignItems: 'center', + }, + info: { + flex: 1, + }, + headline: { + ...typography.headline, + color: colors.text, + }, + status: { + ...typography.subhead, + color: colors.textSecondary, + marginTop: 2, + }, + statusAwaiting: { + color: colors.warning, + }, + inactiveText: { + color: colors.textTertiary, + }, + longest: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + longestText: { + ...typography.caption1, + color: colors.textTertiary, + }, + // Compact styles + compactContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: colors.warningLight, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.full, + }, + compactAwaiting: { + borderWidth: 1, + borderColor: colors.warning, + }, + compactCount: { + ...typography.caption1, + fontWeight: '600', + color: colors.warning, + }, +}); + +export default StreakDisplay; diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index c2ec77b..620cf85 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; @@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => { const isFocused = useIsFocused(); - // Typing indicator hook + // Each hook needs a value the other produces: the message poll reports typing + // state, and an arriving message clears the indicator. The ref breaks the cycle. + const clearTypingRef = useRef<() => void>(() => undefined); + const handleNewMessage = useCallback(() => clearTypingRef.current(), []); + + // Message subscription hook + const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({ + matchId, + userId: user?.id, + onNewMessage: handleNewMessage, + isFocused, + }); + + // Typing indicator hook, reading the state the message poll already fetched const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({ channelName: `chat:${matchId}`, matchId, userId: user?.id, isFocused, + externalTyping: syncedTyping, }); - // Message subscription hook - const { messages, setMessages, isFocusedRef } = useMessageSubscription({ - matchId, - userId: user?.id, - onNewMessage: clearTypingIndicator, - }); + useEffect(() => { + clearTypingRef.current = clearTypingIndicator; + }, [clearTypingIndicator]); // Message actions hook for deletion and reporting @@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => { // Track active chat to prevent notifications for current chat useFocusEffect( useCallback(() => { - isFocusedRef.current = true; if (matchId) { setActiveMatchId(matchId); markMatchMessagesAsRead(matchId); Events.matchViewed(); } - return () => { - isFocusedRef.current = false; - setActiveMatchId(null); - }; + return () => setActiveMatchId(null); }, [matchId, setActiveMatchId, markMatchMessagesAsRead]) ); diff --git a/apps/mobile/src/features/dares/DaresScreen.tsx b/apps/mobile/src/features/dares/DaresScreen.tsx index 16f1606..1273ff8 100644 --- a/apps/mobile/src/features/dares/DaresScreen.tsx +++ b/apps/mobile/src/features/dares/DaresScreen.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { useIsFocused } from "@react-navigation/native"; import Animated, { FadeIn } from "react-native-reanimated"; import { GradientBackground } from "../../components/ui"; import { Paywall } from "../../components/paywall"; @@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [ ]; export function DaresScreen() { - const dares = useDares(); + const isFocused = useIsFocused(); + const dares = useDares({ isFocused }); const [tab, setTab] = useState("active"); const [sheetPack, setSheetPack] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); diff --git a/apps/mobile/src/features/dares/hooks/useDares.ts b/apps/mobile/src/features/dares/hooks/useDares.ts index 9c21627..b0d73dd 100644 --- a/apps/mobile/src/features/dares/hooks/useDares.ts +++ b/apps/mobile/src/features/dares/hooks/useDares.ts @@ -1,11 +1,21 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { daresApi } from "../../../lib/daresApi"; import { ApiError } from "../../../lib/apiClient"; +import { usePolling } from "../../../hooks/usePolling"; import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types"; /** Matches the chat poll cadence; the API has no realtime channel. */ const POLL_INTERVAL_MS = 5_000; +export interface UseDaresConfig { + /** + * Supplied by the screen rather than read from navigation here, so the hook + * stays renderable without a navigator. Polling stops when the screen is not + * on top; `usePolling` additionally stops it when the app backgrounds. + */ + isFocused?: boolean; +} + export interface UseDaresReturn { active: SentDare[]; history: SentDare[]; @@ -25,7 +35,19 @@ export interface UseDaresReturn { cancel: (dareId: string) => Promise; } -export function useDares(): UseDaresReturn { +/** + * Identity of the active set as the user experiences it. History, stats and the + * remaining-send allowance only move when a dare enters or leaves this set or + * changes status, so this is the signal that says the slower endpoints are worth + * re-reading. + */ +const activeSignature = (dares: SentDare[]): string => + dares + .map(dare => `${dare.id}:${dare.status}`) + .sort() + .join("|"); + +export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn { const [active, setActive] = useState([]); const [history, setHistory] = useState([]); const [catalog, setCatalog] = useState(null); @@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn { const [paywallReason, setPaywallReason] = useState(null); const fetchingRef = useRef(false); const mountedRef = useRef(true); + const signatureRef = useRef(null); - const refresh = useCallback(async () => { + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + /** + * `full` reads everything; the polling path reads only the active list and + * follows up on the rest when that list actually changed. A failure updates + * the UI's error state either way, but is only rethrown for the poll path + * (`throwOnError`): `usePolling` needs the rejection to drive its backoff, + * while pull-to-refresh and post-mutation callers already surface the error + * themselves and must not turn it into an unhandled rejection. + */ + const load = useCallback(async (full: boolean, throwOnError = false) => { if (fetchingRef.current) return; fetchingRef.current = true; try { - const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([ - daresApi.list("active"), - daresApi.list("history"), - daresApi.catalog(), - daresApi.stats(), - ]); + const activeResult = await daresApi.list("active"); if (!mountedRef.current) return; setActive(activeResult.dares); - setHistory(historyResult.dares); - setCatalog(catalogResult); - setStats(statsResult); + + const signature = activeSignature(activeResult.dares); + const changed = signature !== signatureRef.current; + signatureRef.current = signature; + + if (full || changed) { + const [historyResult, catalogResult, statsResult] = await Promise.all([ + daresApi.list("history"), + daresApi.catalog(), + daresApi.stats(), + ]); + if (!mountedRef.current) return; + setHistory(historyResult.dares); + setCatalog(catalogResult); + setStats(statsResult); + } setError(null); } catch (cause) { if (mountedRef.current) { setError(cause instanceof ApiError ? cause.message : "Could not load dares"); } + if (throwOnError) throw cause; } finally { fetchingRef.current = false; if (mountedRef.current) setLoading(false); } }, []); - useEffect(() => { - mountedRef.current = true; - void refresh(); - const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS); - return () => { - mountedRef.current = false; - clearInterval(timer); - }; - }, [refresh]); + /** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */ + const refresh = useCallback(() => load(true), [load]); + const poll = useCallback(() => load(false, true), [load]); + + usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused }); /** Premium refusals steer to the paywall; everything else surfaces inline. */ const handleFailure = useCallback((cause: unknown) => { diff --git a/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx b/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx index 38404f3..305fcfd 100644 --- a/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx +++ b/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx @@ -8,6 +8,7 @@ import { useWindowDimensions, } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useIsFocused } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; @@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => { const insets = useSafeAreaInsets(); const { user } = useAuthStore(); const coupleId = coupleIdParam || user?.couple_id; + const isFocused = useIsFocused(); const { width: screenWidth } = useWindowDimensions(); const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]); @@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => { const sync = useDrawingSync({ coupleId: coupleId!, userId: user?.id, + isFocused, onStrokeStart: handleStrokeStartFromPartner, onStrokeContinue: handleStrokeContinueFromPartner, onStrokeEnd: handleStrokeEndFromPartner, diff --git a/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts b/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts index 762b342..7afb207 100644 --- a/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts +++ b/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts @@ -1,9 +1,13 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ApiError } from '../../../lib/apiClient'; import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi'; +import { usePolling } from '../../../hooks/usePolling'; import type { StrokeSegment, StrokePoint } from '../types'; import { updateWidget } from '../utils/widgetBridge'; +/** Fast enough for a shared canvas to feel live, and only while one is on screen. */ +const POLL_INTERVAL_MS = 750; + interface UseDrawingSyncConfig { coupleId: string; userId: string | undefined; @@ -14,6 +18,12 @@ interface UseDrawingSyncConfig { onUndo: (strokeId: string) => void; onRedo: (stroke: StrokeSegment) => void; onLoadStrokes: (strokes: StrokeSegment[]) => void; + /** + * Supplied by the screen rather than read from navigation here, so the hook + * stays renderable without a navigator. A canvas nobody is looking at polls + * nothing; `usePolling` also stops it when the app goes to the background. + */ + isFocused?: boolean; } interface UseDrawingSyncReturn { @@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen } export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn { - const { coupleId, userId, onLoadStrokes } = config; + const { coupleId, userId, onLoadStrokes, isFocused = true } = config; const revisionRef = useRef(0); const baselineRef = useRef([]); const workingRef = useRef([]); @@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error)); }, [coupleId, acceptState]); - useEffect(() => { - if (!coupleId || !userId) return; - let cancelled = false; - const poll = async () => { - if (pendingWritesRef.current > 0) return; - try { - const state = await appDataApi.getLiveDraw(); - if (!cancelled && state.revision > revisionRef.current) acceptState(state); - } catch (error) { console.error('Failed to poll live drawing:', error); } - }; - const timer = setInterval(() => void poll(), 750); - return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); }; - }, [coupleId, userId, acceptState]); + // Our own writes already return the authoritative state, so reading over the top + // of one in flight would only race it back to a revision we are about to replace. + const poll = useCallback(async () => { + if (pendingWritesRef.current > 0) return; + const state = await appDataApi.getLiveDraw(); + if (state.revision > revisionRef.current) acceptState(state); + }, [acceptState]); + + // `leading: false` because the mount effect above already read the initial state; + // usePolling adds the overlap guard, backoff and background pause the raw + // interval had none of. + usePolling(poll, { + intervalMs: POLL_INTERVAL_MS, + enabled: Boolean(coupleId) && Boolean(userId) && isFocused, + leading: false, + resetKey: `${coupleId}:${userId ?? ''}`, + }); + + useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []); const persistStrokes = useCallback((strokes: StrokeSegment[]) => { if (throttleRef.current) clearTimeout(throttleRef.current); diff --git a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts index 3d7bc8b..2561b34 100644 --- a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts +++ b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useAuthStore, usePacksStore, useStreakStore } from "../../../store"; +import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store"; import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions"; import { ApiError, apiClient } from "../../../lib/apiClient"; import { Events } from "../../../lib/analytics"; @@ -352,6 +352,12 @@ export const useSwipeScreen = () => { // wrong partner state for the rest of the session. Refreshing it must never // interfere with recording the answer, so it stays off the critical path. void useStreakStore.getState().fetchStreak().catch(() => undefined); + + // This answer changed pack progress and the answer history. Marking their + // caches stale costs no request: whichever screen the user opens next + // reloads once, and the ones they do not open stay quiet. + usePacksStore.getState().invalidatePacks(); + useResponsesStore.getState().invalidateResponses(); } catch (error) { // A 429 means the server rejected this answer outright. Do not advance the // index, or the question is silently skipped and lost for the day. diff --git a/apps/mobile/src/hooks/index.ts b/apps/mobile/src/hooks/index.ts index 6b01130..126e8c9 100644 --- a/apps/mobile/src/hooks/index.ts +++ b/apps/mobile/src/hooks/index.ts @@ -1,4 +1,7 @@ export { useAmbientOrbAnimation } from './useAmbientOrbAnimation'; +export { usePolling } from './usePolling'; +export type { UsePollingOptions } from './usePolling'; +export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync'; export type { AmbientOrbConfig } from './useAmbientOrbAnimation'; export { useFeatureInterest } from './useFeatureInterest'; export { useMediaPicker } from './useMediaPicker'; diff --git a/apps/mobile/src/hooks/useCoupleSync.ts b/apps/mobile/src/hooks/useCoupleSync.ts new file mode 100644 index 0000000..3293e63 --- /dev/null +++ b/apps/mobile/src/hooks/useCoupleSync.ts @@ -0,0 +1,116 @@ +import { useCallback, useRef } from 'react'; +import { appDataApi, type SyncSummary } from '../lib/appDataApi'; +import { useAuthStore } from '../store/authStore'; +import { useMatchStore } from '../store/matchStore'; +import { useMessageStore } from '../store/messageStore'; +import { usePacksStore } from '../store/packsStore'; +import { useStreakStore } from '../store/streakStore'; +import { usePolling } from './usePolling'; + +/** + * Slower than the five-second full refresh it replaces, because the request it + * makes is a change summary rather than five payloads: the app reacts to a + * partner's action within this window, but pays one small query for it. + */ +export const SYNC_INTERVAL_MS = 15_000; + +/** + * Keeps cached domain data honest without re-fetching it. Each poll reads one + * summary of the couple's state and refreshes only the domains whose markers + * moved — and only the ones already on screen, so a view nobody has opened is + * not loaded in the background. + */ +export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void { + const previousRef = useRef(null); + const identityRef = useRef(''); + // `sync` is memoized on a stable callback, so `taskRef` inside usePolling + // never has to restart the schedule on every render; it reads the current + // identity through this ref instead of closing over the arguments from + // whichever render created it. + const identityValuesRef = useRef({ userId, coupleId }); + identityValuesRef.current = { userId, coupleId }; + + const latestIdentity = useCallback(() => { + const { userId, coupleId } = identityValuesRef.current; + return `${userId ?? ''}:${coupleId ?? ''}`; + }, []); + + const sync = useCallback(async () => { + const identity = latestIdentity(); + if (identity !== identityRef.current) { + // A new account's first summary must not be diffed against the prior + // account's markers, or every domain would silently "refresh" on login. + identityRef.current = identity; + previousRef.current = null; + } + + const summary = await appDataApi.syncSummary(); + // The account can change while this request is in flight. The response + // describes the identity that asked for it, so once that is no longer the + // current one it may not touch anything: not the badge, not the baseline + // the next account will diff against, and not a single refresh decision. + // Both checks matter — `identityRef` catches a newer sync that has already + // started, and `latestIdentity()` catches a switch that has only rendered. + if (identityRef.current !== identity || latestIdentity() !== identity) return; + + const previous = previousRef.current; + previousRef.current = summary; + + // The badge count comes straight out of the summary, so keeping it current + // costs nothing beyond this request. + if (summary.unread_total !== previous?.unread_total) { + useMessageStore.getState().setUnreadCount(summary.unread_total); + } + // The first poll only establishes the baseline: every screen loads its own + // data on mount, and refreshing it again here would undo the saving. + if (!previous) return; + + const auth = useAuthStore.getState(); + const matchStore = useMatchStore.getState(); + const refreshes: Promise[] = []; + + if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) { + // fetchUser also refreshes the couple when one is set. + refreshes.push(auth.fetchUser()); + } else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) { + refreshes.push(auth.fetchCouple()); + } + + if (matchStore.loadedAt.active !== undefined && ( + summary.match_count !== previous.match_count || + summary.new_match_count !== previous.new_match_count || + summary.latest_match_at !== previous.latest_match_at || + // Neither count moves when a partner edits an existing match's type or + // response summary through response editing. + summary.match_state_fingerprint !== previous.match_state_fingerprint || + // `unread_total` can hold steady while unread redistributes between + // matches (one read, another receives a message), which reorders the + // unread-first list without moving the total. + summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) { + refreshes.push(matchStore.fetchMatches(true, { silent: true })); + } + if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) { + refreshes.push(matchStore.fetchPendingQuestions({ silent: true })); + } + if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) { + refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true })); + } + // Only refresh a domain the user has actually opened, matching the rule + // that unopened domains stay unloaded. + if (usePacksStore.getState().enabledPacksLoadedAt !== null + && summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) { + refreshes.push(usePacksStore.getState().fetchEnabledPacks()); + } + if (summary.streak_updated_at !== previous.streak_updated_at) { + refreshes.push(useStreakStore.getState().refreshStreak()); + } + + await Promise.allSettled(refreshes); + }, [latestIdentity]); + + usePolling(sync, { + intervalMs: SYNC_INTERVAL_MS, + enabled: Boolean(userId), + resetKey: `${userId ?? ''}:${coupleId ?? ''}`, + }); +} diff --git a/apps/mobile/src/hooks/useMessageSubscription.ts b/apps/mobile/src/hooks/useMessageSubscription.ts index 95651e5..3ebdf3d 100644 --- a/apps/mobile/src/hooks/useMessageSubscription.ts +++ b/apps/mobile/src/hooks/useMessageSubscription.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { chatApi } from '../lib/chatApi'; +import { chatApi, type MessagesPage } from '../lib/chatApi'; +import { usePolling } from './usePolling'; import type { Message } from '../features/chat/types'; export interface UseMessageSubscriptionConfig { @@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig { userId: string | undefined; onNewMessage?: () => void; pollInterval?: number; + /** Whether the chat is on screen. Polling and read receipts stop when it is not. */ + isFocused?: boolean; } export interface UseMessageSubscriptionReturn { messages: Message[]; setMessages: React.Dispatch>; loading: boolean; - isFocusedRef: React.MutableRefObject; + /** Partner typing state folded into the message poll; undefined until the server reports it. */ + partnerTyping: boolean | undefined; + /** Fetches the next delta now, e.g. straight after sending. */ + refresh: () => Promise; } -/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */ -export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => { +const timeOf = (message: Message): number => { + const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN; + return Number.isNaN(parsed) ? 0 : parsed; +}; + +/** Newest first, matching the order the API returns a snapshot in. */ +const byNewest = (a: Message, b: Message): number => { + const delta = timeOf(b) - timeOf(a); + if (delta !== 0) return delta; + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; +}; + +/** + * Returns the previous array unchanged when a delta carries nothing, so an idle + * poll re-renders neither this hook's consumers nor the message list. + */ +export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => { + if (page.complete !== false) return page.messages; + const removed = page.removed_ids ?? []; + if (page.messages.length === 0 && removed.length === 0) return previous; + + const byId = new Map(previous.map(message => [message.id, message])); + for (const message of page.messages) byId.set(message.id, message); + for (const id of removed) byId.delete(id); + return [...byId.values()].sort(byNewest); +}; + +/** + * Authenticated API polling replaces Supabase Realtime for self-hosted chat. + * + * Each poll asks only for what changed since the cursor the server issued last + * time, so an idle conversation costs an all-but-empty response instead of the + * five hundred messages the screen already has. Read receipts are written only + * when the response actually contains an unread message from the partner, which + * on an idle chat is never. + */ +export const useMessageSubscription = ({ + matchId, + userId, + onNewMessage, + pollInterval = 2_000, + isFocused = true, +}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => { const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const isFocusedRef = useRef(false); + const [loading, setLoading] = useState(Boolean(matchId && userId)); + const [partnerTyping, setPartnerTyping] = useState(undefined); + const sinceRef = useRef(null); const knownIdsRef = useRef>(new Set()); - const fetchingRef = useRef(false); + const loadingRef = useRef(true); + const notifyRef = useRef(onNewMessage); + notifyRef.current = onNewMessage; + + // A different conversation shares none of this one's state, and the poll below + // restarts on the same key so the new one loads immediately. + useEffect(() => { + sinceRef.current = null; + knownIdsRef.current = new Set(); + loadingRef.current = Boolean(matchId && userId); + setMessages([]); + setPartnerTyping(undefined); + setLoading(loadingRef.current); + }, [matchId, userId]); - const refresh = useCallback(async (initial = false) => { - if (!matchId || !userId || fetchingRef.current) return; - fetchingRef.current = true; + const refresh = useCallback(async () => { + if (!matchId || !userId) return; + const since = sinceRef.current; try { - const response = await chatApi.listMessages(matchId); - const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id)); - knownIdsRef.current = new Set(response.messages.map(message => message.id)); - setMessages(response.messages); - if (isFocusedRef.current) await chatApi.markRead(matchId); - if (partnerArrived) onNewMessage?.(); + const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true }); + // Without a cursor the server cannot answer with deltas, so keep asking + // for snapshots rather than silently freezing on a stale page. + sinceRef.current = page.server_time ?? null; + if (page.typing) setPartnerTyping(page.typing.typing); + + const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id)); + for (const message of page.messages) knownIdsRef.current.add(message.id); + for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id); + setMessages(previous => mergeMessagePage(previous, page)); + + if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) { + await chatApi.markRead(matchId); + } + if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.(); } finally { - fetchingRef.current = false; - if (initial) setLoading(false); + if (loadingRef.current) { + loadingRef.current = false; + setLoading(false); + } } - }, [matchId, userId, onNewMessage]); + }, [matchId, userId, isFocused]); - useEffect(() => { - if (!matchId || !userId) { - setMessages([]); - setLoading(false); - return; - } - setLoading(true); - knownIdsRef.current = new Set(); - void refresh(true).catch(() => undefined); - const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval); - return () => clearInterval(timer); - }, [matchId, userId, pollInterval, refresh]); + usePolling(refresh, { + intervalMs: pollInterval, + enabled: Boolean(matchId && userId && isFocused), + resetKey: `${matchId ?? ''}:${userId ?? ''}`, + }); - return { messages, setMessages, loading, isFocusedRef }; + return { messages, setMessages, loading, partnerTyping, refresh }; }; diff --git a/apps/mobile/src/hooks/usePolling.ts b/apps/mobile/src/hooks/usePolling.ts new file mode 100644 index 0000000..3ca066e --- /dev/null +++ b/apps/mobile/src/hooks/usePolling.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react'; +import { isForeground, subscribeToForeground } from '../lib/appForeground'; + +export interface UsePollingOptions { + intervalMs: number; + /** Caller-owned relevance: screen focus, a loaded id, a paired couple. */ + enabled?: boolean; + /** Run once as soon as polling becomes active. Default true. */ + leading?: boolean; + /** Ceiling for the exponential backoff applied after consecutive failures. */ + maxIntervalMs?: number; + /** + * Restarts the schedule when it changes, so a caller that switches subject — + * a different chat, a different couple — gets an immediate run instead of + * waiting out the interval left over from the previous one. + */ + resetKey?: string | number; +} + +/** + * A recurring task with the three properties every poller in this app needs and + * none of them had: it stops while the app is backgrounded, it never runs twice + * concurrently, and it backs off instead of hammering an API that is failing. + * + * The next run is scheduled after the previous one settles rather than on a fixed + * `setInterval`, so a slow response delays the next request instead of queueing + * one behind it. `task` is read through a ref, so a caller may pass a fresh + * closure every render without restarting the schedule. + */ +export function usePolling(task: () => Promise, options: UsePollingOptions): void { + const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options; + const taskRef = useRef(task); + taskRef.current = task; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let running = false; + let failures = 0; + let foreground = isForeground(); + let timer: ReturnType | null = null; + + const clear = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + const schedule = () => { + if (cancelled || !foreground || timer !== null) return; + const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs); + timer = setTimeout(() => { + timer = null; + void run(); + }, delay); + }; + const run = async () => { + if (cancelled || running || !foreground) return; + running = true; + try { + await taskRef.current(); + failures = 0; + } catch { + failures += 1; + } finally { + running = false; + schedule(); + } + }; + const unsubscribe = subscribeToForeground((next) => { + if (next === foreground) return; + foreground = next; + if (!foreground) { + clear(); + return; + } + // Back from the background: the cached data is as stale as the absence + // was long, so catch up now rather than after another full interval. + failures = 0; + void run(); + }); + + if (leading) void run(); + else schedule(); + + return () => { + cancelled = true; + clear(); + unsubscribe(); + }; + }, [enabled, intervalMs, leading, maxIntervalMs, resetKey]); +} diff --git a/apps/mobile/src/hooks/useTypingIndicator.ts b/apps/mobile/src/hooks/useTypingIndicator.ts index 6b1edb2..fbc0be3 100644 --- a/apps/mobile/src/hooks/useTypingIndicator.ts +++ b/apps/mobile/src/hooks/useTypingIndicator.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { chatApi } from '../lib/chatApi'; +import { usePolling } from './usePolling'; export interface UseTypingIndicatorConfig { channelName: string; @@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig { isFocused?: boolean; throttleInterval?: number; pollInterval?: number; + /** + * Typing state already known from another request. When supplied this hook + * stops polling for it, which is how the chat screen gets typing state for + * free out of the message poll instead of a second request every interval. + */ + externalTyping?: boolean | undefined; } export interface UseTypingIndicatorReturn { @@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn { clearTypingIndicator: () => void; } -export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => { +export const useTypingIndicator = ({ + channelName, + userId, + matchId = channelName.replace(/^chat:/, ''), + isFocused = true, + throttleInterval = 2_000, + pollInterval = 1_500, + externalTyping, +}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => { const [partnerTyping, setPartnerTyping] = useState(false); const lastTypingEventRef = useRef(0); + const external = externalTyping !== undefined; useEffect(() => { - if (!matchId || !userId || !isFocused) { + if (externalTyping !== undefined) setPartnerTyping(externalTyping); + }, [externalTyping]); + + // Losing the subject stops both sources of truth, so drop the stale indicator + // rather than leaving the partner typing forever in a chat nobody is watching. + useEffect(() => { + if (!matchId || !userId || !isFocused) setPartnerTyping(false); + }, [matchId, userId, isFocused]); + + const poll = useCallback(async () => { + try { + const state = await chatApi.getTyping(matchId); + setPartnerTyping(state.typing); + } catch { setPartnerTyping(false); - return; } - let active = true; - const poll = async () => { - try { - const state = await chatApi.getTyping(matchId); - if (active) setPartnerTyping(state.typing); - } catch { - if (active) setPartnerTyping(false); - } - }; - void poll(); - const timer = setInterval(() => void poll(), pollInterval); - return () => { - active = false; - clearInterval(timer); - }; - }, [matchId, userId, isFocused, pollInterval]); + }, [matchId]); + + usePolling(poll, { + intervalMs: pollInterval, + enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused, + resetKey: matchId, + }); const sendTypingEvent = useCallback(() => { if (!matchId || !userId) return; diff --git a/apps/mobile/src/lib/appDataApi.ts b/apps/mobile/src/lib/appDataApi.ts index f29ad54..ca2e07d 100644 --- a/apps/mobile/src/lib/appDataApi.ts +++ b/apps/mobile/src/lib/appDataApi.ts @@ -10,7 +10,36 @@ export interface LiveDrawState { updated_by: string | null; } +/** + * A change summary, not a payload: markers the client compares against the ones + * it saw last time to decide which domain, if any, is worth refetching. It + * replaces a periodic full refresh of profile, couple, matches, pending + * questions, packs and unread counts with one small request. + */ +export interface SyncSummary { + server_time: string; + couple_id: string | null; + profile_updated_at: string | null; + partner_id: string | null; + partner_updated_at: string | null; + match_count: number; + new_match_count: number; + latest_match_at: string | null; + pending_yours: number; + pending_theirs: number; + unread_total: number; + /** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */ + enabled_packs_fingerprint: string | null; + /** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */ + match_state_fingerprint: string; + /** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */ + match_unread_fingerprint: string; + /** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */ + streak_updated_at: string | null; +} + export const appDataApi = { + syncSummary: () => apiClient.get('/v1/me/sync'), packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`), packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`), packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`), diff --git a/apps/mobile/src/lib/appForeground.ts b/apps/mobile/src/lib/appForeground.ts new file mode 100644 index 0000000..5ace386 --- /dev/null +++ b/apps/mobile/src/lib/appForeground.ts @@ -0,0 +1,21 @@ +import { AppState, type AppStateStatus } from 'react-native'; + +/** + * `background` is a real suspension and `inactive` is the moment behind the app + * switcher, a call, or a system prompt. Anything else — including states a test + * harness never sets — counts as foreground, so recurring work fails towards + * running rather than towards being silently disabled forever. + */ +export function isForegroundState(state: AppStateStatus | string | undefined): boolean { + return state !== 'background' && state !== 'inactive'; +} + +export function isForeground(): boolean { + return isForegroundState(AppState.currentState as AppStateStatus | undefined); +} + +/** Reports the new foreground flag on every transition. Returns an unsubscribe. */ +export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void { + const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state))); + return () => subscription.remove(); +} diff --git a/apps/mobile/src/lib/chatApi.ts b/apps/mobile/src/lib/chatApi.ts index ebf0184..9e9a3b6 100644 --- a/apps/mobile/src/lib/chatApi.ts +++ b/apps/mobile/src/lib/chatApi.ts @@ -1,8 +1,40 @@ import { apiClient } from './apiClient'; import type { Message, ReportReason } from '../features/chat/types'; +export interface PartnerTyping { + typing: boolean; + expires_at: string | null; +} + +/** + * A snapshot when `complete`, otherwise only what changed since the cursor the + * previous page handed back. Delta rows are whole messages, not patches, so a + * caller merges them by id without a second shape to understand. + */ +export interface MessagesPage { + messages: Message[]; + /** Messages the caller deleted for themselves since the cursor. */ + removed_ids?: string[]; + /** Cursor to send as `since` on the next poll. */ + server_time?: string; + complete?: boolean; + /** Present when requested, saving a separate typing request. */ + typing?: PartnerTyping; +} + +export interface ListMessagesOptions { + since?: string; + typing?: boolean; +} + export const chatApi = { - listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`), + listMessages: (matchId: string, options: ListMessagesOptions = {}) => { + const query: string[] = []; + if (options.since) query.push(`since=${encodeURIComponent(options.since)}`); + if (options.typing) query.push('typing=true'); + const suffix = query.length > 0 ? `?${query.join('&')}` : ''; + return apiClient.get(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`); + }, sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }), unread: () => apiClient.get<{ total: number; by_match: Record }>('/v1/chat/unread'), markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`), @@ -11,5 +43,5 @@ export const chatApi = { deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`), report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }), setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`), - getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`), + getTyping: (matchId: string) => apiClient.get(`/v1/matches/${encodeURIComponent(matchId)}/typing`), }; diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index ea217fe..8e478c5 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -4,104 +4,106 @@ import { authClient } from "../lib/authClient"; import { coupleApi } from "../lib/coupleApi"; import { profileSettingsApi } from "../lib/profileSettingsApi"; import { syncTimezone } from "../lib/reportedTimezone"; -import { Events } from "../lib/analytics"; -import type { Profile, Couple } from "@/types"; - - -interface AuthState { - user: Profile | null; - couple: Couple | null; - partner: Profile | null; - isLoading: boolean; - isAuthenticated: boolean; - isAnonymous: boolean; - - - // Actions - fetchUser: () => Promise; - fetchCouple: () => Promise; - refreshPartner: () => Promise; - signOut: () => Promise; - setUser: (user: Profile | null) => void; - updateLastActive: () => Promise; -} - -// Import other stores lazily to avoid circular dependency issues -const getOtherStores = () => { - const { useMatchStore } = require("./matchStore"); - const { usePacksStore } = require("./packsStore"); - const { useMessageStore } = require("./messageStore"); - const { useSubscriptionStore } = require("./subscriptionStore"); - const { useNotificationPreferencesStore } = require("./notificationPreferencesStore"); - const { useStreakStore } = require("./streakStore"); - return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore }; -}; - - -export const useAuthStore = create((set, get) => ({ - user: null, - couple: null, - partner: null, - isLoading: true, - isAuthenticated: false, - isAnonymous: false, - - fetchUser: async () => { - try { - // First check if we have a session in storage +import { Events } from "../lib/analytics"; +import type { Profile, Couple } from "@/types"; + + +interface AuthState { + user: Profile | null; + couple: Couple | null; + partner: Profile | null; + isLoading: boolean; + isAuthenticated: boolean; + isAnonymous: boolean; + + + // Actions + fetchUser: () => Promise; + fetchCouple: () => Promise; + refreshPartner: () => Promise; + signOut: () => Promise; + setUser: (user: Profile | null) => void; + updateLastActive: () => Promise; +} + +// Import other stores lazily to avoid circular dependency issues +const getOtherStores = () => { + const { useMatchStore } = require("./matchStore"); + const { usePacksStore } = require("./packsStore"); + const { useMessageStore } = require("./messageStore"); + const { useSubscriptionStore } = require("./subscriptionStore"); + const { useNotificationPreferencesStore } = require("./notificationPreferencesStore"); + const { useStreakStore } = require("./streakStore"); + const { useResponsesStore } = require("./responsesStore"); + return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore }; +}; + + +export const useAuthStore = create((set, get) => ({ + user: null, + couple: null, + partner: null, + isLoading: true, + isAuthenticated: false, + isAnonymous: false, + + fetchUser: async () => { + try { + // First check if we have a session in storage const { data: { session } } = await authClient.auth.getSession(); - - if (!session?.user) { - set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); - return; - } - + + if (!session?.user) { + set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); + return; + } + // This endpoint verifies the Supabase access token and creates the // product profile on first use, keeping profile data out of Supabase. const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me"); const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous; - - // Only update user if data actually changed to avoid unnecessary re-renders - const currentUser = get().user; - const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile); - if (userChanged) { - set({ - user: profile, - isAuthenticated: true, - isAnonymous, - }); - } else if (!get().isAuthenticated) { - set({ isAuthenticated: true, isAnonymous }); - } - - // If user has a couple, fetch couple data; otherwise clear couple/partner - if (profile?.couple_id) { - await get().fetchCouple(); - } else { - set({ couple: null, partner: null }); - } - - set({ isLoading: false }); + + // Only update user if data actually changed to avoid unnecessary re-renders + const currentUser = get().user; + const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile); + if (userChanged) { + set({ + user: profile, + isAuthenticated: true, + isAnonymous, + }); + } else if (!get().isAuthenticated) { + set({ isAuthenticated: true, isAnonymous }); + } + + // If user has a couple, fetch couple data; otherwise clear couple/partner + if (profile?.couple_id) { + await get().fetchCouple(); + } else { + set({ couple: null, partner: null }); + } + + set({ isLoading: false }); } catch (error) { if (error instanceof ApiError && error.status === 401) { console.log("[Auth] Session rejected by API, signing out"); set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); useMatchStore.getState().clearMatches(); usePacksStore.getState().clearPacks(); useMessageStore.getState().clearMessages(); useSubscriptionStore.getState().clearSubscription(); useNotificationPreferencesStore.getState().clearPreferences(); useStreakStore.getState().clearStreak(); + useResponsesStore.getState().clearResponses(); await authClient.auth.signOut(); return; } console.error("Error fetching user:", error); set({ isLoading: false }); - } - }, - + } + }, + fetchCouple: async () => { const user = get().user; if (!user?.couple_id) { @@ -111,100 +113,102 @@ export const useAuthStore = create((set, get) => ({ const { couple, partner } = await coupleApi.getState(); set({ couple, partner }); - }, - - /** - * Refresh partner's profile data (useful to get latest public_key_jwk for E2EE) - * Returns the updated partner profile or null if not found - */ - refreshPartner: async () => { - const user = get().user; - if (!user?.couple_id) return null; - + }, + + /** + * Refresh partner's profile data (useful to get latest public_key_jwk for E2EE) + * Returns the updated partner profile or null if not found + */ + refreshPartner: async () => { + const user = get().user; + if (!user?.couple_id) return null; + const { partner } = await coupleApi.getState(); - - if (partner) { - set({ partner }); - } - return partner; - }, - - signOut: async () => { - Events.signOut(); - - // Clear local state FIRST to ensure UI updates even if Supabase call fails - set({ - user: null, - couple: null, - partner: null, - isAuthenticated: false, - isAnonymous: false, - isLoading: false, - }); - // Clear other stores - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); - useMatchStore.getState().clearMatches(); - usePacksStore.getState().clearPacks(); - useMessageStore.getState().clearMessages(); - useSubscriptionStore.getState().clearSubscription(); - useNotificationPreferencesStore.getState().clearPreferences(); - useStreakStore.getState().clearStreak(); - - // Clear badge on sign out - const { clearBadge } = require("../lib/badge"); - await clearBadge(); - - // Then try to sign out from Supabase (don't block on this) - try { + + if (partner) { + set({ partner }); + } + return partner; + }, + + signOut: async () => { + Events.signOut(); + + // Clear local state FIRST to ensure UI updates even if Supabase call fails + set({ + user: null, + couple: null, + partner: null, + isAuthenticated: false, + isAnonymous: false, + isLoading: false, + }); + // Clear other stores + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); + useMatchStore.getState().clearMatches(); + usePacksStore.getState().clearPacks(); + useMessageStore.getState().clearMessages(); + useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); + useResponsesStore.getState().clearResponses(); + + // Clear badge on sign out + const { clearBadge } = require("../lib/badge"); + await clearBadge(); + + // Then try to sign out from Supabase (don't block on this) + try { await authClient.auth.signOut(); - } catch (error) { - console.error("Supabase signOut error:", error); - } - }, - - setUser: (user) => { - set({ - user, - isAuthenticated: !!user, - ...(user === null && { isAnonymous: false }), - isLoading: false, - // Clear couple/partner when user is null (signed out) - ...(user === null && { couple: null, partner: null }) - }); - // Clear other stores when user signs out - if (user === null) { - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); - useMatchStore.getState().clearMatches(); - usePacksStore.getState().clearPacks(); - useMessageStore.getState().clearMessages(); - useSubscriptionStore.getState().clearSubscription(); - useNotificationPreferencesStore.getState().clearPreferences(); - useStreakStore.getState().clearStreak(); - // Clear badge on sign out - const { clearBadge } = require("../lib/badge"); - clearBadge(); - } - }, - - /** - * Update the user's last_active_at timestamp. - * Called when the app comes to the foreground to track user activity. - * This helps prevent sending notifications to users who are already in the app. - */ - updateLastActive: async () => { - const userId = get().user?.id; - if (!userId) return; - - try { + } catch (error) { + console.error("Supabase signOut error:", error); + } + }, + + setUser: (user) => { + set({ + user, + isAuthenticated: !!user, + ...(user === null && { isAnonymous: false }), + isLoading: false, + // Clear couple/partner when user is null (signed out) + ...(user === null && { couple: null, partner: null }) + }); + // Clear other stores when user signs out + if (user === null) { + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); + useMatchStore.getState().clearMatches(); + usePacksStore.getState().clearPacks(); + useMessageStore.getState().clearMessages(); + useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); + useResponsesStore.getState().clearResponses(); + // Clear badge on sign out + const { clearBadge } = require("../lib/badge"); + clearBadge(); + } + }, + + /** + * Update the user's last_active_at timestamp. + * Called when the app comes to the foreground to track user activity. + * This helps prevent sending notifications to users who are already in the app. + */ + updateLastActive: async () => { + const userId = get().user?.id; + if (!userId) return; + + try { await profileSettingsApi.updateLastActive(); - } catch (error) { - // Silently fail - this is not critical - console.error("Failed to update last_active_at:", error); - } - - // Keep the server's idea of the user's timezone current so the daily - // response limit resets at their local midnight, not 00:00 UTC. - void syncTimezone(userId); - }, -})); - + } catch (error) { + // Silently fail - this is not critical + console.error("Failed to update last_active_at:", error); + } + + // Keep the server's idea of the user's timezone current so the daily + // response limit resets at their local midnight, not 00:00 UTC. + void syncTimezone(userId); + }, +})); + diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts index 583b7ff..c282d41 100644 --- a/apps/mobile/src/store/matchStore.ts +++ b/apps/mobile/src/store/matchStore.ts @@ -15,6 +15,20 @@ export interface PendingQuestion { type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn'; +/** + * A refresh that must not disturb what the user is looking at: no spinner, and + * the cached rows stay on screen until the replacement arrives. + */ +export interface FetchOptions { silent?: boolean } + +// Overlap protection lives outside the store because the loading flags are now +// presentational — a silent refresh sets none of them and still must not run twice. +// Keyed to the generation that started the request (see `generation` below) rather +// than a bare presence check: a stale generation's entry must not block the next +// account's request for the same key, and a stale generation's `finally` must not +// clear an entry a newer generation just claimed. +const inFlight = new Map(); + // Rate limit: 12 hours in milliseconds const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000; @@ -32,6 +46,8 @@ interface MatchState { newMatchesCount: number; totalCount: number | null; isLoading: boolean; + /** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */ + isRefreshing: boolean; isLoadingMore: boolean; page: number; hasMore: boolean; @@ -48,11 +64,23 @@ interface MatchState { theirTurnQuestions: PendingQuestion[]; isLoadingTheirTurn: boolean; currentView: MatchViewType; + /** + * When each view last loaded successfully. Tracked separately from the row + * arrays because an empty view is still a loaded view, and revisiting one + * must not refetch. + */ + loadedAt: Partial>; // Nudge state nudgeCooldownUntil: Date | null; isNudging: boolean; + /** + * Bumped on every clear (sign-out/account switch). A fetch captures this before + * it starts and checks it again before writing its result, so a response that + * outlives the clear cannot land in the next account's store. + */ + generation: number; // Methods - fetchMatches: (refresh?: boolean) => Promise; + fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise; markAsSeen: (matchId: string) => Promise; markAllAsSeen: () => Promise; addMatch: (match: Match) => void; @@ -61,13 +89,14 @@ interface MatchState { // Archive methods archiveMatch: (matchId: string) => Promise; unarchiveMatch: (matchId: string) => Promise; - fetchArchivedMatches: () => Promise; + fetchArchivedMatches: (options?: FetchOptions) => Promise; toggleShowArchived: () => void; isMatchArchived: (matchId: string) => boolean; // Pending methods - fetchPendingQuestions: () => Promise; - fetchTheirTurnQuestions: () => Promise; + fetchPendingQuestions: (options?: FetchOptions) => Promise; + fetchTheirTurnQuestions: (options?: FetchOptions) => Promise; setCurrentView: (view: MatchViewType) => void; + ensureViewLoaded: (view?: MatchViewType) => void; // Nudge methods sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>; checkNudgeCooldown: () => Promise; @@ -78,6 +107,7 @@ export const useMatchStore = create((set, get) => ({ newMatchesCount: 0, totalCount: null, isLoading: false, + isRefreshing: false, isLoadingMore: false, page: 0, hasMore: true, @@ -94,40 +124,53 @@ export const useMatchStore = create((set, get) => ({ theirTurnQuestions: [], isLoadingTheirTurn: false, currentView: 'pending' as MatchViewType, + loadedAt: {}, // Nudge state nudgeCooldownUntil: null, isNudging: false, + generation: 0, - fetchMatches: async (refresh = false) => { - const userId = useAuthStore.getState().user?.id; + fetchMatches: async (refresh = false, options = {}) => { const coupleId = useAuthStore.getState().user?.couple_id; + const myGeneration = get().generation; // Early return if no couple - user isn't paired yet if (!coupleId) { - set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false }); + set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false }); return; } const state = get(); - if (state.isLoading || (state.isLoadingMore && !refresh)) return; + if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return; + // Nothing on screen yet is the only case that warrants blanking the list + // for a spinner; every other refresh leaves the cached rows in place. + const initial = state.loadedAt.active === undefined; if (refresh) { - set({ isLoading: true, error: null, page: 0, hasMore: true }); + set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true }); } else { if (!state.hasMore) return; set({ isLoadingMore: true }); } + inFlight.set('active', myGeneration); try { const currentPage = refresh ? 0 : state.page; - const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`); + const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`); + // A sign-out/account switch that happened while this request was in + // flight must not let its response populate the next account's store. + if (get().generation !== myGeneration) return; const nonArchivedMatches = result.matches; const totalCount = result.totalCount; const archivedMatchIds = state.archivedMatchIds; + // The API reports this by reading one row past the page; the length + // comparison is only a fallback for an older server. + const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE; + const loadedAt = { ...state.loadedAt, active: Date.now() }; if (nonArchivedMatches.length === 0) { if (refresh) { - set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds }); + set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt }); } else { set({ isLoadingMore: false, hasMore: false }); } @@ -139,7 +182,7 @@ export const useMatchStore = create((set, get) => ({ // Calculate new matches count - this is tricky with pagination // Ideally we should have a separate query for count, but we'll just count in current batch const newMatchesInBatch = sortedData.filter((m) => m.is_new).length; - + set((state) => { // Deduplicate matches by ID when appending new pages const mergedMatches = refresh @@ -154,22 +197,34 @@ export const useMatchStore = create((set, get) => ({ return { matches: mergedMatches, newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch, - totalCount: refresh ? totalCount : state.totalCount, + // Later pages no longer carry a total, so keep the one the refresh recorded. + totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount, isLoading: false, + isRefreshing: false, isLoadingMore: false, page: currentPage + 1, - hasMore: nonArchivedMatches.length === BATCH_SIZE, + hasMore, archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds, + loadedAt, }; }); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching matches:", err); - set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false }); + set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false }); + } finally { + // An old generation's cleanup must not delete a newer generation's guard. + if (inFlight.get('active') === myGeneration) inFlight.delete('active'); } }, markAsSeen: async (matchId) => { + const myGeneration = get().generation; await apiClient.patch("/v1/matches/seen", { ids: [matchId] }); + // A clear while this was in flight leaves an empty list behind, and the + // recount below would write that emptiness — and a zero badge — over + // whatever the next account has already loaded. + if (get().generation !== myGeneration) return; const matches = get().matches.map((m) => m.id === matchId ? { ...m, is_new: false } : m @@ -179,11 +234,13 @@ export const useMatchStore = create((set, get) => ({ }, markAllAsSeen: async () => { + const myGeneration = get().generation; const newMatches = get().matches.filter((m) => m.is_new); if (newMatches.length === 0) return; const newMatchIds = newMatches.map((m) => m.id); await apiClient.patch("/v1/matches/seen", { ids: newMatchIds }); + if (get().generation !== myGeneration) return; const matches = get().matches.map((m) => m.is_new ? { ...m, is_new: false } : m @@ -210,11 +267,12 @@ export const useMatchStore = create((set, get) => ({ }, clearMatches: () => { - set({ + set((state) => ({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, + isRefreshing: false, error: null, page: 0, hasMore: true, @@ -228,9 +286,16 @@ clearMatches: () => { theirTurnQuestions: [], isLoadingTheirTurn: false, currentView: 'pending', + loadedAt: {}, nudgeCooldownUntil: null, isNudging: false, - }); + // 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, + })); + // A request belonging to the signed-out user must not suppress the next + // one: the guard is a duplicate-request check, not a lock on the data. + inFlight.clear(); }, // Archive methods @@ -240,6 +305,7 @@ clearMatches: () => { // Optimistically update UI const state = get(); + const myGeneration = state.generation; const matchToArchive = state.matches.find(m => m.id === matchId); if (matchToArchive) { @@ -258,7 +324,11 @@ clearMatches: () => { await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true }); } catch (error) { console.error("Error archiving match:", error); - // Revert on error + // Revert on error — but only into the store the optimistic update was + // applied to. After an account switch the row it would restore belongs + // to the previous couple, and putting it back would show one couple's + // match inside another's list. + if (get().generation !== myGeneration) return; if (matchToArchive) { const revertArchivedIds = new Set(get().archivedMatchIds); revertArchivedIds.delete(matchId); @@ -279,6 +349,7 @@ clearMatches: () => { // Optimistically update UI const state = get(); + const myGeneration = state.generation; const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId); if (matchToUnarchive) { @@ -297,7 +368,8 @@ clearMatches: () => { await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false }); } catch (error) { console.error("Error unarchiving match:", error); - // Revert on error + // Revert on error, and only for the account that started it. + if (get().generation !== myGeneration) return; if (matchToUnarchive) { const revertArchivedIds = new Set(get().archivedMatchIds); revertArchivedIds.add(matchId); @@ -312,34 +384,35 @@ clearMatches: () => { } }, - fetchArchivedMatches: async () => { + fetchArchivedMatches: async (options = {}) => { const userId = useAuthStore.getState().user?.id; const coupleId = useAuthStore.getState().user?.couple_id; if (!coupleId || !userId) return; + const myGeneration = get().generation; const state = get(); - if (state.isLoadingArchived) return; - - set({ isLoadingArchived: true }); + if (inFlight.get('archived') === myGeneration) return; + if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true }); + inFlight.set('archived', myGeneration); try { - const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`); + const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`); + if (get().generation !== myGeneration) return; const archivedMatches = result.matches; const archivedIds = archivedMatches.map(match => match.id); - if (archivedMatches.length === 0) { - set({ archivedMatches: [], isLoadingArchived: false }); - return; - } - - set({ + set((current) => ({ archivedMatches, isLoadingArchived: false, archivedMatchIds: new Set(archivedIds), - }); + loadedAt: { ...current.loadedAt, archived: Date.now() }, + })); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching archived matches:", err); set({ isLoadingArchived: false }); + } finally { + if (inFlight.get('archived') === myGeneration) inFlight.delete('archived'); } }, @@ -350,8 +423,8 @@ clearMatches: () => { set({ showArchived: newShowArchived }); // Fetch archived matches if switching to archived view and not yet loaded - if (newShowArchived && state.archivedMatches.length === 0) { - get().fetchArchivedMatches(); + if (newShowArchived && state.loadedAt.archived === undefined) { + void get().fetchArchivedMatches(); } }, @@ -360,7 +433,7 @@ clearMatches: () => { }, // Pending (Your Turn) methods - fetchPendingQuestions: async () => { + fetchPendingQuestions: async (options = {}) => { const userId = useAuthStore.getState().user?.id; const coupleId = useAuthStore.getState().user?.couple_id; @@ -369,22 +442,27 @@ clearMatches: () => { return; } + const myGeneration = get().generation; const state = get(); - if (state.isLoadingPending) return; - - set({ isLoadingPending: true }); + if (inFlight.get('pending') === myGeneration) return; + if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true }); + inFlight.set('pending', myGeneration); try { const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner"); - set({ pendingQuestions, isLoadingPending: false }); + if (get().generation !== myGeneration) return; + set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } })); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching pending questions:", err); set({ isLoadingPending: false }); + } finally { + if (inFlight.get('pending') === myGeneration) inFlight.delete('pending'); } }, // Their Turn methods (user answered, partner hasn't yet) - fetchTheirTurnQuestions: async () => { + fetchTheirTurnQuestions: async (options = {}) => { const userId = useAuthStore.getState().user?.id; const coupleId = useAuthStore.getState().user?.couple_id; @@ -393,35 +471,43 @@ clearMatches: () => { return; } + const myGeneration = get().generation; const state = get(); - if (state.isLoadingTheirTurn) return; - - set({ isLoadingTheirTurn: true }); + if (inFlight.get('their_turn') === myGeneration) return; + if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true }); + inFlight.set('their_turn', myGeneration); try { const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine"); - set({ theirTurnQuestions, isLoadingTheirTurn: false }); + if (get().generation !== myGeneration) return; + set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } })); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching their turn questions:", err); set({ isLoadingTheirTurn: false }); + } finally { + if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn'); } }, setCurrentView: (view: MatchViewType) => { - const state = get(); - // Update showArchived for backwards compatibility const showArchived = view === 'archived'; set({ currentView: view, showArchived }); - // Fetch data for the selected view if not already loaded - if (view === 'archived' && state.archivedMatches.length === 0) { - get().fetchArchivedMatches(); - } else if (view === 'pending' && state.pendingQuestions.length === 0) { - get().fetchPendingQuestions(); - } else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) { - get().fetchTheirTurnQuestions(); - } + get().ensureViewLoaded(view); + }, + + /** + * Loads a view once. Emptiness is not evidence of never having loaded, so + * returning to a legitimately empty view costs no request at all. + */ + ensureViewLoaded: (view = get().currentView) => { + if (get().loadedAt[view] !== undefined) return; + if (view === 'archived') void get().fetchArchivedMatches(); + else if (view === 'pending') void get().fetchPendingQuestions(); + else if (view === 'their_turn') void get().fetchTheirTurnQuestions(); + else void get().fetchMatches(true); }, // Nudge methods @@ -436,10 +522,14 @@ clearMatches: () => { return { success: false, notificationSent: false }; } + const myGeneration = state.generation; set({ isNudging: true }); try { const data = await appDataApi.sendNudge(); + // The cooldown is per-account. Recording the previous account's window + // would either silence the new account's nudge or clear a real cooldown. + if (get().generation !== myGeneration) return { success: false, notificationSent: false }; // Success - update cooldown const cooldownUntil = data.next_nudge_available_at @@ -453,6 +543,7 @@ clearMatches: () => { notificationSent: data.notification_sent ?? false, }; } catch (err) { + if (get().generation !== myGeneration) return { success: false, notificationSent: false }; if (err instanceof ApiError && err.status === 429) { const details = err.details as NudgeResponse | undefined; const cooldownUntil = details?.next_nudge_available_at @@ -471,8 +562,12 @@ clearMatches: () => { const userId = useAuthStore.getState().user?.id; if (!userId) return; + const myGeneration = get().generation; try { const profile = await appDataApi.nudgeStatus(); + // Read for the previous account: neither its cooldown nor its absence + // says anything about whether this one may nudge. + if (get().generation !== myGeneration) return; if (!profile.last_nudge_sent_at) { set({ nudgeCooldownUntil: null }); diff --git a/apps/mobile/src/store/messageStore.ts b/apps/mobile/src/store/messageStore.ts index c3dac86..b0a01a1 100644 --- a/apps/mobile/src/store/messageStore.ts +++ b/apps/mobile/src/store/messageStore.ts @@ -1,85 +1,110 @@ -import { create } from "zustand"; +import { create } from "zustand"; import { chatApi } from "../lib/chatApi"; -import type { Database } from "@/types/supabase"; -import { useAuthStore } from "./authStore"; -import { useMatchStore } from "./matchStore"; - -type Message = Database["public"]["Tables"]["messages"]["Row"]; - -interface MessageWithMatch extends Message { - match?: { - id: string; - question: { - text: string; - }; - }; -} - -interface MessageState { - unreadCount: number; - lastMessage: MessageWithMatch | null; - activeMatchId: string | null; // Track which chat is currently open - fetchUnreadCount: () => Promise; - addMessage: (message: MessageWithMatch) => void; - clearLastMessage: () => void; - setActiveMatchId: (matchId: string | null) => void; - markMatchMessagesAsRead: (matchId: string) => Promise; - clearMessages: () => void; -} - -export const useMessageStore = create((set, get) => ({ - unreadCount: 0, - lastMessage: null, - activeMatchId: null, - - fetchUnreadCount: async () => { - const userId = useAuthStore.getState().user?.id; - if (!userId) { - set({ unreadCount: 0 }); - return; - } - +import type { Database } from "@/types/supabase"; +import { useAuthStore } from "./authStore"; +import { useMatchStore } from "./matchStore"; + +type Message = Database["public"]["Tables"]["messages"]["Row"]; + +interface MessageWithMatch extends Message { + match?: { + id: string; + question: { + text: string; + }; + }; +} + +interface MessageState { + unreadCount: number; + lastMessage: MessageWithMatch | null; + activeMatchId: string | null; // Track which chat is currently open + /** + * Bumped by `clearMessages` on sign-out or a couple change, so a reply that + * outlives the account that asked for it cannot write into the next one's badge. + */ + generation: number; + fetchUnreadCount: () => Promise; + /** Applies a count the sync summary already reported, without a second request. */ + setUnreadCount: (total: number) => void; + addMessage: (message: MessageWithMatch) => void; + clearLastMessage: () => void; + setActiveMatchId: (matchId: string | null) => void; + markMatchMessagesAsRead: (matchId: string) => Promise; + clearMessages: () => void; +} + +export const useMessageStore = create((set, get) => ({ + unreadCount: 0, + lastMessage: null, + activeMatchId: null, + generation: 0, + + fetchUnreadCount: async () => { + const userId = useAuthStore.getState().user?.id; + if (!userId) { + set({ unreadCount: 0 }); + return; + } + + const myGeneration = get().generation; const { total } = await chatApi.unread(); + // This total counts one account's messages; after a clear it would show + // up as the next account's badge. + if (get().generation !== myGeneration) return; set({ unreadCount: total }); - }, - - addMessage: (message) => { - const userId = useAuthStore.getState().user?.id; - const activeMatchId = get().activeMatchId; - - // Only show notification if message is from partner and not in the active chat - if (message.user_id !== userId && message.match_id !== activeMatchId) { - set((state) => ({ - unreadCount: state.unreadCount + 1, - lastMessage: message, - })); - } - }, - - clearLastMessage: () => { - set({ lastMessage: null }); - }, - - setActiveMatchId: (matchId) => { - set({ activeMatchId: matchId }); - }, - - markMatchMessagesAsRead: async (matchId) => { - const userId = useAuthStore.getState().user?.id; - if (!userId) return; - + }, + + setUnreadCount: (total) => { + set({ unreadCount: Math.max(0, total) }); + }, + + addMessage: (message) => { + const userId = useAuthStore.getState().user?.id; + const activeMatchId = get().activeMatchId; + + // Only show notification if message is from partner and not in the active chat + if (message.user_id !== userId && message.match_id !== activeMatchId) { + set((state) => ({ + unreadCount: state.unreadCount + 1, + lastMessage: message, + })); + } + }, + + clearLastMessage: () => { + set({ lastMessage: null }); + }, + + setActiveMatchId: (matchId) => { + set({ activeMatchId: matchId }); + }, + + markMatchMessagesAsRead: async (matchId) => { + const userId = useAuthStore.getState().user?.id; + if (!userId) return; + + const myGeneration = get().generation; const { updated: unreadInMatch } = await chatApi.markRead(matchId); - - // Refetch global unread count - await get().fetchUnreadCount(); - - // Sync per-match unread count in matchStore - if (unreadInMatch > 0) { - useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch); - } - }, - - clearMessages: () => { - set({ unreadCount: 0, lastMessage: null, activeMatchId: null }); - }, -})); + if (unreadInMatch <= 0) return; + + // Those messages belonged to a couple that is no longer loaded, and the + // match they were in is not in the current list, so subtracting them now + // would take the old badge off the new account's. + if (get().generation !== myGeneration) return; + + // The server just told us exactly how many it cleared, so subtract instead + // of spending a request to re-read the total; the sync summary corrects + // any drift on its next pass. + set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) })); + useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch); + }, + + clearMessages: () => { + set((state) => ({ + unreadCount: 0, lastMessage: null, activeMatchId: null, + // Invalidates any request already in flight for the account being left. + generation: state.generation + 1, + })); + }, +})); diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts index c41ea36..6e6934f 100644 --- a/apps/mobile/src/store/packsStore.ts +++ b/apps/mobile/src/store/packsStore.ts @@ -4,6 +4,12 @@ import { Events } from "../lib/analytics"; import type { QuestionPack, Category } from "@/types"; import { useAuthStore } from "./authStore"; +// The catalog load fans out into three requests, so a second caller arriving mid-flight +// must wait for the first rather than triple the cost. Keyed to the generation that +// started the request: a stale generation's entry must not block the next account's +// request for the same key, and its `finally` must not clear a newer generation's guard. +const inFlight = new Map(); + // Progress data for a pack export interface PackProgressData { totalQuestions: number; @@ -16,10 +22,23 @@ interface PacksState { enabledPackIds: string[]; packProgress: Map; isLoading: boolean; + /** + * When the catalog, enabled set and progress were last read together. Null means + * never — which is not the same as "empty", so a couple with no packs enabled + * stops refetching after the first load. + */ + catalogLoadedAt: number | null; + /** Tracked separately because the swipe screen loads the enabled set on its own. */ + enabledPacksLoadedAt: number | null; + /** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */ + generation: number; fetchPacks: () => Promise; fetchEnabledPacks: () => Promise; fetchPackProgress: () => Promise; + ensurePacksLoaded: () => Promise; ensureEnabledPacksLoaded: () => Promise; + /** Marks the cached catalog stale without spending a request; the next ensure reloads it. */ + invalidatePacks: () => void; togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>; clearPacks: () => void; getPackProgress: (packId: string) => PackProgressData | undefined; @@ -31,38 +50,61 @@ export const usePacksStore = create((set, get) => ({ enabledPackIds: [], packProgress: new Map(), isLoading: false, + catalogLoadedAt: null, + enabledPacksLoadedAt: null, + generation: 0, fetchPacks: async () => { + const myGeneration = get().generation; + if (inFlight.get('catalog') === myGeneration) return; + inFlight.set('catalog', myGeneration); set({ isLoading: true }); try { const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>( '/v1/packs?showAllIntensities=false', ); + if (get().generation !== myGeneration) return; set({ packs: catalog.packs, categories: catalog.categories }); await get().fetchEnabledPacks(); await get().fetchPackProgress(); + if (get().generation !== myGeneration) return; + set({ catalogLoadedAt: Date.now() }); } finally { - set({ isLoading: false }); + if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog'); + if (get().generation === myGeneration) set({ isLoading: false }); } }, + ensurePacksLoaded: async () => { + if (get().catalogLoadedAt !== null) return; + await get().fetchPacks(); + }, + + invalidatePacks: () => { + set({ catalogLoadedAt: null }); + }, + fetchEnabledPacks: async () => { const coupleId = useAuthStore.getState().user?.couple_id; + const myGeneration = get().generation; if (!coupleId) { - set({ enabledPackIds: [] }); + set({ enabledPackIds: [], enabledPacksLoadedAt: null }); return; } const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs"); - set({ enabledPackIds: result.enabledPackIds }); + if (get().generation !== myGeneration) return; + set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() }); }, ensureEnabledPacksLoaded: async () => { - // Only fetch if not already loaded - lightweight check for swipe screen + // Only fetch if not already loaded - lightweight check for swipe screen. + // Keyed off the load marker, not the array: "no packs enabled" is a real + // answer and must not be retried on every visit. const coupleId = useAuthStore.getState().user?.couple_id; if (!coupleId) return; - if (get().enabledPackIds.length === 0) { + if (get().enabledPacksLoadedAt === null) { await get().fetchEnabledPacks(); } }, @@ -73,6 +115,7 @@ export const usePacksStore = create((set, get) => ({ return { success: false, reason: "no_couple" }; } + const myGeneration = get().generation; const isEnabled = get().enabledPackIds.includes(packId); const newValue = !isEnabled; @@ -88,9 +131,16 @@ export const usePacksStore = create((set, get) => ({ `/v1/me/enabled-packs/${packId}`, { enabled: newValue }, ); + // These ids belong to the couple that asked; a clear while the request + // was open means they would land in a different couple's selection. + if (get().generation !== myGeneration) return { success: false, reason: "stale" }; set({ enabledPackIds: result.enabledPackIds }); } catch (error) { console.error("Error toggling pack:", error); + // The optimistic update this reverts was cleared along with everything + // else, so reverting now would only push a stale pack id into the next + // couple's list. + if (get().generation !== myGeneration) return { success: false, reason: "stale" }; set({ enabledPackIds: isEnabled ? [...new Set([...get().enabledPackIds, packId])] : get().enabledPackIds.filter(id => id !== packId) }); @@ -108,6 +158,7 @@ export const usePacksStore = create((set, get) => ({ }, fetchPackProgress: async () => { + const myGeneration = get().generation; if (!useAuthStore.getState().user?.id) { set({ packProgress: new Map() }); return; @@ -115,6 +166,7 @@ export const usePacksStore = create((set, get) => ({ const result = await apiClient.get<{ progress: Array }>( "/v1/me/pack-progress", ); + if (get().generation !== myGeneration) return; const progressMap = new Map(); result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => { progressMap.set(packId, { totalQuestions, answeredQuestions }); @@ -128,6 +180,16 @@ export const usePacksStore = create((set, get) => ({ }, clearPacks: () => { - set({ enabledPackIds: [], packProgress: new Map() }); + set((state) => ({ + enabledPackIds: [], + packProgress: new Map(), + catalogLoadedAt: null, + enabledPacksLoadedAt: null, + // Invalidates any request already in flight for the signed-out user. + generation: state.generation + 1, + })); + // See matchStore.clearMatches: the previous user's in-flight catalog read + // must not block the next user's. + inFlight.clear(); }, })); diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts index fd1dae2..d4a7975 100644 --- a/apps/mobile/src/store/responsesStore.ts +++ b/apps/mobile/src/store/responsesStore.ts @@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest"; const BATCH_SIZE = 20; +// Overlap protection independent of the presentational flags: a refresh over cached +// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to +// the generation that started the request: a stale generation's entry must not block +// the next account's request, and its `finally` must not clear a newer generation's guard. +const inFlight = new Map(); + interface ResponsesState { responses: ResponseWithQuestion[]; + /** First load only. A refresh over rows already on screen must not blank the list. */ isLoading: boolean; + /** Refresh over cached rows; drives pull-to-refresh only. */ + isRefreshing: boolean; isLoadingMore: boolean; groupBy: GroupByOption; dateSortOrder: DateSortOrder; hasMore: boolean; page: number; totalCount: number | null; + /** Null until the first successful read; an empty history is a loaded answer. */ + loadedAt: number | null; + /** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */ + generation: number; fetchResponses: (refresh?: boolean) => Promise; + /** Loads the first page once; revisiting the screen reuses what is cached. */ + ensureResponsesLoaded: () => Promise; + /** Marks the cache stale without spending a request; the next ensure reloads it. */ + invalidateResponses: () => void; updateResponse: ( questionId: string, newAnswer: AnswerType, @@ -61,12 +78,15 @@ interface ResponsesState { export const useResponsesStore = create((set, get) => ({ responses: [], isLoading: false, + isRefreshing: false, isLoadingMore: false, groupBy: "date", dateSortOrder: "newest", hasMore: true, page: 0, totalCount: null, + loadedAt: null, + generation: 0, fetchResponses: async (refresh = false) => { const userId = useAuthStore.getState().user?.id; @@ -74,25 +94,31 @@ export const useResponsesStore = create((set, get) => ({ if (!userId || !coupleId) return; + const myGeneration = get().generation; const state = get(); - if (state.isLoading || (state.isLoadingMore && !refresh)) return; + if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return; + // Nothing on screen yet is the only case that warrants a full-screen spinner; + // every other refresh leaves the cached rows in place until replacements land. + const initial = state.loadedAt === null; if (refresh) { - set({ isLoading: true, page: 0, hasMore: true }); + set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true }); } else { if (!state.hasMore) return; set({ isLoadingMore: true }); } + inFlight.set('responses', myGeneration); try { const currentPage = refresh ? 0 : state.page; const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`); + if (get().generation !== myGeneration) return; const responses = result.responses; const totalCount = result.totalCount; if (!responses || responses.length === 0) { if (refresh) { - set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false }); + set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() }); } else { set({ isLoadingMore: false, hasMore: false }); } @@ -100,29 +126,44 @@ export const useResponsesStore = create((set, get) => ({ } const transformedResponses = responses; - + set((state) => ({ responses: refresh ? transformedResponses : [...state.responses, ...transformedResponses], totalCount: refresh ? totalCount : state.totalCount, isLoading: false, + isRefreshing: false, isLoadingMore: false, page: currentPage + 1, - hasMore: responses.length === BATCH_SIZE + hasMore: responses.length === BATCH_SIZE, + loadedAt: Date.now(), })); } catch (error) { + if (get().generation !== myGeneration) return; console.error("Error in fetchResponses:", error); - set({ isLoading: false, isLoadingMore: false }); + set({ isLoading: false, isRefreshing: false, isLoadingMore: false }); + } finally { + if (inFlight.get('responses') === myGeneration) inFlight.delete('responses'); } }, + ensureResponsesLoaded: async () => { + if (get().loadedAt !== null) return; + await get().fetchResponses(true); + }, + + invalidateResponses: () => { + set({ loadedAt: null }); + }, + updateResponse: async ( questionId: string, newAnswer: AnswerType, confirmDelete = false, responseData?: ResponseData | null ): Promise => { + const myGeneration = get().generation; try { const result = await apiClient.patch(`/v1/responses/${questionId}`, { new_answer: newAnswer, @@ -130,6 +171,13 @@ export const useResponsesStore = create((set, get) => ({ response_data: responseData, }); + // Rows are keyed by a question id from the shared catalogue, so once the + // store has been cleared that same id can name a different account's + // answer. Hand the caller the server's result — it is true of whoever + // asked — but write nothing, and refresh no matches, for the account + // that is loaded now. + if (get().generation !== myGeneration) return result; + // If update was successful (not just requiring confirmation), update local state if (result.success && !result.requires_confirmation) { // Update local response state @@ -191,7 +239,15 @@ export const useResponsesStore = create((set, get) => ({ }, clearResponses: () => { - set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null }); + set((state) => ({ + responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest", + page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null, + // Invalidates any request already in flight for the signed-out user. + generation: state.generation + 1, + })); + // See matchStore.clearMatches: the previous user's in-flight page must not + // block the next user's. + inFlight.clear(); }, })); diff --git a/apps/mobile/src/store/streakStore.ts b/apps/mobile/src/store/streakStore.ts index a505bee..aab5d0b 100644 --- a/apps/mobile/src/store/streakStore.ts +++ b/apps/mobile/src/store/streakStore.ts @@ -1,62 +1,110 @@ -import { create } from "zustand"; +import { create } from "zustand"; import { apiClient } from "../lib/apiClient"; -import { useAuthStore } from "./authStore"; - -/** - * Resolved from the signed-in partner's point of view by the API. The stored row is - * positional (user1/user2 by member id), which a client cannot act on without - * re-deriving that ordering, so the endpoint answers in you/partner terms instead. - */ -export interface CoupleStreak { - couple_id: string; - current_streak: number; - longest_streak: number; - last_active_date: string | null; - last_completed_date: string | null; - you_answered_today: boolean; - partner_answered_today: boolean; - partner_name: string | null; - /** The couple's shared IANA zone, which is the day boundary the streak is counted in. */ - timezone: string; - streak_celebrated_at: number; - created_at: string; - updated_at: string; -} - -interface StreakState { - streak: CoupleStreak | null; - isLoading: boolean; - error: string | null; - - // Actions - fetchStreak: () => Promise; - clearStreak: () => void; -} - -export const useStreakStore = create((set) => ({ - streak: null, - isLoading: false, - error: null, - - fetchStreak: async () => { - const coupleId = useAuthStore.getState().user?.couple_id; - if (!coupleId) { - set({ streak: null, isLoading: false }); - return; - } - - set({ isLoading: true, error: null }); - - try { - const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak"); - set({ streak, isLoading: false }); - } catch (error) { - console.error("Error fetching streak:", error); - set({ error: "Failed to load streak", isLoading: false }); - } - }, - - clearStreak: () => { - set({ streak: null, isLoading: false, error: null }); - }, -})); +import { useAuthStore } from "./authStore"; + +/** + * Resolved from the signed-in partner's point of view by the API. The stored row is + * positional (user1/user2 by member id), which a client cannot act on without + * re-deriving that ordering, so the endpoint answers in you/partner terms instead. + */ +export interface CoupleStreak { + couple_id: string; + current_streak: number; + longest_streak: number; + last_active_date: string | null; + last_completed_date: string | null; + you_answered_today: boolean; + partner_answered_today: boolean; + partner_name: string | null; + /** The couple's shared IANA zone, which is the day boundary the streak is counted in. */ + timezone: string; + streak_celebrated_at: number; + created_at: string; + updated_at: string; +} + +interface StreakState { + streak: CoupleStreak | null; + isLoading: boolean; + error: string | null; + /** + * Null until the first successful read. A couple with no streak yet resolves to a + * null `streak`, which is a loaded answer rather than a reason to ask again. + */ + loadedAt: number | null; + /** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */ + generation: number; + + // Actions + fetchStreak: () => Promise; + /** Loads the streak once; later callers reuse the cached value. */ + ensureStreakLoaded: () => Promise; + /** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */ + refreshStreak: () => Promise; + clearStreak: () => void; +} + +// The streak is rendered by a badge, the home screen and the swipe screen, so +// concurrent mounts must share one request rather than each issuing their own. +// Tagged with the generation that started it: clearing/signing out bumps the +// generation, so a stale response can no longer write into the next account's +// store, and a stale `finally` cannot clear a newer generation's guard. +let inFlight: { generation: number; promise: Promise } | null = null; + +export const useStreakStore = create((set, get) => ({ + streak: null, + isLoading: false, + error: null, + loadedAt: null, + generation: 0, + + fetchStreak: async () => { + const coupleId = useAuthStore.getState().user?.couple_id; + const myGeneration = get().generation; + if (!coupleId) { + set({ streak: null, isLoading: false, loadedAt: null }); + return; + } + + if (inFlight && inFlight.generation === myGeneration) return inFlight.promise; + + set({ isLoading: true, error: null }); + + const promise = (async () => { + try { + const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak"); + if (get().generation !== myGeneration) return; + set({ streak, isLoading: false, loadedAt: Date.now() }); + } catch (error) { + if (get().generation !== myGeneration) return; + console.error("Error fetching streak:", error); + set({ error: "Failed to load streak", isLoading: false }); + } finally { + if (inFlight?.generation === myGeneration) inFlight = null; + } + })(); + + inFlight = { generation: myGeneration, promise }; + return promise; + }, + + ensureStreakLoaded: async () => { + if (get().loadedAt !== null) return; + await get().fetchStreak(); + }, + + refreshStreak: async () => { + // Only silently refresh a streak the UI has actually loaded; an unopened + // screen has nothing cached to keep honest. + if (get().loadedAt === null) return; + await get().fetchStreak(); + }, + + clearStreak: () => { + set((state) => ({ + streak: null, isLoading: false, error: null, loadedAt: null, + // Invalidates any request already in flight for the signed-out user. + generation: state.generation + 1, + })); + }, +})); diff --git a/docs/agents/routing.md b/docs/agents/routing.md index ca74fc9..dac7d0b 100644 --- a/docs/agents/routing.md +++ b/docs/agents/routing.md @@ -1,21 +1,58 @@ # Agent routing and effort -Use one implementation agent by default. Add independent agents only when work is -actually separable or a risk trigger requires independent judgment. +The primary agent owns scope, acceptance criteria, task decomposition, and the +final answer. Use one implementation agent when a bounded handoff will keep noisy +execution out of the primary context. Do not pay the coordination cost for a +trivial one-file edit. Add other agents only for separable work or a risk trigger. -| Role | Default effort | Trigger | Do not use for | -|---|---|---|---| -| `repo-scout` | low | locate ownership, callers, contracts | editing or design decisions | -| `implementation` | medium | bounded product or harness change | broad architecture review | -| `test-writer` | medium | focused regression or missing contract test | rubber-stamping green output | -| `verifier` | medium | independently drive changed behavior | editing implementation | -| `database-reviewer` | medium | migrations, RLS, function/query boundaries | ordinary UI changes | -| `security-reviewer` | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change | -| `architecture-reviewer` | high | cross-cutting or hard-to-reverse decisions | local refactors | +## Cost ladder -Escalate effort only when uncertainty, blast radius, or irreversibility justifies -it. “Proactively use” is not a trigger. Verification agents return evidence and a -verdict; they do not make changes. +Project Codex defaults live in `.codex/config.toml`; task profiles live in +`.codex/agents/`. An explicit task handoff may raise or lower reasoning effort. -Procedures belong in skills. Agents read the applicable skill and nearest -`AGENTS.md`, then operate within the task's authorization. +| Role | Codex model | Default effort | Trigger | Do not use for | +|---|---|---:|---|---| +| `repo_scout` | Luna | low | locate ownership, callers, contracts | editing or design decisions | +| `implementation` | Terra | medium, overridable | bounded product or harness change | broad architecture review | +| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output | +| `verifier` | Terra | medium | independently drive implemented behavior | editing implementation | +| `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 | +| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors | + +Claude profiles under `.claude/agents/` provide equivalent roles when Claude is +explicitly requested. They are not part of the normal Codex routing path. + +## Implementation effort + +Keep `implementation` on Terra and choose effort from the task shape: + +- `low`: mechanical, fully specified, narrow change with an obvious check. +- `medium`: normal feature or bug fix with a few files and known contracts. +- `high`: ambiguous behavior, several interacting modules, concurrency, or + difficult debugging. +- `xhigh`: exceptional Terra implementation where high effort has already proved + insufficient or the change has a large but non-security blast radius. + +Security and architecture risk are different jobs, not reasons to silently turn +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 +a different model. + +## Handoff contract + +Every delegated task includes the objective, owned files or surface, acceptance +criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks, +and the evidence to return. The agent must report changed files, commands and +results, assumptions, and blockers. The primary agent independently reviews the +diff and decides whether acceptance has been met. + +Parallel agents are for independent read-heavy work or non-overlapping ownership. +Avoid concurrent edits to the same files, shared database state, fixed local +ports, or one simulator. Subagents consume additional tokens, so parallelism must +buy meaningful time or independent judgment. + +Procedures belong in deterministic skills rather than agent prompts. Agents read +the nearest `AGENTS.md` and the applicable repository skill, then operate only +within the task's authorization. Verification agents return evidence and a +verdict; they do not fix implementation. diff --git a/docs/architecture/client-data-freshness.md b/docs/architecture/client-data-freshness.md new file mode 100644 index 0000000..5745b1d --- /dev/null +++ b/docs/architecture/client-data-freshness.md @@ -0,0 +1,195 @@ +# Client data freshness + +The mobile app talks to a request-per-read API with no realtime channel. This +document is the contract for how it stays current without spending a request +every time the user looks at something they have already loaded. + +## The invariant + +**Cached data is usable data.** Once a screen has loaded, switching away from it +and back must cost zero requests and must never blank what is on screen. A +refresh happens for a reason — the user pulled to refresh, a mutation invalidated +the data, or a change marker moved — never merely because a screen regained +focus. + +Three rules make that hold: + +- **Loaded-ness is recorded, not inferred.** Stores keep a marker + (`loadedAt`, `catalogLoadedAt`, `enabledPacksLoadedAt`) that is set on a + successful load and cleared on sign-out or explicit invalidation. Emptiness is + a legitimate answer, not evidence of never having loaded: keying off + `array.length === 0` makes every visit to an empty list pay for the same empty + response. +- **Initial loading and refreshing are different states.** `isLoading` means + there is nothing to show yet and drives the full-screen loader. `isRefreshing` + drives pull-to-refresh only. A background refresh raises neither, so cached + rows stay on screen and in place until the replacement arrives. +- **Invalidation is free.** A mutation that changes another screen's data marks + that cache stale (`invalidatePacks`, `invalidateResponses`) instead of + refetching it. Whichever screen the user opens next reloads once; screens they + do not open stay quiet. + +Overlap protection lives in a module-scoped `inFlight` set rather than in the +loading flags, because a silent refresh raises no flag and still must not issue a +second request. The set is cleared alongside the store on sign-out, so a +signed-out user's in-flight request cannot suppress the next user's. + +## Recurring work + +`usePolling` (`apps/mobile/src/hooks/usePolling.ts`) owns every recurring read. +It schedules the next run after the previous one settles rather than on a fixed +interval, so a slow response delays the next request instead of queueing one +behind it, and it provides three properties no hand-rolled interval in this app +had: + +- it stops while the app is backgrounded or `inactive`, and catches up + immediately on return; +- it never runs two passes concurrently; +- it backs off exponentially — to `maxIntervalMs`, eight intervals by default — + while the API is failing. + +Callers gate it further with `enabled`, which carries screen focus, a loaded id, +or a paired couple. Screens pass focus in as a prop rather than having the hook +read navigation state, so hooks stay renderable without a navigator. + +| Poller | Interval | Runs while | +|---|---|---| +| Couple sync (`useCoupleSync`) | 15s | signed in, app foregrounded | +| Chat messages | 2s | chat screen focused | +| Dares | 5s | dares screen focused | +| Live draw | 750ms | canvas focused | + +Live draw keeps the sub-second cadence a shared canvas needs, but only for the +seconds someone is actually looking at it. + +## `GET /v1/me/sync` + +One small query summarising everything the app used to discover by re-reading +five endpoints. The client compares it against the previous summary and refreshes +only the domains whose markers moved — and only those already loaded, so a view +nobody has opened is never fetched in the background. + +| Field | Moves when | +|---|---| +| `server_time` | every call | +| `couple_id`, `profile_updated_at` | the user's own profile or couple membership changed | +| `partner_id`, `partner_updated_at` | the partner's profile changed | +| `match_count`, `new_match_count`, `latest_match_at` | matches were created, seen, or archived | +| `pending_yours`, `pending_theirs` | either side answered a question the other is waiting on | +| `unread_total` | chat unread count for this user changed | +| `enabled_packs_fingerprint` | the couple's enabled pack set changed | +| `match_state_fingerprint` | a visible match's type or response summary changed in place | +| `match_unread_fingerprint` | unread moved between matches, with or without a change to the total | +| `streak_updated_at` | the couple's streak row changed | + +`unread_total` is applied directly to the badge, so keeping it current costs +nothing beyond this request. The first poll after sign-in only establishes the +baseline: every screen loads its own data on mount, and refreshing it again would +undo the saving. + +`enabled_packs_fingerprint` is an `md5` digest of the enabled pack ids rather +than a timestamp, because `couple_packs` carries no `updated_at` and equal and +opposite toggles would otherwise be invisible. Zero enabled packs is the stable +digest of the empty string, not `null`. + +The two match digests exist for the same reason in a different shape: the counts +and `latest_match_at` all hold steady when a partner edits an existing match in +place, and `unread_total` holds steady when unread merely moves between matches — +which still reorders an unread-first list. + +Every field is derived for the calling identity's own couple. The summary reveals +nothing about any other couple, and reveals no partner content — only that +something moved. + +## Chat deltas + +`GET /v1/matches/:id/messages` takes two optional parameters: + +- `since` — an ISO 8601 timestamp with offset. Returns only rows whose visible + state moved since then: created, delivered, read, deleted, media viewed, or a + video whose `media_expires_at` fell inside the window. Messages are not + editable, so there is no edit to report. A malformed value is rejected with + `400 invalid_since` rather than silently treated as a full read. +- `typing=true` — folds the partner's typing state into the same response, so a + focused chat makes one request per pass instead of two. + +The response is a `MessagesPage`: + +| Field | Meaning | +|---|---| +| `messages` | rows to merge by id, newest first | +| `removed_ids` | rows the client must drop (deleted, or deleted-for-self) | +| `server_time` | cursor for the next `since` | +| `complete` | true when this is a full snapshot rather than a delta | +| `typing` | present only when `typing=true` was requested | + +`server_time` is deliberately set two seconds behind `now()` so a row committed +during the request cannot fall between two cursors. The overlap re-returns a +handful of already-known rows, which is harmless because the client merges by id. + +`media_expired` is derived in the query — `media_expired or media_expires_at <= +now()` — rather than read from the stored column, which nothing ever writes. That +is the rule the media endpoint already applies when it refuses an expired object, +so a client is no longer handed a video it will not be allowed to fetch. Expiry is +also the one visible change no row write announces, which is why the delta +compares the deadline against the poll window rather than looking for a timestamp +that moved; the row is emitted in the single window its deadline falls into. + +`moderation_status`, `flag_reason` and `category` are returned in full but are +**not** covered by `since`: the classifier and the admin tool rewrite them without +advancing any timestamp, so a delta cannot see them move. No client renders them +today, so nothing user-visible goes stale — but a client that started rendering +them would need a cursor for them, which means a new column on `messages`. + +Deltas do not change access control, ordering, media fields, or deletion +semantics: a delta returns exactly the rows a full read would have returned for +that window, and a caller outside the couple still gets `match_not_found`. + +The client marks the conversation read only when a response actually contains an +unread partner message, and `POST .../read` returns the number of rows it +cleared, so the badge is adjusted by subtraction rather than by re-reading the +total. + +## Match pagination + +`GET /v1/matches` returns `{ matches, totalCount, hasMore }`. + +`totalCount` is a `number` on page 0 and **`null` on every later page**: the +count is a full scan of the couple's matches and nothing in the UI needs it more +than once per refresh. Clients keep the value from the refresh that started the +sequence. + +`hasMore` comes from reading `limit + 1` rows and reporting whether the extra one +existed, so an exactly-full final page reports `false` correctly. Unread counts +are aggregated once in a CTE rather than by a correlated subquery per row. +Ordering (unread first, then newest) and couple isolation are unchanged. + +Pagination itself is still page-number/`OFFSET`-based, not cursor-based. This +pass removed the repeated `count(*)` and the per-row unread subquery — the two +costs that scaled with the couple's history — but paging deep into a large +history still pays an `OFFSET` scan. Cursor pagination would remove that too; +it has not been implemented. + +## `GET /v1/me` is idempotent + +Bootstrapping a profile reads first and writes only what actually differs. An +unchanged read leaves both `updated_at` and `auth_synced_at` alone, so a client +polling `/v1/me` no longer produces a write per call. First use still creates +the profile from the auth identity's name, avatar and email. On every later +call only a changed **email** writes through, syncing the one field the auth +provider is authoritative for; the product's display name and avatar are not +touched, so a locally-edited name or avatar is never overwritten by +potentially stale auth metadata. + +## One database pool + +The API process creates a single `pg` pool and passes it to every repository, so +the process holds one bounded set of server connections rather than a +default-sized pool per domain. Shutdown ends that pool once. Repositories still +accept a connection string in tests, in which case they own and end the pool they +created — see `resolvePool` / `closeResolvedPool` in `apps/api/src/db/pool.ts`. + +Bounds come from `DATABASE_POOL_MAX` (default 10), +`DATABASE_POOL_IDLE_TIMEOUT_MS` (30000) and +`DATABASE_POOL_CONNECTION_TIMEOUT_MS` (10000). They are process-wide; keep the +total across running instances inside the database's own connection limit. diff --git a/docs/dares-loop.md b/docs/dares-loop.md index a4f6b59..e9f04ae 100644 --- a/docs/dares-loop.md +++ b/docs/dares-loop.md @@ -121,9 +121,15 @@ rejected. ## Mobile Reached from the home screen's `DaresTile`, which badges pending incoming dares. The -screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime -channel), and opens the paywall both proactively and in reaction to a `402` — a client -whose entitlement went stale still lands on the paywall rather than an error. +screen has Active / Send / History tabs and opens the paywall both proactively and in +reaction to a `402` — a client whose entitlement went stale still lands on the paywall +rather than an error. + +There is no realtime channel, so the screen polls on a 5s interval — but only while it is +focused and the app is foregrounded, and each pass reads the active list alone. History, +the catalogue and the stats are re-read only when the active set's ids or statuses +actually changed, or on an explicit refresh or a mutation. See +[Client data freshness](architecture/client-data-freshness.md). ## Tests diff --git a/docs/index.md b/docs/index.md index 5b74a3f..cc50425 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,7 @@ historical notes. Agents should load only the section relevant to their task. ## Maintained system documentation - [System architecture](architecture/system.md) +- [Client data freshness](architecture/client-data-freshness.md) - [Local full-stack development](development/local-stack.md) - [Testing strategy](testing/strategy.md) - [Database migration safety](operations/database-migrations.md) diff --git a/scripts/lint-harness.mjs b/scripts/lint-harness.mjs index 9db150f..3611d07 100644 --- a/scripts/lint-harness.mjs +++ b/scripts/lint-harness.mjs @@ -23,6 +23,10 @@ function fail(file, message, fix) { errors.push(`${path.relative(root, file)}: ${message}\n Fix: ${fix}`); } +function tomlString(source, key) { + return source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"\\s*$`, 'm'))?.[1]; +} + const allFiles = await walk(root); for (const file of allFiles) { if (path.basename(file) === 'agents.md') { @@ -37,6 +41,57 @@ for (const file of allFiles) { if (!scoped) { const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); + const codexConfigFile = path.join(root, '.codex', 'config.toml'); + const codexConfig = await readFile(codexConfigFile, 'utf8'); + if (!/^\[agents\]$/m.test(codexConfig) || !/^enabled\s*=\s*true$/m.test(codexConfig)) { + fail(codexConfigFile, 'project subagent routing is not enabled', 'restore the [agents] table with enabled = true'); + } + if (tomlString(codexConfig, 'default_subagent_model') !== 'gpt-5.6-terra') { + fail(codexConfigFile, 'ordinary subagents no longer default to the cost-balanced model', 'set default_subagent_model = "gpt-5.6-terra"'); + } + if (tomlString(codexConfig, 'default_subagent_reasoning_effort') !== 'medium') { + fail(codexConfigFile, 'ordinary subagent effort is no longer the balanced default', 'set default_subagent_reasoning_effort = "medium"'); + } + const maxThreads = Number(codexConfig.match(/^max_concurrent_threads_per_session\s*=\s*(\d+)$/m)?.[1]); + if (!Number.isInteger(maxThreads) || maxThreads < 1 || maxThreads > 3) { + fail(codexConfigFile, 'subagent concurrency is missing or exceeds the repository cost cap', 'set max_concurrent_threads_per_session between 1 and 3'); + } + + const agentContracts = { + 'repo-scout.toml': { name: 'repo_scout', model: 'gpt-5.6-luna', effort: 'low', readOnly: true }, + 'implementation.toml': { name: 'implementation', model: 'gpt-5.6-terra', effort: null, flexibleEffort: true }, + 'test-writer.toml': { name: 'test_writer', model: 'gpt-5.6-terra', effort: 'medium' }, + 'verifier.toml': { name: 'verifier', model: 'gpt-5.6-terra', effort: 'medium' }, + 'database-reviewer.toml': { name: 'database_reviewer', model: 'gpt-5.6-terra', effort: 'high', readOnly: true }, + 'security-reviewer.toml': { name: 'security_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true }, + 'architecture-reviewer.toml': { name: 'architecture_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true }, + }; + for (const [filename, contract] of Object.entries(agentContracts)) { + const agentFile = path.join(root, '.codex', 'agents', filename); + if (!(await exists(agentFile))) { + fail(agentFile, 'required task-agent profile is missing', `restore .codex/agents/${filename}`); + continue; + } + const source = await readFile(agentFile, 'utf8'); + if (tomlString(source, 'name') !== contract.name || tomlString(source, 'model') !== contract.model) { + fail(agentFile, 'task-agent identity or cost tier drifted', `set name = "${contract.name}" and model = "${contract.model}"`); + } + const effort = tomlString(source, 'model_reasoning_effort'); + if (contract.flexibleEffort ? effort !== undefined : effort !== contract.effort) { + fail( + agentFile, + contract.flexibleEffort ? 'implementation reasoning is pinned and cannot flex by task complexity' : 'task-agent reasoning effort drifted', + contract.flexibleEffort ? 'omit model_reasoning_effort so the handoff or project default controls it' : `set model_reasoning_effort = "${contract.effort}"`, + ); + } + if (contract.readOnly && tomlString(source, 'sandbox_mode') !== 'read-only') { + fail(agentFile, 'review-only agent can mutate the workspace', 'set sandbox_mode = "read-only"'); + } + if (!/^description\s*=\s*".+"$/m.test(source) || !/developer_instructions\s*=\s*"""[\s\S]*\S[\s\S]*"""/m.test(source)) { + fail(agentFile, 'custom agent is missing its required description or instructions', 'restore non-empty description and developer_instructions fields'); + } + } + const mobilePackageFile = path.join(root, 'apps', 'mobile', 'package.json'); const mobilePackageJson = JSON.parse(await readFile(mobilePackageFile, 'utf8')); const releaseBuildScripts = Object.entries(mobilePackageJson.scripts ?? {}) From 10c4ae8d84c1f02f189ed6c300c6640ed4c94179 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 21:58:13 +0100 Subject: [PATCH 3/8] docs(agents): require isolated implementation --- AGENTS.md | 13 +++++++++++-- docs/agents/routing.md | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index baab081..42ad059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,17 @@ MCP service in one npm/Turborepo workspace. 5. Read `apps/mobile/DESIGN.md` before mobile UI work. 6. Keep shared package exports type-only and route them through `src/index.ts`. 7. Do not commit secrets, generated evidence, local logs, or runtime state. -8. Scope work to the task. Route bounded implementation through the project +8. Implement in a task-specific git worktree created from current `staging`, + not in the shared `staging` checkout. After the work is verified, merge the + completed task branch back into local `staging`. +9. Before merging, identify every deployed app or service the change can affect + and prove the relevant compatibility contracts still hold. Preserve deployed + interfaces and supported client behavior; repository checks alone do not + prove a live change is safe. +10. Scope work to the task. Route bounded implementation through the project `implementation` agent when delegation is useful; the primary agent retains acceptance, review, and final verification. Do not delegate trivial edits. -9. 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`. ## Repository map @@ -39,6 +46,8 @@ MCP service in one npm/Turborepo workspace. ## Workflows +- Implementation: create a task worktree from current `staging`; merge verified + work back to local `staging` when complete. - Bootstrap: `npm ci` - Full local stack: `scripts/dev-local.sh up` - Fast checks: `npm run verify:fast` diff --git a/docs/agents/routing.md b/docs/agents/routing.md index dac7d0b..6778361 100644 --- a/docs/agents/routing.md +++ b/docs/agents/routing.md @@ -5,6 +5,27 @@ final answer. Use one implementation agent when a bounded handoff will keep nois execution out of the primary context. Do not pay the coordination cost for a trivial one-file edit. Add other agents only for separable work or a risk trigger. +## Implementation and integration + +Implementation starts in a dedicated task worktree created from the current +`staging` branch. Keep the shared `staging` checkout clean and use it only as the +integration target. Complete the scoped change, review its diff, and run the +required verification in the task worktree. Merge the task branch back into local +`staging` only after that evidence passes; an unverified or partially complete +branch does not belong in `staging`. Pushing or deploying `staging` remains a +separate operation subject to the applicable release and production-safety rules. + +Before implementation, map the change to every deployed consumer it can affect, +including supported mobile builds, web and admin clients, the API and worker, and +internal MCP integrations. Preserve their current interfaces, configuration, +authentication and data expectations. Verification must cover the affected +contracts and cross-application path, not only the edited package. If the change +can alter live production, first establish the actual deployed versions, +configuration, dependencies, interfaces, and behavior; after deployment, +independently verify the affected end-to-end behavior. A local test, repository +state, or successful deployment record is not proof that deployed apps remain +functional. + ## Cost ladder Project Codex defaults live in `.codex/config.toml`; task profiles live in From a13bbc3f659ebf25155c0c1ff97fb39eb3c3b37e Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 21:59:50 +0100 Subject: [PATCH 4/8] docs(agents): require published completion --- AGENTS.md | 9 +++++---- docs/agents/routing.md | 12 ++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42ad059..7715587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,9 @@ MCP service in one npm/Turborepo workspace. 6. Keep shared package exports type-only and route them through `src/index.ts`. 7. Do not commit secrets, generated evidence, local logs, or runtime state. 8. Implement in a task-specific git worktree created from current `staging`, - not in the shared `staging` checkout. After the work is verified, merge the - completed task branch back into local `staging`. + not in the shared `staging` checkout. After the work is verified, commit the + scoped changes, merge the completed task branch back into local `staging`, + push `staging`, and prove local and remote commit parity. 9. Before merging, identify every deployed app or service the change can affect and prove the relevant compatibility contracts still hold. Preserve deployed interfaces and supported client behavior; repository checks alone do not @@ -46,8 +47,8 @@ MCP service in one npm/Turborepo workspace. ## Workflows -- Implementation: create a task worktree from current `staging`; merge verified - work back to local `staging` when complete. +- Implementation: create a task worktree from current `staging`; commit verified + work, merge it back to local `staging`, push `staging`, and verify remote parity. - Bootstrap: `npm ci` - Full local stack: `scripts/dev-local.sh up` - Fast checks: `npm run verify:fast` diff --git a/docs/agents/routing.md b/docs/agents/routing.md index 6778361..ff7d803 100644 --- a/docs/agents/routing.md +++ b/docs/agents/routing.md @@ -12,8 +12,16 @@ Implementation starts in a dedicated task worktree created from the current integration target. Complete the scoped change, review its diff, and run the required verification in the task worktree. Merge the task branch back into local `staging` only after that evidence passes; an unverified or partially complete -branch does not belong in `staging`. Pushing or deploying `staging` remains a -separate operation subject to the applicable release and production-safety rules. +branch does not belong in `staging`. + +Completion includes publishing the work: commit only the scoped task changes, +merge the verified commit into local `staging`, push `staging`, then fetch or read +back the remote ref and prove `HEAD` equals `origin/staging`. Do not report an +implementation complete while its commits exist only in a task worktree or the +local integration checkout. A blocked or ambiguous push is a blocker, not a +successful publish; preserve the commit, do not retry blindly, and report the +exact remote state. Pushing and deployment remain subject to applicable branch +protection, release, and production-safety rules. Before implementation, map the change to every deployed consumer it can affect, including supported mobile builds, web and admin clients, the API and worker, and From 5c973c4ac59dc201073e7c5000e6561434393c59 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 22:43:27 +0100 Subject: [PATCH 5/8] perf(api): bound worker pools and add telemetry Share one configured PostgreSQL pool across worker repositories and make API and worker connection budgets explicit. Add privacy-safe aggregate request, query, pool, sync, and outbox telemetry, a loopback-only representative load fixture, bounded worker shutdown, and focused regression and integration coverage. Document the database-wide budget formula and defer pg_stat_statements activation to a reviewed migration and planned restart. --- apps/api/.env.example | 5 +- apps/api/package.json | 1 + apps/api/src/app.ts | 13 ++++ apps/api/src/db/pool.ts | 6 +- apps/api/src/domains/app-data/routes.ts | 3 + apps/api/src/domains/operations/repository.ts | 34 ++++++++++ apps/api/src/domains/operations/runner.ts | 7 +- apps/api/src/index.ts | 8 ++- apps/api/src/load/loopback.ts | 16 +++++ apps/api/src/load/representative.ts | 43 +++++++++++++ apps/api/src/telemetry.ts | 64 +++++++++++++++++++ apps/api/src/workers/config.ts | 3 +- apps/api/src/workers/index.ts | 14 ++-- apps/api/src/workers/lifecycle.ts | 47 ++++++++++++++ apps/api/test/app.test.ts | 12 ++++ apps/api/test/load.loopback.test.ts | 17 +++++ apps/api/test/operations.integration.test.ts | 11 +++- apps/api/test/operations.repository.test.ts | 29 +++++++++ apps/api/test/operations.runner.test.ts | 9 ++- apps/api/test/telemetry.test.ts | 50 +++++++++++++++ apps/api/test/worker.lifecycle.test.ts | 30 +++++++++ docs/architecture/client-data-freshness.md | 5 +- infra/backend/.env.production.example | 7 ++ infra/backend/README.md | 47 ++++++++++++++ infra/backend/compose.production.yaml | 6 ++ 25 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/load/loopback.ts create mode 100644 apps/api/src/load/representative.ts create mode 100644 apps/api/src/telemetry.ts create mode 100644 apps/api/src/workers/lifecycle.ts create mode 100644 apps/api/test/load.loopback.test.ts create mode 100644 apps/api/test/operations.repository.test.ts create mode 100644 apps/api/test/telemetry.test.ts create mode 100644 apps/api/test/worker.lifecycle.test.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index a923cd8..76a26bd 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,8 +5,9 @@ SUPABASE_AUTH_AUDIENCE=authenticated # Optional connection-pool bounds. The API process opens one pool and shares it # across every repository, so these are the process-wide limits, not per-domain -# ones. Raising DATABASE_POOL_MAX raises the server connections this instance can -# hold; keep the total across instances inside the database's own limit. +# ones. The worker has its own separately bounded process pool. Raising either +# maximum raises the server connections this instance can hold; keep the total +# across instances inside the database's own limit. # DATABASE_POOL_MAX=10 # DATABASE_POOL_IDLE_TIMEOUT_MS=30000 # DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000 diff --git a/apps/api/package.json b/apps/api/package.json index bc15148..d4cd82d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "db:migrate:prod": "node dist/db/migrate.js", "db:seed": "tsx src/db/seed.ts", "migration:cutover": "tsx src/migration/run.ts", + "load:representative": "tsx src/load/representative.ts", "test": "vitest run", "test:integration": "vitest run --config vitest.integration.config.ts", "typecheck": "tsc --noEmit", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 55f6ac8..b3b5425 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -27,6 +27,7 @@ import type { FilesystemMediaStorage } from './domains/media/storage.js'; import type { AdminRequestAuth } from './domains/admin/auth.js'; import type { AdminRepository } from './domains/admin/repository.js'; import { registerAdminRoutes } from './domains/admin/routes.js'; +import { recordRequest } from './telemetry.js'; type Variables = { identity: AuthIdentity }; @@ -57,6 +58,18 @@ function error(code: string, message: string): ApiErrorResponse { export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> { const app = new Hono<{ Variables: Variables }>(); + app.use('*', async (c, next) => { + const startedAt = performance.now(); + try { + await next(); + } finally { + // Hono returns the matched template after dispatch, never the raw path. + const candidate = c.req.routePath; + const route = candidate && !/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/i.test(candidate) && !candidate.includes('?') ? candidate : 'unmatched'; + recordRequest(c.req.method, route, c.res.status, startedAt); + } + }); + app.get('/health/live', (c) => c.json({ status: 'ok' })); app.get('/health/ready', async (c) => { try { diff --git a/apps/api/src/db/pool.ts b/apps/api/src/db/pool.ts index 0db68bd..ecb22b7 100644 --- a/apps/api/src/db/pool.ts +++ b/apps/api/src/db/pool.ts @@ -1,4 +1,5 @@ import { Pool } from 'pg'; +import { instrumentPool, type TelemetryProcess } from '../telemetry.js'; /** * A repository is constructed either from a connection string, in which case it @@ -23,13 +24,14 @@ export interface DatabasePoolOptions { */ export const DEFAULT_POOL_MAX = 10; -export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}): Pool { - return new Pool({ +export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}, telemetryProcess?: TelemetryProcess): Pool { + const pool = new Pool({ connectionString: databaseUrl, max: options.max ?? DEFAULT_POOL_MAX, ...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }), ...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }), }); + return telemetryProcess ? instrumentPool(telemetryProcess, pool) : pool; } export interface ResolvedPool { diff --git a/apps/api/src/domains/app-data/routes.ts b/apps/api/src/domains/app-data/routes.ts index 4eaf7eb..c48346f 100644 --- a/apps/api/src/domains/app-data/routes.ts +++ b/apps/api/src/domains/app-data/routes.ts @@ -3,6 +3,7 @@ import type { Hono } from 'hono'; import { z } from 'zod'; import type { AuthIdentity } from '../../auth.js'; import { AppDataError, type AppDataRepository } from './repository.js'; +import { recordSync } from '../../telemetry.js'; type App = Hono<{ Variables: { identity: AuthIdentity } }>; const uuid = z.string().uuid(); @@ -46,7 +47,9 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository): }); // The client polls this instead of re-fetching every domain; see SyncSummary. app.get('/v1/me/sync', async c => { + const startedAt = performance.now(); const result = await run(() => repository.syncSummary(c.get('identity').id)); + recordSync('api',result.ok?'ok':'error',startedAt); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); }); app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id))); diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index 8792c5b..3635815 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -15,9 +15,19 @@ export interface OperationsRepository { message(id: string): Promise; classifierConfig(): Promise; classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise; + outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }>; close(): Promise; } +/** Matches `claim`: only currently due, retryable, and unlocked/expired leases. */ +export const OUTBOX_STATE_SQL = `select count(*)::int due, + coalesce(floor(extract(epoch from now()-min(available_at))),0)::int oldest_due_age_seconds + from operations_outbox +where sent_at is null + and attempts<5 + and available_at<=now() + and (locked_at is null or locked_at { + const client = await this.pool.connect(); + let released = false; + try { + await client.query('begin'); + // A server-side timeout cancels the query and transaction; unlike a + // Promise race it cannot leave an unobserved query or borrowed client. + await client.query("set local statement_timeout = '250ms'"); + const result = await client.query<{ due: number; oldest_due_age_seconds: number }>(OUTBOX_STATE_SQL); + await client.query('commit'); + const row = result.rows[0]; + return { due: row?.due ?? 0, oldestDueAgeSeconds: row?.oldest_due_age_seconds ?? 0 }; + } catch (cause) { + try { await client.query('rollback'); } + catch (rollbackCause) { + // A failed rollback leaves transaction state uncertain. Evict this + // client instead of returning it to the shared pool, but retain the + // original observation failure for callers and logs. + client.release(rollbackCause instanceof Error ? rollbackCause : new Error('outbox observation rollback failed')); + released = true; + } + throw cause; + } finally { if (!released) client.release(); } + } async complete(id:string) { await this.pool.query('update operations_outbox set sent_at=now(),locked_at=null,last_error=null where id=$1',[id]); } async fail(id:string,message:string) { await this.pool.query( `update operations_outbox set locked_at=null,last_error=$2,available_at=now()+make_interval(secs=>least(3600,power(2,attempts)::int*30)) where id=$1`,[id,message.slice(0,500)]); } diff --git a/apps/api/src/domains/operations/runner.ts b/apps/api/src/domains/operations/runner.ts index 8c8fc2f..1943308 100644 --- a/apps/api/src/domains/operations/runner.ts +++ b/apps/api/src/domains/operations/runner.ts @@ -1,6 +1,7 @@ import type { MediaJanitor } from '../media/janitor.js'; import type { OperationsRepository } from './repository.js'; import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js'; +import { emitTelemetry } from '../../telemetry.js'; export class OperationsRunner { constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {} @@ -13,7 +14,11 @@ export class OperationsRunner { await this.repository.complete(item.id);completed++; } catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; } } + // Observation never blocks useful work. It runs after claiming/completing and + // has a short caller-side deadline; the claim lock predicate remains the + // source of truth when several worker instances race. + const after=await this.repository.outboxState().catch(()=>null); + if(after)emitTelemetry({event:'outbox',process:'worker',due_after:after.due,oldest_due_age_seconds:after.oldestDueAgeSeconds,claimed:items.length,completed,failed,count:1}); return {produced,claimed:items.length,completed,failed}; } } - diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0121673..5bd56d8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import { createApp } from './app.js'; import { createAuthVerifier } from './auth.js'; import { loadConfig } from './config.js'; import { createDatabasePool } from './db/pool.js'; +import { recordPool } from './telemetry.js'; import { PostgresRepository } from './db/repository.js'; import { PostgresAppDataRepository } from './domains/app-data/repository.js'; import { AdminRequestAuth } from './domains/admin/auth.js'; @@ -30,7 +31,7 @@ const authVerifier = createAuthVerifier(config); // One deliberately bounded pool for the whole process. Repositories used to open // a default-sized pool each, so a single API instance could hold twelve times the // connections a self-hosted PostgreSQL was sized for. -const pool = createDatabasePool(config.databaseUrl, config.databasePool); +const pool = createDatabasePool(config.databaseUrl, config.databasePool, 'api'); const repository = new PostgresRepository(pool); const coupleRepository = new PostgresCoupleRepository(pool); const packsRepository = new PostgresPacksRepository(pool); @@ -81,6 +82,10 @@ const app = createApp({ ), }); +// Sample pool state on the telemetry cadence rather than once per request. +const poolTelemetryTimer = setInterval(() => recordPool('api', pool), 60_000); +poolTelemetryTimer.unref(); + const server = serve({ fetch: app.fetch, port: config.port }, (info) => { console.log(`Sauci API listening on http://127.0.0.1:${info.port}`); }); @@ -90,6 +95,7 @@ let shuttingDown = false; async function shutdown(signal: string): Promise { if (shuttingDown) return; shuttingDown = true; + clearInterval(poolTelemetryTimer); console.log(`Received ${signal}; shutting down`); const forcedExit = setTimeout(() => { console.error('Graceful shutdown timed out'); diff --git a/apps/api/src/load/loopback.ts b/apps/api/src/load/loopback.ts new file mode 100644 index 0000000..cc6ad8c --- /dev/null +++ b/apps/api/src/load/loopback.ts @@ -0,0 +1,16 @@ +/** Refuse fixture targets outside the local development boundary. */ +export function requireLoopback(url: string, name: string): URL { + const parsed = new URL(url); + if (!['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)) { + throw new Error(`${name} must resolve to loopback; refusing non-local load`); + } + return parsed; +} + +export function requireLocalApi(url: string, expectedPort = 3003): URL { + const parsed = requireLoopback(url, 'SAUCI_LOAD_API_URL'); + if (parsed.protocol !== 'http:' || Number(parsed.port || 80) !== expectedPort) { + throw new Error(`SAUCI_LOAD_API_URL must be http loopback on port ${expectedPort}`); + } + return parsed; +} diff --git a/apps/api/src/load/representative.ts b/apps/api/src/load/representative.ts new file mode 100644 index 0000000..f33e3aa --- /dev/null +++ b/apps/api/src/load/representative.ts @@ -0,0 +1,43 @@ +import { createDatabasePool } from '../db/pool.js'; +import { PostgresOperationsRepository } from '../domains/operations/repository.js'; +import { emitTelemetry, recordPool } from '../telemetry.js'; +import { requireLocalApi, requireLoopback } from './loopback.js'; + +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b); + return Math.round((sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] ?? 0) * 100) / 100; +} +async function read(url: URL, headers: HeadersInit): Promise { + const startedAt = performance.now(); + const response = await fetch(url, { headers }); + if (!response.ok) throw new Error(`representative API read failed with ${response.status}`); + return performance.now() - startedAt; +} + +async function main(): Promise { + const expectedPort = Number(process.env.SAUCI_LOAD_API_PORT ?? '3003'); + const apiUrl = requireLocalApi(process.env.SAUCI_LOAD_API_URL ?? `http://127.0.0.1:${expectedPort}`, expectedPort); + const databaseUrl = process.env.DATABASE_URL; + const authorization = process.env.SAUCI_LOAD_AUTHORIZATION; + const matchId = process.env.SAUCI_LOAD_MATCH_ID; + const expectedUserId = process.env.SAUCI_LOAD_USER_ID; + if (!databaseUrl || !authorization || !matchId || !expectedUserId) throw new Error('DATABASE_URL, SAUCI_LOAD_AUTHORIZATION, SAUCI_LOAD_MATCH_ID, and SAUCI_LOAD_USER_ID are required'); + requireLoopback(databaseUrl, 'DATABASE_URL'); + if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(matchId) || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(expectedUserId)) throw new Error('SAUCI_LOAD_MATCH_ID and SAUCI_LOAD_USER_ID must be UUIDs'); + + const health = await fetch(new URL('/health/live', apiUrl)); + if (!health.ok || (await health.json() as { status?: string }).status !== 'ok') throw new Error('local Sauci API health preflight failed'); + const headers = { authorization }; + const me = await fetch(new URL('/v1/me', apiUrl), { headers }); + const profile = me.ok ? await me.json() as { profile?: { id?: string } } : undefined; + if (profile?.profile?.id !== expectedUserId) throw new Error('local fixture identity preflight failed'); + + const sync = await Promise.all(Array.from({ length: 5 }, () => read(new URL('/v1/me/sync', apiUrl), headers))); + const chat = await Promise.all(Array.from({ length: 5 }, () => read(new URL(`/v1/matches/${matchId}/messages?typing=true`, apiUrl), headers))); + for (const [operation, samples] of [['sync', sync], ['chat', chat]] as const) emitTelemetry({ event: 'load', process: 'load-fixture', operation, count: samples.length, p50_ms: percentile(samples, .5), p95_ms: percentile(samples, .95), p99_ms: percentile(samples, .99) }); + + const pool = createDatabasePool(databaseUrl, { max: 1 }, 'load-fixture'); + try { await new PostgresOperationsRepository(pool).outboxState(); recordPool('load-fixture', pool); } + finally { await pool.end(); } +} +void main(); diff --git a/apps/api/src/telemetry.ts b/apps/api/src/telemetry.ts new file mode 100644 index 0000000..a0932ff --- /dev/null +++ b/apps/api/src/telemetry.ts @@ -0,0 +1,64 @@ +import type { Pool, PoolClient } from 'pg'; + +/** Fixed, privacy-safe JSON log vocabulary. Do not add free-form fields. */ +export type TelemetryProcess = 'api' | 'worker' | 'load-fixture'; +type Outcome = 'ok' | 'error'; +type StatusClass = '2xx' | '3xx' | '4xx' | '5xx'; + +export type TelemetryRecord = + | { event: 'request'; process: 'api'; method: string; route: string; status_class: StatusClass; count: number; duration_ms: number } + | { event: 'query'; process: TelemetryProcess; operation: 'pool_query'; outcome: Outcome; count: number; duration_ms: number } + | { event: 'pool'; process: TelemetryProcess; total: number; idle: number; waiting: number } + | { event: 'sync'; process: 'api' | 'worker'; outcome: Outcome; count: number; duration_ms: number } + | { event: 'outbox'; process: 'worker'; due_after: number; oldest_due_age_seconds: number; claimed: number; completed: number; failed: number; count: number } + | { event: 'load'; process: 'load-fixture'; operation: 'sync' | 'chat'; count: number; p50_ms: number; p95_ms: number; p99_ms: number }; + +type TelemetrySink = (record: TelemetryRecord) => void; +let sink: TelemetrySink = (record) => console.log(JSON.stringify(record)); +let pending = new Map(); + +function aggregate(record: Extract): void { + const key = record.event === 'request' ? `${record.event}:${record.method}:${record.route}:${record.status_class}` : `${record.event}:${record.process}:${record.outcome}`; + const prior = pending.get(key) as typeof record | undefined; + if (!prior) { pending.set(key, { ...record }); return; } + prior.count += record.count; + prior.duration_ms += record.duration_ms; +} + +/** Emits bounded aggregates each minute; workers also flush after a tick. */ +export function flushTelemetry(): void { for (const record of pending.values()) sink(record); pending = new Map(); } +const flushTimer = setInterval(flushTelemetry, 60_000); +flushTimer.unref(); + +export function emitTelemetry(record: Exclude): void { sink(record); } +export function recordRequest(method: string, route: string, status: number, startedAt: number): void { + const statusClass: StatusClass = status >= 500 ? '5xx' : status >= 400 ? '4xx' : status >= 300 ? '3xx' : '2xx'; + aggregate({ event: 'request', process: 'api', method, route, status_class: statusClass, duration_ms: durationMs(startedAt), count: 1 }); +} +export function recordSync(process: 'api' | 'worker', outcome: Outcome, startedAt: number): void { aggregate({ event: 'sync', process, outcome, duration_ms: durationMs(startedAt), count: 1 }); } +/** Test-only hook; production code always uses the JSON console sink. */ +export function setTelemetrySinkForTests(next: TelemetrySink): () => void { const previous = sink; sink = next; return () => { sink = previous; pending = new Map(); }; } +export function durationMs(startedAt: number): number { return Math.max(0, Math.round((performance.now() - startedAt) * 100) / 100); } +export function recordPool(process: TelemetryProcess, pool: Pool): void { emitTelemetry({ event: 'pool', process, total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount }); } + +export function instrumentPool(process: TelemetryProcess, pool: Pool): Pool { + const clients = new WeakSet(); + const instrumentQuery = (target: T): T => { + const query = target.query.bind(target) as (...args: unknown[]) => unknown; + target.query = ((...args: unknown[]) => { + const startedAt = performance.now(); + const finish = (outcome: Outcome) => aggregate({ event: 'query', process, operation: 'pool_query', outcome, duration_ms: durationMs(startedAt), count: 1 }); + try { const result = query(...args); if (result && typeof (result as Promise).then === 'function') return (result as Promise).then((value) => { finish('ok'); return value; }, (error) => { finish('error'); throw error; }); finish('ok'); return result; } + catch (error) { finish('error'); throw error; } + }) as Pool['query']; + return target; + }; + instrumentQuery(pool); + const connect = pool.connect.bind(pool) as (...args: unknown[]) => unknown; + pool.connect = ((...args: unknown[]) => { + const result = connect(...args); + if (result && typeof (result as Promise).then === 'function') return (result as Promise).then((client) => { if (!clients.has(client)) { clients.add(client); instrumentQuery(client); } return client; }); + return result; + }) as Pool['connect']; + return pool; +} diff --git a/apps/api/src/workers/config.ts b/apps/api/src/workers/config.ts index f4edd30..f32eb69 100644 --- a/apps/api/src/workers/config.ts +++ b/apps/api/src/workers/config.ts @@ -4,6 +4,7 @@ const optionalString=z.preprocess(value=>value===''?undefined:value,z.string().m const optionalUrl=z.preprocess(value=>value===''?undefined:value,z.string().url().optional()); const schema=z.object({ NODE_ENV:z.enum(['development','test','production']).default('development'), DATABASE_URL:z.string().url(), + DATABASE_POOL_MAX:z.coerce.number().int().min(1).max(200).default(4), DATABASE_POOL_IDLE_TIMEOUT_MS:z.coerce.number().int().min(0).max(3_600_000).default(30_000), DATABASE_POOL_CONNECTION_TIMEOUT_MS:z.coerce.number().int().min(0).max(60_000).default(10_000), WORKER_POLL_INTERVAL_MS:z.coerce.number().int().min(1000).max(300000).default(30000), WORKER_BATCH_SIZE:z.coerce.number().int().min(1).max(100).default(25), MEDIA_ROOT:z.string().min(1).default('/data/media'), MEDIA_SIGNING_SECRET:z.string().min(32).default('local-media-signing-secret-change-me-123456'), MEDIA_PUBLIC_BASE_URL:z.string().url().default('http://127.0.0.1:3003'), CLASSIFIER_ENABLED:z.enum(['true','false']).default('true').transform(v=>v==='true'), @@ -15,7 +16,7 @@ export function loadWorkerConfig(env:NodeJS.ProcessEnv=process.env) { const value=schema.parse(env); if(value.NODE_ENV==='production'&&value.CLASSIFIER_ENABLED&&!value.OPENROUTER_API_KEY)throw new Error('OPENROUTER_API_KEY is required when classification is enabled'); if(value.ADMIN_PRIVATE_KEY_JWK){try{JSON.parse(value.ADMIN_PRIVATE_KEY_JWK);}catch{throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON');}} - return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE, + return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,databasePool:{max:value.DATABASE_POOL_MAX,idleTimeoutMillis:value.DATABASE_POOL_IDLE_TIMEOUT_MS,connectionTimeoutMillis:value.DATABASE_POOL_CONNECTION_TIMEOUT_MS},pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE, mediaRoot:value.MEDIA_ROOT,mediaSigningSecret:value.MEDIA_SIGNING_SECRET,mediaPublicBaseUrl:value.MEDIA_PUBLIC_BASE_URL,classifierEnabled:value.CLASSIFIER_ENABLED, openRouterApiKey:value.OPENROUTER_API_KEY,classifierModel:value.CLASSIFIER_MODEL,classifierPrompt:env.CLASSIFIER_PROMPT||defaultPrompt, adminPrivateKeyJwk:value.ADMIN_PRIVATE_KEY_JWK,discordWebhookUrl:value.DISCORD_WEBHOOK_URL}; diff --git a/apps/api/src/workers/index.ts b/apps/api/src/workers/index.ts index 89a4d04..5973a7e 100644 --- a/apps/api/src/workers/index.ts +++ b/apps/api/src/workers/index.ts @@ -3,14 +3,16 @@ import { PostgresMediaRepository } from '../domains/media/repository.js'; import { FilesystemMediaStorage } from '../domains/media/storage.js'; import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js'; import { loadWorkerConfig } from './config.js'; +import { createDatabasePool } from '../db/pool.js'; +import { flushTelemetry, recordPool, recordSync } from '../telemetry.js'; +import { WorkerLifecycle } from './lifecycle.js'; -const config=loadWorkerConfig(); const operations=new PostgresOperationsRepository(config.databaseUrl); const mediaRepository=new PostgresMediaRepository(config.databaseUrl); +const config=loadWorkerConfig(); const pool=createDatabasePool(config.databaseUrl,config.databasePool,'worker'); const operations=new PostgresOperationsRepository(pool); const mediaRepository=new PostgresMediaRepository(pool); const mediaStorage=new FilesystemMediaStorage(config.mediaRoot,config.mediaSigningSecret,config.mediaPublicBaseUrl); const runner=new OperationsRunner(operations,new ExpoPushProvider(),new DiscordProvider(config.discordWebhookUrl),new MessageClassifier(operations, {enabled:config.classifierEnabled,apiKey:config.openRouterApiKey,model:config.classifierModel,prompt:config.classifierPrompt,adminPrivateKeyJwk:config.adminPrivateKeyJwk,mediaRoot:config.mediaRoot}), new MediaJanitor(mediaRepository,mediaStorage)); -let stopped=false;let timer:NodeJS.Timeout|undefined; -async function tick(){if(stopped)return;try{const result=await runner.runOnce(new Date(),config.batchSize);if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){console.error('Operations worker tick failed',cause);}finally{if(!stopped){timer=setTimeout(()=>void tick(),config.pollIntervalMs);}}} -async function shutdown(signal:string){if(stopped)return;stopped=true;if(timer)clearTimeout(timer);console.log(`Received ${signal}; stopping operations worker`);await Promise.all([operations.close(),mediaRepository.close()]);} -process.on('SIGINT',()=>void shutdown('SIGINT').then(()=>process.exit(0))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(()=>process.exit(0))); -console.log('Sauci operations worker started');void tick(); +const lifecycle=new WorkerLifecycle({pollIntervalMs:config.pollIntervalMs,closePool:()=>pool.end(),onTickFailure:cause=>console.error('Operations worker tick failed',cause),runTick:async()=>{const startedAt=performance.now();try{const result=await runner.runOnce(new Date(),config.batchSize);recordSync('worker','ok',startedAt);recordPool('worker',pool);flushTelemetry();if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){recordSync('worker','error',startedAt);recordPool('worker',pool);flushTelemetry();throw cause;}}}); +function shutdown(signal:string){console.log(`Received ${signal}; stopping operations worker`);return lifecycle.shutdown();} +process.on('SIGINT',()=>void shutdown('SIGINT').then(code=>process.exit(code))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(code=>process.exit(code))); +console.log('Sauci operations worker started');lifecycle.start(); diff --git a/apps/api/src/workers/lifecycle.ts b/apps/api/src/workers/lifecycle.ts new file mode 100644 index 0000000..837d47f --- /dev/null +++ b/apps/api/src/workers/lifecycle.ts @@ -0,0 +1,47 @@ +export interface WorkerLifecycleDependencies { + pollIntervalMs: number; + runTick: () => Promise; + closePool: () => Promise; + onTickFailure: (cause: unknown) => void; + graceMs?: number; +} + +/** Owns worker tick scheduling and makes shutdown single-flight and bounded. */ +export class WorkerLifecycle { + private stopped = false; + private timer: NodeJS.Timeout | undefined; + private activeTick: Promise | undefined; + private shutdownPromise: Promise | undefined; + private readonly graceMs: number; + + constructor(private readonly deps: WorkerLifecycleDependencies) { this.graceMs = deps.graceMs ?? 25_000; } + + start(): void { if (!this.activeTick && !this.stopped) this.activeTick = this.tick(); } + + shutdown(): Promise { + if (!this.shutdownPromise) this.shutdownPromise = this.stop(); + return this.shutdownPromise; + } + + private async tick(): Promise { + try { await this.deps.runTick(); } + catch (cause) { this.deps.onTickFailure(cause); } + finally { + if (!this.stopped) this.timer = setTimeout(() => { this.activeTick = this.tick(); }, this.deps.pollIntervalMs); + } + } + + private async stop(): Promise { + this.stopped = true; + if (this.timer) clearTimeout(this.timer); + const work = (async () => { + try { if (this.activeTick) await this.activeTick; await this.deps.closePool(); return 0; } + catch { return 1; } + })(); + let timeout: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { timeout = setTimeout(() => resolve(1), this.graceMs); }); + const result = await Promise.race([work, timedOut]); + if (timeout) clearTimeout(timeout); + return result; + } +} diff --git a/apps/api/test/app.test.ts b/apps/api/test/app.test.ts index a0b393c..f19f1a4 100644 --- a/apps/api/test/app.test.ts +++ b/apps/api/test/app.test.ts @@ -5,6 +5,7 @@ import type { ApiRepository, MobileCompatibleProfile, } from '../src/db/repository.js'; +import { flushTelemetry, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js'; const alice: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', @@ -119,4 +120,15 @@ describe('Sauci API', () => { }); expect(response.status).toBe(400); }); + + it('logs only a route template and status class, never raw UUIDs or auth values', async () => { + const records: TelemetryRecord[] = []; const restore = setTelemetrySinkForTests((record) => records.push(record)); + const app = createApp({ auth: verifier(), repository: new MemoryRepository() }); + await app.request('/v1/me/11111111-1111-4111-8111-111111111111?token=private-token', { headers: { authorization: 'Bearer private-token' } }); + flushTelemetry(); restore(); + const serialized = JSON.stringify(records); + expect(records).toContainEqual(expect.objectContaining({ event: 'request', status_class: '4xx' })); + expect(serialized).not.toContain('11111111-1111-4111-8111-111111111111'); + expect(serialized).not.toContain('private-token'); + }); }); diff --git a/apps/api/test/load.loopback.test.ts b/apps/api/test/load.loopback.test.ts new file mode 100644 index 0000000..0b4b12b --- /dev/null +++ b/apps/api/test/load.loopback.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { requireLocalApi, requireLoopback } from '../src/load/loopback.js'; + +describe('representative load fixture boundary', () => { + it('accepts only loopback API and PostgreSQL URLs', () => { + expect(requireLoopback('http://127.0.0.1:3003', 'API').hostname).toBe('127.0.0.1'); + expect(requireLoopback('postgresql://sauci:test@localhost:5432/sauci', 'DATABASE').hostname).toBe('localhost'); + expect(() => requireLoopback('https://api.sauci.example', 'API')).toThrow('must resolve to loopback'); + expect(() => requireLoopback('postgresql://sauci:test@db.internal:5432/sauci', 'DATABASE')).toThrow('must resolve to loopback'); + }); + + it('allows only the controlled local HTTP API port', () => { + expect(requireLocalApi('http://127.0.0.1:3003').port).toBe('3003'); + expect(() => requireLocalApi('https://127.0.0.1:3003')).toThrow('must be http loopback'); + expect(() => requireLocalApi('http://127.0.0.1:3004')).toThrow('must be http loopback'); + }); +}); diff --git a/apps/api/test/operations.integration.test.ts b/apps/api/test/operations.integration.test.ts index 820297c..b76e9c8 100644 --- a/apps/api/test/operations.integration.test.ts +++ b/apps/api/test/operations.integration.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { Pool } from 'pg'; import { afterAll,beforeAll,describe,expect,it } from 'vitest'; -import { PostgresOperationsRepository } from '../src/domains/operations/repository.js'; +import { OUTBOX_STATE_SQL, PostgresOperationsRepository } from '../src/domains/operations/repository.js'; const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false; if(databaseUrl&&!local)throw new Error('Operations integration tests only permit a localhost DATABASE_URL'); @@ -111,4 +111,13 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{ const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0); expect([...a,...b].find(item=>item.dedupeKey.startsWith('message:'))?.pushToken).toBeNull(); }); + + it('keeps due/unlocked outbox observation eligible for the existing due index',async()=>{ + const client=await pool.connect(); + try { + await client.query('begin'); await client.query('set local enable_seqscan=off'); + const plan=await client.query<{"QUERY PLAN":string}>(`explain (costs off) ${OUTBOX_STATE_SQL}`); + expect(plan.rows.map(row=>row['QUERY PLAN']).join('\n')).toContain('operations_outbox_due_idx'); + } finally { await client.query('rollback').catch(()=>undefined); client.release(); } + }); }); diff --git a/apps/api/test/operations.repository.test.ts b/apps/api/test/operations.repository.test.ts new file mode 100644 index 0000000..ce8670a --- /dev/null +++ b/apps/api/test/operations.repository.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { OUTBOX_STATE_SQL, PostgresOperationsRepository } from '../src/domains/operations/repository.js'; + +describe('operations outbox observation', () => { + it('uses a transaction-scoped statement timeout and always releases on query failure', async () => { + const query = vi.fn(async (sql: string) => { + if (sql === OUTBOX_STATE_SQL) throw new Error('statement timeout'); + return { rows: [], rowCount: 0 }; + }); + const release = vi.fn(); + const pool = { connect: vi.fn(async () => ({ query, release })) }; + const repository = new PostgresOperationsRepository(pool as never); + await expect(repository.outboxState()).rejects.toThrow('statement timeout'); + expect(query).toHaveBeenNthCalledWith(1, 'begin'); + expect(query).toHaveBeenNthCalledWith(2, "set local statement_timeout = '250ms'"); + expect(query).toHaveBeenNthCalledWith(3, OUTBOX_STATE_SQL); + expect(query).toHaveBeenCalledWith('rollback'); + expect(release).toHaveBeenCalledOnce(); + }); + + it('evicts the client when rollback itself fails while preserving the original error', async () => { + const original = new Error('statement timeout'); const rollback = new Error('rollback failed'); + const query = vi.fn(async (sql: string) => { if (sql === OUTBOX_STATE_SQL) throw original; if (sql === 'rollback') throw rollback; return { rows: [], rowCount: 0 }; }); + const release = vi.fn(); const repository = new PostgresOperationsRepository({ connect: vi.fn(async () => ({ query, release })) } as never); + await expect(repository.outboxState()).rejects.toBe(original); + expect(release).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledWith(rollback); + }); +}); diff --git a/apps/api/test/operations.runner.test.ts b/apps/api/test/operations.runner.test.ts index c06900d..f1d3e30 100644 --- a/apps/api/test/operations.runner.test.ts +++ b/apps/api/test/operations.runner.test.ts @@ -7,7 +7,7 @@ import { loadWorkerConfig } from '../src/workers/config.js'; const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0}; function repository(items:OperationItem[]):OperationsRepository { - return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined), + return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),outboxState:vi.fn(async()=>({due:items.length,oldestDueAgeSeconds:0})), message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)}; } @@ -71,4 +71,11 @@ describe('worker configuration',()=>{ discordWebhookUrl:undefined, }); }); + + it('uses a distinct bounded pool configuration for the worker process',()=>{ + expect(loadWorkerConfig({ + DATABASE_URL:'postgresql://sauci:test@localhost:5432/sauci', + DATABASE_POOL_MAX:'3', DATABASE_POOL_IDLE_TIMEOUT_MS:'1200', DATABASE_POOL_CONNECTION_TIMEOUT_MS:'3400', + }).databasePool).toEqual({max:3,idleTimeoutMillis:1200,connectionTimeoutMillis:3400}); + }); }); diff --git a/apps/api/test/telemetry.test.ts b/apps/api/test/telemetry.test.ts new file mode 100644 index 0000000..1f4a8ba --- /dev/null +++ b/apps/api/test/telemetry.test.ts @@ -0,0 +1,50 @@ +import { Pool } from 'pg'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDatabasePool } from '../src/db/pool.js'; +import { flushTelemetry, recordPool, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js'; + +const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test'; + +describe('privacy-safe telemetry', () => { + let restore: () => void = () => {}; + afterEach(() => { restore(); vi.restoreAllMocks(); }); + + it('records query duration without SQL or bound values', async () => { + const records: TelemetryRecord[] = []; + restore = setTelemetrySinkForTests((record) => records.push(record)); + vi.spyOn(Pool.prototype, 'query').mockResolvedValueOnce({ rows: [], rowCount: 0 } as never); + const pool = createDatabasePool(url, {}, 'api'); + await pool.query('select secret from profiles where id=$1', ['private-user-id']); + flushTelemetry(); + const serialized = JSON.stringify(records); + expect(records).toContainEqual(expect.objectContaining({ event: 'query', process: 'api', operation: 'pool_query', outcome: 'ok', count: 1 })); + expect(serialized).not.toContain('secret'); + expect(serialized).not.toContain('private-user-id'); + await pool.end(); + }); + + it('emits only numeric pool state', async () => { + const records: TelemetryRecord[] = []; + restore = setTelemetrySinkForTests((record) => records.push(record)); + const pool = new Pool({ connectionString: url }); + recordPool('worker', pool); + expect(records).toEqual([{ event: 'pool', process: 'worker', total: 0, idle: 0, waiting: 0 }]); + await pool.end(); + }); + + it('also instruments transaction-client queries', async () => { + const records: TelemetryRecord[] = []; + restore = setTelemetrySinkForTests((record) => records.push(record)); + const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })), release: vi.fn() }; + vi.spyOn(Pool.prototype, 'connect').mockResolvedValueOnce(client as never); + const pool = createDatabasePool(url, {}, 'worker'); + const borrowed = await pool.connect(); + await borrowed.query('select message_body from messages where id=$1', ['private-message-id']); + flushTelemetry(); + const serialized = JSON.stringify(records); + expect(records).toContainEqual(expect.objectContaining({ event: 'query', process: 'worker', outcome: 'ok', count: 1 })); + expect(serialized).not.toContain('message_body'); + expect(serialized).not.toContain('private-message-id'); + await pool.end(); + }); +}); diff --git a/apps/api/test/worker.lifecycle.test.ts b/apps/api/test/worker.lifecycle.test.ts new file mode 100644 index 0000000..fff9eec --- /dev/null +++ b/apps/api/test/worker.lifecycle.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WorkerLifecycle } from '../src/workers/lifecycle.js'; + +function deferred() { let resolve!: (value: T) => void; const promise = new Promise((next) => { resolve = next; }); return { promise, resolve }; } + +describe('WorkerLifecycle', () => { + it('reuses one shutdown promise, awaits the active tick, and closes once', async () => { + const tick = deferred(); const closePool = vi.fn(async () => undefined); + const lifecycle = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: () => tick.promise, closePool, onTickFailure: vi.fn(), graceMs: 50 }); + lifecycle.start(); const first = lifecycle.shutdown(); const second = lifecycle.shutdown(); + expect(second).toBe(first); expect(closePool).not.toHaveBeenCalled(); + tick.resolve(); await expect(first).resolves.toBe(0); expect(closePool).toHaveBeenCalledTimes(1); + }); + + it('returns nonzero when an active tick exceeds the one total deadline', async () => { + vi.useFakeTimers(); + const lifecycle = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: () => new Promise(() => undefined), closePool: vi.fn(async () => undefined), onTickFailure: vi.fn(), graceMs: 10 }); + lifecycle.start(); const shutdown = lifecycle.shutdown(); await vi.advanceTimersByTimeAsync(10); + await expect(shutdown).resolves.toBe(1); vi.useRealTimers(); + }); + + it('returns nonzero and closes once when pool shutdown rejects or hangs', async () => { + const rejected = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: async () => undefined, closePool: vi.fn(async () => { throw new Error('close failed'); }), onTickFailure: vi.fn(), graceMs: 50 }); + rejected.start(); await expect(rejected.shutdown()).resolves.toBe(1); + vi.useFakeTimers(); const closePool = vi.fn(() => new Promise(() => undefined)); + const hung = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: async () => undefined, closePool, onTickFailure: vi.fn(), graceMs: 10 }); + hung.start(); const shutdown = hung.shutdown(); await vi.advanceTimersByTimeAsync(10); + await expect(shutdown).resolves.toBe(1); expect(closePool).toHaveBeenCalledTimes(1); vi.useRealTimers(); + }); +}); diff --git a/docs/architecture/client-data-freshness.md b/docs/architecture/client-data-freshness.md index 5745b1d..f447af9 100644 --- a/docs/architecture/client-data-freshness.md +++ b/docs/architecture/client-data-freshness.md @@ -189,7 +189,10 @@ default-sized pool per domain. Shutdown ends that pool once. Repositories still accept a connection string in tests, in which case they own and end the pool they created — see `resolvePool` / `closeResolvedPool` in `apps/api/src/db/pool.ts`. -Bounds come from `DATABASE_POOL_MAX` (default 10), +Bounds come from `DATABASE_POOL_MAX` (default 10 for API, 4 for worker), `DATABASE_POOL_IDLE_TIMEOUT_MS` (30000) and `DATABASE_POOL_CONNECTION_TIMEOUT_MS` (10000). They are process-wide; keep the total across running instances inside the database's own connection limit. +Production Compose exposes these as separate `API_DATABASE_POOL_MAX` and +`WORKER_DATABASE_POOL_MAX` budgets; `infra/backend/README.md` documents the +database-wide formula and deferred `pg_stat_statements` prerequisite. diff --git a/infra/backend/.env.production.example b/infra/backend/.env.production.example index 847fcde..91f40c9 100644 --- a/infra/backend/.env.production.example +++ b/infra/backend/.env.production.example @@ -4,6 +4,13 @@ POSTGRES_USER=sauci POSTGRES_PASSWORD=generate-a-long-random-password DATABASE_URL=postgresql://sauci:URL_ENCODED_PASSWORD@postgres:5432/sauci +# Keep this total below PostgreSQL max_connections: +# (API replicas * API_DATABASE_POOL_MAX) + (worker replicas * WORKER_DATABASE_POOL_MAX) + migration/admin headroom. +API_DATABASE_POOL_MAX=10 +WORKER_DATABASE_POOL_MAX=4 +DATABASE_POOL_IDLE_TIMEOUT_MS=30000 +DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000 + # Hosted Supabase Auth only. The service-role key is used solely by the # server-side Auth Admin endpoint for account deletion and admin identity # enrichment. It belongs on the API only, never the worker, MCP, or a client. diff --git a/infra/backend/README.md b/infra/backend/README.md index 9cd6833..9bd3568 100644 --- a/infra/backend/README.md +++ b/infra/backend/README.md @@ -111,6 +111,53 @@ docker compose --env-file infra/backend/.env.production.local \ -f infra/backend/compose.production.yaml config ``` +## PostgreSQL connection and telemetry contract + +The API and worker each create exactly one process-owned PostgreSQL pool and +hand it to every repository. They have separate deployment variables: +`API_DATABASE_POOL_MAX` (default 10) and `WORKER_DATABASE_POOL_MAX` (default +4). Set PostgreSQL `max_connections` above: + +```text +(API replicas * API_DATABASE_POOL_MAX) + (worker replicas * WORKER_DATABASE_POOL_MAX) + migration/admin headroom +``` + +The headroom covers the one-shot migrator, an operator session, and any +backup/monitoring connection; allocating the full server limit to application +pools is unsafe. `DATABASE_POOL_IDLE_TIMEOUT_MS` and +`DATABASE_POOL_CONNECTION_TIMEOUT_MS` apply to both process pools. + +API and worker write privacy-safe JSON telemetry to normal logs. Fixed event +types are request, query, pool, sync, and outbox; fields are route templates, +method/status/outcome, durations, counts, and pool/outbox sizes. They never +include SQL, query values, raw paths, identities, tokens, content, dedupe keys, +or request bodies. There is deliberately no public `/metrics` endpoint. +Request, query, and sync events are aggregated into one fixed-interval JSON +record per label set (and worker flushes after its tick); pool state is sampled +once per minute rather than per request. This prevents telemetry itself from +becoming a connection or log-volume amplifier. Outbox observation runs after +work with a 250ms best-effort deadline and uses the same due/unlocked predicate +as claims, so a slow observation cannot delay the next lease. + +`pg_stat_statements` is useful for aggregate query investigation but is not +enabled by this repository change. A database operator must first confirm the +PostgreSQL image/provider supports it, add `shared_preload_libraries = +'pg_stat_statements'`, and restart PostgreSQL in a planned window. After that, +the extension itself must be introduced by a reviewed, committed migration in +`apps/api/drizzle/`; do not run ad hoc provider/operator DDL. This changes +server startup and remains deferred from ordinary API deployments. + +For a controlled local observation, `npm run load:representative -w @sauci/api` +performs five authenticated sync reads, five authenticated chat reads, and a +read-only outbox-state query. It requires `DATABASE_URL`, +`SAUCI_LOAD_AUTHORIZATION`, `SAUCI_LOAD_USER_ID`, and `SAUCI_LOAD_MATCH_ID`. +It accepts only `http` loopback API port 3003 (or the explicit local +`SAUCI_LOAD_API_PORT`) and loopback PostgreSQL, verifies `/health/live` and the +authenticated fixture identity before load, and emits counts plus p50/p95/p99 +summaries without any pass/fail latency threshold. Use disposable local or +designated non-production Auth credentials only; never pass a staging or +production token to this fixture. + Run the repeatable disposable container check before changing the Dokploy application. It uses hard-coded test-only credentials, creates a unique Compose project, verifies migration/startup/security/shutdown behaviour, and removes its diff --git a/infra/backend/compose.production.yaml b/infra/backend/compose.production.yaml index c3c0287..eee0a83 100644 --- a/infra/backend/compose.production.yaml +++ b/infra/backend/compose.production.yaml @@ -8,6 +8,9 @@ x-common-environment: &common-environment x-api-environment: &api-environment <<: *common-environment PORT: 3003 + DATABASE_POOL_MAX: ${API_DATABASE_POOL_MAX:-10} + DATABASE_POOL_IDLE_TIMEOUT_MS: ${DATABASE_POOL_IDLE_TIMEOUT_MS:-30000} + DATABASE_POOL_CONNECTION_TIMEOUT_MS: ${DATABASE_POOL_CONNECTION_TIMEOUT_MS:-10000} SUPABASE_AUTH_URL: ${SUPABASE_AUTH_URL:?set SUPABASE_AUTH_URL} SUPABASE_AUTH_AUDIENCE: ${SUPABASE_AUTH_AUDIENCE:-authenticated} SUPABASE_AUTH_SERVICE_ROLE_KEY: ${SUPABASE_AUTH_SERVICE_ROLE_KEY:-} @@ -20,6 +23,9 @@ x-api-environment: &api-environment x-worker-environment: &worker-environment <<: *common-environment + DATABASE_POOL_MAX: ${WORKER_DATABASE_POOL_MAX:-4} + DATABASE_POOL_IDLE_TIMEOUT_MS: ${DATABASE_POOL_IDLE_TIMEOUT_MS:-30000} + DATABASE_POOL_CONNECTION_TIMEOUT_MS: ${DATABASE_POOL_CONNECTION_TIMEOUT_MS:-10000} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} CLASSIFIER_ENABLED: ${CLASSIFIER_ENABLED:-true} CLASSIFIER_MODEL: ${CLASSIFIER_MODEL:-openai/gpt-4o} From 5f77b0604414470d2b6a77d12add29a88421c360 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 22:50:03 +0100 Subject: [PATCH 6/8] test(coverage): enforce API and app floors Measure untouched mobile source, add regression coverage for native media paths and account operations, and enforce ratcheting API and mobile thresholds in required CI checks. --- .github/workflows/verify.yml | 4 +- apps/api/package.json | 3 + .../test/account-operations.service.test.ts | 84 ++++++++++++ apps/api/vitest.config.ts | 11 ++ apps/api/vitest.integration.config.ts | 11 ++ apps/mobile/jest.config.js | 15 ++ apps/mobile/package.json | 1 + .../__tests__/hooks/useAudioRecorder.test.ts | 118 ++++++++++++++++ .../hooks/useResponseMediaUpload.test.ts | 84 ++++++++++++ .../src/__tests__/lib/imageCache.test.ts | 122 +++++++++++++++++ docs/testing/strategy.md | 10 ++ package-lock.json | 129 ++++++++++++++++++ package.json | 1 + 13 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/account-operations.service.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts create mode 100644 apps/mobile/src/__tests__/lib/imageCache.test.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4878774..f870058 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -47,6 +47,8 @@ jobs: - run: npm ci - run: npm run learn:check - run: npm run verify:fast + - name: Enforce API and mobile coverage floors + run: npm run test:coverage - run: npm run build backend-integration: @@ -76,7 +78,7 @@ jobs: - name: Run standalone backend integration tests env: DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test - run: npm run test:api:integration + run: npm run test:integration:coverage -w @sauci/api container-contract: runs-on: ubuntu-latest diff --git a/apps/api/package.json b/apps/api/package.json index d4cd82d..918d723 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,7 +19,9 @@ "migration:cutover": "tsx src/migration/run.ts", "load:representative": "tsx src/load/representative.ts", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit" }, @@ -33,6 +35,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@vitest/coverage-v8": "^2.1.9", "@types/node": "^22.0.0", "@types/pg": "^8.15.5", "drizzle-kit": "^0.31.4", diff --git a/apps/api/test/account-operations.service.test.ts b/apps/api/test/account-operations.service.test.ts new file mode 100644 index 0000000..a44fa6c --- /dev/null +++ b/apps/api/test/account-operations.service.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AccountOperationsService } from '../src/domains/account-operations/service.js'; +import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from '../src/domains/account-operations/clients.js'; +import type { AccountOperationsRepository } from '../src/domains/account-operations/repository.js'; + +function setup(partnerPushToken: string | null = 'ExponentPushToken[partner]') { + const repository = { + deleteRelationship: vi.fn(async () => ({ partnerPushToken })), + resetProgress: vi.fn(async () => ({ partnerPushToken })), + deleteAccount: vi.fn(async (_userId: string, deleteAuthUser: () => Promise) => { + await deleteAuthUser(); + return { partnerPushToken }; + }), + setPremium: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + } satisfies AccountOperationsRepository; + const authAdmin = { deleteUser: vi.fn(async () => undefined) } satisfies AuthAdminClient; + const revenueCat = { isEntitled: vi.fn(async () => true) } satisfies RevenueCatClient; + const notifier = { + relationshipDeleted: vi.fn(async () => undefined), + progressReset: vi.fn(async () => undefined), + partnerAccountDeleted: vi.fn(async () => undefined), + } satisfies PartnerNotifier; + return { + repository, + authAdmin, + revenueCat, + notifier, + service: new AccountOperationsService(repository, authAdmin, revenueCat, notifier), + }; +} + +describe('AccountOperationsService', () => { + it('deletes relationship data and notifies the partner', async () => { + const { service, repository, notifier } = setup(); + await expect(service.deleteRelationship('user-1')).resolves.toEqual({ + success: true, + message: 'Relationship data deleted successfully', + }); + expect(repository.deleteRelationship).toHaveBeenCalledWith('user-1'); + expect(notifier.relationshipDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]'); + }); + + it('resets progress and skips notification without a partner token', async () => { + const { service, notifier } = setup(null); + await expect(service.resetProgress('user-1')).resolves.toEqual({ + success: true, + message: 'Progress reset successfully', + }); + expect(notifier.progressReset).not.toHaveBeenCalled(); + }); + + it('deletes hosted auth inside the repository operation before notifying', async () => { + const { service, repository, authAdmin, notifier } = setup(); + await expect(service.deleteAccount('user-1')).resolves.toEqual({ + success: true, + message: 'Account deleted successfully', + }); + expect(repository.deleteAccount).toHaveBeenCalledOnce(); + expect(authAdmin.deleteUser).toHaveBeenCalledWith('user-1'); + expect(notifier.partnerAccountDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]'); + }); + + it('persists the entitlement returned by RevenueCat', async () => { + const { service, repository, revenueCat } = setup(); + revenueCat.isEntitled.mockResolvedValueOnce(false); + await expect(service.syncSubscription('user-1')).resolves.toEqual({ + success: true, + is_premium: false, + }); + expect(repository.setPremium).toHaveBeenCalledWith('user-1', false); + }); + + it('keeps a committed destructive operation successful when notification fails', async () => { + const { service, notifier } = setup(); + notifier.relationshipDeleted.mockRejectedValueOnce(new Error('push unavailable')); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(service.deleteRelationship('user-1')).resolves.toMatchObject({ success: true }); + expect(error).toHaveBeenCalledWith('Partner notification failed', expect.any(Error)); + error.mockRestore(); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index ad5a0c6..59dd41a 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -5,5 +5,16 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.test.ts'], exclude: ['test/**/*.integration.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text', 'json-summary', 'lcov'], + thresholds: { + statements: 30, + branches: 60, + functions: 40, + lines: 30, + }, + }, }, }); diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts index c8e0835..d50c78a 100644 --- a/apps/api/vitest.integration.config.ts +++ b/apps/api/vitest.integration.config.ts @@ -5,5 +5,16 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.integration.test.ts'], fileParallelism: false, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text', 'json-summary', 'lcov'], + thresholds: { + statements: 55, + branches: 65, + functions: 65, + lines: 55, + }, + }, }, }); diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 49ae8b9..d20f75c 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -17,6 +17,21 @@ module.exports = { '/src/**/__tests__/**/*.(spec|test).(ts|tsx)', '/src/**/*.(spec|test).(ts|tsx)', ], + collectCoverageFrom: [ + '/app/**/*.{ts,tsx}', + '/src/**/*.{ts,tsx}', + '!/src/**/__tests__/**', + '!/src/test/**', + '!/**/*.d.ts', + ], + coverageThreshold: { + global: { + statements: 23, + branches: 17, + functions: 21, + lines: 23, + }, + }, transformIgnorePatterns: [ 'node_modules/(?!(jest-)?react-native|@react-native|@react-navigation|expo(nent)?|@expo(nent)?/.*|expo-router|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|@sentry/react-native|@sentry/.*)', ], diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0af06f9..cd4aa7f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -13,6 +13,7 @@ "test": "jest", "test:watch": "jest --watch", "test:ci": "jest --runInBand --ci", + "test:coverage": "jest --runInBand --ci --coverage", "release:ios": "dotenv -e .env -- eas build --platform ios --profile production --local", "release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local", "release:android": "dotenv -e .env -- eas build --platform android --profile production --local", diff --git a/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts b/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts new file mode 100644 index 0000000..ac58182 --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Alert } from 'react-native'; +import { Audio } from 'expo-av'; + +import { useAudioRecorder } from '@/hooks/useAudioRecorder'; + +const makeRecording = (overrides: Record = {}) => ({ + getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 2400 })), + stopAndUnloadAsync: jest.fn(async () => undefined), + getURI: jest.fn(() => 'file://recording.m4a'), + setOnRecordingStatusUpdate: jest.fn(), + setProgressUpdateInterval: jest.fn(), + ...overrides, +}); + +describe('useAudioRecorder', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + (Audio.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + (Audio.setAudioModeAsync as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('alerts and refuses to start without microphone permission', async () => { + (Audio.requestPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); + const { result } = renderHook(() => useAudioRecorder()); + + let started = true; + await act(async () => { started = await result.current.startRecording(); }); + + expect(started).toBe(false); + expect(Alert.alert).toHaveBeenCalledWith( + 'Microphone Permission', + 'Please allow microphone access to record audio.' + ); + expect(Audio.Recording.createAsync).not.toHaveBeenCalled(); + }); + + it('starts, reports duration, and returns the completed recording', async () => { + const recording = makeRecording(); + (Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording }); + const onComplete = jest.fn(); + const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete })); + + await act(async () => { expect(await result.current.startRecording()).toBe(true); }); + expect(result.current.state).toBe('recording'); + expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(100); + + const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0]; + act(() => statusCallback({ isRecording: true, durationMillis: 2400 })); + expect(result.current.durationSeconds).toBe(2); + + let completed: unknown; + await act(async () => { completed = await result.current.stopRecording(); }); + expect(completed).toEqual({ uri: 'file://recording.m4a', duration: 2 }); + expect(result.current).toMatchObject({ + state: 'stopped', + recordingUri: 'file://recording.m4a', + durationSeconds: 2, + }); + expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 2); + }); + + it('auto-stops when the configured duration is reached', async () => { + const recording = makeRecording({ + getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 1000 })), + }); + (Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording }); + const onComplete = jest.fn(); + const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete })); + + await act(async () => { await result.current.startRecording(); }); + const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0]; + await act(async () => { statusCallback({ isRecording: true, durationMillis: 1000 }); }); + + expect(recording.stopAndUnloadAsync).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1); + }); + + it('resets an active recording and cleans it up on unmount', async () => { + const first = makeRecording(); + const second = makeRecording(); + (Audio.Recording.createAsync as jest.Mock) + .mockResolvedValueOnce({ recording: first }) + .mockResolvedValueOnce({ recording: second }); + const view = renderHook(() => useAudioRecorder()); + + await act(async () => { await view.result.current.startRecording(); }); + act(() => view.result.current.resetRecording()); + expect(first.stopAndUnloadAsync).toHaveBeenCalled(); + expect(view.result.current.state).toBe('idle'); + + await act(async () => { await view.result.current.startRecording(); }); + view.unmount(); + expect(second.stopAndUnloadAsync).toHaveBeenCalled(); + expect(Audio.setAudioModeAsync).toHaveBeenLastCalledWith({ + allowsRecordingIOS: false, + playsInSilentModeIOS: true, + }); + }); + + it('recovers when starting or stopping throws', async () => { + (Audio.Recording.createAsync as jest.Mock).mockRejectedValueOnce(new Error('native unavailable')); + const first = renderHook(() => useAudioRecorder()); + await act(async () => { expect(await first.result.current.startRecording()).toBe(false); }); + expect(first.result.current.state).toBe('idle'); + + const recording = makeRecording({ getStatusAsync: jest.fn(async () => { throw new Error('lost'); }) }); + (Audio.Recording.createAsync as jest.Mock).mockResolvedValueOnce({ recording }); + const second = renderHook(() => useAudioRecorder()); + await act(async () => { await second.result.current.startRecording(); }); + await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); }); + expect(second.result.current.state).toBe('idle'); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts b/apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts new file mode 100644 index 0000000..3db4d0e --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts @@ -0,0 +1,84 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Platform } from 'react-native'; + +import { useResponseMediaUpload } from '@/hooks/useResponseMediaUpload'; +import { uploadMedia } from '@/lib/mediaApi'; + +jest.mock('@/lib/mediaApi', () => ({ uploadMedia: jest.fn() })); + +const setPlatform = (os: string) => Object.defineProperty(Platform, 'OS', { configurable: true, value: os }); + +describe('useResponseMediaUpload', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + setPlatform('ios'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + (uploadMedia as jest.Mock).mockResolvedValue({ reference: 'media:uploaded' }); + }); + + afterEach(() => jest.restoreAllMocks()); + afterAll(() => setPlatform(originalPlatform)); + + it('fails closed when identity context is incomplete', async () => { + const { result } = renderHook(() => useResponseMediaUpload({ userId: '', questionId: 'q1' })); + await expect(result.current.uploadPhoto('file://photo.jpg')).resolves.toEqual({ + success: false, + error: 'Missing userId or questionId', + }); + expect(uploadMedia).not.toHaveBeenCalled(); + }); + + it.each([ + ['file://photo.jpeg', 'image/jpeg'], + ['file://photo.png', 'image/png'], + ['file://photo.unknown', 'image/jpeg'], + ])('uploads native photo %s with %s', async (uri, mimeType) => { + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + let response: unknown; + await act(async () => { response = await result.current.uploadPhoto(uri); }); + expect(response).toEqual({ success: true, mediaPath: 'media:uploaded' }); + expect(uploadMedia).toHaveBeenCalledWith(uri, { kind: 'response', mimeType, questionId: 'q1' }); + expect(result.current.uploading).toBe(false); + }); + + it.each([ + ['file://voice.mp3', 'audio/mpeg'], + ['file://voice.wav', 'audio/wav'], + ['file://voice.aac', 'audio/aac'], + ['file://voice.caf', 'audio/x-caf'], + ['file://voice.unknown', 'audio/m4a'], + ])('uploads native audio %s with %s', async (uri, mimeType) => { + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + await act(async () => { await result.current.uploadAudio(uri, 12); }); + expect(uploadMedia).toHaveBeenCalledWith(uri, { kind: 'response', mimeType, questionId: 'q1' }); + }); + + it('detects web photo and audio MIME types', async () => { + setPlatform('web'); + const fetchSpy = jest.spyOn(global, 'fetch') + .mockResolvedValueOnce({ blob: async () => ({ type: 'image/webp' }) } as Response) + .mockResolvedValueOnce({ blob: async () => ({ type: 'audio/webm' }) } as Response); + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + + await act(async () => { await result.current.uploadPhoto('blob:photo'); }); + expect(uploadMedia).toHaveBeenLastCalledWith('blob:photo', { + kind: 'response', mimeType: 'image/webp', questionId: 'q1', + }); + await act(async () => { await result.current.uploadAudio('blob:audio', 3); }); + expect(uploadMedia).toHaveBeenLastCalledWith('blob:audio', { + kind: 'response', mimeType: 'audio/webm', questionId: 'q1', + }); + fetchSpy.mockRestore(); + }); + + it('returns the provider error and clears uploading state', async () => { + (uploadMedia as jest.Mock).mockRejectedValueOnce(new Error('upload failed')); + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + let response: unknown; + await act(async () => { response = await result.current.uploadPhoto('file://photo.jpg'); }); + expect(response).toEqual({ success: false, error: 'upload failed' }); + expect(result.current.uploading).toBe(false); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/imageCache.test.ts b/apps/mobile/src/__tests__/lib/imageCache.test.ts new file mode 100644 index 0000000..73c18b6 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/imageCache.test.ts @@ -0,0 +1,122 @@ +import { Platform } from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; + +import { + cacheVideo, + clearExpiredUrls, + clearUrlCache, + clearVideoCache, + getCachedSignedUrl, + getStoragePath, + getVideoCachedUri, + getVideoCachePath, + getVideoCacheSize, + prefetchSignedUrls, +} from '@/lib/imageCache'; +import { getMediaUrl, mediaId } from '@/lib/mediaApi'; + +jest.mock('@/lib/mediaApi', () => ({ getMediaUrl: jest.fn(), mediaId: jest.fn() })); +jest.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + getInfoAsync: jest.fn(), + makeDirectoryAsync: jest.fn(), + downloadAsync: jest.fn(), + deleteAsync: jest.fn(), + readDirectoryAsync: jest.fn(), +})); + +const setPlatform = (os: string) => Object.defineProperty(Platform, 'OS', { configurable: true, value: os }); + +describe('imageCache', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + clearUrlCache(); + setPlatform('ios'); + jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + (mediaId as jest.Mock).mockReturnValue('id-1'); + (getMediaUrl as jest.Mock).mockResolvedValue({ + url: 'https://signed.example/media', + expires_at: new Date(2_000_000).toISOString(), + }); + }); + + afterEach(() => jest.restoreAllMocks()); + afterAll(() => setPlatform(originalPlatform)); + + it('caches signed URLs and refreshes expired entries', async () => { + await expect(getCachedSignedUrl('media:id-1')).resolves.toBe('https://signed.example/media'); + await expect(getCachedSignedUrl('media:id-1')).resolves.toBe('https://signed.example/media'); + expect(getMediaUrl).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(3_000_000); + clearExpiredUrls(); + await getCachedSignedUrl('media:id-1'); + expect(getMediaUrl).toHaveBeenCalledTimes(2); + }); + + it('preserves legacy URLs and extracts their storage paths', async () => { + (mediaId as jest.Mock).mockReturnValue(null); + await expect(getCachedSignedUrl('https://legacy.example/chat-media/folder/file.jpg')) + .resolves.toBe('https://legacy.example/chat-media/folder/file.jpg'); + expect(getStoragePath('https://legacy.example/response-media/a/b.jpg')).toBe('a/b.jpg'); + expect(getStoragePath('media:id')).toBe('media:id'); + }); + + it('returns null when signing fails and prefetches every path', async () => { + (getMediaUrl as jest.Mock).mockRejectedValueOnce(new Error('offline')); + await expect(getCachedSignedUrl('media:bad')).resolves.toBeNull(); + await prefetchSignedUrls(['media:a', 'media:b']); + expect(getMediaUrl).toHaveBeenCalledTimes(3); + }); + + it('uses safe cache paths and returns an existing cached video', async () => { + expect(getVideoCachePath('folder/a:b?.mp4')).toBe('file:///cache/video-cache/folder_a_b_.mp4'); + (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true }); + await expect(getVideoCachedUri('video.mp4')).resolves.toBe('file:///cache/video-cache/video.mp4'); + }); + + it('creates the cache directory and downloads a video once', async () => { + (FileSystem.getInfoAsync as jest.Mock) + .mockResolvedValueOnce({ exists: false }) + .mockResolvedValueOnce({ exists: false }); + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ status: 200 }); + + await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves + .toBe('file:///cache/video-cache/video.mp4'); + expect(FileSystem.makeDirectoryAsync).toHaveBeenCalledWith( + 'file:///cache/video-cache/', { intermediates: true } + ); + expect(FileSystem.downloadAsync).toHaveBeenCalled(); + }); + + it('handles failed downloads and web without writing', async () => { + (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: false }); + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ status: 503 }); + await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves.toBeNull(); + + setPlatform('web'); + await expect(getVideoCachedUri('video.mp4')).resolves.toBeNull(); + await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves.toBeNull(); + await expect(getVideoCacheSize()).resolves.toBe(0); + }); + + it('clears videos and sums cached file sizes', async () => { + (FileSystem.getInfoAsync as jest.Mock) + .mockResolvedValueOnce({ exists: true }) + .mockResolvedValueOnce({ exists: true }) + .mockResolvedValueOnce({ exists: true, size: 12 }) + .mockResolvedValueOnce({ exists: true, size: 8 }); + (FileSystem.readDirectoryAsync as jest.Mock).mockResolvedValue(['a', 'b']); + + await clearVideoCache(); + expect(FileSystem.deleteAsync).toHaveBeenCalledWith( + 'file:///cache/video-cache/', { idempotent: true } + ); + await expect(getVideoCacheSize()).resolves.toBe(20); + }); +}); diff --git a/docs/testing/strategy.md b/docs/testing/strategy.md index 8730b81..5dafba2 100644 --- a/docs/testing/strategy.md +++ b/docs/testing/strategy.md @@ -12,6 +12,16 @@ is proved through the real stack. already-running local stack. - `verify`: full plus E2E; this is the pre-PR target. +The required CI checks also run instrumented coverage for the API and mobile +workspaces. Mobile coverage includes every TypeScript file under `app` and +`src`, including files no test imports. The committed thresholds are ratchets, +not targets: raise them as coverage improves, and never narrow the collected +source set to make a percentage pass. + +- API unit floor: 30% statements/lines, 60% branches, 40% functions. +- API PostgreSQL integration floor: 55% statements/lines, 65% branches/functions. +- Mobile floor: 23% statements/lines, 17% branches, 21% functions. + CI runs deterministic checks on every pull request and on pushes to `staging` and `main`. The backend integration job provisions disposable PostgreSQL and refuses non-loopback database URLs. User-facing browser/native acceptance remains diff --git a/package-lock.json b/package-lock.json index 42cdd86..4112bb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/pg": "^8.15.5", + "@vitest/coverage-v8": "^2.1.9", "drizzle-kit": "^0.31.4", "tsup": "^8.2.4", "tsx": "^4.19.0", @@ -819,6 +820,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -11942,6 +11957,108 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -22396,6 +22513,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/package.json b/package.json index b7f031a..6a2619a 100755 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "turbo lint && npm run lint:harness", "lint:harness": "node scripts/lint-harness.mjs", "test": "turbo test --filter=!@sauci/e2e", + "test:coverage": "npm run test:coverage -w @sauci/api && npm run test:coverage -w @sauci/mobile", "test:supabase": "scripts/test-supabase.sh", "test:api:integration": "scripts/test-api-integration.sh", "test:mobile:e2e": "maestro test e2e/maestro", From 9da44217f7bd5c62754e5e7834dcbd027e751273 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 23:22:28 +0100 Subject: [PATCH 7/8] feat(release): add generation workflow Add a guarded mobile release skill that synchronizes public versions, writes on-brand notes, verifies signed iOS and Android artifacts, and tags the verified release commit. --- .codex/skills/generate-release/SKILL.md | 116 ++++++++++++++++++ .../generate-release/agents/openai.yaml | 4 + .../references/artifact-verification.md | 39 ++++++ .../references/release-notes-voice.md | 51 ++++++++ .../generate-release/scripts/set_version.mjs | 105 ++++++++++++++++ .codex/skills/release-mobile/SKILL.md | 9 +- 6 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 .codex/skills/generate-release/SKILL.md create mode 100644 .codex/skills/generate-release/agents/openai.yaml create mode 100644 .codex/skills/generate-release/references/artifact-verification.md create mode 100644 .codex/skills/generate-release/references/release-notes-voice.md create mode 100755 .codex/skills/generate-release/scripts/set_version.mjs diff --git a/.codex/skills/generate-release/SKILL.md b/.codex/skills/generate-release/SKILL.md new file mode 100644 index 0000000..4643851 --- /dev/null +++ b/.codex/skills/generate-release/SKILL.md @@ -0,0 +1,116 @@ +--- +name: generate-release +description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle. +--- + +# Generate release + +Create a reproducible release whose tag identifies the reviewed release source +and final platform build metadata. Read `apps/mobile/AGENTS.md`, +`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read +[references/release-notes-voice.md](references/release-notes-voice.md) before +drafting notes and [references/artifact-verification.md](references/artifact-verification.md) +before accepting a build. + +A request to generate or create a release authorizes the scoped version edits, +release notes, local builds, release commits, and one local annotated tag. It +does not authorize pushing commits or tags, uploading binaries, submitting a +store release, changing signing credentials, or regenerating native projects. + +## Defaults + +- Build both iOS and Android production binaries unless the user narrows the + platforms. +- Increment the patch version unless the user specifies `minor`, `major`, or an + exact SemVer version. +- Use `v` for the annotated Git tag. +- Write artifacts beneath ignored `dist/releases/v/`; never commit + binaries, credentials, logs, or build directories. + +## Preflight + +Fetch the remote and inspect the worktree, branch, upstream, existing tags, +toolchains, EAS authentication, and signing availability without changing them. +Require all of the following: + +- a named branch, normally `main` for a production release; +- a clean starting worktree with `HEAD` equal to its fetched upstream; +- the previous reachable `v[0-9]*` release tag and no collision for the next tag; +- consistent public versions across `app.json`, Android `versionName`, and every + iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`); +- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS; +- the configured production API/Auth/RevenueCat environment, without printing + secret values. + +Do not switch branches, discard work, repair credentials, push, or weaken branch +protection merely to pass preflight. Stop with the exact blocker. Never run +`expo prebuild` as part of this workflow. + +## Prepare the release + +1. Derive the next version with `scripts/set_version.mjs --bump ` or set an + explicit version with `--version `. The script updates the checked-in + public-version sources but deliberately leaves platform build numbers to EAS. +2. Review the complete first-parent and ordinary commit diff from the previous + reachable release tag to the starting commit. Inspect the actual product diff + for every user-facing claim; commit subjects alone are not evidence. +3. Add the new entry immediately below the title in `docs/release-notes.md`. + Describe user-visible value, meaningful fixes, privacy or reliability changes, + and only claims supported by the diff. Omit internal migrations, refactors, + CI, infrastructure, dependency churn, and implementation jargon unless the + user experiences a direct result. Use a temporary `Build numbers pending + artifact verification` line until the binaries have been inspected; never + guess or predict build numbers. +4. Run `npm run verify:full` and the native acceptance required by the + `release-mobile` skill. A missing prerequisite is a failure/limitation, not a + pass. +5. Review the scoped diff and create `chore(release): prepare v`. Do not + stage unrelated paths. This clean release-preparation commit must descend + directly from the fetched upstream commit. + +## Build and capture platform metadata + +Build from the clean prepared source using the package scripts in +`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the +versioned artifact directory. When building both platforms, build Android first, +then iOS. + +After each build: + +1. Inspect the worktree before doing anything else. Accept only the documented + EAS build-number changes in `app.json`, that platform's native version source, + and the iOS widget plist when applicable. Treat any implementation, resource, + dependency, or native-project regeneration as a failed build. +2. Independently inspect the binary for package/bundle identifier, public + version, platform build number, production endpoint configuration where + inspectable, and signing identity/fingerprint. Require the embedded public + version to equal the requested version. +3. Record the verified platform build number in the new release-note entry. +4. Commit only the expected metadata and release-note update as + `chore(): record v build `. The worktree must be + clean before starting the next platform build. + +Do not accept a successful EAS exit code as proof of a valid artifact. If either +requested platform fails, do not tag a partial release. + +## Tag and prove the result + +Re-run focused version checks and `npm run verify:fast`. Confirm every requested +artifact exists, has a SHA-256 digest, passed independent version/signature +inspection, and was produced from the release commit chain. Require a clean +worktree, then create the annotated local tag `v` at `HEAD`. + +Read back the tag object and prove: + +- the tag resolves to `HEAD`; +- the tag version equals all public-version sources and embedded artifacts; +- recorded build numbers equal the embedded platform build numbers; +- no binary or ignored build output entered the commit; +- the release commits descend from the fetched starting branch commit; +- for a two-platform release, the Android artifact's source commit differs from + the final tag only by the later verified iOS build metadata. + +Report the branch, tag, commit, release-note summary, artifact paths, versions, +build numbers, signatures, SHA-256 digests, checks run, and any environmental +limitations. State explicitly that the tag and commits remain local unless a +separate authorized push was completed. diff --git a/.codex/skills/generate-release/agents/openai.yaml b/.codex/skills/generate-release/agents/openai.yaml new file mode 100644 index 0000000..f26406e --- /dev/null +++ b/.codex/skills/generate-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Generate Release" + short_description: "Bump, build, note, and tag Sauci releases" + default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android." diff --git a/.codex/skills/generate-release/references/artifact-verification.md b/.codex/skills/generate-release/references/artifact-verification.md new file mode 100644 index 0000000..cb7f82b --- /dev/null +++ b/.codex/skills/generate-release/references/artifact-verification.md @@ -0,0 +1,39 @@ +# Release artifact verification + +Use the strongest locally available platform tools and record the commands and +results. Never print provisioning profiles, keys, tokens, or environment values. + +## Android AAB + +Require a production `.aab`. Inspect its manifest with `bundletool dump manifest` +or Android SDK `apkanalyzer`, and verify: + +- package `com.sauci.app`; +- `versionName` equals the release version; +- `versionCode` equals the recorded Android build number. + +Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the +signing certificate SHA-1 to the production fingerprint documented in +`docs/releasing.md`; do not copy credentials into commands or reports. Calculate +the artifact SHA-256 with `shasum -a 256`. + +## iOS IPA + +Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read +the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify: + +- `CFBundleIdentifier` is `com.sauci.app`; +- `CFBundleShortVersionString` equals the release version; +- `CFBundleVersion` equals the recorded iOS build number. + +Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app +to inspect and verify signing without exposing the embedded profile. Calculate the +original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary +extraction directory after inspection. + +## Source correspondence + +For both platforms, retain the build command, artifact path, build completion +time, and digest in the final report. Compare the post-build tracked diff with the +documented allowed version files. A binary with the right version but unexpected +source regeneration, wrong signing, or an unverifiable manifest is not accepted. diff --git a/.codex/skills/generate-release/references/release-notes-voice.md b/.codex/skills/generate-release/references/release-notes-voice.md new file mode 100644 index 0000000..f991422 --- /dev/null +++ b/.codex/skills/generate-release/references/release-notes-voice.md @@ -0,0 +1,51 @@ +# Sauci release-note voice + +Sauci sounds warm, confident, inviting, and quietly playful. It is a premium +couples app about private connection and shared curiosity: supportive without +being clinical, polished without sounding corporate, and intimate without being +graphic or presumptuous. + +## Shape + +Use a short opening sentence followed by three to six compact bullets. Lead with +the change that most improves a couple's experience. Combine related work into +one benefit rather than mirroring the commit list. Include a brief closing line +only when it adds warmth or useful context. + +In `docs/release-notes.md`, use: + +```markdown +## v1.2.3 — 29 August 2026 +_iOS build 44 · Android build 48_ + +A little more ease, clarity, and connection in every check-in. + +- **Closer conversations** — Find the prompts that feel right for both of you. +- **Smoother moments** — Chats and matches now feel quicker and more reliable. +- **More control** — Clearer choices help keep your shared space comfortable. +``` + +Adapt the words to the evidence; do not reuse this copy when those claims are not +present in the release. If only one platform was requested, record only its build. + +## Language + +- Address the couple's experience with `you`, `your partner`, `together`, or + `your shared space` where natural. +- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`, + `keep conversations flowing`, `stay in control`. +- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo, + breathless hype, exclamation marks, emoji, and claims that Sauci will improve or + fix a relationship. +- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to + `revolutionary`, `game-changing`, `massive`, or `best ever`. +- Explain a fix as the experience restored, not the defect mechanics. Name a + security or privacy change plainly when users need to understand it. + +## Evidence boundary + +Release notes describe shipped behavior only. Do not claim a feature from a plan, +flagged-off path, migration, test, or commit title without confirming the runtime +path in the diff and release configuration. Do not expose internal service names, +database details, moderation logic, security-sensitive implementation, or content +that the product intentionally keeps private between partners. diff --git a/.codex/skills/generate-release/scripts/set_version.mjs b/.codex/skills/generate-release/scripts/set_version.mjs new file mode 100755 index 0000000..8e67b4c --- /dev/null +++ b/.codex/skills/generate-release/scripts/set_version.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../../../.."); +const files = { + app: path.join(repoRoot, "apps/mobile/app.json"), + android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"), + ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"), +}; + +const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/; + +function fail(message) { + console.error(`set_version: ${message}`); + process.exit(1); +} + +function parseVersion(value, label) { + const match = semverPattern.exec(value); + if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`); + return match.slice(1).map(Number); +} + +function readState() { + const appRaw = fs.readFileSync(files.app, "utf8"); + const androidRaw = fs.readFileSync(files.android, "utf8"); + const iosRaw = fs.readFileSync(files.ios, "utf8"); + const appVersion = JSON.parse(appRaw).expo?.version; + const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map( + (match) => match[1], + ); + const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map( + (match) => match[1].trim(), + ); + + if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version"); + if (androidVersions.length !== 1) fail("expected exactly one Android versionName"); + if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values"); + + const versions = [appVersion, ...androidVersions, ...iosVersions]; + versions.forEach((version) => parseVersion(version, `configured version ${version}`)); + if (new Set(versions).size !== 1) { + fail(`public versions disagree: ${versions.join(", ")}`); + } + + return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length }; +} + +function nextVersion(current, bump) { + const [major, minor, patch] = parseVersion(current, "current version"); + if (bump === "major") return `${major + 1}.0.0`; + if (bump === "minor") return `${major}.${minor + 1}.0`; + if (bump === "patch") return `${major}.${minor}.${patch + 1}`; + fail("--bump must be patch, minor, or major"); +} + +const args = process.argv.slice(2); +const check = args.length === 1 && args[0] === "--check"; +const bumpIndex = args.indexOf("--bump"); +const versionIndex = args.indexOf("--version"); + +if (!check && (bumpIndex === -1) === (versionIndex === -1)) { + fail("use --check, --bump patch|minor|major, or --version x.y.z"); +} + +const state = readState(); +if (check) { + console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`); + process.exit(0); +} + +const desired = + bumpIndex !== -1 + ? nextVersion(state.current, args[bumpIndex + 1]) + : (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]); + +if (desired === state.current) fail(`requested version is already ${state.current}`); +const currentParts = parseVersion(state.current, "current version"); +const desiredParts = parseVersion(desired, "requested version"); +const increases = desiredParts.some( + (part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]), +); +if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`); + +const appRaw = state.appRaw.replace( + /(\"version\"\s*:\s*\")([^\"]+)(\")/, + `$1${desired}$3`, +); +const androidRaw = state.androidRaw.replace( + /(\bversionName\s+["'])([^"']+)(["'])/, + `$1${desired}$3`, +); +const iosRaw = state.iosRaw.replace( + /(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g, + `$1${desired}$3`, +); + +fs.writeFileSync(files.app, appRaw); +fs.writeFileSync(files.android, androidRaw); +fs.writeFileSync(files.ios, iosRaw); +console.log(`updated public version ${state.current} -> ${desired}`); diff --git a/.codex/skills/release-mobile/SKILL.md b/.codex/skills/release-mobile/SKILL.md index f7151cd..6638d97 100644 --- a/.codex/skills/release-mobile/SKILL.md +++ b/.codex/skills/release-mobile/SKILL.md @@ -10,9 +10,12 @@ scoped instructions. Confirm target platform and release profile. Run repository verification plus a native acceptance pass before starting a release build. Before a build from the current upstream branch, fetch, require a clean worktree, -and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android -projects, so routine release builds must package those checked-in sources. Do not -run `expo prebuild` first; native regeneration is a separate reviewed change. +and prove `HEAD` matches its upstream. The `generate-release` skill may instead +build a clean, scoped release-preparation commit that descends directly from that +fetched upstream; between its multi-platform builds it commits only independently +verified EAS version metadata. Sauci tracks its native iOS and Android projects, +so routine release builds must package those checked-in sources. Do not run +`expo prebuild` first; native regeneration is a separate reviewed change. Use the package scripts in `apps/mobile/package.json`; they create artifacts only. Never infer upload or submission authority from build authorization. Store upload From 2bc69c279073bb0eada96ee3f240eee5d044663d Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 23:23:01 +0100 Subject: [PATCH 8/8] feat(mobile): surface pending questions Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior. --- apps/mobile/app/(app)/index.tsx | 58 ++++- .../components/PendingQuestionsCard.test.tsx | 65 ++++++ .../src/__tests__/lib/notifications.test.ts | 47 ++++ .../discovery/PendingQuestionsCard.tsx | 208 ++++++++++++++++++ apps/mobile/src/components/discovery/index.ts | 1 + apps/mobile/src/lib/analytics.ts | 4 + apps/mobile/src/lib/notifications.ts | 13 +- 7 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx create mode 100644 apps/mobile/src/__tests__/lib/notifications.test.ts create mode 100644 apps/mobile/src/components/discovery/PendingQuestionsCard.tsx diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index e28fcc6..cc7d527 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,15 +1,17 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native'; -import { useFocusEffect } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; +import * as Haptics from 'expo-haptics'; +import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; -import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; +import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery'; import { StreakDisplay } from '../../src/components/StreakDisplay'; -import { colors, spacing, typography, radius } from '../../src/theme'; +import { Events } from '../../src/lib/analytics'; +import { colors, spacing, typography } from '../../src/theme'; import type { QuestionPack, Category } from '../../src/types'; const MAX_CONTENT_WIDTH = 600; @@ -69,14 +71,21 @@ function groupPacksByCategory( export default function DiscoveryScreen() { const { user, partner, couple } = useAuthStore(); const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore(); + const { pendingQuestions, fetchPendingQuestions } = useMatchStore(); const { subscription } = useSubscriptionStore(); + const router = useRouter(); const { width } = useWindowDimensions(); const [showPaywall, setShowPaywall] = useState(false); const [headerHeight, setHeaderHeight] = useState(130); + const lastPromptImpression = useRef(null); // Check all sources of premium access: user flag, partner flag, or active subscription const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser; const isWideScreen = width > MAX_CONTENT_WIDTH; + const pendingQuestionIds = useMemo( + () => pendingQuestions.map(question => question.id).join(','), + [pendingQuestions], + ); // Load the catalog once. Returning to this tab shows what is cached; answering a // question invalidates it, and StreakDisplay owns the streak, so neither needs a @@ -84,7 +93,17 @@ export default function DiscoveryScreen() { useFocusEffect( useCallback(() => { void ensurePacksLoaded(); - }, [ensurePacksLoaded]) + void fetchPendingQuestions(); + + if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) { + Events.pendingQuestionsPromptViewed(pendingQuestions.length); + lastPromptImpression.current = pendingQuestionIds; + } + + return () => { + lastPromptImpression.current = null; + }; + }, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length]) ); // Group packs by category @@ -96,6 +115,18 @@ export default function DiscoveryScreen() { // Check if user has no packs enabled (empty state) const hasNoPacks = packs.length === 0; + const handlePendingQuestionsPress = useCallback(() => { + const firstQuestion = pendingQuestions[0]; + if (!firstQuestion) return; + + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + Events.pendingQuestionsPromptTapped(pendingQuestions.length); + router.push({ + pathname: '/(app)/swipe', + params: { mode: 'pending', startQuestionId: firstQuestion.question.id }, + }); + }, [pendingQuestions, router]); + return ( + + {/* Shared streak - hidden entirely for couples with nothing running */} - + {/* Live Draw Activity Tile */} - + {/* Dares */} - + {/* Content Rows by Category */} {packsByCategory.map((group, index) => ( @@ -136,7 +174,7 @@ export default function DiscoveryScreen() { packs={group.packs} isPremiumUser={isPremiumUser} categoryId={group.category.id} - delay={200 + index * 100} + delay={250 + index * 100} getPackProgress={getPackProgress} onShowPaywall={() => setShowPaywall(true)} /> diff --git a/apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx b/apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx new file mode 100644 index 0000000..f8d8bce --- /dev/null +++ b/apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render } from '@/test/test-utils'; +import { PendingQuestionsCard, formatWaitingSince } from '@/components/discovery/PendingQuestionsCard'; +import type { PendingQuestion } from '@/store'; + +const pendingQuestion = (id: string, partnerAnsweredAt: string): PendingQuestion => ({ + id: `response-${id}`, + partnerAnsweredAt, + question: { + id, + pack_id: 'pack-1', + text: `Question ${id}`, + intensity: 1, + created_at: '2026-08-01T00:00:00.000Z', + pack: { id: 'pack-1', name: 'Connection', icon: 'heart' }, + }, +}); + +describe('PendingQuestionsCard', () => { + it('stays out of Home when there is nothing to answer', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('pending-questions-card')).toBeNull(); + }); + + it('names the partner, shows the count, and preserves answer privacy', () => { + const questions = [ + pendingQuestion('q1', '2026-08-29T09:00:00.000Z'), + pendingQuestion('q2', '2026-08-29T10:00:00.000Z'), + ]; + const { getByText, getByLabelText } = render( + , + ); + + expect(getByText('Sam has answered 2 questions')).toBeTruthy(); + expect(getByText('Answer yours to see where you match. Their answers stay private until you respond.')).toBeTruthy(); + expect(getByLabelText('2 questions waiting')).toBeTruthy(); + }); + + it('starts the answer action from its accessible CTA', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('pending-questions-answer')); + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); + +describe('formatWaitingSince', () => { + const now = new Date('2026-08-29T12:00:00.000Z'); + + it('formats useful compact waiting ages', () => { + expect(formatWaitingSince('2026-08-29T11:59:30.000Z', now)).toBe('just now'); + expect(formatWaitingSince('2026-08-29T11:42:00.000Z', now)).toBe('18m ago'); + expect(formatWaitingSince('2026-08-29T09:00:00.000Z', now)).toBe('3h ago'); + expect(formatWaitingSince('2026-08-27T12:00:00.000Z', now)).toBe('2d ago'); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/notifications.test.ts b/apps/mobile/src/__tests__/lib/notifications.test.ts new file mode 100644 index 0000000..fb235d0 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/notifications.test.ts @@ -0,0 +1,47 @@ +import { router } from 'expo-router'; +import { handleNotificationResponse } from '@/lib/notifications'; +import { useMatchStore } from '@/store/matchStore'; + +jest.mock('expo-router', () => ({ + router: { push: jest.fn() }, +})); + +jest.mock('expo-notifications', () => ({ + setNotificationHandler: jest.fn(), + addNotificationResponseReceivedListener: jest.fn(), + getLastNotificationResponseAsync: jest.fn(), + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + getExpoPushTokenAsync: jest.fn(), +})); + +jest.mock('@/lib/crashlytics', () => ({ + captureError: jest.fn(), +})); + +const notificationResponse = (type: string) => ({ + notification: { + request: { + content: { data: { type } }, + }, + }, +}) as never; + +describe('handleNotificationResponse', () => { + beforeEach(() => { + useMatchStore.setState({ currentView: 'active' }); + }); + + it('opens the answer queue for a catch-up reminder', () => { + handleNotificationResponse(notificationResponse('catchup_reminder')); + + expect(useMatchStore.getState().currentView).toBe('pending'); + expect(router.push).toHaveBeenCalledWith('/(app)/swipe?mode=pending'); + }); + + it('ignores unknown notification types', () => { + handleNotificationResponse(notificationResponse('unknown')); + + expect(router.push).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/discovery/PendingQuestionsCard.tsx b/apps/mobile/src/components/discovery/PendingQuestionsCard.tsx new file mode 100644 index 0000000..dda388b --- /dev/null +++ b/apps/mobile/src/components/discovery/PendingQuestionsCard.tsx @@ -0,0 +1,208 @@ +import { useMemo } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import type { PendingQuestion } from '../../store'; +import { colors, gradients, radius, shadows, spacing, typography } from '../../theme'; + +interface PendingQuestionsCardProps { + questions: PendingQuestion[]; + partnerName?: string | null; + onPress: () => void; + delay?: number; +} + +export function formatWaitingSince(dateString: string, now = new Date()): string { + const answeredAt = new Date(dateString); + const diffMs = Math.max(0, now.getTime() - answeredAt.getTime()); + const minutes = Math.floor(diffMs / 60_000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return answeredAt.toLocaleDateString(); +} + +export function PendingQuestionsCard({ + questions, + partnerName, + onPress, + delay = 0, +}: PendingQuestionsCardProps) { + const oldestQuestion = useMemo( + () => questions.reduce((oldest, question) => { + if (!oldest) return question; + return new Date(question.partnerAnsweredAt) < new Date(oldest.partnerAnsweredAt) + ? question + : oldest; + }, null), + [questions], + ); + + if (!oldestQuestion) return null; + + const count = questions.length; + const subject = partnerName?.trim() || 'Your partner'; + const questionLabel = count === 1 ? 'question' : 'questions'; + + return ( + + + + + + + + Your turn + + + {count} + + + + + {subject} has answered {count} {questionLabel} + + + Answer yours to see where you match. Their answers stay private until you respond. + + + + + Answer now + + + + + + + + Oldest waiting · {formatWaitingSince(oldestQuestion.partnerAnsweredAt)} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: spacing.lg, + marginTop: spacing.sm, + marginBottom: spacing.sm, + }, + card: { + position: 'relative', + overflow: 'hidden', + padding: spacing.md, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: 'rgba(232, 164, 174, 0.36)', + backgroundColor: colors.surface, + }, + accent: { + position: 'absolute', + top: 0, + right: 0, + width: 96, + height: 96, + borderBottomLeftRadius: 96, + backgroundColor: colors.primaryLight, + }, + labelRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + labelGroup: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: radius.full, + backgroundColor: colors.primary, + shadowColor: colors.primary, + shadowOpacity: 0.55, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + }, + label: { + ...typography.caption1, + color: colors.premium.rose, + fontWeight: '700', + letterSpacing: 1.2, + textTransform: 'uppercase', + }, + countBadge: { + minWidth: 32, + height: 26, + paddingHorizontal: spacing.sm, + borderRadius: radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary, + }, + countText: { + ...typography.caption1, + color: colors.text, + fontWeight: '800', + }, + title: { + ...typography.title3, + color: colors.text, + marginTop: spacing.md, + paddingRight: spacing.xl, + }, + description: { + ...typography.subhead, + color: colors.textSecondary, + marginTop: spacing.sm, + marginBottom: spacing.md, + }, + button: { + minHeight: 46, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + }, + buttonText: { + ...typography.callout, + color: colors.text, + fontWeight: '700', + }, + waitingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.sm, + }, + waitingText: { + ...typography.caption2, + color: colors.textTertiary, + }, +}); diff --git a/apps/mobile/src/components/discovery/index.ts b/apps/mobile/src/components/discovery/index.ts index e1c5f2e..f118af2 100644 --- a/apps/mobile/src/components/discovery/index.ts +++ b/apps/mobile/src/components/discovery/index.ts @@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader'; export { CircularProgress } from './CircularProgress'; export { LiveDrawTile } from './LiveDrawTile'; export { DaresTile } from './DaresTile'; +export { PendingQuestionsCard } from './PendingQuestionsCard'; diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index fd3f486..7406655 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -216,6 +216,10 @@ export const Events = { questionSkipped: () => logEvent("question_skipped"), matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }), allQuestionsExhausted: () => logEvent("all_questions_exhausted"), + pendingQuestionsPromptViewed: (count: number) => + logEvent("pending_questions_prompt_viewed", { question_count: count }), + pendingQuestionsPromptTapped: (count: number) => + logEvent("pending_questions_prompt_tapped", { question_count: count }), // Chat events matchViewed: () => logEvent("match_viewed"), diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 485c3b3..bbf6d21 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -23,7 +23,7 @@ Notifications.setNotificationHandler({ // Types export interface NotificationData { - type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary"; + type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary" | "catchup_reminder"; match_id?: string; message_id?: string; count?: number; @@ -36,6 +36,7 @@ const notificationTypes = new Set([ "partner_activity", "nudge", "weekly_summary", + "catchup_reminder", ]); function isNotificationData(value: unknown): value is NotificationData { @@ -254,12 +255,18 @@ export function handleNotificationResponse( return; } - if (data.type === "partner_activity") { + if (data.type === "partner_activity") { // Navigate to Matches > Your Turn tab (questions to answer) useMatchStore.getState().setCurrentView('pending'); router.push("/(app)/matches"); return; - } + } + + if (data.type === "catchup_reminder") { + useMatchStore.getState().setCurrentView('pending'); + router.push("/(app)/swipe?mode=pending"); + return; + } if (!data.match_id) { return;