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/.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 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 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/AGENTS.md b/AGENTS.md index a1f0b73..7715587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,18 @@ 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. Implement in a task-specific git worktree created from current `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 + 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. +11. Use high-effort reviewers only for the risk triggers in `docs/agents/routing.md`. ## Repository map @@ -36,6 +47,8 @@ MCP service in one npm/Turborepo workspace. ## Workflows +- 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/apps/api/.env.example b/apps/api/.env.example index 08b0374..76a26bd 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,6 +3,15 @@ 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. 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 + # 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/package.json b/apps/api/package.json index bc15148..918d723 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,8 +17,11 @@ "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: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" }, @@ -32,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/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/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..ecb22b7 --- /dev/null +++ b/apps/api/src/db/pool.ts @@ -0,0 +1,52 @@ +import { Pool } from 'pg'; +import { instrumentPool, type TelemetryProcess } from '../telemetry.js'; + +/** + * 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 = {}, 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 { + 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..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(); @@ -44,6 +45,13 @@ 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 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))); 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..3635815 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 { @@ -14,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(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) => { @@ -278,6 +290,30 @@ export class PostgresOperationsRepository implements OperationsRepository { return result.rows.map(item); }); } + async outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }> { + 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)]); } @@ -312,7 +348,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/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/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..5bd56d8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,6 +2,8 @@ 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 { 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'; @@ -26,30 +28,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, 'api'); +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), @@ -76,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}`); }); @@ -85,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'); @@ -94,20 +105,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/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/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/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/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/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/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/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/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..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, useStreakStore, 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; @@ -68,23 +70,40 @@ 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 { 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], + ); - // 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(); + 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/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/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__/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__/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/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/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/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__/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__/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/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/__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/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/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/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/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/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; 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..ff7d803 100644 --- a/docs/agents/routing.md +++ b/docs/agents/routing.md @@ -1,21 +1,87 @@ # 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 | +## Implementation and integration -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. +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`. -Procedures belong in skills. Agents read the applicable skill and nearest -`AGENTS.md`, then operate within the task's authorization. +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 +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 +`.codex/agents/`. An explicit task handoff may raise or lower reasoning effort. + +| 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..f447af9 --- /dev/null +++ b/docs/architecture/client-data-freshness.md @@ -0,0 +1,198 @@ +# 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 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/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/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/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} 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", 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 ?? {})