mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e4cddd1c8 | ||
|
|
440f224095 | ||
|
|
22871d037c | ||
|
|
266233a473 | ||
|
|
2bc69c2790 | ||
|
|
9da44217f7 | ||
|
|
5f77b06044 | ||
|
|
5c973c4ac5 | ||
|
|
a13bbc3f65 | ||
|
|
10c4ae8d84 | ||
|
|
ac4a05d5ed | ||
|
|
c7ee8a4851 | ||
|
|
9957fa43f2 | ||
|
|
502452c9e4 | ||
|
|
e6bb368bc8 | ||
|
|
aedba2a480 | ||
|
|
78900a24fb | ||
|
|
03443fe898 | ||
|
|
b60c788362 | ||
|
|
61ec3692b7 | ||
|
|
1482305810 | ||
|
|
8b37569818 | ||
|
|
2178750d07 | ||
|
|
028e21e54c | ||
|
|
b24a402b4c | ||
|
|
ce0b188776 | ||
|
|
f35114dc77 | ||
|
|
aacb4f8731 | ||
|
|
1be54175e4 | ||
|
|
a7470310ae | ||
|
|
3acadeff7c |
No files matched your search
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-28-maestro-stable-login-readiness
|
||||||
|
date: 2026-08-28
|
||||||
|
status: promoted
|
||||||
|
scope: native Auth-shell acceptance flows
|
||||||
|
evidence: iOS simulator login-shell acceptance on current main
|
||||||
|
destination: e2e/maestro
|
||||||
|
review_after: 2027-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
The credential-free native smoke failed even though the current app reached the
|
||||||
|
login screen and displayed its required controls.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
|
||||||
|
for the retired tagline `Explore intimacy together`; the app displayed updated
|
||||||
|
marketing copy while `Sauci` and `Password` were present.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
Login readiness was coupled to mutable marketing copy rather than a stable
|
||||||
|
required control.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Wait for the password control that defines login readiness, while retaining the
|
||||||
|
brand and password assertions. Apply the same readiness condition to the
|
||||||
|
authenticated and credential-free flows.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Simulator read-back confirmed the current login shell exposes `Sauci` and
|
||||||
|
`Password`. The maintained Maestro flows now wait for that stable control rather
|
||||||
|
than the tagline.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-28-mobile-release-source-integrity
|
||||||
|
date: 2026-08-28
|
||||||
|
status: promoted
|
||||||
|
scope: mobile release build and upload workflow
|
||||||
|
evidence: clean-source local EAS builds for iOS 43 and Android 46
|
||||||
|
destination: scripts/lint-harness.mjs
|
||||||
|
review_after: 2027-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
A build-only request initially regenerated tracked native projects. The generated
|
||||||
|
Android widget provider replaced the checked-in implementation, and the Android
|
||||||
|
release script would also have uploaded after building.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run the former mobile release scripts or run `expo prebuild` before a local EAS
|
||||||
|
build, then inspect `git diff`. The reproduced diff included the Android widget
|
||||||
|
provider, widget XML, and Xcode project rather than version metadata alone.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
The release scripts combined three independently reviewed operations: native
|
||||||
|
regeneration, artifact creation, and store upload. Sauci already tracks native
|
||||||
|
projects, and EAS skips prebuild when those directories exist.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Keep native regeneration explicit, make release scripts artifact-only, expose
|
||||||
|
upload separately, and lint the scripts so those responsibilities cannot be
|
||||||
|
silently recombined.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
|
||||||
|
built successfully from the clean tracked sources; the post-build worktree
|
||||||
|
contained only the four expected build-number files. `npm run lint:harness`
|
||||||
|
enforces the release-script boundary.
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
@@ -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 <prompt-file> [result-json]
|
||||||
|
claude_delegate.sh resume <session-id> <prompt-file> [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
|
||||||
@@ -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<version>` for the annotated Git tag.
|
||||||
|
- Write artifacts beneath ignored `dist/releases/v<version>/`; 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 <kind>` or set an
|
||||||
|
explicit version with `--version <x.y.z>`. 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<version>`. 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(<platform>): record v<version> build <number>`. 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<version>` 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.
|
||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+105
@@ -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}`);
|
||||||
@@ -9,7 +9,50 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
|
|||||||
scoped instructions. Confirm target platform and release profile. Run repository
|
scoped instructions. Confirm target platform and release profile. Run repository
|
||||||
verification plus a native acceptance pass before starting a release build.
|
verification plus a native acceptance pass before starting a release build.
|
||||||
|
|
||||||
Use the package scripts in `apps/mobile/package.json`; preserve platform signing
|
Before a build from the current upstream branch, fetch, require a clean worktree,
|
||||||
and versioning. Building or uploading to a store is an external mutation: obtain
|
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||||
explicit authorization for the requested target, then report the build artifact,
|
build a clean, scoped release-preparation commit that descends directly from that
|
||||||
version/build number, upload result, and read-back state.
|
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
|
||||||
|
uses a separately authorized command.
|
||||||
|
|
||||||
|
## Upload authorization gate
|
||||||
|
|
||||||
|
After an artifact passes verification, stop immediately before the external
|
||||||
|
upload and ask the user for confirmation. The request must identify:
|
||||||
|
|
||||||
|
- the platform, exact artifact path, bundle/package identifier, public version,
|
||||||
|
build number, signature identity, and SHA-256 digest;
|
||||||
|
- the exact destination and resulting state: Google Play internal-track draft or
|
||||||
|
App Store Connect/TestFlight processing;
|
||||||
|
- that the upload does not authorize a public rollout or App Review submission.
|
||||||
|
|
||||||
|
For iOS, inspect the current App Store Connect automatic-distribution behavior
|
||||||
|
before asking. If the build would be distributed automatically, name the exact
|
||||||
|
TestFlight groups or testers in the request and include that effect in the fresh
|
||||||
|
authorization. If the effect cannot be established, stop; changing an automatic-
|
||||||
|
distribution setting also requires separate authorization.
|
||||||
|
|
||||||
|
Even when the original request includes building, releasing, uploading, or
|
||||||
|
submitting, require this fresh confirmation once the exact verified artifact and
|
||||||
|
destination are known. End the request with: `Upload this verified artifact to
|
||||||
|
<destination> now?` A clear affirmative response authorizes one upload of that
|
||||||
|
artifact to that destination, plus only any automatic TestFlight distribution
|
||||||
|
explicitly disclosed in the request. Run `eas submit` with the explicit
|
||||||
|
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
|
||||||
|
select `--latest`. If the result is ambiguous, inspect EAS and the store before
|
||||||
|
doing anything else, do not retry, and ask again before another upload.
|
||||||
|
|
||||||
|
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
|
||||||
|
and the store consoles. A later public rollout, App Review submission, or
|
||||||
|
additional TestFlight distribution requires another fresh authorization.
|
||||||
|
|
||||||
|
Preserve platform signing and local EAS versioning. After a build, verify that
|
||||||
|
tracked changes are limited to the expected version metadata, then independently
|
||||||
|
read the artifact version and signature. Report the artifact, version/build
|
||||||
|
number, upload result when authorized, and store read-back state.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
name: Verify
|
name: Verify
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [staging, main]
|
branches: [staging, main]
|
||||||
@@ -11,10 +12,10 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
branch-policy:
|
branch-policy:
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Enforce promotion flow
|
- name: Enforce promotion flow
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
env:
|
env:
|
||||||
BASE_BRANCH: ${{ github.base_ref }}
|
BASE_BRANCH: ${{ github.base_ref }}
|
||||||
HEAD_BRANCH: ${{ github.head_ref }}
|
HEAD_BRANCH: ${{ github.head_ref }}
|
||||||
@@ -31,6 +32,9 @@ jobs:
|
|||||||
echo "Feature pull requests target staging; promotions target main" >&2
|
echo "Feature pull requests target staging; promotions target main" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
- name: Confirm direct verification
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: echo "Direct branch verification requested for $GITHUB_SHA"
|
||||||
|
|
||||||
static:
|
static:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -43,6 +47,8 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run learn:check
|
- run: npm run learn:check
|
||||||
- run: npm run verify:fast
|
- run: npm run verify:fast
|
||||||
|
- name: Enforce API and mobile coverage floors
|
||||||
|
run: npm run test:coverage
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
backend-integration:
|
backend-integration:
|
||||||
@@ -72,7 +78,7 @@ jobs:
|
|||||||
- name: Run standalone backend integration tests
|
- name: Run standalone backend integration tests
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
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:
|
container-contract:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
||||||
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
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.
|
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`.
|
`docs/agents/routing.md`.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
@@ -36,6 +47,8 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
|
|
||||||
## Workflows
|
## 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`
|
- Bootstrap: `npm ci`
|
||||||
- Full local stack: `scripts/dev-local.sh up`
|
- Full local stack: `scripts/dev-local.sh up`
|
||||||
- Fast checks: `npm run verify:fast`
|
- Fast checks: `npm run verify:fast`
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "~19.1.10",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "~19.1.7",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
|
|||||||
Daily Response Limit (Free Users)
|
Daily Response Limit (Free Users)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59)
|
Limit the number of questions non-premium users can answer per day, on their own local calendar day
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
|
|||||||
className="max-w-[200px]"
|
className="max-w-[200px]"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Maximum number of questions a non-premium user can answer per UTC day.
|
Maximum number of questions a non-premium user can answer per day.
|
||||||
Premium users (via personal or partner subscription) bypass this limit.
|
Premium users (via personal or partner subscription) bypass this limit.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Set to <strong>0</strong> to disable daily limits entirely.
|
Set to <strong>0</strong> to disable daily limits entirely.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
|
Resets at midnight in the user's own timezone, as reported by their device.
|
||||||
|
Users who have not reported a timezone yet fall back to 00:00 UTC.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Does not count toward the limit:</strong> editing an existing answer, or
|
||||||
|
answering a question the partner had already answered (catching up is always free).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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_URL=https://your-project.supabase.co
|
||||||
SUPABASE_AUTH_AUDIENCE=authenticated
|
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
|
# 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
|
# enriching admin user lists; it is never exposed to the browser or used as a
|
||||||
# Supabase database client.
|
# Supabase database client.
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications.
|
||||||
|
--
|
||||||
|
-- Three concerns land together because the send path depends on all of them:
|
||||||
|
-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden.
|
||||||
|
-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history.
|
||||||
|
-- 3. Outbox rows for every status transition, which is the whole retention argument.
|
||||||
|
|
||||||
|
-- `to_regtype` resolves through the active search_path, so this creates the type in
|
||||||
|
-- the current schema rather than skipping because an unrelated schema already has it.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regtype('content_review_status') IS NULL THEN
|
||||||
|
CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE dare_packs
|
||||||
|
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||||
|
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE dares
|
||||||
|
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||||
|
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMENT ON COLUMN dare_packs.content_status IS
|
||||||
|
'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.';
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMENT ON COLUMN dares.content_status IS
|
||||||
|
'Store-safety review state. Only `allowed` dares are sendable.';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx
|
||||||
|
ON dare_packs (content_status, is_public, sort_order);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS dares_content_status_idx
|
||||||
|
ON dares (pack_id, content_status, intensity);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check`
|
||||||
|
-- requires one of the two source columns to survive. Deleting a dare that had ever
|
||||||
|
-- been sent therefore aborted with a check violation. Snapshotting the text at send
|
||||||
|
-- time removes the dependency entirely and keeps history stable across content edits.
|
||||||
|
ALTER TABLE sent_dares
|
||||||
|
ADD COLUMN IF NOT EXISTS dare_text_snapshot text,
|
||||||
|
ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE sent_dares sd
|
||||||
|
SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text),
|
||||||
|
dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity)
|
||||||
|
FROM dares d
|
||||||
|
WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE sent_dares
|
||||||
|
SET dare_text_snapshot = custom_dare_text,
|
||||||
|
dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1)
|
||||||
|
WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares
|
||||||
|
ALTER COLUMN dare_text_snapshot SET NOT NULL,
|
||||||
|
ALTER COLUMN dare_intensity_snapshot SET NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares
|
||||||
|
ADD CONSTRAINT sent_dares_intensity_snapshot_check
|
||||||
|
CHECK (dare_intensity_snapshot BETWEEN 1 AND 5);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares
|
||||||
|
ADD CONSTRAINT sent_dares_participants_distinct_check
|
||||||
|
CHECK (sender_id <> recipient_id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- `submitted` closes the loop the original design left open: the recipient performs
|
||||||
|
-- the dare, so the recipient reports it done and the sender confirms.
|
||||||
|
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares
|
||||||
|
ADD CONSTRAINT sent_dares_status_check CHECK (
|
||||||
|
status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx
|
||||||
|
ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted');
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE notification_preferences
|
||||||
|
ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true;
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION touch_sent_dare();
|
||||||
|
--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Push copy is deliberately generic. Store compliance forbids dare, question,
|
||||||
|
-- response, or message text in a notification preview.
|
||||||
|
CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
v_recipients uuid[];
|
||||||
|
v_recipient uuid;
|
||||||
|
v_title text;
|
||||||
|
v_body text;
|
||||||
|
v_event text;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF;
|
||||||
|
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
v_event := 'sent';
|
||||||
|
v_recipients := ARRAY[NEW.recipient_id];
|
||||||
|
v_title := 'New dare';
|
||||||
|
v_body := 'Your partner sent you a dare';
|
||||||
|
ELSIF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||||
|
v_event := NEW.status;
|
||||||
|
CASE NEW.status
|
||||||
|
WHEN 'active' THEN
|
||||||
|
v_recipients := ARRAY[NEW.sender_id];
|
||||||
|
v_title := 'Dare accepted';
|
||||||
|
v_body := 'Your partner accepted your dare';
|
||||||
|
WHEN 'declined' THEN
|
||||||
|
v_recipients := ARRAY[NEW.sender_id];
|
||||||
|
v_title := 'Dare declined';
|
||||||
|
v_body := 'Your partner passed on your dare';
|
||||||
|
WHEN 'submitted' THEN
|
||||||
|
v_recipients := ARRAY[NEW.sender_id];
|
||||||
|
v_title := 'Dare done';
|
||||||
|
v_body := 'Your partner says they completed your dare';
|
||||||
|
WHEN 'completed' THEN
|
||||||
|
v_recipients := ARRAY[NEW.recipient_id];
|
||||||
|
v_title := 'Dare complete';
|
||||||
|
v_body := 'Your partner confirmed your dare';
|
||||||
|
WHEN 'cancelled' THEN
|
||||||
|
v_recipients := ARRAY[NEW.recipient_id];
|
||||||
|
v_title := 'Dare cancelled';
|
||||||
|
v_body := 'Your partner cancelled the dare';
|
||||||
|
WHEN 'expired' THEN
|
||||||
|
-- Both sides lose the dare, so both sides hear about it.
|
||||||
|
v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id];
|
||||||
|
v_title := 'Dare expired';
|
||||||
|
v_body := 'A dare ran out of time';
|
||||||
|
ELSE
|
||||||
|
RETURN NEW;
|
||||||
|
END CASE;
|
||||||
|
ELSE
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOREACH v_recipient IN ARRAY v_recipients LOOP
|
||||||
|
CONTINUE WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM profiles p
|
||||||
|
LEFT JOIN notification_preferences np ON np.user_id = p.id
|
||||||
|
WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload)
|
||||||
|
VALUES (
|
||||||
|
'expo',
|
||||||
|
'dare:' || NEW.id || ':' || v_event || ':' || v_recipient,
|
||||||
|
v_recipient,
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', v_title,
|
||||||
|
'body', v_body,
|
||||||
|
'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ON CONFLICT (dedupe_key) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION queue_dare_operations();
|
||||||
|
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Mirror the review decision already enforced on the legacy catalogue
|
||||||
|
-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety
|
||||||
|
-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so
|
||||||
|
-- this never overrides a later decision made in the admin portal.
|
||||||
|
UPDATE dare_packs
|
||||||
|
SET content_status = 'allowed',
|
||||||
|
content_review_reason = 'Reviewed as non-sexual romantic gestures'
|
||||||
|
WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE dare_packs
|
||||||
|
SET content_status = 'archived',
|
||||||
|
content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares'
|
||||||
|
WHERE id IN (
|
||||||
|
'a1b2c3d4-2222-2222-2222-222222222222'::uuid,
|
||||||
|
'a1b2c3d4-3333-3333-3333-333333333333'::uuid,
|
||||||
|
'a1b2c3d4-4444-4444-4444-444444444444'::uuid
|
||||||
|
) AND content_status = 'unreviewed';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE dares d
|
||||||
|
SET content_status = dp.content_status,
|
||||||
|
content_review_reason = CASE
|
||||||
|
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
|
||||||
|
ELSE 'Archived with non-store-safe dare pack'
|
||||||
|
END
|
||||||
|
FROM dare_packs dp
|
||||||
|
WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "timezone" text;--> statement-breakpoint
|
||||||
|
COMMENT ON COLUMN "profiles"."timezone" IS 'IANA zone reported by the client. NULL falls back to UTC when bucketing the daily response limit.';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- The daily response limit counts a user's responses within their current local day.
|
||||||
|
CREATE INDEX IF NOT EXISTS "responses_user_created_idx" ON "responses" ("user_id", "created_at" DESC);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Streaks are counted in the couple's shared local day, not the server's calendar day.
|
||||||
|
-- The zone is the first reported timezone in member id order (the same ordering that
|
||||||
|
-- decides user1/user2), falling back to UTC when neither partner has reported one.
|
||||||
|
COMMENT ON COLUMN "couple_streaks"."last_active_date" IS 'Couple-local date on which either partner last answered. The answered-today flags are read as false once this is not the couple-local today.';--> statement-breakpoint
|
||||||
|
COMMENT ON COLUMN "couple_streaks"."last_completed_date" IS 'Couple-local date on which both partners last answered. The streak is alive while this is the couple-local today or yesterday.';--> statement-breakpoint
|
||||||
|
|
||||||
|
-- Prunes the evening at-risk scan to couples that actually have a streak to lose.
|
||||||
|
CREATE INDEX IF NOT EXISTS "couple_streaks_live_idx" ON "couple_streaks" ("last_completed_date") WHERE "current_streak" > 0;--> statement-breakpoint
|
||||||
|
|
||||||
|
-- The legacy data plane maintained streaks from a trigger that read auth.uid(). The API
|
||||||
|
-- owns that write now and runs it in the same transaction as the response, so drop the
|
||||||
|
-- trigger defensively in case a cutover ever carries these objects across.
|
||||||
|
DROP TRIGGER IF EXISTS "on_response_update_streak" ON "responses";--> statement-breakpoint
|
||||||
|
DROP FUNCTION IF EXISTS "update_couple_streak"();--> statement-breakpoint
|
||||||
|
DROP FUNCTION IF EXISTS "reset_daily_streak_flags"();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- A shared streak only changes behaviour if somebody is told it is still open, so the
|
||||||
|
-- evening at-risk nudge gets its own opt-out rather than riding on milestone celebrations.
|
||||||
|
ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "streak_reminders_enabled" boolean NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Preserve the reviewed production catalogue and its append-only decision history
|
||||||
|
-- when importing from the legacy Supabase data plane.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regtype('content_entity_type') IS NULL THEN
|
||||||
|
CREATE TYPE content_entity_type AS ENUM (
|
||||||
|
'categories', 'question_packs', 'questions', 'dare_packs', 'dares'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE categories
|
||||||
|
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||||
|
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE question_packs
|
||||||
|
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||||
|
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||||
|
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS categories_content_status_idx
|
||||||
|
ON categories (content_status, is_public, sort_order);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS question_packs_content_status_idx
|
||||||
|
ON question_packs (content_status, is_public, sort_order);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS questions_content_status_idx
|
||||||
|
ON questions (pack_id, content_status, intensity);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS content_reviews (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
entity_type content_entity_type NOT NULL,
|
||||||
|
entity_id uuid NOT NULL,
|
||||||
|
previous_status content_review_status NOT NULL,
|
||||||
|
new_status content_review_status NOT NULL,
|
||||||
|
reason text NOT NULL,
|
||||||
|
changed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS content_reviews_entity_idx
|
||||||
|
ON content_reviews (entity_type, entity_id, created_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS content_reviews_created_at_idx
|
||||||
|
ON content_reviews (created_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- The standalone audit model separates the acting profile from the admin row.
|
||||||
|
-- Retain legacy descriptive fields rather than dropping production history.
|
||||||
|
ALTER TABLE audit_logs
|
||||||
|
ADD COLUMN IF NOT EXISTS changed_fields text[],
|
||||||
|
ADD COLUMN IF NOT EXISTS admin_role text,
|
||||||
|
ADD COLUMN IF NOT EXISTS admin_username text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- A small number of legacy Supabase objects have neither an owner nor a live
|
||||||
|
-- database reference. Preserve their bytes and metadata without making them
|
||||||
|
-- addressable through the authenticated media API or assigning false ownership.
|
||||||
|
CREATE TABLE IF NOT EXISTS legacy_media_quarantine (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
original_storage_key text NOT NULL UNIQUE,
|
||||||
|
bucket_id text NOT NULL,
|
||||||
|
object_name text NOT NULL,
|
||||||
|
source_owner_id uuid,
|
||||||
|
metadata jsonb,
|
||||||
|
mime_type text NOT NULL,
|
||||||
|
byte_size integer NOT NULL CHECK (byte_size > 0),
|
||||||
|
reason text NOT NULL,
|
||||||
|
source_created_at timestamptz NOT NULL,
|
||||||
|
source_updated_at timestamptz,
|
||||||
|
imported_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -92,6 +92,48 @@
|
|||||||
"when": 1787908500000,
|
"when": 1787908500000,
|
||||||
"tag": "0012_catalogue_field_parity",
|
"tag": "0012_catalogue_field_parity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787909400000,
|
||||||
|
"tag": "0013_dares_loop",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787994900000,
|
||||||
|
"tag": "0014_daily_limit_local_reset",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787994960000,
|
||||||
|
"tag": "0015_daily_limit_response_index",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788081360000,
|
||||||
|
"tag": "0016_couple_streak_locality",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788081420000,
|
||||||
|
"tag": "0017_streak_reminder_preference",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788081480000,
|
||||||
|
"tag": "0018_cutover_source_parity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,11 @@
|
|||||||
"db:migrate:prod": "node dist/db/migrate.js",
|
"db:migrate:prod": "node dist/db/migrate.js",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
"migration:cutover": "tsx src/migration/run.ts",
|
"migration:cutover": "tsx src/migration/run.ts",
|
||||||
|
"load:representative": "tsx src/load/representative.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js';
|
|||||||
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||||
import type { ChatRepository } from './domains/chat/repository.js';
|
import type { ChatRepository } from './domains/chat/repository.js';
|
||||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||||
|
import type { DaresRepository } from './domains/dares/repository.js';
|
||||||
|
import { registerDareRoutes } from './domains/dares/routes.js';
|
||||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
import type { CoupleService } from './domains/couples/service.js';
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
import type { PacksRepository } from './domains/packs/repository.js';
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -25,6 +27,7 @@ import type { FilesystemMediaStorage } from './domains/media/storage.js';
|
|||||||
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
||||||
import type { AdminRepository } from './domains/admin/repository.js';
|
import type { AdminRepository } from './domains/admin/repository.js';
|
||||||
import { registerAdminRoutes } from './domains/admin/routes.js';
|
import { registerAdminRoutes } from './domains/admin/routes.js';
|
||||||
|
import { recordRequest } from './telemetry.js';
|
||||||
|
|
||||||
type Variables = { identity: AuthIdentity };
|
type Variables = { identity: AuthIdentity };
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ export interface AppDependencies {
|
|||||||
packsRepository?: PacksRepository;
|
packsRepository?: PacksRepository;
|
||||||
answersRepository?: AnswersRepository;
|
answersRepository?: AnswersRepository;
|
||||||
chatRepository?: ChatRepository;
|
chatRepository?: ChatRepository;
|
||||||
|
daresRepository?: DaresRepository;
|
||||||
profileSettingsRepository?: ProfileSettingsRepository;
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
accountOperationsService?: AccountOperationsService;
|
accountOperationsService?: AccountOperationsService;
|
||||||
mediaRepository?: MediaRepository;
|
mediaRepository?: MediaRepository;
|
||||||
@@ -54,6 +58,18 @@ function error(code: string, message: string): ApiErrorResponse {
|
|||||||
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
||||||
const app = new 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/live', (c) => c.json({ status: 'ok' }));
|
||||||
app.get('/health/ready', async (c) => {
|
app.get('/health/ready', async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -109,6 +125,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
|||||||
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||||
|
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const baseSchema = z.object({
|
|||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||||
DATABASE_URL: z.string().url(),
|
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_URL: z.string().url().optional(),
|
||||||
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
||||||
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
||||||
@@ -28,6 +31,7 @@ export interface AppConfig {
|
|||||||
nodeEnv: 'development' | 'test' | 'production';
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
port: number;
|
port: number;
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
|
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
|
||||||
authIssuer: string;
|
authIssuer: string;
|
||||||
authJwksUrl?: string;
|
authJwksUrl?: string;
|
||||||
authAudience: string;
|
authAudience: string;
|
||||||
@@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
nodeEnv: parsed.NODE_ENV,
|
nodeEnv: parsed.NODE_ENV,
|
||||||
port: parsed.PORT,
|
port: parsed.PORT,
|
||||||
databaseUrl: parsed.DATABASE_URL,
|
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,
|
authIssuer,
|
||||||
authJwksUrl,
|
authJwksUrl,
|
||||||
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
if (owned) await pool.end();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared';
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js';
|
||||||
import type { AuthIdentity } from '../auth.js';
|
import type { AuthIdentity } from '../auth.js';
|
||||||
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
||||||
|
|
||||||
@@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
|||||||
|
|
||||||
export class PostgresRepository implements ApiRepository {
|
export class PostgresRepository implements ApiRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
private readonly db: NodePgDatabase;
|
private readonly db: NodePgDatabase;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
this.db = drizzle(this.pool);
|
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);
|
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<MobileCompatibleProfile> {
|
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
||||||
|
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({
|
const [row] = await this.db.insert(profiles).values({
|
||||||
id: identity.id,
|
id: identity.id,
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
@@ -65,7 +83,10 @@ export class PostgresRepository implements ApiRepository {
|
|||||||
target: profiles.id,
|
target: profiles.id,
|
||||||
set: {
|
set: {
|
||||||
email: sql`coalesce(excluded.email, ${profiles.email})`,
|
email: sql`coalesce(excluded.email, ${profiles.email})`,
|
||||||
authSyncedAt: new Date(),
|
// Keep the write on the same clock as the column default. API and
|
||||||
|
// PostgreSQL hosts can differ slightly, which must not move this
|
||||||
|
// monotonic sync marker backwards.
|
||||||
|
authSyncedAt: sql`now()`,
|
||||||
},
|
},
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
@@ -93,6 +114,6 @@ export class PostgresRepository implements ApiRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', {
|
|||||||
email: text('email'),
|
email: text('email'),
|
||||||
avatarUrl: text('avatar_url'),
|
avatarUrl: text('avatar_url'),
|
||||||
pushToken: text('push_token'),
|
pushToken: text('push_token'),
|
||||||
|
timezone: text('timezone'),
|
||||||
isPremium: boolean('is_premium').notNull().default(false),
|
isPremium: boolean('is_premium').notNull().default(false),
|
||||||
coupleId: uuid('couple_id'),
|
coupleId: uuid('couple_id'),
|
||||||
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { AccountOperationError } from './types.js';
|
import { AccountOperationError } from './types.js';
|
||||||
|
|
||||||
interface ProfileOperationRecord extends QueryResultRow {
|
interface ProfileOperationRecord extends QueryResultRow {
|
||||||
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
|
|||||||
|
|
||||||
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||||
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { resolve, sep } from 'node:path';
|
import { resolve, sep } from 'node:path';
|
||||||
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
||||||
@@ -139,9 +140,10 @@ export interface AdminRepository {
|
|||||||
|
|
||||||
export class PostgresAdminRepository implements AdminRepository {
|
export class PostgresAdminRepository implements AdminRepository {
|
||||||
private readonly pool: Pool;
|
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;
|
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<AdminPrincipal> {
|
async principal(userId: string): Promise<AdminPrincipal> {
|
||||||
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
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)]);
|
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<void> { await this.pool.end(); }
|
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,38 @@
|
|||||||
import { Pool, type PoolClient } from 'pg';
|
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';
|
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 }
|
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
||||||
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
|
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
|
||||||
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
|
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
|
||||||
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
|
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
|
||||||
|
interface StreakRow { couple_id: string; current_streak: number; longest_streak: number; last_active_date: string | null; last_completed_date: string | null; user1_answered_today: boolean; user2_answered_today: boolean; streak_celebrated_at: number; created_at: Date; updated_at: Date }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A streak resolved from one partner's point of view. The stored row is positional
|
||||||
|
* (user1/user2 by member id order) because the writer has to be deterministic, but a
|
||||||
|
* client cannot act on "user2 has not answered" without re-deriving that ordering, so
|
||||||
|
* the API answers in terms of you and your partner instead.
|
||||||
|
*/
|
||||||
|
export interface CoupleStreakView {
|
||||||
|
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;
|
||||||
|
timezone: string;
|
||||||
|
streak_celebrated_at: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A response to a question the partner answered first is catching up, not new exploration.
|
||||||
|
// Those neither consume nor are blocked by the daily limit, so the reciprocity loop the
|
||||||
|
// answer-gap mechanic pushes can never be refused by the meter.
|
||||||
|
const catchUpExemption = "select 1 from responses pr where pr.question_id=r.question_id and pr.couple_id=r.couple_id and pr.user_id<>r.user_id and pr.created_at<r.created_at";
|
||||||
|
|
||||||
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
||||||
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
||||||
@@ -17,16 +45,18 @@ export interface AnswersRepository {
|
|||||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
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<void>;
|
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||||
streak(userId: string): Promise<Record<string, unknown> | null>;
|
streak(userId: string): Promise<CoupleStreakView | null>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PostgresAnswersRepository implements AnswersRepository {
|
export class PostgresAnswersRepository implements AnswersRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
private readonly ownsPool: boolean;
|
||||||
|
private zones: Promise<Set<string>> | null = null;
|
||||||
|
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||||
|
|
||||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||||
@@ -41,7 +71,7 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
const p = partner.rows[0];
|
const p = partner.rows[0];
|
||||||
const config = await this.config();
|
const config = await this.config();
|
||||||
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
|
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
|
||||||
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
|
if (!ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
|
||||||
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
|
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
|
||||||
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
|
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
|
||||||
const result = await this.pool.query<QuestionRow>(`
|
const result = await this.pool.query<QuestionRow>(`
|
||||||
@@ -117,7 +147,10 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
await this.assertEligible(client,userId,ctx,q);
|
await this.assertEligible(client,userId,ctx,q);
|
||||||
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
|
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
|
||||||
if (!existing.rowCount) {
|
if (!existing.rowCount) {
|
||||||
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
|
const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]);
|
||||||
|
if (!catchingUp.rowCount) {
|
||||||
|
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
|
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
|
||||||
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
|
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
|
||||||
@@ -157,12 +190,30 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The streak day is the couple's shared local day, not the server's. Both partners have
|
||||||
|
* to land inside the same calendar date for the day to count, so the zone belongs to the
|
||||||
|
* couple rather than to whoever happens to be answering: the first reported zone in member
|
||||||
|
* id order, which is the same ordering that decides user1/user2, falling back to UTC when
|
||||||
|
* neither partner has reported one. Without this a couple in a western zone loses the
|
||||||
|
* streak at UTC midnight while it is still the same evening for them.
|
||||||
|
*/
|
||||||
|
private async coupleZone(client:Pool|PoolClient,reported:(string|null)[]):Promise<string>{
|
||||||
|
const zone=reported.find(value=>value);
|
||||||
|
return zone&&(await this.timezoneNames(client)).has(zone)?zone:'UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async streakDay(client:Pool|PoolClient,zone:string){
|
||||||
|
const r=await client.query<{today:string;yesterday:string}>("select (now() at time zone $1::text)::date::text today,((now() at time zone $1::text)::date-1)::text yesterday",[zone]);
|
||||||
|
return r.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
|
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
|
||||||
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
|
const users=await client.query<{id:string;timezone:string|null}>('select id,timezone from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
|
||||||
const user1=users.rows[0].id===userId;
|
const user1=users.rows[0].id===userId;
|
||||||
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
|
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
|
||||||
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
|
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
|
||||||
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
|
const row=locked.rows[0]; const {today,yesterday}=await this.streakDay(client,await this.coupleZone(client,users.rows.map(member=>member.timezone)));
|
||||||
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
|
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
|
||||||
if(user1)first=true;else second=true;
|
if(user1)first=true;else second=true;
|
||||||
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
|
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
|
||||||
@@ -181,22 +232,81 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
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){
|
async matches(userId:string,page:number,limit:number,archived:boolean){
|
||||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||||
const params=[userId,ctx.couple_id,archived,limit,offset];
|
const params=[userId,ctx.couple_id,archived,limit+1,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 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 [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))]);
|
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||||
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
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 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]);}
|
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]);}
|
||||||
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
|
async streak(userId:string):Promise<CoupleStreakView|null>{
|
||||||
|
const ctx=await this.context(this.pool,userId);
|
||||||
|
const r=await this.pool.query<StreakRow>(`select couple_id,current_streak,longest_streak,last_active_date::text last_active_date,last_completed_date::text last_completed_date,
|
||||||
|
user1_answered_today,user2_answered_today,streak_celebrated_at,created_at,updated_at from couple_streaks where couple_id=$1`,[ctx.couple_id]);
|
||||||
|
const row=r.rows[0]; if(!row)return null;
|
||||||
|
const members=await this.pool.query<{id:string;name:string|null;timezone:string|null}>('select id,name,timezone from profiles where couple_id=$1 order by id',[ctx.couple_id]);
|
||||||
|
if(members.rows.length<2)return null;
|
||||||
|
const zone=await this.coupleZone(this.pool,members.rows.map(member=>member.timezone));
|
||||||
|
const {today,yesterday}=await this.streakDay(this.pool,zone);
|
||||||
|
// The row is only rewritten when somebody answers, so a lapsed streak keeps its stored
|
||||||
|
// number until the next response arrives. Reading applies the same day rules the writer
|
||||||
|
// does rather than showing a count that the next answer will silently reset to 1.
|
||||||
|
const active=row.last_active_date===today;
|
||||||
|
const first=active&&row.user1_answered_today; const second=active&&row.user2_answered_today;
|
||||||
|
const alive=row.last_completed_date===today||row.last_completed_date===yesterday;
|
||||||
|
const isUser1=members.rows[0].id===userId; const partner=members.rows[isUser1?1:0];
|
||||||
|
return {
|
||||||
|
couple_id:row.couple_id,
|
||||||
|
current_streak:alive?row.current_streak:0,
|
||||||
|
longest_streak:row.longest_streak,
|
||||||
|
last_active_date:row.last_active_date,
|
||||||
|
last_completed_date:row.last_completed_date,
|
||||||
|
you_answered_today:isUser1?first:second,
|
||||||
|
partner_answered_today:isUser1?second:first,
|
||||||
|
partner_name:partner?.name??null,
|
||||||
|
timezone:zone,
|
||||||
|
streak_celebrated_at:row.streak_celebrated_at,
|
||||||
|
created_at:row.created_at.toISOString(),
|
||||||
|
updated_at:row.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
|
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
|
||||||
|
private timezoneNames(client:Pool|PoolClient){
|
||||||
|
// pg_timezone_names is a ~600-row function scan costing ~12ms. The list is static for the
|
||||||
|
// life of the process, so load it once instead of on every daily-limit check.
|
||||||
|
this.zones??=client.query<{name:string}>('select name from pg_timezone_names')
|
||||||
|
.then(r=>new Set(r.rows.map(row=>row.name)))
|
||||||
|
.catch(cause=>{this.zones=null;throw cause;});
|
||||||
|
return this.zones;
|
||||||
|
}
|
||||||
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
|
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
|
||||||
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
|
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
|
||||||
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
const cfg=await this.config(client);const unlimited={responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||||
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
if(cfg.daily_response_limit===0)return unlimited;
|
||||||
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
|
// Premium is shared across a couple: either partner's subscription lifts the cap for both.
|
||||||
|
const profile=await client.query<{premium:boolean;zone:string|null}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium,p.timezone zone from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium,p.timezone',[userId]);
|
||||||
|
if(profile.rows[0]?.premium)return unlimited;
|
||||||
|
// The window is the user's local calendar day; an unreported or unrecognised zone falls
|
||||||
|
// back to UTC. Validating in JS keeps an unknown zone from erroring the query without
|
||||||
|
// paying for a pg_timezone_names scan inside submit()'s locked transaction.
|
||||||
|
const reported=profile.rows[0]?.zone;
|
||||||
|
const zone=reported&&(await this.timezoneNames(client)).has(reported)?reported:'UTC';
|
||||||
|
const r=await client.query<{count:number;reset_at:string}>(`select count(*)::int count,((date_trunc('day',now() at time zone $2::text)+interval '1 day') at time zone $2::text) reset_at from responses r where r.user_id=$1 and r.created_at>=(date_trunc('day',now() at time zone $2::text) at time zone $2::text) and not exists(${catchUpExemption})`,[userId,zone]);
|
||||||
|
const count=r.rows[0]?.count??0;
|
||||||
|
return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:r.rows[0]?new Date(r.rows[0].reset_at).toISOString():null,is_blocked:count>=cfg.daily_response_limit};
|
||||||
}
|
}
|
||||||
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
|
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
|
||||||
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
|
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
|
||||||
@@ -207,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
}
|
}
|
||||||
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{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 async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{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<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
private dates<T extends Record<string,any>>(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);}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type QueryResultRow } from 'pg';
|
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 PackContext { id: string; name: string; icon: string | null }
|
||||||
export interface AppQuestion {
|
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 }
|
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 {
|
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<string, unknown>) { super(message); }
|
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<string, unknown>) { 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 }>;
|
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 }>;
|
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 }>;
|
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
||||||
|
syncSummary(userId: string): Promise<SyncSummary>;
|
||||||
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
||||||
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
@@ -38,11 +84,22 @@ export interface AppDataRepository {
|
|||||||
|
|
||||||
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { 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() }; }
|
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
||||||
|
|
||||||
export class PostgresAppDataRepository implements AppDataRepository {
|
export class PostgresAppDataRepository implements AppDataRepository {
|
||||||
private readonly pool: Pool;
|
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<PackContext> {
|
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
||||||
const result = await this.pool.query<PackContext>(`
|
const result = await this.pool.query<PackContext>(`
|
||||||
@@ -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 };
|
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
||||||
}
|
}
|
||||||
async close(): Promise<void> { 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<SyncSummary> {
|
||||||
|
const result = await this.pool.query<SyncRow>(`
|
||||||
|
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<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Hono } from 'hono';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { AuthIdentity } from '../../auth.js';
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
import { AppDataError, type AppDataRepository } from './repository.js';
|
import { AppDataError, type AppDataRepository } from './repository.js';
|
||||||
|
import { recordSync } from '../../telemetry.js';
|
||||||
|
|
||||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
const uuid = z.string().uuid();
|
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));
|
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);
|
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.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
||||||
app.post('/v1/me/nudge', async c => {
|
app.post('/v1/me/nudge', async c => {
|
||||||
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
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';
|
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
|
|||||||
|
|
||||||
export class PostgresBillingRepository implements BillingRepository {
|
export class PostgresBillingRepository implements BillingRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processRevenueCatEvent(
|
async processRevenueCatEvent(
|
||||||
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type QueryResultRow } from 'pg';
|
import { Pool, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
|
||||||
export type ChatMediaType = 'image' | 'video' | null;
|
export type ChatMediaType = 'image' | 'video' | null;
|
||||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
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 {
|
export interface ChatRepository {
|
||||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
|
||||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||||
@@ -55,13 +85,21 @@ export interface ChatRepository {
|
|||||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `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,
|
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.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`;
|
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||||
|
|
||||||
function iso(value: Date | null): string | null {
|
function iso(value: Date | null): string | null {
|
||||||
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
|
|||||||
|
|
||||||
export class PostgresChatRepository implements ChatRepository {
|
export class PostgresChatRepository implements ChatRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||||
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
/**
|
||||||
|
* 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<MessagesPage> {
|
||||||
await this.assertMatchAccess(userId, matchId);
|
await this.assertMatchAccess(userId, matchId);
|
||||||
const result = await this.pool.query<MessageRecord>(
|
const since = options.since ?? null;
|
||||||
`select ${columns}
|
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
|
||||||
from messages m
|
// Expiry is the one visible change no row write announces: the clock crosses
|
||||||
where m.match_id = $1
|
// `media_expires_at` and nothing is updated, so no timestamp moves past the
|
||||||
and not exists (
|
// cursor. Comparing the deadline against the window instead emits the row in
|
||||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
// the single poll whose `(since, now()]` contains it, which is exactly when
|
||||||
)
|
// the derived `media_expired` above flips.
|
||||||
order by m.created_at desc, m.id desc
|
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
|
||||||
limit 500`,
|
or m.deleted_at > $3 or m.media_viewed_at > $3
|
||||||
[matchId, userId],
|
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
|
||||||
);
|
|
||||||
return result.rows.map(toMessage);
|
const [clock, messages, removed, typing] = await Promise.all([
|
||||||
|
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
|
||||||
|
this.pool.query<MessageRecord>(
|
||||||
|
`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<ChatMessage> {
|
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||||
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
/** Access already asserted by the caller, so the message poll can fold this in for free. */
|
||||||
await this.assertMatchAccess(userId, matchId);
|
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||||
const result = await this.pool.query<{ expires_at: Date }>(
|
const result = await this.pool.query<{ expires_at: Date }>(
|
||||||
`select expires_at from chat_typing_states
|
`select expires_at from chat_typing_states
|
||||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
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) };
|
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||||
|
await this.assertMatchAccess(userId, matchId);
|
||||||
|
return this.partnerTyping(userId, matchId);
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
|||||||
const id = z.string().uuid();
|
const id = z.string().uuid();
|
||||||
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
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 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 {
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
return { error: { code, message } };
|
return { error: { code, message } };
|
||||||
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
|
|||||||
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
||||||
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
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) => {
|
app.get('/v1/matches/:matchId/messages', async (c) => {
|
||||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
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);
|
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));
|
const rawSince = c.req.query('since');
|
||||||
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
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) => {
|
app.post('/v1/matches/:matchId/messages', async (c) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Couple, Profile } from '@sauci/shared';
|
import type { Couple, Profile } from '@sauci/shared';
|
||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
|||||||
|
|
||||||
export class PostgresCoupleRepository implements CoupleRepository {
|
export class PostgresCoupleRepository implements CoupleRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getState(userId: string): Promise<CoupleStateResponse> {
|
async getState(userId: string): Promise<CoupleStateResponse> {
|
||||||
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
|
||||||
|
export const FREE_WEEKLY_SEND_LIMIT = 3;
|
||||||
|
|
||||||
|
/** Durations offered in the send sheet. `null` means no deadline. */
|
||||||
|
export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const;
|
||||||
|
|
||||||
|
export interface DarePack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
category_id: string | null;
|
||||||
|
min_intensity: number | null;
|
||||||
|
max_intensity: number | null;
|
||||||
|
avg_intensity: number | null;
|
||||||
|
dare_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DareItem {
|
||||||
|
id: string;
|
||||||
|
pack_id: string;
|
||||||
|
text: string;
|
||||||
|
intensity: number;
|
||||||
|
suggested_duration_hours: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentDare {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
dare_id: string | null;
|
||||||
|
text: string;
|
||||||
|
intensity: number;
|
||||||
|
is_custom: boolean;
|
||||||
|
sender_id: string;
|
||||||
|
recipient_id: string;
|
||||||
|
direction: 'incoming' | 'outgoing';
|
||||||
|
status: DareStatus;
|
||||||
|
sender_notes: string | null;
|
||||||
|
sent_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
submitted_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DareEntitlement {
|
||||||
|
is_premium: boolean;
|
||||||
|
can_send_custom: boolean;
|
||||||
|
weekly_send_limit: number | null;
|
||||||
|
sends_remaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DareCatalog {
|
||||||
|
entitlement: DareEntitlement;
|
||||||
|
packs: DarePack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DareStats {
|
||||||
|
sent: number;
|
||||||
|
received: number;
|
||||||
|
completed_together: number;
|
||||||
|
active: number;
|
||||||
|
completed_by_me: number;
|
||||||
|
completed_by_partner: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendDareInput {
|
||||||
|
dare_id?: string | null;
|
||||||
|
custom_dare_text?: string | null;
|
||||||
|
custom_dare_intensity?: number | null;
|
||||||
|
duration_hours?: number | null;
|
||||||
|
sender_notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DaresError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
public readonly status: 400 | 402 | 403 | 404 | 409,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextRow extends QueryResultRow {
|
||||||
|
couple_id: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
hide_nsfw: boolean;
|
||||||
|
max_intensity: number;
|
||||||
|
partner_id: string | null;
|
||||||
|
partner_premium: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentDareRecord extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
dare_id: string | null;
|
||||||
|
dare_text_snapshot: string;
|
||||||
|
dare_intensity_snapshot: number;
|
||||||
|
custom_dare_text: string | null;
|
||||||
|
sender_id: string;
|
||||||
|
recipient_id: string;
|
||||||
|
status: DareStatus;
|
||||||
|
sender_notes: string | null;
|
||||||
|
sent_at: Date;
|
||||||
|
accepted_at: Date | null;
|
||||||
|
submitted_at: Date | null;
|
||||||
|
completed_at: Date | null;
|
||||||
|
expires_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaresRepository {
|
||||||
|
getCatalog(userId: string): Promise<DareCatalog>;
|
||||||
|
listPackDares(userId: string, packId: string): Promise<DareItem[]>;
|
||||||
|
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
|
||||||
|
send(userId: string, input: SendDareInput): Promise<SentDare>;
|
||||||
|
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
|
||||||
|
submit(userId: string, dareId: string): Promise<SentDare>;
|
||||||
|
complete(userId: string, dareId: string): Promise<SentDare>;
|
||||||
|
cancel(userId: string, dareId: string): Promise<SentDare>;
|
||||||
|
stats(userId: string): Promise<DareStats>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
|
||||||
|
custom_dare_text, sender_id, recipient_id, status, sender_notes,
|
||||||
|
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
|
||||||
|
|
||||||
|
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
|
||||||
|
|
||||||
|
/** Statuses still awaiting somebody's action; everything else is history. */
|
||||||
|
const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted'];
|
||||||
|
|
||||||
|
function iso(value: Date | null): string | null {
|
||||||
|
return value?.toISOString() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const result = await operation(client);
|
||||||
|
await client.query('commit');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
couple_id: row.couple_id,
|
||||||
|
dare_id: row.dare_id,
|
||||||
|
text: row.dare_text_snapshot,
|
||||||
|
intensity: row.dare_intensity_snapshot,
|
||||||
|
is_custom: row.custom_dare_text !== null,
|
||||||
|
sender_id: row.sender_id,
|
||||||
|
recipient_id: row.recipient_id,
|
||||||
|
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
|
||||||
|
status: row.status,
|
||||||
|
sender_notes: row.sender_notes,
|
||||||
|
sent_at: row.sent_at.toISOString(),
|
||||||
|
accepted_at: iso(row.accepted_at),
|
||||||
|
submitted_at: iso(row.submitted_at),
|
||||||
|
completed_at: iso(row.completed_at),
|
||||||
|
expires_at: iso(row.expires_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresDaresRepository implements DaresRepository {
|
||||||
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
|
constructor(connection: DatabaseConnection) {
|
||||||
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Premium is couple-shared, matching the answers domain: either partner paying
|
||||||
|
* unlocks the catalogue for both.
|
||||||
|
*/
|
||||||
|
private async context(userId: string): Promise<ContextRow> {
|
||||||
|
const result = await this.pool.query<ContextRow>(
|
||||||
|
`select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity,
|
||||||
|
partner.id as partner_id,
|
||||||
|
coalesce(partner.is_premium, false) as partner_premium
|
||||||
|
from profiles p
|
||||||
|
left join lateral (
|
||||||
|
select id, is_premium from profiles other
|
||||||
|
where other.couple_id = p.couple_id and other.id <> p.id
|
||||||
|
order by other.id limit 1
|
||||||
|
) partner on true
|
||||||
|
where p.id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isPremium(context: ContextRow): boolean {
|
||||||
|
return context.is_premium || context.partner_premium;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise<number> {
|
||||||
|
const result = await client.query<{ count: string }>(
|
||||||
|
`select count(*) as count from sent_dares
|
||||||
|
where sender_id = $1 and sent_at > now() - interval '7 days'`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return Number(result.rows[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async entitlement(context: ContextRow, userId: string): Promise<DareEntitlement> {
|
||||||
|
const premium = PostgresDaresRepository.isPremium(context);
|
||||||
|
if (premium) {
|
||||||
|
return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null };
|
||||||
|
}
|
||||||
|
const used = await this.sendsThisWeek(userId);
|
||||||
|
return {
|
||||||
|
is_premium: false,
|
||||||
|
can_send_custom: false,
|
||||||
|
weekly_send_limit: FREE_WEEKLY_SEND_LIMIT,
|
||||||
|
sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCatalog(userId: string): Promise<DareCatalog> {
|
||||||
|
const context = await this.context(userId);
|
||||||
|
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
|
||||||
|
const [entitlement, packs] = await Promise.all([
|
||||||
|
this.entitlement(context, userId),
|
||||||
|
this.pool.query<DarePack & QueryResultRow>(
|
||||||
|
`select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit,
|
||||||
|
dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity,
|
||||||
|
dp.avg_intensity::double precision as avg_intensity,
|
||||||
|
count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count
|
||||||
|
from dare_packs dp
|
||||||
|
left join categories c on c.id = dp.category_id
|
||||||
|
left join dares d on d.pack_id = dp.id
|
||||||
|
where dp.is_public = true
|
||||||
|
and dp.content_status = 'allowed'
|
||||||
|
and (dp.category_id is null or c.is_public = true)
|
||||||
|
and ($1::boolean = false or dp.is_explicit = false)
|
||||||
|
and (dp.min_intensity is null or dp.min_intensity <= $2::integer)
|
||||||
|
group by dp.id
|
||||||
|
having count(d.id) filter (where d.content_status = 'allowed') > 0
|
||||||
|
order by dp.sort_order, dp.name, dp.id`,
|
||||||
|
[context.hide_nsfw, maxIntensity],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return { entitlement, packs: packs.rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPackDares(userId: string, packId: string): Promise<DareItem[]> {
|
||||||
|
const context = await this.context(userId);
|
||||||
|
const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>(
|
||||||
|
`select dp.is_premium, dp.is_explicit
|
||||||
|
from dare_packs dp
|
||||||
|
left join categories c on c.id = dp.category_id
|
||||||
|
where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed'
|
||||||
|
and (dp.category_id is null or c.is_public = true)`,
|
||||||
|
[packId],
|
||||||
|
);
|
||||||
|
const row = pack.rows[0];
|
||||||
|
if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404);
|
||||||
|
if (row.is_explicit && context.hide_nsfw) {
|
||||||
|
throw new DaresError('pack_not_found', 'Dare pack not found', 404);
|
||||||
|
}
|
||||||
|
if (row.is_premium && !PostgresDaresRepository.isPremium(context)) {
|
||||||
|
throw new DaresError('premium_required', 'This dare pack requires premium', 402);
|
||||||
|
}
|
||||||
|
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
|
||||||
|
const result = await this.pool.query<DareItem & QueryResultRow>(
|
||||||
|
`select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours
|
||||||
|
from dares d
|
||||||
|
where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer
|
||||||
|
order by d.intensity, d.created_at, d.id`,
|
||||||
|
[packId, maxIntensity],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]> {
|
||||||
|
const context = await this.context(userId);
|
||||||
|
if (!context.couple_id) return [];
|
||||||
|
const result = await this.pool.query<SentDareRecord>(
|
||||||
|
`select ${SENT_COLUMNS}
|
||||||
|
from sent_dares sd
|
||||||
|
where sd.couple_id = $1
|
||||||
|
and (sd.status = any($2::text[])) = $3::boolean
|
||||||
|
order by sd.sent_at desc, sd.id desc
|
||||||
|
limit 200`,
|
||||||
|
[context.couple_id, OPEN_STATUSES, filter === 'active'],
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => toSentDare(row, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(userId: string, input: SendDareInput): Promise<SentDare> {
|
||||||
|
const context = await this.context(userId);
|
||||||
|
if (!context.couple_id || !context.partner_id) {
|
||||||
|
throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409);
|
||||||
|
}
|
||||||
|
const premium = PostgresDaresRepository.isPremium(context);
|
||||||
|
|
||||||
|
const duration = input.duration_hours ?? null;
|
||||||
|
if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) {
|
||||||
|
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
let intensity: number;
|
||||||
|
let dareId: string | null = null;
|
||||||
|
let customText: string | null = null;
|
||||||
|
let customIntensity: number | null = null;
|
||||||
|
|
||||||
|
if (input.dare_id) {
|
||||||
|
// Re-check entitlement against the pack so a stale client cannot send a locked dare.
|
||||||
|
const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>(
|
||||||
|
`select d.text, d.intensity, dp.is_premium, dp.is_explicit
|
||||||
|
from dares d
|
||||||
|
join dare_packs dp on dp.id = d.pack_id
|
||||||
|
left join categories c on c.id = dp.category_id
|
||||||
|
where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed'
|
||||||
|
and dp.is_public = true and (dp.category_id is null or c.is_public = true)`,
|
||||||
|
[input.dare_id],
|
||||||
|
);
|
||||||
|
const row = dare.rows[0];
|
||||||
|
if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||||
|
if (row.is_explicit && context.hide_nsfw) {
|
||||||
|
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||||
|
}
|
||||||
|
if (row.is_premium && !premium) {
|
||||||
|
throw new DaresError('premium_required', 'This dare requires premium', 402);
|
||||||
|
}
|
||||||
|
dareId = input.dare_id;
|
||||||
|
text = row.text;
|
||||||
|
intensity = row.intensity;
|
||||||
|
} else {
|
||||||
|
if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402);
|
||||||
|
const trimmed = input.custom_dare_text?.trim();
|
||||||
|
if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400);
|
||||||
|
customText = trimmed;
|
||||||
|
customIntensity = input.custom_dare_intensity ?? 1;
|
||||||
|
text = trimmed;
|
||||||
|
intensity = customIntensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quota is a paywall boundary, so the count and the insert have to be atomic:
|
||||||
|
// locking the sender's profile row serializes concurrent sends by the same user.
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
if (!premium) {
|
||||||
|
await client.query('select 1 from profiles where id = $1 for update', [userId]);
|
||||||
|
const used = await this.sendsThisWeek(userId, client);
|
||||||
|
if (used >= FREE_WEEKLY_SEND_LIMIT) {
|
||||||
|
throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.query<SentDareRecord>(
|
||||||
|
`insert into sent_dares (
|
||||||
|
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
|
||||||
|
dare_text_snapshot, dare_intensity_snapshot,
|
||||||
|
sender_id, recipient_id, sender_notes, expires_at
|
||||||
|
) values (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||||
|
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
|
||||||
|
)
|
||||||
|
returning ${BARE_COLUMNS}`,
|
||||||
|
[
|
||||||
|
context.couple_id, dareId, customText, customIntensity, text, intensity,
|
||||||
|
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return toSentDare(result.rows[0]!, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a status change, enforcing both the actor's role and the legal
|
||||||
|
* transitions out of the current status. Notifications are emitted by the
|
||||||
|
* `operations_dare_changed` trigger, so no enqueue happens here.
|
||||||
|
*/
|
||||||
|
private async transition(
|
||||||
|
userId: string,
|
||||||
|
dareId: string,
|
||||||
|
actor: 'sender' | 'recipient',
|
||||||
|
from: readonly DareStatus[],
|
||||||
|
to: DareStatus,
|
||||||
|
timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null,
|
||||||
|
): Promise<SentDare> {
|
||||||
|
const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id';
|
||||||
|
const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : '';
|
||||||
|
const result = await this.pool.query<SentDareRecord>(
|
||||||
|
`update sent_dares sd
|
||||||
|
set status = $3::text${setTimestamp}
|
||||||
|
where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[])
|
||||||
|
returning ${SENT_COLUMNS}`,
|
||||||
|
[dareId, userId, to, from],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (row) return toSentDare(row, userId);
|
||||||
|
|
||||||
|
// Distinguish "not yours / gone" from "wrong state" so the client can react.
|
||||||
|
const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>(
|
||||||
|
'select sender_id, recipient_id, status from sent_dares where id = $1',
|
||||||
|
[dareId],
|
||||||
|
);
|
||||||
|
const found = existing.rows[0];
|
||||||
|
if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) {
|
||||||
|
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||||
|
}
|
||||||
|
const actorId = actor === 'sender' ? found.sender_id : found.recipient_id;
|
||||||
|
if (actorId !== userId) {
|
||||||
|
throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403);
|
||||||
|
}
|
||||||
|
throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare> {
|
||||||
|
return action === 'accept'
|
||||||
|
? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at')
|
||||||
|
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(userId: string, dareId: string): Promise<SentDare> {
|
||||||
|
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(userId: string, dareId: string): Promise<SentDare> {
|
||||||
|
return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(userId: string, dareId: string): Promise<SentDare> {
|
||||||
|
return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stats(userId: string): Promise<DareStats> {
|
||||||
|
const context = await this.context(userId);
|
||||||
|
if (!context.couple_id) {
|
||||||
|
return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 };
|
||||||
|
}
|
||||||
|
const result = await this.pool.query<Record<string, string>>(
|
||||||
|
`select
|
||||||
|
count(*) filter (where sender_id = $2)::text as sent,
|
||||||
|
count(*) filter (where recipient_id = $2)::text as received,
|
||||||
|
count(*) filter (where status = 'completed')::text as completed_together,
|
||||||
|
count(*) filter (where status = any($3::text[]))::text as active,
|
||||||
|
count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me,
|
||||||
|
count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner
|
||||||
|
from sent_dares where couple_id = $1`,
|
||||||
|
[context.couple_id, userId, OPEN_STATUSES],
|
||||||
|
);
|
||||||
|
const row = result.rows[0] ?? {};
|
||||||
|
const read = (key: string): number => Number(row[key] ?? 0);
|
||||||
|
return {
|
||||||
|
sent: read('sent'),
|
||||||
|
received: read('received'),
|
||||||
|
completed_together: read('completed_together'),
|
||||||
|
active: read('active'),
|
||||||
|
completed_by_me: read('completed_by_me'),
|
||||||
|
completed_by_partner: read('completed_by_partner'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ApiErrorResponse } from '@sauci/shared';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
|
import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js';
|
||||||
|
|
||||||
|
type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
|
|
||||||
|
const id = z.string().uuid();
|
||||||
|
|
||||||
|
const sendBody = z
|
||||||
|
.object({
|
||||||
|
dare_id: id.nullish(),
|
||||||
|
custom_dare_text: z.string().trim().min(1).max(500).nullish(),
|
||||||
|
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
|
||||||
|
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
|
||||||
|
sender_notes: z.string().trim().max(500).nullish(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
|
||||||
|
message: 'Provide either dare_id or custom_dare_text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
|
||||||
|
|
||||||
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
|
return { error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function result<T>(operation: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return { ok: true as const, value: await operation() };
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof DaresError) return { ok: false as const, cause };
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void {
|
||||||
|
app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS }));
|
||||||
|
|
||||||
|
app.get('/v1/dares/packs', async (c) => {
|
||||||
|
const response = await result(() => repository.getCatalog(c.get('identity').id));
|
||||||
|
return response.ok
|
||||||
|
? c.json(response.value)
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/dares/packs/:packId/dares', async (c) => {
|
||||||
|
const packId = id.safeParse(c.req.param('packId'));
|
||||||
|
if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||||
|
const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data));
|
||||||
|
return response.ok
|
||||||
|
? c.json({ dares: response.value })
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/dares', async (c) => {
|
||||||
|
const filter = c.req.query('filter') ?? 'active';
|
||||||
|
if (filter !== 'active' && filter !== 'history') {
|
||||||
|
return c.json(error('invalid_filter', 'Filter must be active or history'), 400);
|
||||||
|
}
|
||||||
|
const response = await result(() => repository.listDares(c.get('identity').id, filter));
|
||||||
|
return response.ok
|
||||||
|
? c.json({ dares: response.value })
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/dares/stats', async (c) => {
|
||||||
|
const response = await result(() => repository.stats(c.get('identity').id));
|
||||||
|
return response.ok
|
||||||
|
? c.json(response.value)
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/dares', async (c) => {
|
||||||
|
const body = sendBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400);
|
||||||
|
const response = await result(() => repository.send(c.get('identity').id, body.data));
|
||||||
|
return response.ok
|
||||||
|
? c.json({ dare: response.value }, 201)
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/dares/:dareId/respond', async (c) => {
|
||||||
|
const dareId = id.safeParse(c.req.param('dareId'));
|
||||||
|
const body = respondBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||||
|
if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400);
|
||||||
|
const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action));
|
||||||
|
return response.ok
|
||||||
|
? c.json({ dare: response.value })
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [path, method] of [
|
||||||
|
['submit', 'submit'],
|
||||||
|
['complete', 'complete'],
|
||||||
|
['cancel', 'cancel'],
|
||||||
|
] as const) {
|
||||||
|
app.post(`/v1/dares/:dareId/${path}`, async (c) => {
|
||||||
|
const dareId = id.safeParse(c.req.param('dareId'));
|
||||||
|
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||||
|
const response = await result(() => repository[method](c.get('identity').id, dareId.data));
|
||||||
|
return response.ok
|
||||||
|
? c.json({ dare: response.value })
|
||||||
|
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { profiles } from '../../db/schema.js';
|
||||||
|
|
||||||
|
// `couples`, `categories`, and the review-status enum are owned by other verticals.
|
||||||
|
// Their FKs stay in SQL so this fragment does not import across domain boundaries.
|
||||||
|
export const darePacks = pgTable('dare_packs', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
icon: text('icon'),
|
||||||
|
isPremium: boolean('is_premium').notNull().default(false),
|
||||||
|
isPublic: boolean('is_public').notNull().default(true),
|
||||||
|
isExplicit: boolean('is_explicit').notNull().default(false),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
categoryId: uuid('category_id'),
|
||||||
|
minIntensity: integer('min_intensity'),
|
||||||
|
maxIntensity: integer('max_intensity'),
|
||||||
|
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }),
|
||||||
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||||
|
contentReviewReason: text('content_review_reason'),
|
||||||
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||||
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]);
|
||||||
|
|
||||||
|
export const dares = pgTable('dares', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
intensity: integer('intensity').notNull().default(1),
|
||||||
|
suggestedDurationHours: integer('suggested_duration_hours'),
|
||||||
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||||
|
contentReviewReason: text('content_review_reason'),
|
||||||
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||||
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('dares_pack_idx').on(table.packId),
|
||||||
|
index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's
|
||||||
|
// history must survive the dare being edited, archived, or deleted.
|
||||||
|
export const sentDares = pgTable('sent_dares', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
coupleId: uuid('couple_id').notNull(),
|
||||||
|
dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }),
|
||||||
|
customDareText: text('custom_dare_text'),
|
||||||
|
customDareIntensity: integer('custom_dare_intensity'),
|
||||||
|
dareTextSnapshot: text('dare_text_snapshot').notNull(),
|
||||||
|
dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(),
|
||||||
|
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
|
recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
|
status: text('status').notNull().default('pending'),
|
||||||
|
senderNotes: text('sender_notes'),
|
||||||
|
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||||
|
submittedAt: timestamp('submitted_at', { withTimezone: true }),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('sent_dares_couple_idx').on(table.coupleId),
|
||||||
|
index('sent_dares_recipient_status_idx').on(table.recipientId, table.status),
|
||||||
|
index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt),
|
||||||
|
check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`),
|
||||||
|
check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const dareMessages = pgTable('dare_messages', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }),
|
||||||
|
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
readAt: timestamp('read_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
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';
|
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
|
||||||
|
|
||||||
interface MediaRow extends QueryResultRow {
|
interface MediaRow extends QueryResultRow {
|
||||||
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
|
|||||||
|
|
||||||
export class PostgresMediaRepository implements MediaRepository {
|
export class PostgresMediaRepository implements MediaRepository {
|
||||||
private readonly pool: Pool;
|
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) {
|
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
|
|||||||
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
||||||
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
||||||
}
|
}
|
||||||
async close() { await this.pool.end(); }
|
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
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';
|
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
|
||||||
|
|
||||||
interface OutboxRow extends QueryResultRow {
|
interface OutboxRow extends QueryResultRow {
|
||||||
@@ -14,9 +15,19 @@ export interface OperationsRepository {
|
|||||||
message(id: string): Promise<MessageForClassification | null>;
|
message(id: string): Promise<MessageForClassification | null>;
|
||||||
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
|
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
|
||||||
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
|
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
|
||||||
|
outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<now()-interval '5 minutes')`;
|
||||||
|
|
||||||
function item(row: OutboxRow): OperationItem {
|
function item(row: OutboxRow): OperationItem {
|
||||||
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
|
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
|
||||||
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
|
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
|
||||||
@@ -31,12 +42,13 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
|||||||
|
|
||||||
export class PostgresOperationsRepository implements OperationsRepository {
|
export class PostgresOperationsRepository implements OperationsRepository {
|
||||||
private readonly pool: Pool;
|
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<ProducerSummary> {
|
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||||
return transaction(this.pool, async (client) => {
|
return transaction(this.pool, async (client) => {
|
||||||
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
||||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
||||||
if (!lock.rows[0]?.locked) return empty;
|
if (!lock.rows[0]?.locked) return empty;
|
||||||
|
|
||||||
const released=await client.query<{id:string;name:string}>(
|
const released=await client.query<{id:string;name:string}>(
|
||||||
@@ -138,12 +150,77 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
||||||
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||||
|
|
||||||
|
// Expiring pending dares too: the original design only swept `active`, so an
|
||||||
|
// invitation the recipient never opened stayed open forever.
|
||||||
|
const expiredDares=await client.query(
|
||||||
|
`with due as (
|
||||||
|
select id from sent_dares
|
||||||
|
where expires_at is not null and expires_at<=$1
|
||||||
|
and status in ('pending','active','submitted')
|
||||||
|
order by expires_at for update skip locked limit $2
|
||||||
|
) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]);
|
||||||
|
|
||||||
const catchup=await this.produceCatchups(client,now,limit);
|
const catchup=await this.produceCatchups(client,now,limit);
|
||||||
|
const streakReminders=await this.produceStreakReminders(client,now);
|
||||||
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
||||||
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup};
|
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,
|
||||||
|
catchupReminders:catchup,streakReminders,daresExpired:expiredDares.rowCount??0};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shared streak only changes behaviour if somebody hears that it is open before the day
|
||||||
|
* closes, so this fires in the couple's own late evening rather than on a server clock.
|
||||||
|
* Only the partner who still owes an answer is notified, and the copy names the other
|
||||||
|
* partner as an invitation rather than as a debt: guilt between partners is the failure
|
||||||
|
* mode for this mechanic, not the goal.
|
||||||
|
*/
|
||||||
|
private async produceStreakReminders(client: PoolClient, now: Date): Promise<number> {
|
||||||
|
const result=await client.query(
|
||||||
|
`with zones as (select name from pg_timezone_names),
|
||||||
|
couple_zone as (
|
||||||
|
select r.couple_id,coalesce(z.name,'UTC') zone from (
|
||||||
|
select p.couple_id,(array_agg(p.timezone order by p.id) filter (where p.timezone is not null))[1] reported
|
||||||
|
from profiles p join couple_streaks cs on cs.couple_id=p.couple_id and cs.current_streak>0
|
||||||
|
group by p.couple_id
|
||||||
|
) r left join zones z on z.name=r.reported
|
||||||
|
),
|
||||||
|
at_risk as (
|
||||||
|
select cs.couple_id,cs.current_streak,cs.last_active_date,cs.user1_answered_today,cs.user2_answered_today,
|
||||||
|
($1::timestamptz at time zone cz.zone)::date local_date
|
||||||
|
from couple_streaks cs join couple_zone cz on cz.couple_id=cs.couple_id
|
||||||
|
where cs.current_streak>0
|
||||||
|
and cs.last_completed_date=($1::timestamptz at time zone cz.zone)::date-1
|
||||||
|
and extract(hour from ($1::timestamptz at time zone cz.zone))>=20
|
||||||
|
),
|
||||||
|
sides as (
|
||||||
|
select ar.couple_id,ar.current_streak,to_char(ar.local_date,'YYYY-MM-DD') local_date,
|
||||||
|
me.id me_id,me.push_token,nullif(btrim(partner.name),'') partner_name,
|
||||||
|
case when ar.last_active_date is distinct from ar.local_date then false
|
||||||
|
when me.id<partner.id then ar.user1_answered_today else ar.user2_answered_today end me_answered,
|
||||||
|
case when ar.last_active_date is distinct from ar.local_date then false
|
||||||
|
when me.id<partner.id then ar.user2_answered_today else ar.user1_answered_today end partner_answered
|
||||||
|
from at_risk ar
|
||||||
|
join profiles me on me.couple_id=ar.couple_id
|
||||||
|
join profiles partner on partner.couple_id=ar.couple_id and partner.id<>me.id
|
||||||
|
)
|
||||||
|
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||||
|
select 'expo','streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id,s.me_id,
|
||||||
|
jsonb_build_object(
|
||||||
|
'title',case when s.partner_answered then coalesce(s.partner_name,'Your partner')||' answered today'
|
||||||
|
else 'Your '||s.current_streak||'-day streak' end,
|
||||||
|
'body',case when s.partner_answered
|
||||||
|
then 'You two are '||s.current_streak||' days in together. One question keeps it going.'
|
||||||
|
else 'You and '||coalesce(s.partner_name,'your partner')||' are '||s.current_streak||' days in. One question each keeps it going.' end,
|
||||||
|
'data',jsonb_build_object('type','streak_reminder','streak',s.current_streak))
|
||||||
|
from sides s left join notification_preferences np on np.user_id=s.me_id
|
||||||
|
where not s.me_answered and s.push_token is not null and coalesce(np.streak_reminders_enabled,true)
|
||||||
|
and not exists(select 1 from operations_outbox o
|
||||||
|
where o.dedupe_key='streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id)
|
||||||
|
on conflict(dedupe_key) do nothing`,[now]);
|
||||||
|
return result.rowCount??0;
|
||||||
|
}
|
||||||
|
|
||||||
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
|
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
|
||||||
await client.query(
|
await client.query(
|
||||||
`with behind as (
|
`with behind as (
|
||||||
@@ -202,6 +279,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end
|
when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end
|
||||||
when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end
|
when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end
|
||||||
when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end
|
when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end
|
||||||
|
when 'streak_reminder' then case when coalesce(np.streak_reminders_enabled,true) then p.push_token end
|
||||||
when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end
|
when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end
|
||||||
when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end
|
when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end
|
||||||
when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end
|
when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end
|
||||||
@@ -212,6 +290,30 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
return result.rows.map(item);
|
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 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(
|
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)]); }
|
`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)]); }
|
||||||
@@ -246,7 +348,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
|
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]);
|
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<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function streakCopy(days:number) {
|
function streakCopy(days:number) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { MediaJanitor } from '../media/janitor.js';
|
import type { MediaJanitor } from '../media/janitor.js';
|
||||||
import type { OperationsRepository } from './repository.js';
|
import type { OperationsRepository } from './repository.js';
|
||||||
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
|
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
|
||||||
|
import { emitTelemetry } from '../../telemetry.js';
|
||||||
|
|
||||||
export class OperationsRunner {
|
export class OperationsRunner {
|
||||||
constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {}
|
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++;
|
await this.repository.complete(item.id);completed++;
|
||||||
} catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; }
|
} 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};
|
return {produced,claimed:items.length,completed,failed};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +48,6 @@ export interface ProducerSummary {
|
|||||||
weeklySummaries: number;
|
weeklySummaries: number;
|
||||||
unpairedReminders: number;
|
unpairedReminders: number;
|
||||||
catchupReminders: number;
|
catchupReminders: number;
|
||||||
|
streakReminders: number;
|
||||||
|
daresExpired: number;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
|
||||||
export interface PackCategory {
|
export interface PackCategory {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
|
|||||||
|
|
||||||
export class PostgresPacksRepository implements PacksRepository {
|
export class PostgresPacksRepository implements PacksRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
||||||
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,10 @@ export const categories = pgTable('categories', {
|
|||||||
color: text('color'),
|
color: text('color'),
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
isPublic: boolean('is_public').notNull().default(true),
|
isPublic: boolean('is_public').notNull().default(true),
|
||||||
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||||
|
contentReviewReason: text('content_review_reason'),
|
||||||
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||||
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,6 +30,10 @@ export const questionPacks = pgTable('question_packs', {
|
|||||||
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
|
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
|
||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||||
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||||
|
contentReviewReason: text('content_review_reason'),
|
||||||
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||||
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
||||||
|
|
||||||
@@ -42,6 +50,10 @@ export const questions = pgTable('questions', {
|
|||||||
config: jsonb('config').notNull().default({}),
|
config: jsonb('config').notNull().default({}),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
inverseOf: uuid('inverse_of'),
|
inverseOf: uuid('inverse_of'),
|
||||||
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
||||||
|
contentReviewReason: text('content_review_reason'),
|
||||||
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
||||||
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
|
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
|
||||||
|
|
||||||
@@ -64,3 +76,17 @@ export const couplePacks = pgTable('couple_packs', {
|
|||||||
enabled: boolean('enabled').notNull().default(true),
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
|
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
|
||||||
|
|
||||||
|
export const contentReviews = pgTable('content_reviews', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
entityId: uuid('entity_id').notNull(),
|
||||||
|
previousStatus: text('previous_status').notNull(),
|
||||||
|
newStatus: text('new_status').notNull(),
|
||||||
|
reason: text('reason').notNull(),
|
||||||
|
changedBy: uuid('changed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('content_reviews_entity_idx').on(table.entityType, table.entityId, table.createdAt),
|
||||||
|
index('content_reviews_created_at_idx').on(table.createdAt),
|
||||||
|
]);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Pool, type QueryResultRow } from 'pg';
|
import { Pool, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import type {
|
import type {
|
||||||
FeedbackSubmission,
|
FeedbackSubmission,
|
||||||
NotificationPreferenceKey,
|
NotificationPreferenceKey,
|
||||||
@@ -16,9 +17,11 @@ interface PreferencesRow extends QueryResultRow {
|
|||||||
pack_changes_enabled: boolean;
|
pack_changes_enabled: boolean;
|
||||||
new_packs_enabled: boolean;
|
new_packs_enabled: boolean;
|
||||||
streak_milestones_enabled: boolean;
|
streak_milestones_enabled: boolean;
|
||||||
|
streak_reminders_enabled: boolean;
|
||||||
weekly_summary_enabled: boolean;
|
weekly_summary_enabled: boolean;
|
||||||
unpaired_reminders_enabled: boolean;
|
unpaired_reminders_enabled: boolean;
|
||||||
catchup_reminders_enabled: boolean;
|
catchup_reminders_enabled: boolean;
|
||||||
|
dares_enabled: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -49,6 +52,7 @@ const profileColumns: Record<keyof ProfileUpdate, string> = {
|
|||||||
onboarding_version: 'onboarding_version',
|
onboarding_version: 'onboarding_version',
|
||||||
public_key_jwk: 'public_key_jwk',
|
public_key_jwk: 'public_key_jwk',
|
||||||
push_token: 'push_token',
|
push_token: 'push_token',
|
||||||
|
timezone: 'timezone',
|
||||||
};
|
};
|
||||||
|
|
||||||
function toPreferences(row: PreferencesRow): NotificationPreferences {
|
function toPreferences(row: PreferencesRow): NotificationPreferences {
|
||||||
@@ -61,9 +65,11 @@ function toPreferences(row: PreferencesRow): NotificationPreferences {
|
|||||||
pack_changes_enabled: row.pack_changes_enabled,
|
pack_changes_enabled: row.pack_changes_enabled,
|
||||||
new_packs_enabled: row.new_packs_enabled,
|
new_packs_enabled: row.new_packs_enabled,
|
||||||
streak_milestones_enabled: row.streak_milestones_enabled,
|
streak_milestones_enabled: row.streak_milestones_enabled,
|
||||||
|
streak_reminders_enabled: row.streak_reminders_enabled,
|
||||||
weekly_summary_enabled: row.weekly_summary_enabled,
|
weekly_summary_enabled: row.weekly_summary_enabled,
|
||||||
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
||||||
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
||||||
|
dares_enabled: row.dares_enabled,
|
||||||
created_at: row.created_at.toISOString(),
|
created_at: row.created_at.toISOString(),
|
||||||
updated_at: row.updated_at.toISOString(),
|
updated_at: row.updated_at.toISOString(),
|
||||||
};
|
};
|
||||||
@@ -75,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
|
|||||||
|
|
||||||
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
||||||
@@ -141,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,8 @@ const profileUpdateSchema = z.object({
|
|||||||
onboarding_version: z.number().int().min(0).optional(),
|
onboarding_version: z.number().int().min(0).optional(),
|
||||||
public_key_jwk: z.record(z.unknown()).nullable().optional(),
|
public_key_jwk: z.record(z.unknown()).nullable().optional(),
|
||||||
push_token: z.string().trim().min(1).max(512).nullable().optional(),
|
push_token: z.string().trim().min(1).max(512).nullable().optional(),
|
||||||
|
// IANA zone from the client; the daily response limit buckets on the user's local day.
|
||||||
|
timezone: z.string().trim().min(1).max(64).nullable().optional(),
|
||||||
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
|
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
|
||||||
|
|
||||||
const preferenceSchema = z.object({
|
const preferenceSchema = z.object({
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ export const notificationPreferenceKeys = [
|
|||||||
'pack_changes_enabled',
|
'pack_changes_enabled',
|
||||||
'new_packs_enabled',
|
'new_packs_enabled',
|
||||||
'streak_milestones_enabled',
|
'streak_milestones_enabled',
|
||||||
|
'streak_reminders_enabled',
|
||||||
'weekly_summary_enabled',
|
'weekly_summary_enabled',
|
||||||
'unpaired_reminders_enabled',
|
'unpaired_reminders_enabled',
|
||||||
'catchup_reminders_enabled',
|
'catchup_reminders_enabled',
|
||||||
|
'dares_enabled',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
||||||
@@ -30,6 +32,7 @@ export interface ProfileUpdate {
|
|||||||
onboarding_version?: number;
|
onboarding_version?: number;
|
||||||
public_key_jwk?: Record<string, unknown> | null;
|
public_key_jwk?: Record<string, unknown> | null;
|
||||||
push_token?: string | null;
|
push_token?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackSubmission {
|
export interface FeedbackSubmission {
|
||||||
|
|||||||
+28
-24
@@ -2,6 +2,8 @@ import { serve } from '@hono/node-server';
|
|||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
import { createAuthVerifier } from './auth.js';
|
import { createAuthVerifier } from './auth.js';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
|
import { createDatabasePool } from './db/pool.js';
|
||||||
|
import { recordPool } from './telemetry.js';
|
||||||
import { PostgresRepository } from './db/repository.js';
|
import { PostgresRepository } from './db/repository.js';
|
||||||
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
|
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
|
||||||
import { AdminRequestAuth } from './domains/admin/auth.js';
|
import { AdminRequestAuth } from './domains/admin/auth.js';
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
} from './domains/account-operations/index.js';
|
} from './domains/account-operations/index.js';
|
||||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||||
|
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||||
import { CoupleService } from './domains/couples/service.js';
|
import { CoupleService } from './domains/couples/service.js';
|
||||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -25,29 +28,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media
|
|||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const authVerifier = createAuthVerifier(config);
|
const authVerifier = createAuthVerifier(config);
|
||||||
const repository = new PostgresRepository(config.databaseUrl);
|
// One deliberately bounded pool for the whole process. Repositories used to open
|
||||||
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
|
// a default-sized pool each, so a single API instance could hold twelve times the
|
||||||
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
// connections a self-hosted PostgreSQL was sized for.
|
||||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
const pool = createDatabasePool(config.databaseUrl, config.databasePool, 'api');
|
||||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
const repository = new PostgresRepository(pool);
|
||||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
const coupleRepository = new PostgresCoupleRepository(pool);
|
||||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
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(
|
const accountOperationsService = new AccountOperationsService(
|
||||||
accountOperationsRepository,
|
accountOperationsRepository,
|
||||||
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||||
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
|
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
|
||||||
new ExpoPartnerNotifier(),
|
new ExpoPartnerNotifier(),
|
||||||
);
|
);
|
||||||
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
|
const mediaRepository = new PostgresMediaRepository(pool);
|
||||||
const mediaStorage = new FilesystemMediaStorage(
|
const mediaStorage = new FilesystemMediaStorage(
|
||||||
config.mediaRoot,
|
config.mediaRoot,
|
||||||
config.mediaSigningSecret,
|
config.mediaSigningSecret,
|
||||||
config.mediaPublicBaseUrl,
|
config.mediaPublicBaseUrl,
|
||||||
);
|
);
|
||||||
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
|
const appDataRepository = new PostgresAppDataRepository(pool);
|
||||||
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
|
const billingRepository = new PostgresBillingRepository(pool);
|
||||||
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
|
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
|
||||||
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
|
const adminRepository = new PostgresAdminRepository(pool, {
|
||||||
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
|
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
|
||||||
mediaRoot: config.mediaRoot,
|
mediaRoot: config.mediaRoot,
|
||||||
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||||
@@ -59,6 +67,7 @@ const app = createApp({
|
|||||||
packsRepository,
|
packsRepository,
|
||||||
answersRepository,
|
answersRepository,
|
||||||
chatRepository,
|
chatRepository,
|
||||||
|
daresRepository,
|
||||||
profileSettingsRepository,
|
profileSettingsRepository,
|
||||||
accountOperationsService,
|
accountOperationsService,
|
||||||
mediaRepository,
|
mediaRepository,
|
||||||
@@ -73,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) => {
|
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
console.log(`Sauci API listening on http://127.0.0.1:${info.port}`);
|
console.log(`Sauci API listening on http://127.0.0.1:${info.port}`);
|
||||||
});
|
});
|
||||||
@@ -82,6 +95,7 @@ let shuttingDown = false;
|
|||||||
async function shutdown(signal: string): Promise<void> {
|
async function shutdown(signal: string): Promise<void> {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
clearInterval(poolTelemetryTimer);
|
||||||
console.log(`Received ${signal}; shutting down`);
|
console.log(`Received ${signal}; shutting down`);
|
||||||
const forcedExit = setTimeout(() => {
|
const forcedExit = setTimeout(() => {
|
||||||
console.error('Graceful shutdown timed out');
|
console.error('Graceful shutdown timed out');
|
||||||
@@ -91,19 +105,9 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
// Repositories share the process pool and no longer own a connection each,
|
||||||
repository.close(),
|
// so ending the pool once is the whole of the database shutdown.
|
||||||
coupleRepository.close(),
|
await pool.end();
|
||||||
packsRepository.close(),
|
|
||||||
answersRepository.close(),
|
|
||||||
chatRepository.close(),
|
|
||||||
profileSettingsRepository.close(),
|
|
||||||
accountOperationsRepository.close(),
|
|
||||||
mediaRepository.close(),
|
|
||||||
appDataRepository.close(),
|
|
||||||
billingRepository.close(),
|
|
||||||
adminRepository.close(),
|
|
||||||
]);
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to close database connections', error);
|
console.error('Failed to close database connections', error);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
import type { Pool, PoolClient } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
import type { DatabaseMigrationOptions, TableResult } from './types.js';
|
import type { DatabaseMigrationOptions, TableResult } from './types.js';
|
||||||
|
|
||||||
interface TableSpec { name: string; key: string[]; replace?: boolean }
|
interface TableSpec {
|
||||||
|
name: string;
|
||||||
|
key: string[];
|
||||||
|
replace?: boolean;
|
||||||
|
/** Target-column to legacy source-column rename. */
|
||||||
|
columnMap?: Record<string, string>;
|
||||||
|
/** Target-column SQL expressions for legacy rows that need a derived value. */
|
||||||
|
sourceExpressions?: Record<string, string>;
|
||||||
|
/** Columns populated after all rows exist, for self-referential foreign keys. */
|
||||||
|
deferredColumns?: string[];
|
||||||
|
/** Keep a newer standalone-backend row when the same key exists in both systems. */
|
||||||
|
preserveNewer?: boolean;
|
||||||
|
/** Singleton runtime configuration is owned by the live target during an initial merge. */
|
||||||
|
preserveExistingOnInitial?: boolean;
|
||||||
|
}
|
||||||
export const TABLES: TableSpec[] = [
|
export const TABLES: TableSpec[] = [
|
||||||
{ name: 'couples', key: ['id'] }, { name: 'profiles', key: ['id'] },
|
{ name: 'couples', key: ['id'], sourceExpressions: { invite_code: 'upper("couples"."invite_code")' } },
|
||||||
|
{ name: 'profiles', key: ['id'], preserveNewer: true },
|
||||||
{ name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
|
{ name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
|
||||||
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
|
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
|
||||||
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] },
|
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] },
|
||||||
{ name: 'questions', key: ['id'] }, { name: 'app_config', key: ['id'], replace: true },
|
{ name: 'questions', key: ['id'], deferredColumns: ['inverse_of'] },
|
||||||
{ name: 'ai_config', key: ['id'], replace: true },
|
{ name: 'content_reviews', key: ['id'] },
|
||||||
|
{ name: 'app_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
|
||||||
|
{ name: 'ai_config', key: ['id'], replace: true, preserveExistingOnInitial: true },
|
||||||
{ name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
|
{ name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
|
||||||
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
|
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
|
||||||
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] },
|
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] },
|
||||||
@@ -18,8 +35,21 @@ export const TABLES: TableSpec[] = [
|
|||||||
{ name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
|
{ name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
|
||||||
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
|
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
|
||||||
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] },
|
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] },
|
||||||
{ name: 'feature_interests', key: ['user_id', 'feature'] }, { name: 'live_draw_sessions', key: ['couple_id'] },
|
{ name: 'feature_interests', key: ['user_id', 'feature'], columnMap: { feature: 'feature_name' } },
|
||||||
{ name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] },
|
{
|
||||||
|
name: 'live_draw_sessions', key: ['couple_id'],
|
||||||
|
sourceExpressions: {
|
||||||
|
updated_by: '(select p.id from profiles p where p.couple_id="live_draw_sessions"."couple_id" order by p.created_at,p.id limit 1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'catchup_reminder_tracking', key: ['user_id'] },
|
||||||
|
{
|
||||||
|
name: 'audit_logs', key: ['id'],
|
||||||
|
sourceExpressions: {
|
||||||
|
actor_user_id: '"audit_logs"."admin_user_id"',
|
||||||
|
admin_user_id: '(select au.id from admin_users au where au.user_id="audit_logs"."admin_user_id" limit 1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
||||||
@@ -27,18 +57,42 @@ async function columns(pool: Pool | PoolClient, table: string): Promise<string[]
|
|||||||
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]);
|
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]);
|
||||||
return result.rows.map((row) => row.column_name);
|
return result.rows.map((row) => row.column_name);
|
||||||
}
|
}
|
||||||
|
async function jsonColumns(pool: Pool | PoolClient, table: string): Promise<Set<string>> {
|
||||||
|
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 and data_type in ('json','jsonb')`, [table]);
|
||||||
|
return new Set(result.rows.map((row) => row.column_name));
|
||||||
|
}
|
||||||
async function count(pool: Pool | PoolClient, table: string): Promise<number> {
|
async function count(pool: Pool | PoolClient, table: string): Promise<number> {
|
||||||
return Number((await pool.query<{ count: string }>(`select count(*) count from ${quote(table)}`)).rows[0]?.count ?? 0);
|
return Number((await pool.query<{ count: string }>(`select count(*) count from ${quote(table)}`)).rows[0]?.count ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[]): Promise<number> {
|
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[], jsonNames: Set<string>, finalSync: boolean): Promise<number> {
|
||||||
if (spec.replace) await client.query(`delete from ${quote(spec.name)}`);
|
if (spec.preserveExistingOnInitial && !finalSync && await count(client, spec.name)) return 0;
|
||||||
|
if (spec.replace && finalSync) await client.query(`delete from ${quote(spec.name)}`);
|
||||||
if (!rows.length) return 0;
|
if (!rows.length) return 0;
|
||||||
const update = names.filter((name) => !spec.key.includes(name));
|
const deferred = new Set(spec.deferredColumns ?? []);
|
||||||
|
const immediateNames = names.filter((name) => !deferred.has(name));
|
||||||
|
const update = immediateNames.filter((name) => !spec.key.includes(name));
|
||||||
const conflict = spec.key.map(quote).join(',');
|
const conflict = spec.key.map(quote).join(',');
|
||||||
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}` : 'do nothing';
|
const freshness = spec.preserveNewer && names.includes('updated_at')
|
||||||
|
? ` where ${quote(spec.name)}.${quote('updated_at')} is null or (excluded.${quote('updated_at')} is not null and excluded.${quote('updated_at')}>${quote(spec.name)}.${quote('updated_at')})`
|
||||||
|
: '';
|
||||||
|
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}${freshness}` : 'do nothing';
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
await client.query(`insert into ${quote(spec.name)} (${names.map(quote).join(',')}) values (${names.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, names.map((name) => row[name]));
|
await client.query(
|
||||||
|
`insert into ${quote(spec.name)} (${immediateNames.map(quote).join(',')}) values (${immediateNames.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`,
|
||||||
|
immediateNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (deferred.size) {
|
||||||
|
const deferredNames = names.filter((name) => deferred.has(name));
|
||||||
|
for (const row of rows) {
|
||||||
|
const values = deferredNames.map((name) => jsonNames.has(name) && row[name] !== null ? JSON.stringify(row[name]) : row[name]);
|
||||||
|
const whereValues = spec.key.map((name) => row[name]);
|
||||||
|
await client.query(
|
||||||
|
`update ${quote(spec.name)} set ${deferredNames.map((name, index) => `${quote(name)}=$${index + 1}`).join(',')} where ${spec.key.map((name, index) => `${quote(name)}=$${deferredNames.length + index + 1}`).join(' and ')}`,
|
||||||
|
[...values, ...whereValues],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
@@ -59,16 +113,24 @@ async function pruneRows(client: PoolClient, spec: TableSpec, sourceRows: Record
|
|||||||
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
|
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
|
||||||
const results: TableResult[] = [];
|
const results: TableResult[] = [];
|
||||||
for (const spec of TABLES) {
|
for (const spec of TABLES) {
|
||||||
const [sourceColumns, targetColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name)]);
|
const [sourceColumns, targetColumns, targetJsonColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name), jsonColumns(target, spec.name)]);
|
||||||
if (!sourceColumns.length || !targetColumns.length) {
|
if (!sourceColumns.length || !targetColumns.length) {
|
||||||
results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' });
|
results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const common = targetColumns.filter((name) => sourceColumns.includes(name));
|
const sourceName = (targetName: string) => spec.columnMap?.[targetName] ?? targetName;
|
||||||
const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name));
|
const common = targetColumns.filter((name) => sourceColumns.includes(sourceName(name)) || Boolean(spec.sourceExpressions?.[name]));
|
||||||
|
const consumedSourceColumns = new Set(common.map(sourceName).filter((name) => sourceColumns.includes(name)));
|
||||||
|
const sourceOnly = sourceColumns.filter((name) => !consumedSourceColumns.has(name));
|
||||||
if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`);
|
if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`);
|
||||||
const sourceRows = (await source.query<Record<string, unknown>>(`select ${common.map(quote).join(',')} from ${quote(spec.name)}`)).rows;
|
const selections = common.map((targetName) => {
|
||||||
let importedRows = 0; let prunedRows = 0;
|
const expression = spec.sourceExpressions?.[targetName] ?? quote(sourceName(targetName));
|
||||||
|
return sourceName(targetName) === targetName && !spec.sourceExpressions?.[targetName]
|
||||||
|
? expression
|
||||||
|
: `${expression} as ${quote(targetName)}`;
|
||||||
|
});
|
||||||
|
const sourceRows = (await source.query<Record<string, unknown>>(`select ${selections.join(',')} from ${quote(spec.name)}`)).rows;
|
||||||
|
let importedRows = 0; let prunedRows = 0; let reconciledProfileCouples = 0;
|
||||||
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
|
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
|
||||||
if (!options.dryRun && !skip) {
|
if (!options.dryRun && !skip) {
|
||||||
const client = await target.connect();
|
const client = await target.connect();
|
||||||
@@ -77,7 +139,19 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
|
|||||||
// Historical imports must not enqueue user notifications, classification,
|
// Historical imports must not enqueue user notifications, classification,
|
||||||
// Discord events, or other operational side effects.
|
// Discord events, or other operational side effects.
|
||||||
await client.query("set local sauci.suppress_operations = 'on'");
|
await client.query("set local sauci.suppress_operations = 'on'");
|
||||||
importedRows = await upsertRows(client, spec, common, sourceRows);
|
importedRows = await upsertRows(client, spec, common, sourceRows, targetJsonColumns, options.finalSync);
|
||||||
|
if (spec.name === 'profiles' && !options.finalSync) {
|
||||||
|
for (const row of sourceRows) {
|
||||||
|
if (!row.couple_id) continue;
|
||||||
|
const reconciled = await client.query(
|
||||||
|
`update profiles p set couple_id=$2
|
||||||
|
where p.id=$1 and p.couple_id is distinct from $2
|
||||||
|
and (select count(*) from profiles member where member.couple_id=p.couple_id)<=1`,
|
||||||
|
[row.id, row.couple_id],
|
||||||
|
);
|
||||||
|
reconciledProfileCouples += reconciled.rowCount ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
|
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
|
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
|
||||||
@@ -85,8 +159,23 @@ export async function migrateDatabase(source: Pool, target: Pool, options: Datab
|
|||||||
await options.saveCheckpoint?.(options.checkpoint);
|
await options.saveCheckpoint?.(options.checkpoint);
|
||||||
}
|
}
|
||||||
const targetRows = await count(target, spec.name);
|
const targetRows = await count(target, spec.name);
|
||||||
const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean);
|
const targetKeys = targetColumns.length
|
||||||
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
|
? (await target.query<Record<string, unknown>>(`select ${spec.key.map(quote).join(',')} from ${quote(spec.name)}`)).rows
|
||||||
|
: [];
|
||||||
|
const keyValue = (row: Record<string, unknown>) => JSON.stringify(spec.key.map((key) => row[key]));
|
||||||
|
const targetKeySet = new Set(targetKeys.map(keyValue));
|
||||||
|
const coveredSourceRows = sourceRows.filter((row) => targetKeySet.has(keyValue(row))).length;
|
||||||
|
const preservedSingleton = Boolean(spec.preserveExistingOnInitial && !options.finalSync && targetRows && !coveredSourceRows);
|
||||||
|
const parity = options.finalSync ? sourceRows.length === targetRows : preservedSingleton || coveredSourceRows === sourceRows.length;
|
||||||
|
const preservedTargetRows = Math.max(0, targetRows - coveredSourceRows);
|
||||||
|
const notes = [
|
||||||
|
skip ? 'resumed from checkpoint' : '',
|
||||||
|
preservedSingleton ? 'kept existing live target singleton during initial merge' : '',
|
||||||
|
reconciledProfileCouples ? `restored ${reconciledProfileCouples} established couple membership(s) over one-person target couples` : '',
|
||||||
|
!options.finalSync && preservedTargetRows ? `preserved ${preservedTargetRows} target-only row(s)` : '',
|
||||||
|
sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: parity ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ interface StorageObject {
|
|||||||
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
|
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
|
||||||
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback',
|
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback',
|
||||||
};
|
};
|
||||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
// PostgreSQL's uuid type accepts legacy UUID-shaped identifiers whose version
|
||||||
|
// nibble is not RFC 4122 (the production fixture profile uses version 0).
|
||||||
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
||||||
|
|
||||||
function safeStorageKey(bucket: string, name: string): string {
|
function safeStorageKey(bucket: string, name: string): string {
|
||||||
@@ -50,22 +52,57 @@ export function createSupabaseDownloader(baseUrl: string, serviceRoleKey: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mediaRelation(target: Pool, object: StorageObject, key: string) {
|
const legacyPredicate = (column: string) => `(
|
||||||
const first = object.name.split('/')[0];
|
${column}=$1 or ${column}=$2 or
|
||||||
const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null);
|
(right(${column},length($1))=$1 and position('/'||$3||'/' in ${column})>0)
|
||||||
if (!owner) throw new Error(`Cannot determine owner for ${key}`);
|
)`;
|
||||||
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [owner]);
|
|
||||||
if (!profile.rows[0]) throw new Error(`Storage owner does not exist in target for ${key}`);
|
async function sourceReference(source: Pool, object: StorageObject) {
|
||||||
let matchId: string | null = null; let questionId: string | null = null;
|
const values = [object.name, `${object.bucket_id}/${object.name}`, object.bucket_id];
|
||||||
if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
|
if (object.bucket_id === 'avatars') {
|
||||||
if (object.bucket_id === 'response-media') {
|
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
|
||||||
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
|
`select id owner_id,null::uuid match_id,null::uuid question_id from profiles where ${legacyPredicate('avatar_url')} limit 1`, values,
|
||||||
if (uuidPattern.test(candidate)) questionId = candidate;
|
)).rows[0] ?? null;
|
||||||
}
|
}
|
||||||
return { ownerId: owner, coupleId: profile.rows[0].couple_id, matchId, questionId };
|
if (object.bucket_id === 'chat-media') {
|
||||||
|
return (await source.query<{ owner_id: string; match_id: string; question_id: null }>(
|
||||||
|
`select user_id owner_id,match_id,null::uuid question_id from messages where ${legacyPredicate('media_path')} limit 1`, values,
|
||||||
|
)).rows[0] ?? null;
|
||||||
|
}
|
||||||
|
if (object.bucket_id === 'feedback-screenshots') {
|
||||||
|
return (await source.query<{ owner_id: string; match_id: null; question_id: null }>(
|
||||||
|
`select user_id owner_id,null::uuid match_id,null::uuid question_id from feedback where ${legacyPredicate('screenshot_url')} limit 1`, values,
|
||||||
|
)).rows[0] ?? null;
|
||||||
|
}
|
||||||
|
return (await source.query<{ owner_id: string; match_id: null; question_id: string }>(
|
||||||
|
`select user_id owner_id,null::uuid match_id,question_id from responses
|
||||||
|
where response_data is not null and ${legacyPredicate("response_data->>'media_path'")} limit 1`, values,
|
||||||
|
)).rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteLegacyReference(target: Pool, object: StorageObject): Promise<void> {
|
async function mediaRelation(source: Pool, target: Pool, object: StorageObject) {
|
||||||
|
const first = object.name.split('/')[0];
|
||||||
|
const reference = await sourceReference(source, object);
|
||||||
|
const ownerCandidates = [object.owner_id, reference?.owner_id, object.bucket_id === 'chat-media' ? null : first]
|
||||||
|
.filter((value): value is string => Boolean(value && uuidPattern.test(value)));
|
||||||
|
let ownerId: string | null = null; let coupleId: string | null = null;
|
||||||
|
for (const candidate of ownerCandidates) {
|
||||||
|
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [candidate]);
|
||||||
|
if (profile.rows[0]) { ownerId = candidate; coupleId = profile.rows[0].couple_id; break; }
|
||||||
|
}
|
||||||
|
let matchId = reference?.match_id && uuidPattern.test(reference.match_id) ? reference.match_id : null;
|
||||||
|
if (!matchId && object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
|
||||||
|
if (matchId && !(await target.query('select 1 from matches where id=$1', [matchId])).rowCount) matchId = null;
|
||||||
|
let questionId = reference?.question_id && uuidPattern.test(reference.question_id) ? reference.question_id : null;
|
||||||
|
if (object.bucket_id === 'response-media') {
|
||||||
|
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
|
||||||
|
if (!questionId && uuidPattern.test(candidate)) questionId = candidate;
|
||||||
|
}
|
||||||
|
if (questionId && !(await target.query('select 1 from questions where id=$1', [questionId])).rowCount) questionId = null;
|
||||||
|
return { ownerId, coupleId, matchId, questionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rewriteLegacyReference(source: Pool, target: Pool, object: StorageObject): Promise<void> {
|
||||||
const reference = `media:${object.id}`;
|
const reference = `media:${object.id}`;
|
||||||
const legacyName = object.name;
|
const legacyName = object.name;
|
||||||
const legacyKey = `${object.bucket_id}/${legacyName}`;
|
const legacyKey = `${object.bucket_id}/${legacyName}`;
|
||||||
@@ -78,7 +115,11 @@ async function rewriteLegacyReference(target: Pool, object: StorageObject): Prom
|
|||||||
} else if (object.bucket_id === 'chat-media') {
|
} else if (object.bucket_id === 'chat-media') {
|
||||||
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
|
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
|
||||||
} else if (object.bucket_id === 'feedback-screenshots') {
|
} else if (object.bucket_id === 'feedback-screenshots') {
|
||||||
await target.query(`update feedback set screenshot_url=$1 where ${matchesLegacy('screenshot_url')}`, [reference, legacyName, legacyKey]);
|
const sourceRows = await source.query<{ id: string }>(
|
||||||
|
`select id from feedback where ${legacyPredicate('screenshot_url')}`,
|
||||||
|
[legacyName, legacyKey, object.bucket_id],
|
||||||
|
);
|
||||||
|
if (sourceRows.rows.length) await target.query('update feedback set screenshot_media_id=$1 where id=any($2::uuid[])', [object.id, sourceRows.rows.map((row) => row.id)]);
|
||||||
} else if (object.bucket_id === 'response-media') {
|
} else if (object.bucket_id === 'response-media') {
|
||||||
await target.query(`
|
await target.query(`
|
||||||
update responses
|
update responses
|
||||||
@@ -92,7 +133,7 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
const schema = options.storageSchema ?? 'storage';
|
const schema = options.storageSchema ?? 'storage';
|
||||||
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
|
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
|
||||||
const objects = await source.query<StorageObject>(`select id,bucket_id,name,owner_id,metadata,created_at,updated_at from ${quote(schema)}.objects where bucket_id=any($1::text[]) order by bucket_id,name`, [Object.keys(BUCKET_KIND)]);
|
const objects = await source.query<StorageObject>(`select id,bucket_id,name,owner_id,metadata,created_at,updated_at from ${quote(schema)}.objects where bucket_id=any($1::text[]) order by bucket_id,name`, [Object.keys(BUCKET_KIND)]);
|
||||||
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, copiedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
|
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, preservedTargetFiles: 0, copiedFiles: 0, quarantinedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
|
||||||
const sourceKeys = new Set<string>();
|
const sourceKeys = new Set<string>();
|
||||||
for (const object of objects.rows) {
|
for (const object of objects.rows) {
|
||||||
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
|
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
|
||||||
@@ -101,10 +142,11 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
const marker = options.checkpoint.storage[key];
|
const marker = options.checkpoint.storage[key];
|
||||||
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
|
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
|
||||||
if (options.dryRun) { result.skippedFiles += 1; continue; }
|
if (options.dryRun) { result.skippedFiles += 1; continue; }
|
||||||
const registered = await target.query('select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3', [object.id, key, marker.byteSize]);
|
const registered = await target.query(`select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3
|
||||||
|
union all select 1 from legacy_media_quarantine where id=$1 and original_storage_key=$2 and byte_size=$3`, [object.id, key, marker.byteSize]);
|
||||||
try {
|
try {
|
||||||
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
|
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
|
||||||
await rewriteLegacyReference(target, object);
|
await rewriteLegacyReference(source, target, object);
|
||||||
result.skippedFiles += 1;
|
result.skippedFiles += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -112,16 +154,25 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
}
|
}
|
||||||
if (options.dryRun) continue;
|
if (options.dryRun) continue;
|
||||||
try {
|
try {
|
||||||
|
const relation = await mediaRelation(source, target, object);
|
||||||
const bytes = await options.download(object.bucket_id, object.name);
|
const bytes = await options.download(object.bucket_id, object.name);
|
||||||
if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`);
|
if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`);
|
||||||
const relation = await mediaRelation(target, object, key);
|
|
||||||
const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true });
|
const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true });
|
||||||
const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path);
|
const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path);
|
||||||
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
|
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
|
||||||
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
|
if (relation.ownerId) {
|
||||||
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
|
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
|
||||||
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
|
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
|
||||||
await rewriteLegacyReference(target, object);
|
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
|
||||||
|
await target.query('delete from legacy_media_quarantine where id=$1', [object.id]);
|
||||||
|
await rewriteLegacyReference(source, target, object);
|
||||||
|
} else {
|
||||||
|
await target.query(`insert into legacy_media_quarantine(id,original_storage_key,bucket_id,object_name,source_owner_id,metadata,mime_type,byte_size,reason,source_created_at,source_updated_at)
|
||||||
|
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) on conflict(id) do update set original_storage_key=excluded.original_storage_key,bucket_id=excluded.bucket_id,object_name=excluded.object_name,source_owner_id=excluded.source_owner_id,metadata=excluded.metadata,mime_type=excluded.mime_type,byte_size=excluded.byte_size,reason=excluded.reason,source_created_at=excluded.source_created_at,source_updated_at=excluded.source_updated_at`,
|
||||||
|
[object.id, key, object.bucket_id, object.name, object.owner_id, object.metadata, mime, bytes.byteLength, 'legacy object has no verifiable owner or live reference', object.created_at, object.updated_at]);
|
||||||
|
await target.query('delete from media_objects where id=$1', [object.id]);
|
||||||
|
result.quarantinedFiles += 1;
|
||||||
|
}
|
||||||
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
|
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
|
||||||
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
|
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,16 +182,21 @@ export async function migrateStorage(source: Pool, target: Pool, options: Storag
|
|||||||
}
|
}
|
||||||
if (!options.dryRun) {
|
if (!options.dryRun) {
|
||||||
if (options.prune) {
|
if (options.prune) {
|
||||||
const existing = await target.query<{ id: string; storage_key: string }>("select id,storage_key from media_objects where kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
|
const existing = await target.query<{ id: string; storage_key: string; quarantined: boolean }>(`
|
||||||
|
select id,storage_key,false quarantined from media_objects where kind=any($1::text[])
|
||||||
|
union all select id,original_storage_key storage_key,true quarantined from legacy_media_quarantine
|
||||||
|
`, [Object.values(BUCKET_KIND)]);
|
||||||
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
|
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
|
||||||
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
|
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
|
||||||
await target.query('delete from media_objects where id=$1', [row.id]);
|
await target.query(row.quarantined ? 'delete from legacy_media_quarantine where id=$1' : 'delete from media_objects where id=$1', [row.id]);
|
||||||
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
|
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
|
||||||
}
|
}
|
||||||
await options.saveCheckpoint?.(options.checkpoint);
|
await options.saveCheckpoint?.(options.checkpoint);
|
||||||
}
|
}
|
||||||
const registered = await target.query<{ storage_key: string; byte_size: number }>("select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
|
const registered = await target.query<{ storage_key: string; byte_size: number }>(`select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])
|
||||||
result.targetFiles = registered.rows.length;
|
union all select original_storage_key storage_key,byte_size from legacy_media_quarantine`, [Object.values(BUCKET_KIND)]);
|
||||||
|
result.targetFiles = registered.rows.filter((row) => sourceKeys.has(row.storage_key)).length;
|
||||||
|
result.preservedTargetFiles = registered.rows.length - result.targetFiles;
|
||||||
for (const row of registered.rows) {
|
for (const row of registered.rows) {
|
||||||
try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); }
|
try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); }
|
||||||
catch { result.missingOnDisk.push(row.storage_key); }
|
catch { result.missingOnDisk.push(row.storage_key); }
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export interface TableResult {
|
|||||||
export interface StorageResult {
|
export interface StorageResult {
|
||||||
sourceFiles: number;
|
sourceFiles: number;
|
||||||
targetFiles: number;
|
targetFiles: number;
|
||||||
|
preservedTargetFiles: number;
|
||||||
copiedFiles: number;
|
copiedFiles: number;
|
||||||
|
quarantinedFiles: number;
|
||||||
skippedFiles: number;
|
skippedFiles: number;
|
||||||
prunedFiles: number;
|
prunedFiles: number;
|
||||||
failedFiles: number;
|
failedFiles: number;
|
||||||
|
|||||||
@@ -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<string, TelemetryRecord>();
|
||||||
|
|
||||||
|
function aggregate(record: Extract<TelemetryRecord, { event: 'request' | 'query' | 'sync' }>): 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<TelemetryRecord, { event: 'request' | 'query' | 'sync' }>): 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<PoolClient>();
|
||||||
|
const instrumentQuery = <T extends { query: Pool['query'] }>(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<unknown>).then === 'function') return (result as Promise<unknown>).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<PoolClient>).then === 'function') return (result as Promise<PoolClient>).then((client) => { if (!clients.has(client)) { clients.add(client); instrumentQuery(client); } return client; });
|
||||||
|
return result;
|
||||||
|
}) as Pool['connect'];
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
@@ -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 optionalUrl=z.preprocess(value=>value===''?undefined:value,z.string().url().optional());
|
||||||
const schema=z.object({
|
const schema=z.object({
|
||||||
NODE_ENV:z.enum(['development','test','production']).default('development'), DATABASE_URL:z.string().url(),
|
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),
|
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_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'),
|
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);
|
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.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');}}
|
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,
|
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,
|
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};
|
adminPrivateKeyJwk:value.ADMIN_PRIVATE_KEY_JWK,discordWebhookUrl:value.DISCORD_WEBHOOK_URL};
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { PostgresMediaRepository } from '../domains/media/repository.js';
|
|||||||
import { FilesystemMediaStorage } from '../domains/media/storage.js';
|
import { FilesystemMediaStorage } from '../domains/media/storage.js';
|
||||||
import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js';
|
import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js';
|
||||||
import { loadWorkerConfig } from './config.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 mediaStorage=new FilesystemMediaStorage(config.mediaRoot,config.mediaSigningSecret,config.mediaPublicBaseUrl);
|
||||||
const runner=new OperationsRunner(operations,new ExpoPushProvider(),new DiscordProvider(config.discordWebhookUrl),new MessageClassifier(operations,
|
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}),
|
{enabled:config.classifierEnabled,apiKey:config.openRouterApiKey,model:config.classifierModel,prompt:config.classifierPrompt,adminPrivateKeyJwk:config.adminPrivateKeyJwk,mediaRoot:config.mediaRoot}),
|
||||||
new MediaJanitor(mediaRepository,mediaStorage));
|
new MediaJanitor(mediaRepository,mediaStorage));
|
||||||
let stopped=false;let timer:NodeJS.Timeout|undefined;
|
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;}}});
|
||||||
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);}}}
|
function shutdown(signal:string){console.log(`Received ${signal}; stopping operations worker`);return lifecycle.shutdown();}
|
||||||
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(code=>process.exit(code))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(code=>process.exit(code)));
|
||||||
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');lifecycle.start();
|
||||||
console.log('Sauci operations worker started');void tick();
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface WorkerLifecycleDependencies {
|
||||||
|
pollIntervalMs: number;
|
||||||
|
runTick: () => Promise<void>;
|
||||||
|
closePool: () => Promise<void>;
|
||||||
|
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<void> | undefined;
|
||||||
|
private shutdownPromise: Promise<number> | 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<number> {
|
||||||
|
if (!this.shutdownPromise) this.shutdownPromise = this.stop();
|
||||||
|
return this.shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
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<number> {
|
||||||
|
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<number>((resolve) => { timeout = setTimeout(() => resolve(1), this.graceMs); });
|
||||||
|
const result = await Promise.race([work, timedOut]);
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,13 +5,108 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
|||||||
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
|
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');
|
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;
|
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';
|
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']){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'])",[q,pack,concurrentQ,femaleOnlyQ]);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('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('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);});
|
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);});
|
||||||
it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});});
|
it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});});
|
||||||
it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});});
|
it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});});
|
||||||
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
|
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,you_answered_today:true,partner_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
|
||||||
it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});});
|
it('answers the streak from the caller side rather than by stored position',async()=>{await pool.query("update profiles set name='Alex' where id=$1",[u2]);await pool.query("update profiles set name='Sam' where id=$1",[u1]);await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=0,last_active_date=null,last_completed_date=null,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);
|
||||||
|
await repo.submit(u1,{questionId:q,answer:'yes'});
|
||||||
|
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'});
|
||||||
|
const expected=await pool.query<{day:string}>("select (now() at time zone 'Pacific/Auckland')::date::text as day");
|
||||||
|
expect(await repo.streak(u1)).toMatchObject({timezone:'Pacific/Auckland',last_active_date:expected.rows[0].day});
|
||||||
|
// The lower member id decides the zone, so an unreported zone there falls through to the partner.
|
||||||
|
await pool.query('update profiles set timezone=null where id=$1',[u1]);
|
||||||
|
expect((await repo.streak(u1))?.timezone).toBe('America/Los_Angeles');
|
||||||
|
await pool.query("update profiles set timezone='Not/AZone' where id=$1",[u1]);
|
||||||
|
expect((await repo.streak(u1))?.timezone).toBe('UTC');
|
||||||
|
await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);});
|
||||||
|
|
||||||
|
it('reports a lapsed streak as broken before the next answer rewrites it',async()=>{await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=5,longest_streak=9,last_active_date=current_date-3,last_completed_date=current_date-3,user1_answered_today=true,user2_answered_today=true where couple_id=$1",[couple]);
|
||||||
|
expect(await repo.streak(u1)).toMatchObject({current_streak:0,longest_streak:9,you_answered_today:false,partner_answered_today:false});
|
||||||
|
// A streak completed yesterday is still alive and still shows its count.
|
||||||
|
await pool.query('update couple_streaks set last_completed_date=current_date-1,last_active_date=current_date-1 where couple_id=$1',[couple]);
|
||||||
|
expect(await repo.streak(u1)).toMatchObject({current_streak:5,you_answered_today:false});});
|
||||||
|
|
||||||
|
const resetLimit=async(limit:number,timezone:string|null=null)=>{await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query('update app_config set daily_response_limit=$1',[limit]);await pool.query('update profiles set timezone=$2 where id=$1',[u1,timezone]);};
|
||||||
|
it('serializes the daily limit across different questions',async()=>{await resetLimit(1);const results=await Promise.allSettled([repo.submit(u1,{questionId:limitQ1,answer:'yes'}),repo.submit(u1,{questionId:limitQ2,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,remaining:0,is_blocked:true});});
|
||||||
|
it('exempts catching up on a question the partner answered first',async()=>{await resetLimit(1);await repo.submit(u2,{questionId:catchUpQ,answer:'yes'});await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:catchUpQ,answer:'yes'})).resolves.toBeTruthy();expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit'});});
|
||||||
|
it('reports the daily limit error with the shape the client renders',async()=>{await resetLimit(1);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit',status:429,details:{daily_limit:1,responses_today:1,remaining:0}});});
|
||||||
|
it('buckets the window on the user local day and falls back to UTC',async()=>{await resetLimit(5,'Pacific/Auckland');const nz=await repo.dailyLimit(u1);const nzBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'Pacific/Auckland')+interval '1 day') at time zone 'Pacific/Auckland') expected");expect(nz.reset_at).toBe(new Date(nzBoundary.rows[0].expected).toISOString());
|
||||||
|
const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected");
|
||||||
|
for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}});
|
||||||
|
it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);});
|
||||||
});
|
});
|
||||||
@@ -10,12 +10,24 @@ const questionId='22222222-2222-4222-8222-222222222222';
|
|||||||
function repo():AnswersRepository{return {
|
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})),
|
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})),
|
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;}
|
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',()=>{
|
describe('answer routes',()=>{
|
||||||
it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});});
|
it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});});
|
||||||
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'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);});
|
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);});
|
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);});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
|
|||||||
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
||||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||||
repository = new PostgresRepository(isolatedUrl.toString());
|
repository = new PostgresRepository(isolatedUrl.toString());
|
||||||
const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8');
|
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql']
|
||||||
|
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
|
||||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||||
if (statement.trim()) await pool.query(statement);
|
if (statement.trim()) await pool.query(statement);
|
||||||
}
|
}
|
||||||
@@ -74,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 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())
|
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
|
||||||
.toEqual({ feature: 'better-chat', interested: true });
|
.toEqual({ feature: 'better-chat', interested: true });
|
||||||
|
|||||||
@@ -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]);
|
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();});
|
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()=>{
|
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.packQuestions(alice,pack))[0]?.text).toBe('Question');
|
||||||
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
|
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ function repository(): AppDataRepository {
|
|||||||
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
|
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
|
||||||
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_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' })),
|
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 })),
|
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 })),
|
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ApiRepository,
|
ApiRepository,
|
||||||
MobileCompatibleProfile,
|
MobileCompatibleProfile,
|
||||||
} from '../src/db/repository.js';
|
} from '../src/db/repository.js';
|
||||||
|
import { flushTelemetry, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js';
|
||||||
|
|
||||||
const alice: AuthIdentity = {
|
const alice: AuthIdentity = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -119,4 +120,15 @@ describe('Sauci API', () => {
|
|||||||
});
|
});
|
||||||
expect(response.status).toBe(400);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
|
|||||||
|
|
||||||
it('sends, lists, counts and reads text messages for couple members only', async () => {
|
it('sends, lists, counts and reads text messages for couple members only', async () => {
|
||||||
const sent = await repository.sendText(alice, match, 'Hello');
|
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.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
|
||||||
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
|
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
|
||||||
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
|
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 () => {
|
it('enforces per-user and author-only deletions plus reports', async () => {
|
||||||
const sent = await repository.sendText(alice, match, 'Moderate me');
|
const sent = await repository.sendText(alice, match, 'Moderate me');
|
||||||
await repository.deleteForSelf(bob, sent.id);
|
await repository.deleteForSelf(bob, sent.id);
|
||||||
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
|
expect((await repository.listMessages(bob, match)).messages.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(alice, match)).messages.some(item => item.id === sent.id)).toBe(true);
|
||||||
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
|
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
|
||||||
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
|
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
|
||||||
await repository.report(bob, sent.id, 'harassment');
|
await repository.report(bob, sent.id, 'harassment');
|
||||||
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
|
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 () => {
|
it('stores typing state with a short expiry and exposes only the partner state', async () => {
|
||||||
await repository.setTyping(alice, match, 4_000);
|
await repository.setTyping(alice, match, 4_000);
|
||||||
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
|
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten
|
|||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const repository: ChatRepository = {
|
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 } })),
|
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
|
||||||
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
|
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
|
||||||
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
|
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
|
||||||
@@ -43,6 +44,28 @@ describe('chat routes', () => {
|
|||||||
expect(repository.sendText).not.toHaveBeenCalled();
|
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 () => {
|
it('supports unread, typing, deletion and reporting actions', async () => {
|
||||||
const { app, repository } = setup();
|
const { app, repository } = setup();
|
||||||
expect((await app.request('/v1/chat/unread')).status).toBe(200);
|
expect((await app.request('/v1/chat/unread')).status).toBe(200);
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/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('Dare integration tests only permit localhost');
|
||||||
|
|
||||||
|
describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||||
|
const admin = new Pool({ connectionString: url });
|
||||||
|
const schema = `dares_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
let pool: Pool;
|
||||||
|
let repo: PostgresDaresRepository;
|
||||||
|
|
||||||
|
const sender = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const recipient = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const outsider = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const couple = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const otherCouple = '55555555-5555-4555-8555-555555555555';
|
||||||
|
const freePack = '66666666-6666-4666-8666-666666666666';
|
||||||
|
const premiumPack = '77777777-7777-4777-8777-777777777777';
|
||||||
|
const unreviewedPack = '88888888-8888-4888-8888-888888888888';
|
||||||
|
const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||||
|
const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||||
|
const unreviewedDare = '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 PostgresDaresRepository(isolated.toString());
|
||||||
|
|
||||||
|
const dir = new URL('../drizzle/', import.meta.url);
|
||||||
|
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
||||||
|
for (const name of files) {
|
||||||
|
const sql = await readFile(new URL(name, dir), 'utf8');
|
||||||
|
for (const statement of sql.split('--> statement-breakpoint')) {
|
||||||
|
if (statement.trim()) await pool.query(statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']);
|
||||||
|
await pool.query(
|
||||||
|
'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)',
|
||||||
|
[sender, recipient, outsider, couple, otherCouple],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`insert into dare_packs(id,name,is_premium,content_status) values
|
||||||
|
($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`,
|
||||||
|
[freePack, premiumPack, unreviewedPack],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`insert into dares(id,pack_id,text,intensity,content_status) values
|
||||||
|
($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`,
|
||||||
|
[freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await pool.query('delete from sent_dares');
|
||||||
|
await pool.query('delete from operations_outbox');
|
||||||
|
await pool.query('update profiles set is_premium=false');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await repo.close();
|
||||||
|
await pool.end();
|
||||||
|
await admin.query(`drop schema "${schema}" cascade`);
|
||||||
|
await admin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides unreviewed packs and dares from the catalogue', async () => {
|
||||||
|
const catalog = await repo.getCatalog(sender);
|
||||||
|
const ids = catalog.packs.map((pack) => pack.id);
|
||||||
|
expect(ids).toContain(freePack);
|
||||||
|
expect(ids).toContain(premiumPack);
|
||||||
|
expect(ids).not.toContain(unreviewedPack);
|
||||||
|
await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' });
|
||||||
|
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
||||||
|
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' });
|
||||||
|
|
||||||
|
// Premium is couple-shared: the partner paying unlocks the sender.
|
||||||
|
await pool.query('update profiles set is_premium=true where id=$1', [recipient]);
|
||||||
|
expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null });
|
||||||
|
await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1);
|
||||||
|
const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 });
|
||||||
|
expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the free weekly send allowance without blocking the response side', async () => {
|
||||||
|
for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) {
|
||||||
|
await repo.send(sender, { dare_id: freeDare });
|
||||||
|
}
|
||||||
|
expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0);
|
||||||
|
await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' });
|
||||||
|
|
||||||
|
// The quota is per-sender, so the recipient can still answer back.
|
||||||
|
const reply = await repo.send(recipient, { dare_id: freeDare });
|
||||||
|
expect(reply.direction).toBe('outgoing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent sends so the free allowance cannot be raced', async () => {
|
||||||
|
const attempts = await Promise.allSettled(
|
||||||
|
Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })),
|
||||||
|
);
|
||||||
|
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT);
|
||||||
|
const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]);
|
||||||
|
expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks the full loop and records each transition exactly once', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' });
|
||||||
|
expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' });
|
||||||
|
expect(dare.expires_at).not.toBeNull();
|
||||||
|
|
||||||
|
expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active');
|
||||||
|
expect((await repo.submit(recipient, dare.id)).status).toBe('submitted');
|
||||||
|
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||||
|
|
||||||
|
const events = await pool.query<{ dedupe_key: string; recipient_id: string }>(
|
||||||
|
"select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at",
|
||||||
|
);
|
||||||
|
expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']);
|
||||||
|
expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never puts dare text in a notification payload', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
const leaked = await pool.query<{ count: string }>(
|
||||||
|
"select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'",
|
||||||
|
);
|
||||||
|
expect(leaked.rows[0]!.count).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the dares notification preference', async () => {
|
||||||
|
await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]);
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]);
|
||||||
|
expect(events.rowCount).toBe(0);
|
||||||
|
// The sender has not opted out, so their side still fires.
|
||||||
|
await repo.respond(recipient, dare.id, 'decline');
|
||||||
|
const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]);
|
||||||
|
expect(senderEvents.rowCount).toBe(1);
|
||||||
|
await pool.query('delete from notification_preferences where user_id=$1', [recipient]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces actor and state on every transition', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' });
|
||||||
|
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||||
|
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||||
|
await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||||
|
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' });
|
||||||
|
expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled');
|
||||||
|
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps history intact when the source dare is deleted', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await pool.query('delete from dares where id=$1', [freeDare]);
|
||||||
|
const history = await repo.listDares(sender, 'active');
|
||||||
|
expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 });
|
||||||
|
await pool.query(
|
||||||
|
"insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')",
|
||||||
|
[freeDare, freePack],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes listing and stats to the couple', async () => {
|
||||||
|
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||||
|
await repo.respond(recipient, dare.id, 'accept');
|
||||||
|
await repo.submit(recipient, dare.id);
|
||||||
|
await repo.complete(sender, dare.id);
|
||||||
|
|
||||||
|
expect(await repo.listDares(outsider, 'active')).toEqual([]);
|
||||||
|
expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 });
|
||||||
|
expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 });
|
||||||
|
expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 });
|
||||||
|
|
||||||
|
const active = await repo.listDares(sender, 'active');
|
||||||
|
const history = await repo.listDares(sender, 'history');
|
||||||
|
expect(active).toEqual([]);
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
expect(history[0]!.direction).toBe('outgoing');
|
||||||
|
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects durations outside the offered presets', async () => {
|
||||||
|
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to send without a partner', async () => {
|
||||||
|
await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { AuthIdentity } from '../src/auth.js';
|
||||||
|
import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js';
|
||||||
|
import { registerDareRoutes } from '../src/domains/dares/routes.js';
|
||||||
|
|
||||||
|
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||||
|
const partner = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const dareId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const packId = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
||||||
|
|
||||||
|
const dare: SentDare = {
|
||||||
|
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||||
|
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||||
|
direction: 'outgoing', status: 'pending', sender_notes: null,
|
||||||
|
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||||
|
const repository: DaresRepository = {
|
||||||
|
getCatalog: vi.fn(async () => ({
|
||||||
|
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 },
|
||||||
|
packs: [],
|
||||||
|
})),
|
||||||
|
listPackDares: vi.fn(async () => []),
|
||||||
|
listDares: vi.fn(async () => [dare]),
|
||||||
|
send: vi.fn(async () => dare),
|
||||||
|
respond: vi.fn(async () => ({ ...dare, status: 'active' as const })),
|
||||||
|
submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })),
|
||||||
|
complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })),
|
||||||
|
cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })),
|
||||||
|
stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||||
|
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||||
|
registerDareRoutes(app, repository);
|
||||||
|
return { app, repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||||
|
return app.request(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dare routes', () => {
|
||||||
|
it('derives the actor from the bearer identity, never the body', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await post(app, '/v1/dares', {
|
||||||
|
dare_id: catalogueDareId,
|
||||||
|
sender_notes: ' be brave ',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects client-supplied ownership fields', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires exactly one of dare_id or custom text', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
expect((await post(app, '/v1/dares', {})).status).toBe(400);
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400);
|
||||||
|
expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts only preset durations', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201);
|
||||||
|
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates identifiers and filters', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400);
|
||||||
|
expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400);
|
||||||
|
expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400);
|
||||||
|
expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes each transition to its repository method', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||||
|
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||||
|
await post(app, `/v1/dares/${dareId}/submit`);
|
||||||
|
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
|
await post(app, `/v1/dares/${dareId}/complete`);
|
||||||
|
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
|
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||||
|
expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces domain errors with their status codes', async () => {
|
||||||
|
const { app } = setup({
|
||||||
|
send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }),
|
||||||
|
submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }),
|
||||||
|
});
|
||||||
|
const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' });
|
||||||
|
expect(paywalled.status).toBe(402);
|
||||||
|
expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } });
|
||||||
|
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the duration presets the send sheet offers', async () => {
|
||||||
|
const { app } = setup();
|
||||||
|
const response = await app.request('/v1/dares/durations');
|
||||||
|
expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,476 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import { join } from 'node:path';
|
import { join } from "node:path";
|
||||||
import { Pool } from 'pg';
|
import { Pool } from "pg";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { migrateDatabase, relationalParity } from '../src/migration/database.js';
|
import {
|
||||||
import { migrateStorage, fileDigest } from '../src/migration/storage.js';
|
migrateDatabase,
|
||||||
import type { MigrationCheckpoint } from '../src/migration/types.js';
|
relationalParity,
|
||||||
|
} from "../src/migration/database.js";
|
||||||
|
import { migrateStorage, fileDigest } from "../src/migration/storage.js";
|
||||||
|
import type { MigrationCheckpoint } from "../src/migration/types.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('Migration integration tests only permit localhost');
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
describe.skipIf(!databaseUrl||!local)('cutover migration fixtures',()=>{
|
const local = databaseUrl
|
||||||
const admin=new Pool({connectionString:databaseUrl});const suffix=randomUUID().replaceAll('-','');const sourceSchema=`migration_source_${suffix}`;const targetSchema=`migration_target_${suffix}`;const storageSchema=`migration_storage_${suffix}`;let source:Pool;let target:Pool;let mediaRoot:string;
|
? ["127.0.0.1", "localhost", "::1"].includes(new URL(databaseUrl).hostname)
|
||||||
const couple='11111111-1111-4111-8111-111111111111',user='22222222-2222-4222-8222-222222222222',pack='33333333-3333-4333-8333-333333333333',question='44444444-4444-4444-8444-444444444444',response='55555555-5555-4555-8555-555555555555',object='66666666-6666-4666-8666-666666666666';
|
: false;
|
||||||
const checkpoint=():MigrationCheckpoint=>({version:1,runId:randomUUID(),mode:'initial',completedTables:[],storage:{},updatedAt:new Date().toISOString()});
|
if (databaseUrl && !local)
|
||||||
beforeAll(async()=>{
|
throw new Error("Migration integration tests only permit localhost");
|
||||||
await admin.query(`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`);
|
describe.skipIf(!databaseUrl || !local)("cutover migration fixtures", () => {
|
||||||
const sourceUrl=new URL(databaseUrl!);sourceUrl.searchParams.set('options',`-c search_path=${sourceSchema}`);source=new Pool({connectionString:sourceUrl.toString()});
|
const admin = new Pool({ connectionString: databaseUrl });
|
||||||
const targetUrl=new URL(databaseUrl!);targetUrl.searchParams.set('options',`-c search_path=${targetSchema}`);target=new Pool({connectionString:targetUrl.toString()});
|
const suffix = randomUUID().replaceAll("-", "");
|
||||||
for(let index=0;index<=12;index++){const files=(await import('node:fs/promises')).readdir(new URL('../drizzle',import.meta.url));const name=(await files).find(file=>file.startsWith(`${String(index).padStart(4,'0')}_`));if(!name)continue;const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await target.query(statement);}
|
const sourceSchema = `migration_source_${suffix}`;
|
||||||
await source.query(`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);`);
|
const targetSchema = `migration_target_${suffix}`;
|
||||||
await source.query(`insert into couples values($1,'CUTOVER1','2024-01-01T00:00:00Z')`,[couple]);
|
const storageSchema = `migration_storage_${suffix}`;
|
||||||
await source.query(`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,[user,couple]);
|
let source: Pool;
|
||||||
await source.query('alter table profiles add column avatar_url text');
|
let target: Pool;
|
||||||
await source.query('update profiles set avatar_url=$2 where id=$1',[user,`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`]);
|
let mediaRoot: string;
|
||||||
await source.query(`insert into question_packs values($1,'Core',true,false,3.36,'2024-01-04T00:00:00Z')`,[pack]);
|
const couple = "11111111-1111-4111-8111-111111111111",
|
||||||
await source.query(`insert into questions values($1,$2,'Question',2,array['blindfold'],'2024-01-05T00:00:00Z')`,[question,pack]);
|
user = "22222222-2222-4222-8222-222222222222",
|
||||||
await source.query(`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,[response,user,question,couple]);
|
pack = "33333333-3333-4333-8333-333333333333",
|
||||||
await admin.query(`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`);
|
question = "44444444-4444-4444-8444-444444444444",
|
||||||
await admin.query(`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,[object,`${user}/avatar.png`,user,{size:4,mimetype:'image/png'}]);
|
inverseQuestion = "45454545-4545-4545-8545-454545454545",
|
||||||
mediaRoot=await mkdtemp(join(tmpdir(),'sauci-migration-'));
|
response = "55555555-5555-4555-8555-555555555555",
|
||||||
|
object = "66666666-6666-4666-8666-666666666666",
|
||||||
|
adminRow = "77777777-7777-4777-8777-777777777777",
|
||||||
|
category = "88888888-8888-4888-8888-888888888888",
|
||||||
|
review = "99999999-9999-4999-8999-999999999999",
|
||||||
|
audit = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
legacyUser = "a0000000-0000-0000-0000-000000000001",
|
||||||
|
sourceAppConfig = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
const checkpoint = (): MigrationCheckpoint => ({
|
||||||
|
version: 1,
|
||||||
|
runId: randomUUID(),
|
||||||
|
mode: "initial",
|
||||||
|
completedTables: [],
|
||||||
|
storage: {},
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
beforeAll(async () => {
|
||||||
|
await admin.query(
|
||||||
|
`create schema "${sourceSchema}"; create schema "${targetSchema}"; create schema "${storageSchema}"`,
|
||||||
|
);
|
||||||
|
const sourceUrl = new URL(databaseUrl!);
|
||||||
|
sourceUrl.searchParams.set("options", `-c search_path=${sourceSchema}`);
|
||||||
|
source = new Pool({ connectionString: sourceUrl.toString() });
|
||||||
|
const targetUrl = new URL(databaseUrl!);
|
||||||
|
targetUrl.searchParams.set("options", `-c search_path=${targetSchema}`);
|
||||||
|
target = new Pool({ connectionString: targetUrl.toString() });
|
||||||
|
for (let index = 0; index <= 18; index++) {
|
||||||
|
const files = (await import("node:fs/promises")).readdir(
|
||||||
|
new URL("../drizzle", import.meta.url),
|
||||||
|
);
|
||||||
|
const name = (await files).find((file) =>
|
||||||
|
file.startsWith(`${String(index).padStart(4, "0")}_`),
|
||||||
|
);
|
||||||
|
if (!name) continue;
|
||||||
|
const sql = await readFile(
|
||||||
|
new URL(`../drizzle/${name}`, import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const statement of sql.split("--> statement-breakpoint"))
|
||||||
|
if (statement.trim()) await target.query(statement);
|
||||||
|
}
|
||||||
|
await source.query(
|
||||||
|
`create table couples(id uuid primary key,invite_code text,created_at timestamptz);create table profiles(id uuid primary key,name text,email text,couple_id uuid,created_at timestamptz,updated_at timestamptz);create table admin_users(id uuid primary key,user_id uuid,role text,created_at timestamptz,created_by uuid,updated_at timestamptz,permissions jsonb);create table categories(id uuid primary key,name text,is_public boolean,sort_order int,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table question_packs(id uuid primary key,name text,is_public boolean,is_premium boolean,avg_intensity numeric(3,2),content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table questions(id uuid primary key,pack_id uuid,text text,intensity int,required_props text[],inverse_of uuid,content_status text,content_review_reason text,content_reviewed_at timestamptz,content_reviewed_by uuid,created_at timestamptz);create table content_reviews(id uuid primary key,entity_type text,entity_id uuid,previous_status text,new_status text,reason text,changed_by uuid,created_at timestamptz);create table responses(id uuid primary key,user_id uuid,question_id uuid,couple_id uuid,answer text,created_at timestamptz);create table feature_interests(id uuid primary key,user_id uuid,feature_name text,created_at timestamptz);create table live_draw_sessions(id uuid primary key,couple_id uuid,strokes jsonb,updated_at timestamptz,created_at timestamptz);create table audit_logs(id uuid primary key,table_name text,record_id uuid,action text,old_values jsonb,new_values jsonb,changed_fields text[],admin_user_id uuid,admin_role text,created_at timestamptz,admin_username text);create table app_config(id uuid primary key,answer_gap_threshold int,daily_response_limit int,updated_at timestamptz);`,
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into couples values($1,'e5d3012e','2024-01-01T00:00:00Z')`,
|
||||||
|
[couple],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into app_config values($1,99,99,'2024-01-01T00:00:00Z')`,
|
||||||
|
[sourceAppConfig],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into profiles values($1,'Alice','alice@test',$2,'2024-01-02T00:00:00Z','2024-01-03T00:00:00Z')`,
|
||||||
|
[user, couple],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into admin_users values($1,$2,'super_admin',now(),null,now(),$3::jsonb)`,
|
||||||
|
[adminRow, user, JSON.stringify(["manage_packs"])],
|
||||||
|
);
|
||||||
|
await source.query("alter table profiles add column avatar_url text");
|
||||||
|
await source.query("update profiles set avatar_url=$2 where id=$1", [
|
||||||
|
user,
|
||||||
|
`https://source.test/storage/v1/object/public/avatars/${user}/avatar.png`,
|
||||||
|
]);
|
||||||
|
await source.query(
|
||||||
|
`insert into categories values($1,'Core',true,1,'allowed','Reviewed',null,null,'2024-01-03T00:00:00Z')`,
|
||||||
|
[category],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into question_packs values($1,'Core',true,false,3.36,'allowed','Reviewed',null,null,'2024-01-04T00:00:00Z')`,
|
||||||
|
[pack],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into questions values($1,$2,'Question',2,array['blindfold'],$3,'allowed','Reviewed',null,null,'2024-01-05T00:00:00Z'),($3,$2,'Inverse',2,array[]::text[],null,'allowed','Reviewed',null,null,'2024-01-05T00:00:01Z')`,
|
||||||
|
[question, pack, inverseQuestion],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into content_reviews values($1,'categories',$2,'unreviewed','allowed','Reviewed',null,'2024-01-03T00:00:00Z')`,
|
||||||
|
[review, category],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into responses values($1,$2,$3,$4,'yes','2024-01-06T00:00:00Z')`,
|
||||||
|
[response, user, question, couple],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into feature_interests values(gen_random_uuid(),$1,'daily_dares','2024-01-07T00:00:00Z')`,
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into live_draw_sessions values(gen_random_uuid(),$1,'[]'::jsonb,'2024-01-08T00:00:00Z','2024-01-08T00:00:00Z')`,
|
||||||
|
[couple],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
`insert into audit_logs values($1,'categories',$2,'UPDATE',null,$3::jsonb,array['content_status'],$4,'super_admin','2024-01-09T00:00:00Z','alice')`,
|
||||||
|
[audit, category, JSON.stringify({ content_status: "allowed" }), user],
|
||||||
|
);
|
||||||
|
await admin.query(
|
||||||
|
`create table "${storageSchema}".objects(id uuid primary key,bucket_id text,name text,owner_id uuid,metadata jsonb,created_at timestamptz,updated_at timestamptz)`,
|
||||||
|
);
|
||||||
|
await admin.query(
|
||||||
|
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now())`,
|
||||||
|
[object, `${user}/avatar.png`, user, { size: 4, mimetype: "image/png" }],
|
||||||
|
);
|
||||||
|
mediaRoot = await mkdtemp(join(tmpdir(), "sauci-migration-"));
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all([source?.end(), target?.end()]);
|
||||||
|
await admin.query(
|
||||||
|
`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`,
|
||||||
|
);
|
||||||
|
await admin.end();
|
||||||
|
if (mediaRoot) await rm(mediaRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
it("preserves IDs, schema mappings, catalogue reviews, timestamps, side-effect suppression, and FK parity", async () => {
|
||||||
|
const state = checkpoint();
|
||||||
|
const result = await migrateDatabase(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
finalSync: false,
|
||||||
|
prune: false,
|
||||||
|
checkpoint: state,
|
||||||
|
});
|
||||||
|
expect(result.find((row) => row.table === "responses")).toMatchObject({
|
||||||
|
sourceRows: 1,
|
||||||
|
targetRows: 1,
|
||||||
|
status: "equal",
|
||||||
|
});
|
||||||
|
expect(result.find((row) => row.table === "app_config")).toMatchObject({
|
||||||
|
sourceRows: 1,
|
||||||
|
targetRows: 1,
|
||||||
|
importedRows: 0,
|
||||||
|
status: "equal",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await target.query("select id from app_config")).rows[0].id,
|
||||||
|
).not.toBe(sourceAppConfig);
|
||||||
|
const row = await target.query("select id,created_at from responses");
|
||||||
|
expect(row.rows[0].id).toBe(response);
|
||||||
|
expect(new Date(row.rows[0].created_at).toISOString()).toBe(
|
||||||
|
"2024-01-06T00:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query("select invite_code from couples where id=$1", [
|
||||||
|
couple,
|
||||||
|
])
|
||||||
|
).rows[0].invite_code,
|
||||||
|
).toBe("E5D3012E");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select avg_intensity,content_status from question_packs where id=$1",
|
||||||
|
[pack],
|
||||||
|
)
|
||||||
|
).rows[0],
|
||||||
|
).toMatchObject({ avg_intensity: "3.36", content_status: "allowed" });
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select required_props,inverse_of,content_status from questions where id=$1",
|
||||||
|
[question],
|
||||||
|
)
|
||||||
|
).rows[0],
|
||||||
|
).toMatchObject({
|
||||||
|
required_props: ["blindfold"],
|
||||||
|
inverse_of: inverseQuestion,
|
||||||
|
content_status: "allowed",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select feature from feature_interests where user_id=$1",
|
||||||
|
[user],
|
||||||
|
)
|
||||||
|
).rows[0].feature,
|
||||||
|
).toBe("daily_dares");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select updated_by from live_draw_sessions where couple_id=$1",
|
||||||
|
[couple],
|
||||||
|
)
|
||||||
|
).rows[0].updated_by,
|
||||||
|
).toBe(user);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select actor_user_id,admin_user_id,changed_fields,admin_role,admin_username from audit_logs where id=$1",
|
||||||
|
[audit],
|
||||||
|
)
|
||||||
|
).rows[0],
|
||||||
|
).toMatchObject({
|
||||||
|
actor_user_id: user,
|
||||||
|
admin_user_id: adminRow,
|
||||||
|
changed_fields: ["content_status"],
|
||||||
|
admin_role: "super_admin",
|
||||||
|
admin_username: "alice",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select count(*)::int count from content_reviews where id=$1",
|
||||||
|
[review],
|
||||||
|
)
|
||||||
|
).rows[0].count,
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
(await target.query("select count(*)::int count from operations_outbox"))
|
||||||
|
.rows[0].count,
|
||||||
|
).toBe(0);
|
||||||
|
expect(await relationalParity(target)).toEqual({
|
||||||
|
profiles_without_couple: 0,
|
||||||
|
responses_with_missing_owner: 0,
|
||||||
|
matches_with_missing_relation: 0,
|
||||||
|
messages_with_missing_relation: 0,
|
||||||
|
couple_mismatch_responses: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps a newer live profile during an initial merge", async () => {
|
||||||
|
const liveCouple = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||||
|
await target.query(
|
||||||
|
"insert into couples(id,invite_code,created_at) values($1,'LIVE0001',now())",
|
||||||
|
[liveCouple],
|
||||||
|
);
|
||||||
|
await target.query(
|
||||||
|
"update profiles set name='Live Alice',couple_id=$2,updated_at='2026-08-28T00:00:00Z' where id=$1",
|
||||||
|
[user, liveCouple],
|
||||||
|
);
|
||||||
|
await migrateDatabase(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
finalSync: false,
|
||||||
|
prune: false,
|
||||||
|
checkpoint: checkpoint(),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select name,couple_id,updated_at from profiles where id=$1",
|
||||||
|
[user],
|
||||||
|
)
|
||||||
|
).rows[0],
|
||||||
|
).toMatchObject({ name: "Live Alice", couple_id: couple });
|
||||||
|
});
|
||||||
|
it("dry-run reads and reports without changing the target", async () => {
|
||||||
|
await source.query(
|
||||||
|
"update profiles set name='Changed at source' where id=$1",
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
await migrateDatabase(source, target, {
|
||||||
|
dryRun: true,
|
||||||
|
finalSync: false,
|
||||||
|
prune: false,
|
||||||
|
checkpoint: checkpoint(),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await target.query("select name from profiles where id=$1", [user]))
|
||||||
|
.rows[0].name,
|
||||||
|
).toBe("Live Alice");
|
||||||
|
await source.query("update profiles set name='Alice' where id=$1", [user]);
|
||||||
|
});
|
||||||
|
it("is resumable and a stopped-source prune restores exact row parity", async () => {
|
||||||
|
const state = checkpoint();
|
||||||
|
state.completedTables = ["couples"];
|
||||||
|
await target.query(
|
||||||
|
`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`,
|
||||||
|
);
|
||||||
|
const resumed = await migrateDatabase(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
finalSync: false,
|
||||||
|
prune: false,
|
||||||
|
checkpoint: state,
|
||||||
|
});
|
||||||
|
expect(resumed.find((row) => row.table === "couples")?.note).toContain(
|
||||||
|
"resumed from checkpoint",
|
||||||
|
);
|
||||||
|
const final = await migrateDatabase(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
finalSync: true,
|
||||||
|
prune: true,
|
||||||
|
checkpoint: checkpoint(),
|
||||||
|
});
|
||||||
|
expect(final.find((row) => row.table === "couples")).toMatchObject({
|
||||||
|
sourceRows: 1,
|
||||||
|
targetRows: 1,
|
||||||
|
prunedRows: 2,
|
||||||
|
status: "equal",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("copies storage atomically, quarantines unattributable legacy objects, rewrites references, verifies checkpoints, and prunes source-deleted files", async () => {
|
||||||
|
const legacyAvatar = randomUUID(),
|
||||||
|
orphanChat = randomUUID(),
|
||||||
|
unmatchedChat = randomUUID(),
|
||||||
|
feedbackObject = randomUUID(),
|
||||||
|
feedbackId = randomUUID();
|
||||||
|
await source.query(
|
||||||
|
"insert into profiles values($1,$2,$3,$4,now(),now(),null)",
|
||||||
|
[legacyUser, "Legacy", "legacy@test", couple],
|
||||||
|
);
|
||||||
|
await target.query(
|
||||||
|
"insert into profiles(id,name,email,couple_id,created_at,updated_at) values($1,$2,$3,$4,now(),now())",
|
||||||
|
[legacyUser, "Legacy", "legacy@test", couple],
|
||||||
|
);
|
||||||
|
await source.query(
|
||||||
|
"create table messages(user_id uuid,match_id uuid,media_path text);create table feedback(id uuid primary key,user_id uuid,screenshot_url text)",
|
||||||
|
);
|
||||||
|
await source.query("insert into feedback values($1,$2,$3)", [
|
||||||
|
feedbackId,
|
||||||
|
user,
|
||||||
|
`https://source.test/storage/v1/object/public/feedback-screenshots/${user}/shot.jpg`,
|
||||||
|
]);
|
||||||
|
await target.query(
|
||||||
|
"insert into feedback(id,user_id,type,title,description) values($1,$2,'bug','Screenshot','Details')",
|
||||||
|
[feedbackId, user],
|
||||||
|
);
|
||||||
|
await admin.query(
|
||||||
|
`insert into "${storageSchema}".objects values($1,'avatars',$2,$3,$4,now(),now()),($5,'chat-media',$6,null,$4,now(),now()),($7,'chat-media',$8,$9,$4,now(),now()),($10,'feedback-screenshots',$11,$9,$4,now(),now())`,
|
||||||
|
[
|
||||||
|
legacyAvatar,
|
||||||
|
`${legacyUser}/legacy.jpg`,
|
||||||
|
legacyUser,
|
||||||
|
{ size: 4, mimetype: "image/jpeg" },
|
||||||
|
orphanChat,
|
||||||
|
`${randomUUID()}/orphan.jpg`,
|
||||||
|
unmatchedChat,
|
||||||
|
`${randomUUID()}/chat.jpg`,
|
||||||
|
user,
|
||||||
|
feedbackObject,
|
||||||
|
`${user}/shot.jpg`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const state = checkpoint();
|
||||||
|
const first = await migrateStorage(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
mediaRoot,
|
||||||
|
storageSchema,
|
||||||
|
checkpoint: state,
|
||||||
|
download: async () => new Uint8Array([1, 2, 3, 4]),
|
||||||
|
});
|
||||||
|
expect(first.failures).toEqual([]);
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
sourceFiles: 5,
|
||||||
|
targetFiles: 5,
|
||||||
|
copiedFiles: 5,
|
||||||
|
quarantinedFiles: 1,
|
||||||
|
failedFiles: 0,
|
||||||
|
missingOnDisk: [],
|
||||||
|
});
|
||||||
|
const path = join(mediaRoot, "avatars", user, "avatar.png");
|
||||||
|
expect(await fileDigest(path)).toBe(
|
||||||
|
"9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select owner_id,kind,byte_size from media_objects where id=$1",
|
||||||
|
[object],
|
||||||
|
)
|
||||||
|
).rows[0],
|
||||||
|
).toMatchObject({ owner_id: user, kind: "avatar", byte_size: 4 });
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query("select owner_id from media_objects where id=$1", [
|
||||||
|
legacyAvatar,
|
||||||
|
])
|
||||||
|
).rows[0].owner_id,
|
||||||
|
).toBe(legacyUser);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query("select match_id from media_objects where id=$1", [
|
||||||
|
unmatchedChat,
|
||||||
|
])
|
||||||
|
).rows[0].match_id,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select reason from legacy_media_quarantine where id=$1",
|
||||||
|
[orphanChat],
|
||||||
|
)
|
||||||
|
).rowCount,
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query(
|
||||||
|
"select screenshot_media_id from feedback where id=$1",
|
||||||
|
[feedbackId],
|
||||||
|
)
|
||||||
|
).rows[0].screenshot_media_id,
|
||||||
|
).toBe(feedbackObject);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await target.query("select avatar_url from profiles where id=$1", [
|
||||||
|
user,
|
||||||
|
])
|
||||||
|
).rows[0].avatar_url,
|
||||||
|
).toBe(`media:${object}`);
|
||||||
|
const resumed = await migrateStorage(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
mediaRoot,
|
||||||
|
storageSchema,
|
||||||
|
checkpoint: state,
|
||||||
|
download: async () => {
|
||||||
|
throw new Error("must not redownload");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resumed).toMatchObject({
|
||||||
|
copiedFiles: 0,
|
||||||
|
skippedFiles: 5,
|
||||||
|
failedFiles: 0,
|
||||||
|
});
|
||||||
|
const stale = "77777777-7777-4777-8777-777777777777";
|
||||||
|
const staleKey = `avatars/${user}/stale.png`;
|
||||||
|
await target.query(
|
||||||
|
"insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",
|
||||||
|
[stale, user, couple, staleKey],
|
||||||
|
);
|
||||||
|
await writeFile(join(mediaRoot, staleKey), new Uint8Array([9]));
|
||||||
|
await admin.query(
|
||||||
|
`delete from "${storageSchema}".objects where id=$1`,
|
||||||
|
[orphanChat],
|
||||||
|
);
|
||||||
|
const pruned = await migrateStorage(source, target, {
|
||||||
|
dryRun: false,
|
||||||
|
prune: true,
|
||||||
|
mediaRoot,
|
||||||
|
storageSchema,
|
||||||
|
checkpoint: state,
|
||||||
|
download: async () => {
|
||||||
|
throw new Error("must not redownload");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(pruned).toMatchObject({
|
||||||
|
sourceFiles: 4,
|
||||||
|
targetFiles: 4,
|
||||||
|
prunedFiles: 2,
|
||||||
|
skippedFiles: 4,
|
||||||
|
failedFiles: 0,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await target.query("select 1 from legacy_media_quarantine where id=$1", [orphanChat])).rowCount,
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
afterAll(async()=>{await Promise.all([source?.end(),target?.end()]);await admin.query(`drop schema if exists "${storageSchema}" cascade;drop schema if exists "${sourceSchema}" cascade;drop schema if exists "${targetSchema}" cascade`);await admin.end();if(mediaRoot)await rm(mediaRoot,{recursive:true,force:true});});
|
|
||||||
it('preserves IDs, precision, catalogue fields, timestamps, side-effect suppression, and FK parity',async()=>{const state=checkpoint();const result=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(result.find(row=>row.table==='responses')).toMatchObject({sourceRows:1,targetRows:1,status:'equal'});const row=await target.query('select id,created_at from responses');expect(row.rows[0].id).toBe(response);expect(new Date(row.rows[0].created_at).toISOString()).toBe('2024-01-06T00:00:00.000Z');expect((await target.query('select avg_intensity from question_packs where id=$1',[pack])).rows[0].avg_intensity).toBe('3.36');expect((await target.query('select required_props from questions where id=$1',[question])).rows[0].required_props).toEqual(['blindfold']);expect((await target.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(0);expect(await relationalParity(target)).toEqual({profiles_without_couple:0,responses_with_missing_owner:0,matches_with_missing_relation:0,messages_with_missing_relation:0,couple_mismatch_responses:0});});
|
|
||||||
it('dry-run reads and reports without changing the target',async()=>{await source.query("update profiles set name='Changed at source' where id=$1",[user]);await migrateDatabase(source,target,{dryRun:true,finalSync:false,prune:false,checkpoint:checkpoint()});expect((await target.query('select name from profiles where id=$1',[user])).rows[0].name).toBe('Alice');await source.query("update profiles set name='Alice' where id=$1",[user]);});
|
|
||||||
it('is resumable and a stopped-source prune restores exact row parity',async()=>{const state=checkpoint();state.completedTables=['couples'];await target.query(`insert into couples(id,invite_code,created_at) values('77777777-7777-4777-8777-777777777777','STALE001',now())`);const resumed=await migrateDatabase(source,target,{dryRun:false,finalSync:false,prune:false,checkpoint:state});expect(resumed.find(row=>row.table==='couples')?.note).toBe('resumed from checkpoint');const final=await migrateDatabase(source,target,{dryRun:false,finalSync:true,prune:true,checkpoint:checkpoint()});expect(final.find(row=>row.table==='couples')).toMatchObject({sourceRows:1,targetRows:1,prunedRows:1,status:'equal'});});
|
|
||||||
it('copies storage atomically, rewrites legacy references, verifies checkpoints, and prunes source-deleted files',async()=>{const state=checkpoint();const first=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>new Uint8Array([1,2,3,4])});expect(first).toMatchObject({sourceFiles:1,targetFiles:1,copiedFiles:1,failedFiles:0,missingOnDisk:[]});const path=join(mediaRoot,'avatars',user,'avatar.png');expect(await fileDigest(path)).toBe('9f64a747e1b97f131fabb6b447296c9b6f0201e79fb3c5356e6c77e89b6a806a');expect((await target.query('select owner_id,kind,byte_size from media_objects where id=$1',[object])).rows[0]).toMatchObject({owner_id:user,kind:'avatar',byte_size:4});expect((await target.query('select avatar_url from profiles where id=$1',[user])).rows[0].avatar_url).toBe(`media:${object}`);const resumed=await migrateStorage(source,target,{dryRun:false,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(resumed).toMatchObject({copiedFiles:0,skippedFiles:1,failedFiles:0});const stale='77777777-7777-4777-8777-777777777777';const staleKey=`avatars/${user}/stale.png`;await target.query("insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,'avatar',$4,'image/png',1)",[stale,user,couple,staleKey]);await writeFile(join(mediaRoot,staleKey),new Uint8Array([9]));const pruned=await migrateStorage(source,target,{dryRun:false,prune:true,mediaRoot,storageSchema,checkpoint:state,download:async()=>{throw new Error('must not redownload');}});expect(pruned).toMatchObject({sourceFiles:1,targetFiles:1,prunedFiles:1,skippedFiles:1,failedFiles:0});});
|
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
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;
|
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');
|
if(databaseUrl&&!local)throw new Error('Operations integration tests only permit a localhost DATABASE_URL');
|
||||||
@@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
|
|||||||
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
||||||
beforeAll(async()=>{
|
beforeAll(async()=>{
|
||||||
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
|
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.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','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql']){
|
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','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){
|
||||||
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
|
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
|
||||||
}
|
}
|
||||||
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
|
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
|
||||||
@@ -68,9 +68,56 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
|
|||||||
await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before);
|
await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('nudges only the partner who still owes an answer, in the couple evening',async()=>{
|
||||||
|
// Alice sorts below Bob, so Alice is user1. She has answered today; Bob has not.
|
||||||
|
await pool.query("update profiles set timezone='UTC' where couple_id=$1",[couple]);
|
||||||
|
const evening=new Date('2026-08-27T21:00:00Z');
|
||||||
|
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,last_active_date,last_completed_date,user1_answered_today,user2_answered_today,streak_celebrated_at)
|
||||||
|
values($1,6,6,'2026-08-27','2026-08-26',true,false,6) on conflict(couple_id) do update set current_streak=6,longest_streak=6,
|
||||||
|
last_active_date='2026-08-27',last_completed_date='2026-08-26',user1_answered_today=true,user2_answered_today=false,streak_celebrated_at=6`,[couple]);
|
||||||
|
|
||||||
|
expect((await repo.produce(evening,100)).streakReminders).toBe(1);
|
||||||
|
const queued=await pool.query<{recipient_id:string;title:string;body:string}>(
|
||||||
|
`select recipient_id,payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like 'streak_risk:%'`);
|
||||||
|
expect(queued.rows).toHaveLength(1);
|
||||||
|
expect(queued.rows[0]).toMatchObject({recipient_id:bob,title:'Alice answered today'});
|
||||||
|
expect(queued.rows[0].body).toContain('6 days in');
|
||||||
|
expect((await pool.query('select 1 from operations_outbox where dedupe_key=$1',[`streak_risk:${couple}:2026-08-27:${bob}`])).rowCount).toBe(1);
|
||||||
|
|
||||||
|
// A second tick inside the same couple-local evening must not queue a second nudge.
|
||||||
|
expect((await repo.produce(evening,100)).streakReminders).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addresses both partners when neither has answered and honours the preference',async()=>{
|
||||||
|
const next=new Date('2026-08-28T21:00:00Z');
|
||||||
|
await pool.query(`update couple_streaks set last_active_date='2026-08-27',last_completed_date='2026-08-27',
|
||||||
|
user1_answered_today=true,user2_answered_today=true where couple_id=$1`,[couple]);
|
||||||
|
expect((await repo.produce(next,100)).streakReminders).toBe(2);
|
||||||
|
const both=await pool.query<{title:string;body:string}>(
|
||||||
|
`select payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like $1 order by recipient_id`,
|
||||||
|
[`streak_risk:${couple}:2026-08-28:%`]);
|
||||||
|
expect(both.rows.map(r=>r.title)).toEqual(['Your 6-day streak','Your 6-day streak']);
|
||||||
|
expect(both.rows[0].body).toContain('One question each');
|
||||||
|
|
||||||
|
await pool.query(`insert into notification_preferences(user_id,streak_reminders_enabled) values($1,false),($2,false)
|
||||||
|
on conflict(user_id) do update set streak_reminders_enabled=false`,[alice,bob]);
|
||||||
|
await pool.query(`update couple_streaks set last_active_date='2026-08-28',last_completed_date='2026-08-28' where couple_id=$1`,[couple]);
|
||||||
|
expect((await repo.produce(new Date('2026-08-29T21:00:00Z'),100)).streakReminders).toBe(0);
|
||||||
|
await pool.query('update notification_preferences set streak_reminders_enabled=true where user_id in ($1,$2)',[alice,bob]);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{
|
it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{
|
||||||
await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]);
|
await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]);
|
||||||
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);
|
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();
|
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(); }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,9 @@ import { OperationsRunner } from '../src/domains/operations/runner.js';
|
|||||||
import type { OperationItem } from '../src/domains/operations/types.js';
|
import type { OperationItem } from '../src/domains/operations/types.js';
|
||||||
import { loadWorkerConfig } from '../src/workers/config.js';
|
import { loadWorkerConfig } from '../src/workers/config.js';
|
||||||
|
|
||||||
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
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 {
|
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)};
|
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,
|
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});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -20,9 +20,11 @@ const preferences = {
|
|||||||
pack_changes_enabled: true,
|
pack_changes_enabled: true,
|
||||||
new_packs_enabled: true,
|
new_packs_enabled: true,
|
||||||
streak_milestones_enabled: true,
|
streak_milestones_enabled: true,
|
||||||
|
streak_reminders_enabled: true,
|
||||||
weekly_summary_enabled: true,
|
weekly_summary_enabled: true,
|
||||||
unpaired_reminders_enabled: true,
|
unpaired_reminders_enabled: true,
|
||||||
catchup_reminders_enabled: true,
|
catchup_reminders_enabled: true,
|
||||||
|
dares_enabled: true,
|
||||||
created_at: '2026-08-27T10:00:00.000Z',
|
created_at: '2026-08-27T10:00:00.000Z',
|
||||||
updated_at: '2026-08-27T10:00:00.000Z',
|
updated_at: '2026-08-27T10:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WorkerLifecycle } from '../src/workers/lifecycle.js';
|
||||||
|
|
||||||
|
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((next) => { resolve = next; }); return { promise, resolve }; }
|
||||||
|
|
||||||
|
describe('WorkerLifecycle', () => {
|
||||||
|
it('reuses one shutdown promise, awaits the active tick, and closes once', async () => {
|
||||||
|
const tick = deferred<void>(); 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<void>(() => 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<void>(() => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,5 +5,16 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
exclude: ['test/**/*.integration.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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -5,5 +5,16 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['test/**/*.integration.test.ts'],
|
include: ['test/**/*.integration.test.ts'],
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
reporter: ['text', 'json-summary', 'lcov'],
|
||||||
|
thresholds: {
|
||||||
|
statements: 55,
|
||||||
|
branches: 65,
|
||||||
|
functions: 65,
|
||||||
|
lines: 55,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -14,6 +14,7 @@ react {
|
|||||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
|
||||||
|
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||||
// works correctly with Expo projects.
|
// works correctly with Expo projects.
|
||||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||||
@@ -63,9 +64,9 @@ react {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
* Set this to true in release builds to optimize the app using R8.
|
||||||
*/
|
*/
|
||||||
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
|
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preferred build flavor of JavaScriptCore (JSC)
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
@@ -78,7 +79,7 @@ def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInRelea
|
|||||||
* give correct results when using with locales other than en-US. Note that
|
* give correct results when using with locales other than en-US. Note that
|
||||||
* this variant is about 6MiB larger per architecture than default.
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
*/
|
*/
|
||||||
def jscFlavor = 'org.webkit:android-jsc:+'
|
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
ndkVersion rootProject.ext.ndkVersion
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
@@ -91,8 +92,9 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 45
|
versionCode 47
|
||||||
versionName "1.0.5"
|
versionName "1.0.5"
|
||||||
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
@@ -110,15 +112,18 @@ android {
|
|||||||
// Caution! In production, you need to generate your own keystore file.
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
// see https://reactnative.dev/docs/signed-apk-android.
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||||
minifyEnabled enableProguardInReleaseBuilds
|
shrinkResources enableShrinkResources.toBoolean()
|
||||||
|
minifyEnabled enableMinifyInReleaseBuilds
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
|
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||||
|
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||||
|
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
@@ -156,15 +161,15 @@ dependencies {
|
|||||||
|
|
||||||
if (isGifEnabled) {
|
if (isGifEnabled) {
|
||||||
// For animated gif support
|
// For animated gif support
|
||||||
implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}")
|
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWebpEnabled) {
|
if (isWebpEnabled) {
|
||||||
// For webp support
|
// For webp support
|
||||||
implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}")
|
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||||
if (isWebpAnimatedEnabled) {
|
if (isWebpAnimatedEnabled) {
|
||||||
// Animated webp support
|
// Animated webp support
|
||||||
implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}")
|
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:enableOnBackInvokedCallback="false">
|
||||||
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
|
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||||
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
|
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
|
||||||
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
|
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import com.facebook.react.ReactApplication
|
|||||||
import com.facebook.react.ReactNativeHost
|
import com.facebook.react.ReactNativeHost
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ReactHost
|
import com.facebook.react.ReactHost
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.common.ReleaseLevel
|
||||||
import com.facebook.soloader.SoLoader
|
|
||||||
|
|
||||||
import expo.modules.ApplicationLifecycleDispatcher
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
import expo.modules.ReactNativeHostWrapper
|
import expo.modules.ReactNativeHostWrapper
|
||||||
@@ -42,11 +42,12 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
} catch (e: IllegalArgumentException) {
|
||||||
load()
|
ReleaseLevel.STABLE
|
||||||
}
|
}
|
||||||
|
loadReactNative(this)
|
||||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
|
||||||
<item name="android:textColor">@android:color/black</item>
|
<item name="android:textColor">@android:color/black</item>
|
||||||
<item name="android:editTextStyle">@style/ResetEditText</item>
|
<item name="android:editTextStyle">@style/ResetEditText</item>
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
|
||||||
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
|
|
||||||
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
|
|
||||||
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
|
|
||||||
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
|
|
||||||
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
|
||||||
|
|
||||||
ndkVersion = "26.1.10909125"
|
|
||||||
}
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -19,23 +10,15 @@ buildscript {
|
|||||||
classpath 'com.google.gms:google-services:4.4.1'
|
classpath 'com.google.gms:google-services:4.4.1'
|
||||||
classpath('com.android.tools.build:gradle')
|
classpath('com.android.tools.build:gradle')
|
||||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply plugin: "expo-root-project"
|
||||||
apply plugin: "com.facebook.react.rootproject"
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
|
||||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
|
||||||
url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
|
|
||||||
}
|
|
||||||
maven {
|
|
||||||
// Android JSC is installed from npm
|
|
||||||
url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
|
|
||||||
}
|
|
||||||
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url 'https://www.jitpack.io' }
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
|||||||
# your application. You should enable this flag either if you want
|
# your application. You should enable this flag either if you want
|
||||||
# to write custom TurboModules/Fabric components OR use libraries that
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
# are providing them.
|
# are providing them.
|
||||||
newArchEnabled=false
|
newArchEnabled=true
|
||||||
|
|
||||||
# Use this property to enable or disable the Hermes JS engine.
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
# If set to false, you will be using JSC instead.
|
# If set to false, you will be using JSC instead.
|
||||||
@@ -55,6 +55,16 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
|||||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
android.targetSdkVersion=35
|
android.targetSdkVersion=36
|
||||||
android.kotlinVersion=1.9.25
|
|
||||||
android.extraMavenRepos=[]
|
android.extraMavenRepos=[]
|
||||||
|
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
|
||||||
|
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
|
||||||
|
expo.edgeToEdgeEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable edge-to-edge display support.
|
||||||
|
# This allows your app to draw behind system bars for an immersive UI.
|
||||||
|
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||||
|
edgeToEdgeEnabled=true
|
||||||
|
|
||||||
|
android.compileSdkVersion=36
|
||||||
|
android.buildToolsVersion=36.0.0
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,38 +1,39 @@
|
|||||||
pluginManagement {
|
pluginManagement {
|
||||||
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString())
|
def reactNativeGradlePlugin = new File(
|
||||||
|
providers.exec {
|
||||||
|
workingDir(rootDir)
|
||||||
|
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||||
|
}.standardOutput.asText.get().trim()
|
||||||
|
).getParentFile().absolutePath
|
||||||
|
includeBuild(reactNativeGradlePlugin)
|
||||||
|
|
||||||
|
def expoPluginsPath = new File(
|
||||||
|
providers.exec {
|
||||||
|
workingDir(rootDir)
|
||||||
|
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||||
|
}.standardOutput.asText.get().trim(),
|
||||||
|
"../android/expo-gradle-plugin"
|
||||||
|
).absolutePath
|
||||||
|
includeBuild(expoPluginsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.facebook.react.settings")
|
||||||
|
id("expo-autolinking-settings")
|
||||||
}
|
}
|
||||||
plugins { id("com.facebook.react.settings") }
|
|
||||||
|
|
||||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||||
ex.autolinkLibrariesFromCommand()
|
ex.autolinkLibrariesFromCommand()
|
||||||
} else {
|
} else {
|
||||||
def command = [
|
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||||
'node',
|
|
||||||
'--no-warnings',
|
|
||||||
'--eval',
|
|
||||||
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
|
|
||||||
'react-native-config',
|
|
||||||
'--json',
|
|
||||||
'--platform',
|
|
||||||
'android'
|
|
||||||
].toList()
|
|
||||||
ex.autolinkLibrariesFromCommand(command)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'Sauci'
|
rootProject.name = 'Sauci'
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
expoAutolinking.useExpoModules()
|
||||||
versionCatalogs {
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
reactAndroidLibs {
|
|
||||||
from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
|
|
||||||
useExpoModules()
|
|
||||||
|
|
||||||
include ':app'
|
include ':app'
|
||||||
includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
|
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": false,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"group.com.sauci.app"
|
"group.com.sauci.app"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"buildNumber": "42",
|
"buildNumber": "43",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"googleServicesFile": "./GoogleService-Info.plist",
|
"googleServicesFile": "./GoogleService-Info.plist",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.",
|
"NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.",
|
||||||
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
|
"NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner."
|
||||||
},
|
},
|
||||||
"privacyManifest": {
|
"privacyManifests": {
|
||||||
"NSPrivacyTracking": false,
|
"NSPrivacyTracking": false,
|
||||||
"NSPrivacyCollectedDataTypes": [
|
"NSPrivacyCollectedDataTypes": [
|
||||||
{
|
{
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 45,
|
"versionCode": 47,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -205,8 +205,9 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"android": {
|
"android": {
|
||||||
"targetSdkVersion": 35,
|
"buildToolsVersion": "36.0.0",
|
||||||
"kotlinVersion": "1.9.25",
|
"compileSdkVersion": 36,
|
||||||
|
"targetSdkVersion": 36,
|
||||||
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||||
"useLegacyPackaging": false,
|
"useLegacyPackaging": false,
|
||||||
"manifestPlaceholders": {
|
"manifestPlaceholders": {
|
||||||
|
|||||||
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user