mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3659cc8c2f | ||
|
|
4b227ad62c | ||
|
|
7c8d91c89c | ||
|
|
6883befff3 | ||
|
|
636811e66a | ||
|
|
d825e3d80c | ||
|
|
13094267a6 | ||
|
|
ecc31a13a2 | ||
|
|
7a76936829 | ||
|
|
9609a9084b | ||
|
|
90cde4aca4 | ||
|
|
ecff9e2ec8 | ||
|
|
ffc655f576 | ||
|
|
d8bedead0c | ||
|
|
6c3a181613 | ||
|
|
d8ff39a2f3 | ||
|
|
b8cb2addff | ||
|
|
5e28117662 | ||
|
|
196428f796 | ||
|
|
6bd04a6c06 | ||
|
|
7aca39aa8f | ||
|
|
79233f58aa | ||
|
|
c9e8835810 | ||
|
|
ff8f99f418 | ||
|
|
604eec3322 | ||
|
|
dbf548c7bd | ||
|
|
4973f2c631 | ||
|
|
2b05e50312 | ||
|
|
26312e5a58 | ||
|
|
66c25849e9 | ||
|
|
5c4a4608b1 | ||
|
|
659cee76b6 | ||
|
|
6bc4e31719 | ||
|
|
aa062c9fd0 | ||
|
|
5947220fc6 | ||
|
|
8bf93f372b | ||
|
|
2f05e9ae87 | ||
|
|
3fc6f8aa1a | ||
|
|
02a7349899 | ||
|
|
8f70dbcc00 | ||
|
|
ac80d6b614 | ||
|
|
056bb62d04 | ||
|
|
80ec1663f9 | ||
|
|
8ff05c050b | ||
|
|
549419892d | ||
|
|
a46b8b95e6 | ||
|
|
e54f6d31ed | ||
|
|
267f3c3cf9 | ||
|
|
02aa6e4a75 | ||
|
|
cf19d23d8a | ||
|
|
db5980c9bd | ||
|
|
2664791233 | ||
|
|
f37673cad7 | ||
|
|
0ada812294 | ||
|
|
f4db567267 | ||
|
|
d298a33656 | ||
|
|
af4e1d0cf6 | ||
|
|
9e6e4612f5 | ||
|
|
7d392e756d | ||
|
|
3c08963a70 | ||
|
|
d86d57d5c2 | ||
|
|
57501230cd | ||
|
|
986d0b21cb | ||
|
|
504eddaf68 | ||
|
|
f52b8536be | ||
|
|
09255da7db | ||
|
|
8e83cc0d85 | ||
|
|
8955e49aae | ||
|
|
04822043ae | ||
|
|
eab73e6314 | ||
|
|
fd8981fa28 | ||
|
|
25b7c6238e | ||
|
|
77f2718fdd | ||
|
|
f22d4a3649 | ||
|
|
45d24911fb | ||
|
|
f38b6f14fa | ||
|
|
74c1acfe2d | ||
|
|
23db4ed951 | ||
|
|
ccd3f03437 | ||
|
|
6523ddeb1c | ||
|
|
4f9950f21b | ||
|
|
d48b44baa8 | ||
|
|
5564f9bbab | ||
|
|
c972b1c236 | ||
|
|
30aa7bc386 | ||
|
|
8bbb0e8cea | ||
|
|
fbdb2a8fff | ||
|
|
bdfe8b7171 | ||
|
|
2cbe9e9c55 | ||
|
|
8d9a3f4f1c | ||
|
|
476e4c845f | ||
|
|
3d53d001b7 | ||
|
|
8658fc38fe | ||
|
|
24de9af4cd | ||
|
|
5e581efe51 | ||
|
|
12c6b8bcc9 | ||
|
|
599be22eb1 | ||
|
|
9514835bf2 | ||
|
|
65bd1bbd6b | ||
|
|
e0813ba5a3 | ||
|
|
f7c686d5cf | ||
|
|
314664ecd6 | ||
|
|
11f89b1345 | ||
|
|
2481956c11 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
2e4cddd1c8 | ||
|
|
440f224095 | ||
|
|
22871d037c | ||
|
|
fd38b303f0 | ||
|
|
266233a473 | ||
|
|
387b242aea | ||
|
|
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,43 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-30-ios-extension-version-verification
|
||||||
|
date: 2026-08-30
|
||||||
|
status: promoted
|
||||||
|
scope: iOS release artifact verification
|
||||||
|
evidence: v1.0.6 exported IPA inspection and reproduced v1.0.7 Xcode build-setting mismatch
|
||||||
|
destination: apps/mobile/scripts/release-preflight.mjs and .codex/skills/release-mobile/SKILL.md
|
||||||
|
review_after: 2027-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
The signed iOS build logged that the widget extension build number 44 did not
|
||||||
|
match the containing app build number 45, even though export completed.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Build iOS version 1.0.6 with EAS auto-increment from build 44 to 45 and retain the
|
||||||
|
archive log. Extract the resulting IPA with `ditto`, then read the containing app
|
||||||
|
and every packaged extension `Info.plist` with `PlistBuddy`.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
The v1.0.6 archive warning described an intermediate state and was not
|
||||||
|
authoritative for that exported package. The later v1.0.7 reproduction found a
|
||||||
|
separate real hazard: `CURRENT_PROJECT_VERSION` in both LiveDrawWidget
|
||||||
|
configurations can override the widget plist and package an older build number.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Before every iOS release, require public-version parity across app config, both
|
||||||
|
plists, and Xcode marketing settings. Require the widget Debug and Release
|
||||||
|
`CURRENT_PROJECT_VERSION` values to equal both plists. Then inspect the exported
|
||||||
|
app and all packaged extensions. Reject or rebuild only when the final IPA values
|
||||||
|
differ; do not accept or reject a release from archive warnings alone.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
|
||||||
|
both v1.0.6 packaged plists. The v1.0.7 build later proved a stale widget Xcode
|
||||||
|
setting can survive plist-only review. `release-preflight.mjs` and its node test
|
||||||
|
now reject that mismatch before EAS allocation; exported IPA inspection remains
|
||||||
|
the final authority.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-expo-prebuild-native-regression
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: apps/mobile native projects (ios/, android/) and expo prebuild
|
||||||
|
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
|
||||||
|
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
|
||||||
|
review_after: 2026-11-30
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
|
||||||
|
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
|
||||||
|
native invariants. Each regression was caught only by a release gate or by
|
||||||
|
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
|
||||||
|
prebuild itself.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
|
||||||
|
the regenerated ios/ and android/ projects against the previously committed
|
||||||
|
projects.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
|
||||||
|
the target folder name, changing com.sauci.app.LiveDrawWidget to
|
||||||
|
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
|
||||||
|
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
|
||||||
|
2. Android manifest lost the tools:node="remove" attributes on
|
||||||
|
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
|
||||||
|
Play-forbidden permissions (verified via bundletool dump on the rejected
|
||||||
|
build 50 AAB).
|
||||||
|
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
|
||||||
|
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
|
||||||
|
4. android/gradle.properties reset to stock -Xmx2048m
|
||||||
|
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
|
||||||
|
under RN 0.85.
|
||||||
|
|
||||||
|
Separately, a package absent from the new SDK's bundledNativeModules
|
||||||
|
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
|
||||||
|
imports removed ExpoModulesCore headers).
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Fixed by restoring widget identity and tracking
|
||||||
|
targets/widget/generated.entitlements with a pinned bundleIdentifier
|
||||||
|
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
|
||||||
|
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
|
||||||
|
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
|
||||||
|
expo-video/expo-audio (b8cb2add).
|
||||||
|
|
||||||
|
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
|
||||||
|
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
|
||||||
|
preflight check; item 4 hard-fails the build by itself). Add an "After
|
||||||
|
native regeneration" checklist to docs/releasing.md.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
|
||||||
|
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
|
||||||
|
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
|
||||||
|
NSLocalNetworkUsageDescription, or RCTMetroPort.
|
||||||
|
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
|
||||||
|
checks.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-local-eas-build-preconditions
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: apps/mobile local EAS release builds
|
||||||
|
evidence: v1.0.8 local EAS build session, commit d8bedea
|
||||||
|
destination: docs/releasing.md
|
||||||
|
review_after: 2026-11-30
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
Local EAS release builds failed before native compilation for environment
|
||||||
|
reasons unrelated to the app source, and a successful iOS build left the
|
||||||
|
tracked source in a state that fails release-preflight afterward.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run `eas build --local` from a non-interactive shell without first exporting
|
||||||
|
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
|
||||||
|
found" because non-interactive shells do not load the user profile. Run it
|
||||||
|
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
|
||||||
|
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
|
||||||
|
build log through `tail`: the full log needed for diagnosis is lost. After a
|
||||||
|
successful iOS build, EAS auto-increment updates both Info.plists but not
|
||||||
|
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
|
||||||
|
release-preflight fails until it is synced.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
|
||||||
|
23.0.0 verified). Non-interactive shells used for automation do not source
|
||||||
|
the profile that normally exports the Android/Java environment variables.
|
||||||
|
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
|
||||||
|
auto-increment writes plist versions but does not touch the widget target's
|
||||||
|
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
|
||||||
|
one increment after every successful build.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Before a local EAS build: verify `eas` is on PATH, explicitly export
|
||||||
|
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
|
||||||
|
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
|
||||||
|
apps/mobile. Capture the full build log to a file rather than piping through
|
||||||
|
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
|
||||||
|
to the new build number (commit d8bedea, sed 46 to 47) and sync
|
||||||
|
ios/Podfile.lock from the successful build's working directory when
|
||||||
|
dependencies changed, since the archive's pod install resolution is
|
||||||
|
authoritative and local CocoaPods here is broken.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
docs/releasing.md's local-build section documents the environment
|
||||||
|
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
|
||||||
|
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-mobile-release-preflight-and-attempts
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: mobile production release preparation and local EAS builds
|
||||||
|
evidence: v1.0.7 Android and iOS local EAS release investigation and verified artifacts
|
||||||
|
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, docs/releasing.md, and release skills
|
||||||
|
review_after: 2027-02-28
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
The v1.0.7 release spent repeated attempts on archive, plugin startup, package
|
||||||
|
installation, disk-space, signing, and iOS extension-version failures before
|
||||||
|
valid Android and iOS artifacts were accepted.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Run a local production EAS build with an ignored public OTA certificate, a
|
||||||
|
package-directory plugin path, insufficient disk space, or divergent widget
|
||||||
|
`CURRENT_PROJECT_VERSION`. Each condition fails or produces an invalid input
|
||||||
|
before a valid final artifact can be accepted.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
Pre-compilation setup failures were treated as rebuild failures. Source parity,
|
||||||
|
archive inclusion, local EAS plugin shape, disk capacity, and effective iOS widget
|
||||||
|
build settings were not checked together before EAS allocation.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Run the deterministic mobile release preflight before every production build.
|
||||||
|
Classify attempts before retrying: archive, plugin, npm, and ENOSPC failures do
|
||||||
|
not consume a native build; signing, profile, and capability failures require
|
||||||
|
provider read-back before one replacement build. Retain work directories and logs
|
||||||
|
at stable ignored paths. Accept only independently inspected artifacts and keep
|
||||||
|
rejected artifacts out of the upload path.
|
||||||
|
|
||||||
|
The Android native input failure was a Gradle task ordering issue: Prefab inputs
|
||||||
|
must be available before dependent native tasks. Preserve the explicit ordering
|
||||||
|
fix rather than removing Firebase or changing dependency versions without proof.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
The preflight node tests reject an ignored OTA certificate and an effective widget
|
||||||
|
build-setting mismatch. The checked-in source invariants pass the preflight. The
|
||||||
|
maintained release guide and skills record local EAS path requirements, provider
|
||||||
|
read-back, phase-specific status language, artifact inspection, store read-back,
|
||||||
|
and scoped upload authorization boundaries.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: 2026-08-31-npm-lockfile-divergence
|
||||||
|
date: 2026-08-31
|
||||||
|
status: promoted
|
||||||
|
scope: root package.json, package-lock.json, apps/web dependencies, CI
|
||||||
|
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
|
||||||
|
destination: docs/development/dependencies.md
|
||||||
|
review_after: 2026-11-30
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
|
||||||
|
invisibly while a lockfile regenerated on this dev machine (node 26/npm
|
||||||
|
11.19) passed local `npm ci`. Pushes to staging bypass required status
|
||||||
|
checks, so the red CI ran unnoticed for hours.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
|
||||||
|
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
|
||||||
|
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
|
||||||
|
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
|
||||||
|
"Invalid: lock file's ... does not satisfy". The reverse direction also
|
||||||
|
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
|
||||||
|
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
|
||||||
|
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
|
||||||
|
variants breaking local npm ci), matching the npm/cli#4828-style
|
||||||
|
optional-dependency resolution gap.
|
||||||
|
|
||||||
|
## Cause
|
||||||
|
|
||||||
|
Two different npm major versions in active use (CI/Docker on npm 10, this
|
||||||
|
dev machine on npm 11) resolve wildcard/range dependencies to different
|
||||||
|
versions at the same tree position, and each npm's own platform-optional
|
||||||
|
binary selection is not portable to the other npm's lockfile.
|
||||||
|
|
||||||
|
## Remediation
|
||||||
|
|
||||||
|
Pin the wildcard-range dependency via a root package.json override
|
||||||
|
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
|
||||||
|
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
|
||||||
|
binaries as direct optionalDependencies (must be bumped together with any
|
||||||
|
`next` version bump). Regenerate the lock and validate it both ways: local
|
||||||
|
`npm ci` plus `npm run verify:fast`, and
|
||||||
|
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
|
||||||
|
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
|
||||||
|
and the platform-binary check is meaningless). After any push that bypassed
|
||||||
|
required status checks, check `gh run list --branch staging`.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
|
||||||
|
and record a lockfile that passes both the amd64-emulated `npm ci` under
|
||||||
|
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: release-mobile
|
name: release-mobile
|
||||||
description: Prepare and verify Sauci iOS or Android releases and store uploads.
|
description: Prepare, verify, and ship Sauci iOS or Android releases end to end, including store uploads, review submission, and rollout.
|
||||||
---
|
---
|
||||||
|
|
||||||
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
|
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
|
||||||
@@ -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,14 @@
|
|||||||
|
name = "implementation"
|
||||||
|
description = "Terra executor for one continuous 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. Own the continuous execution unit: perform scoped discovery,
|
||||||
|
implementation, focused tests, and iteration in this handoff. 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
|
||||||
@@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services.
|
|||||||
|
|
||||||
| Service | Port |
|
| Service | Port |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| web | 3000 |
|
| PostgreSQL | 54320 |
|
||||||
| admin | 3001 |
|
| API | 3003 |
|
||||||
| MCP | 3002 |
|
| web | 3000 (overridable with `SAUCI_WEB_PORT`) |
|
||||||
|
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
|
||||||
|
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
|
||||||
| Expo Metro | 8081 |
|
| Expo Metro | 8081 |
|
||||||
| Supabase API / DB / Studio | 54321 / 54322 / 54323 |
|
|
||||||
|
|
||||||
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and
|
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
|
||||||
the repository Supabase CLI dependency.
|
and the configured hosted Auth credentials where an authenticated client needs them.
|
||||||
|
|
||||||
- `scripts/dev-local.sh up` — idempotently start infra and services.
|
- `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
|
||||||
- `status`, `logs <service>`, `restart <service>`, `attach` — inspect.
|
- `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
|
||||||
- `reset` — local-only database reset plus deterministic fixtures.
|
- `reset`: local-only database reset plus deterministic fixtures.
|
||||||
- `ios` / `android` — launch the native app against local Supabase.
|
- `e2e`: start or safely reuse the local stack, validate local endpoints, and
|
||||||
- `down`; `down --all` — stop services, optionally infrastructure.
|
run Playwright with launcher-derived URLs and a loopback PostgreSQL fixture.
|
||||||
|
- `ios` / `android`: launch the native app against the standalone local API and
|
||||||
|
configured hosted Auth project.
|
||||||
|
- `down`; `down --all`: stop services, optionally infrastructure.
|
||||||
|
|
||||||
If a port is occupied, use `status` and inspect its owning process before stopping
|
Hosted Supabase provides Auth only. Local E2E never starts or writes to a
|
||||||
anything. If a tmux window exited, read its logs before restarting it.
|
Supabase data plane. If a port is occupied, use `status` and inspect its owning
|
||||||
|
process before stopping anything. If a tmux window exited, read its logs before
|
||||||
|
restarting it.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
---
|
||||||
|
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. When
|
||||||
|
the request also asks to ship, upload, submit, or release to the stores, it
|
||||||
|
authorizes the complete store pipeline for the stated platforms after the exact
|
||||||
|
artifact has been verified: upload, App Review / Google Play review submission,
|
||||||
|
and rollout, including full production rollout (see the `release-mobile` skill).
|
||||||
|
It does not authorize pushing commits or tags, changing signing credentials, or
|
||||||
|
regenerating native projects unless the request explicitly includes that action.
|
||||||
|
|
||||||
|
## 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 and OTA runtime versions across `app.json`, explicit
|
||||||
|
`app.config.js` runtime, Android, and every iOS target
|
||||||
|
(`scripts/set_version.mjs --check`);
|
||||||
|
- `npm run release:preflight -w @sauci/mobile`, which also checks the iOS widget
|
||||||
|
effective build settings and OTA certificate archive inclusion;
|
||||||
|
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||||
|
- the configured production API/Auth/RevenueCat environment, without printing
|
||||||
|
secret values.
|
||||||
|
|
||||||
|
For an OTA release, run `npm run ota:preflight:production -w @sauci/mobile`
|
||||||
|
before any publish. This exercises the EOAS-compatible Expo export environment
|
||||||
|
without contacting the update server or uploading an update.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
For local EAS, retain the working directory and logs under a stable ignored path,
|
||||||
|
set `EAS_LOCAL_BUILD_ARTIFACTS_DIR`, and preflight available disk space before
|
||||||
|
starting. Set `EAS_LOCAL_BUILD_PLUGIN_PATH` only to the matching plugin
|
||||||
|
executable `bin/run`, never a package directory or a hardcoded cache location.
|
||||||
|
Classify plugin, archive, npm, and ENOSPC failures as pre-compilation failures;
|
||||||
|
repair and preflight them without spending another build. Make at most one
|
||||||
|
meaningful build for each newly proven input state. After a signing, profile, or
|
||||||
|
capability failure, read back and prove the provider correction before rebuilding.
|
||||||
|
|
||||||
|
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, signing identity/fingerprint, OTA runtime/channel/certificate,
|
||||||
|
and SHA-256. Require the embedded public version to equal the requested
|
||||||
|
version. For Android, also inspect all required ABIs, Worklets/Reanimated/Skia
|
||||||
|
libraries, and the release permissions. For iOS, inspect every packaged
|
||||||
|
extension after export.
|
||||||
|
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. Quarantine
|
||||||
|
or remove rejected artifacts so only accepted paths can be offered for upload. 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.
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#!/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"),
|
||||||
|
appConfig: path.join(repoRoot, "apps/mobile/app.config.js"),
|
||||||
|
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
|
||||||
|
androidExpo: path.join(repoRoot, "apps/mobile/android/app/src/main/res/values/strings.xml"),
|
||||||
|
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
|
||||||
|
iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"),
|
||||||
|
iosExpo: path.join(repoRoot, "apps/mobile/ios/Sauci/Supporting/Expo.plist"),
|
||||||
|
widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"),
|
||||||
|
};
|
||||||
|
|
||||||
|
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 writePreservingFinalNewline(file, contents, previous) {
|
||||||
|
fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
const appRaw = fs.readFileSync(files.app, "utf8");
|
||||||
|
const appConfigRaw = fs.readFileSync(files.appConfig, "utf8");
|
||||||
|
const androidRaw = fs.readFileSync(files.android, "utf8");
|
||||||
|
const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8");
|
||||||
|
const iosRaw = fs.readFileSync(files.ios, "utf8");
|
||||||
|
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
|
||||||
|
const iosExpoRaw = fs.readFileSync(files.iosExpo, "utf8");
|
||||||
|
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "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(),
|
||||||
|
);
|
||||||
|
const plistVersion = (raw, label) => {
|
||||||
|
const versions = [...raw.matchAll(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
if (versions.length !== 1) fail(`expected exactly one ${label} CFBundleShortVersionString`);
|
||||||
|
return versions[0];
|
||||||
|
};
|
||||||
|
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
|
||||||
|
const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget Info.plist");
|
||||||
|
const expoRuntimeVersion = (raw, label) => {
|
||||||
|
const versions = [...raw.matchAll(/<key>EXUpdatesRuntimeVersion<\/key>\s*<string>([^<]+)<\/string>/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
if (versions.length !== 1) fail(`expected exactly one ${label} Expo runtime version`);
|
||||||
|
return versions[0];
|
||||||
|
};
|
||||||
|
const appConfigRuntimeVersions = [...appConfigRaw.matchAll(/runtimeVersion:\s*['"]([^'"]+)['"]/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
const androidRuntimeVersions = [...androidExpoRaw.matchAll(/<string name="expo_runtime_version">([^<]+)<\/string>/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
if (appConfigRuntimeVersions.length !== 1) fail("expected exactly one explicit app.config.js runtimeVersion");
|
||||||
|
if (androidRuntimeVersions.length !== 1) fail("expected exactly one Android Expo runtime version");
|
||||||
|
const iosExpoVersion = expoRuntimeVersion(iosExpoRaw, "iOS");
|
||||||
|
|
||||||
|
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,
|
||||||
|
iosInfoVersion,
|
||||||
|
widgetInfoVersion,
|
||||||
|
appConfigRuntimeVersions[0],
|
||||||
|
androidRuntimeVersions[0],
|
||||||
|
iosExpoVersion,
|
||||||
|
];
|
||||||
|
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
|
||||||
|
if (new Set(versions).size !== 1) {
|
||||||
|
fail(`public versions disagree: ${versions.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appRaw,
|
||||||
|
appConfigRaw,
|
||||||
|
androidRaw,
|
||||||
|
androidExpoRaw,
|
||||||
|
iosRaw,
|
||||||
|
iosInfoRaw,
|
||||||
|
iosExpoRaw,
|
||||||
|
widgetInfoRaw,
|
||||||
|
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 and OTA runtime 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 appConfigRaw = state.appConfigRaw.replace(
|
||||||
|
/(runtimeVersion:\s*['"])([^'"]+)(['"])/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const androidExpoRaw = state.androidExpoRaw.replace(
|
||||||
|
/(<string name="expo_runtime_version">)([^<]+)(<\/string>)/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const iosRaw = state.iosRaw.replace(
|
||||||
|
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const iosInfoRaw = state.iosInfoRaw.replace(
|
||||||
|
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const iosExpoRaw = state.iosExpoRaw.replace(
|
||||||
|
/(<key>EXUpdatesRuntimeVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const widgetInfoRaw = state.widgetInfoRaw.replace(
|
||||||
|
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
|
||||||
|
writePreservingFinalNewline(files.app, appRaw, state.appRaw);
|
||||||
|
writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw);
|
||||||
|
writePreservingFinalNewline(files.android, androidRaw, state.androidRaw);
|
||||||
|
writePreservingFinalNewline(files.androidExpo, androidExpoRaw, state.androidExpoRaw);
|
||||||
|
writePreservingFinalNewline(files.ios, iosRaw, state.iosRaw);
|
||||||
|
writePreservingFinalNewline(files.iosInfo, iosInfoRaw, state.iosInfoRaw);
|
||||||
|
writePreservingFinalNewline(files.iosExpo, iosExpoRaw, state.iosExpoRaw);
|
||||||
|
writePreservingFinalNewline(files.widgetInfo, widgetInfoRaw, state.widgetInfoRaw);
|
||||||
|
console.log(`updated public version ${state.current} -> ${desired}`);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: release-mobile
|
name: release-mobile
|
||||||
description: Prepare and verify Sauci iOS or Android releases. Use for versioning, EAS/native builds, Fastlane upload, release notes, or store submission preparation.
|
description: Prepare, verify, and ship Sauci iOS or Android releases end to end. Use for versioning, EAS/native builds, store uploads, App Review / Play review submission, and rollout.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Mobile release
|
# Mobile release
|
||||||
@@ -9,7 +9,139 @@ 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
|
## OTA update vs full store release
|
||||||
and versioning. Building or uploading to a store is an external mutation: obtain
|
|
||||||
explicit authorization for the requested target, then report the build artifact,
|
Choose the delivery mechanism before doing anything else; they are different
|
||||||
version/build number, upload result, and read-back state.
|
pipelines with different blast radii. `docs/mobile-ota.md` is the authority.
|
||||||
|
|
||||||
|
- **OTA update** (`npm run ota:publish:<env>` in `apps/mobile`): ships only the
|
||||||
|
JavaScript bundle and assets to builds already installed on devices. No store
|
||||||
|
review, live within minutes, and rolled back by repointing the XPREM channel
|
||||||
|
at a previous update. Valid only when the change touches no native code, no
|
||||||
|
native dependency, no `ios/`/`android/` project files, and no config that
|
||||||
|
alters the native binary. The OTA runtime version equals the public app
|
||||||
|
version, so an OTA update can only reach installs of the current
|
||||||
|
`expo.version`.
|
||||||
|
- **Full store release** (this skill): a new signed binary through App Store
|
||||||
|
Connect and Google Play, including review. Required whenever native code,
|
||||||
|
native dependencies, the checked-in native projects, Expo SDK, permissions,
|
||||||
|
entitlements, or `expo.version` change. Rollback is coarser: Play staged
|
||||||
|
rollouts can be halted, iOS phased release can be paused, and a bad release is
|
||||||
|
otherwise superseded by shipping the next version.
|
||||||
|
|
||||||
|
When a change qualifies for OTA, prefer OTA unless the user asks for a store
|
||||||
|
release. Never present an OTA publish as a store release or vice versa; report
|
||||||
|
which mechanism was used.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
An explicit request for a mobile release authorizes the complete pipeline for
|
||||||
|
the stated platforms: version bump, build, verification, store upload, review
|
||||||
|
submission (App Review / Google Play review), and rollout — including full
|
||||||
|
production rollout. Rollout is recoverable by design (staged-rollout halt,
|
||||||
|
phased-release pause, OTA supersession, or the next store version), so do not
|
||||||
|
pause to re-confirm these steps; execute them and report. A request that
|
||||||
|
explicitly narrows scope (for example "upload only", "TestFlight only", or a
|
||||||
|
named track) still bounds what you do.
|
||||||
|
|
||||||
|
Before a build from the current upstream branch, fetch, require a clean worktree,
|
||||||
|
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||||
|
build a clean, scoped release-preparation commit that descends directly from that
|
||||||
|
fetched upstream; between its multi-platform builds it commits only independently
|
||||||
|
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
|
||||||
|
so routine release builds must package those checked-in sources. Do not run
|
||||||
|
`expo prebuild` first; native regeneration is a separate reviewed change.
|
||||||
|
|
||||||
|
Run `npm run release:preflight -w @sauci/mobile` before allocating an EAS build.
|
||||||
|
It rejects inconsistent public/runtime versions, an iOS widget build-setting
|
||||||
|
override, and a public OTA certificate that would be omitted from the archive.
|
||||||
|
For a local EAS build, also run `npm run release:preflight:local-eas -w
|
||||||
|
@sauci/mobile` after setting `EAS_LOCAL_BUILD_PLUGIN_PATH` to the matching
|
||||||
|
plugin's executable `bin/run`. Do not point it at the package directory or
|
||||||
|
hardcode a machine-local cache path.
|
||||||
|
|
||||||
|
Classify a failure before rebuilding. Archive, plugin-spawn, package-install, and
|
||||||
|
disk-space failures happen before native compilation and must be repaired and
|
||||||
|
preflighted without consuming another build. After a signing, profile, or
|
||||||
|
capability failure, read back the provider state and prove only the affected
|
||||||
|
input changed before rebuilding. Retain the EAS work directory, command log, and
|
||||||
|
stable artifact path for every meaningful attempt. Never retry an ambiguous
|
||||||
|
upload.
|
||||||
|
|
||||||
|
Report provider progress by phase: packaging, native compilation, submission
|
||||||
|
queue, or store processing. Do not describe every failure as a rebuild.
|
||||||
|
|
||||||
|
Use the package scripts in `apps/mobile/package.json` to build artifacts and the
|
||||||
|
Fastlane lanes in `apps/mobile/fastlane/Fastfile` to upload, submit, and roll
|
||||||
|
out.
|
||||||
|
|
||||||
|
## Upload and submission execution
|
||||||
|
|
||||||
|
Verification before upload is unchanged and mandatory. Independently verify and
|
||||||
|
report:
|
||||||
|
|
||||||
|
- the platform, exact artifact path, bundle/package identifier, public version,
|
||||||
|
build number, signature identity, and SHA-256 digest;
|
||||||
|
- the exact destination and resulting state: which Play track and rollout
|
||||||
|
status, or App Store Connect version state and whether App Review submission
|
||||||
|
and automatic release are included.
|
||||||
|
|
||||||
|
Once these details match the request, proceed through upload, review
|
||||||
|
submission, and rollout without requesting further confirmation.
|
||||||
|
|
||||||
|
- **Android**: `bundle exec fastlane android release aab:<absolute path>`
|
||||||
|
commits the release on the production track (pass `track:` to target another
|
||||||
|
track, `rollout:0.1` for a staged rollout). Play review runs automatically;
|
||||||
|
rollout proceeds on approval. Use the `upload` lane only when the request is
|
||||||
|
explicitly upload-only.
|
||||||
|
- **iOS**: `bundle exec fastlane ios release ipa:<absolute path>` uploads,
|
||||||
|
creates the App Store version, submits for App Review, and releases
|
||||||
|
automatically on approval (pass `phased_release:true` for a 7-day phased
|
||||||
|
release, `release_notes:"..."` for What's New). Use the `upload` lane for
|
||||||
|
TestFlight-only requests. `eas submit` remains valid for upload-only flows;
|
||||||
|
when used, pass explicit `--platform`, `--profile production`, `--path`, and
|
||||||
|
`--wait` — never `--latest`.
|
||||||
|
|
||||||
|
App Store Connect API group association does not establish the automatic-
|
||||||
|
distribution switch. Verify that switch in an authenticated App Store Connect UI
|
||||||
|
session before upload; if that UI verification is unavailable, stop rather
|
||||||
|
than inferring the distribution effect from API data.
|
||||||
|
|
||||||
|
If an upload or submission command returns an ambiguous result, inspect EAS and
|
||||||
|
the store before doing anything else; do not retry blind. After submission, read
|
||||||
|
back the store state (Play Console track status / App Store Connect version
|
||||||
|
state) and report it.
|
||||||
|
|
||||||
|
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. For an iOS IPA, inspect every packaged
|
||||||
|
app extension as well as the containing app and require matching
|
||||||
|
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
|
||||||
|
warnings as diagnostic evidence, not the final artifact state: decide whether to
|
||||||
|
reject or rebuild only after reading the exported IPA itself. Report the
|
||||||
|
artifact, version/build number, upload and submission results, rollout state,
|
||||||
|
and store read-back state.
|
||||||
|
|
||||||
|
For Android, verify the accepted AAB has all four required ABIs, the Worklets,
|
||||||
|
Reanimated, and Skia native libraries, no broad `READ_MEDIA_IMAGES` or
|
||||||
|
`READ_MEDIA_VIDEO` permissions, and the production OTA runtime, channel, and
|
||||||
|
public certificate. For iOS, verify the exported IPA, not archive warnings, and
|
||||||
|
check both the containing app and every extension.
|
||||||
|
|
||||||
|
For the Android native dependency ordering already tracked in Gradle, preserve
|
||||||
|
the explicit task dependency that makes Prefab inputs available before dependent
|
||||||
|
native tasks. Fix task validation with ordering, not Firebase removal or
|
||||||
|
dependency-version roulette.
|
||||||
|
|
||||||
|
After Android upload, EAS Submit `FINISHED`, the exact internal/DRAFT/no-rollout
|
||||||
|
submit configuration, and the retained artifact digest are the available
|
||||||
|
authoritative read-back when a direct Play track read would require creating an
|
||||||
|
edit. Never create an edit merely to claim read-only verification.
|
||||||
|
|
||||||
|
For a checked-in iOS entitlement, inspect the real Apple App ID capability state
|
||||||
|
before a signing rebuild. The capability sync bug tracked in
|
||||||
|
https://github.com/expo/eas-cli/issues/3986 can report success while Apple
|
||||||
|
rejects a malformed update. Read back Associated Domains, regenerate only the
|
||||||
|
affected profile, and decode that profile to prove the entitlement before
|
||||||
|
rebuilding. Noninteractive managed App Store Connect key auth may require
|
||||||
|
`EXPO_APPLE_TEAM_ID`; preserve certificates and unrelated target profiles.
|
||||||
@@ -5,25 +5,34 @@ description: Prove a completed Sauci engineering task works before a PR. Use whe
|
|||||||
|
|
||||||
# Verify Sauci work
|
# Verify Sauci work
|
||||||
|
|
||||||
Run locally on a non-default branch with the implementation committed.
|
Run locally on a non-default branch with the implementation committed. Use an
|
||||||
|
independent verifier for user-facing, cross-boundary, or high-risk runtime
|
||||||
|
changes. Documentation, mechanical configuration, isolated tests, and low-risk
|
||||||
|
package-local changes may use focused deterministic checks without a verifier.
|
||||||
|
|
||||||
1. Start the stack once with `scripts/dev-local.sh up`; reset fixtures first when
|
1. Package-local deterministic checks do not need a running stack. For runtime
|
||||||
the task changes durable state. Web is `http://127.0.0.1:3000`, admin is
|
verification, reuse a compatible running local stack when available; otherwise
|
||||||
`http://127.0.0.1:3001`, and MCP health is `http://127.0.0.1:3002/health`.
|
start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
|
||||||
2. Give a fresh read-only verifier the acceptance criteria. It drives browser
|
first when the task changes durable state. Web is `http://127.0.0.1:3000`,
|
||||||
|
admin is `http://127.0.0.1:3001`, and MCP health is
|
||||||
|
`http://127.0.0.1:3002/health`.
|
||||||
|
2. When independent runtime verification is required, give a fresh read-only
|
||||||
|
verifier the acceptance criteria. It drives browser
|
||||||
surfaces with the repository browser capability, native surfaces with Maestro
|
surfaces with the repository browser capability, native surfaces with Maestro
|
||||||
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
||||||
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
||||||
3. The verifier writes screenshots, traces, video, or response evidence under
|
3. The verifier writes screenshots, traces, video, or response evidence under
|
||||||
`evidence/` and returns only expected, observed, evidence paths, and
|
`evidence/` and returns only expected, observed, evidence paths, and
|
||||||
`works|broken`. It never edits code.
|
`works|broken`. It never edits code.
|
||||||
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and
|
4. If independent verification is broken, fix and use a fresh verifier. Stop
|
||||||
report the unresolved evidence.
|
after three failed rounds and report the unresolved evidence.
|
||||||
5. After the task works, run `npm run verify:fast`, affected E2E, and
|
5. Run focused checks for the affected change. Run affected E2E when the runtime
|
||||||
`npm run verify:full` when local Supabase/Deno are available. Never weaken an
|
path requires it. Run `npm run verify:full` when its prerequisites are
|
||||||
assertion just to pass.
|
available; it already runs `npm run verify:fast`, so do not run both in
|
||||||
|
sequence. Never weaken an assertion just to pass.
|
||||||
6. Open a PR only after both task verification and regression checks pass. Upload
|
6. Open a PR only after both task verification and regression checks pass. Upload
|
||||||
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
||||||
the screenshot plus a video/trace link in the PR body.
|
the screenshot plus a video/trace link in the PR body.
|
||||||
|
|
||||||
A green test suite without an independently observed task outcome is incomplete.
|
A green test suite without an independently observed outcome is incomplete when
|
||||||
|
independent runtime verification is required.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
.next
|
||||||
|
**/.next
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
@@ -23,13 +25,25 @@ apps/mcp
|
|||||||
apps/mobile
|
apps/mobile
|
||||||
apps/supabase
|
apps/supabase
|
||||||
apps/web
|
apps/web
|
||||||
|
!apps/web
|
||||||
|
!apps/web/**
|
||||||
e2e
|
e2e
|
||||||
evidence
|
evidence
|
||||||
etc
|
etc
|
||||||
infra
|
infra
|
||||||
scripts
|
scripts
|
||||||
|
!scripts
|
||||||
|
!scripts/fix-hoisting.js
|
||||||
terraform
|
terraform
|
||||||
.agents
|
.agents
|
||||||
.claude
|
.claude
|
||||||
.codex
|
.codex
|
||||||
.ralph
|
.ralph
|
||||||
|
|
||||||
|
# The admin app builds from this same monorepo context (apps/admin/Dockerfile).
|
||||||
|
!apps/admin
|
||||||
|
!apps/admin/**
|
||||||
|
|
||||||
|
# Host build artefacts must never leak into any image context.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
@@ -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 }}
|
||||||
@@ -23,14 +24,22 @@ jobs:
|
|||||||
echo "master is frozen; target staging or main" >&2
|
echo "master is frozen; target staging or main" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then
|
if [ "$BASE_BRANCH" = "main" ]; then
|
||||||
echo "Only staging may be promoted to main" >&2
|
case "$HEAD_BRANCH" in
|
||||||
|
staging|hotfix/*|release/*) ;;
|
||||||
|
*)
|
||||||
|
echo "main takes staging promotions, or a hotfix/* or release/* branch for direct work" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
|
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
|
||||||
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 +52,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 +83,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
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ e2e/playwright-report/
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
|
!apps/mobile/certs/certificate.pem
|
||||||
.cache/
|
.cache/
|
||||||
.temp/
|
.temp/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|||||||
@@ -17,8 +17,29 @@ 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. The Sol primary agent owns scope, risk classification, acceptance criteria,
|
||||||
|
progress steering, final diff and evidence review, and the final answer.
|
||||||
|
Delegate every substantive repository task to a project agent. Each
|
||||||
|
substantive implementation must use one Terra `implementation` agent as a
|
||||||
|
coherent execution unit, normally combining scoped discovery, implementation,
|
||||||
|
focused tests, and iteration. "Substantive" includes repository exploration,
|
||||||
|
multi-file reasoning, implementation, test execution, or contract/risk
|
||||||
|
analysis. Add a `repo_scout`, reviewer, or verifier only for separable work or
|
||||||
|
the triggers in `docs/agents/routing.md`; do not duplicate the implementation
|
||||||
|
agent's detailed exploration or checks without a concrete reason. Skip
|
||||||
|
delegation only for trivial conversation, a single obvious lookup, or a
|
||||||
|
mechanical one-line edit whose coordination cost exceeds the work.
|
||||||
|
11. Use high-effort reviewers only for the risk triggers in
|
||||||
`docs/agents/routing.md`.
|
`docs/agents/routing.md`.
|
||||||
|
12. Agents must not use em dash characters in text they add to this repository.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
@@ -36,6 +57,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`
|
||||||
@@ -53,6 +76,7 @@ environment limitation; do not call it a passing product check.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| System boundaries and data flow | `docs/architecture/system.md` |
|
| System boundaries and data flow | `docs/architecture/system.md` |
|
||||||
| Local services, ports, fixtures | `docs/development/local-stack.md` |
|
| Local services, ports, fixtures | `docs/development/local-stack.md` |
|
||||||
|
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
|
||||||
| Test layers and required gates | `docs/testing/strategy.md` |
|
| Test layers and required gates | `docs/testing/strategy.md` |
|
||||||
| Database migration safety | `docs/operations/database-migrations.md` |
|
| Database migration safety | `docs/operations/database-migrations.md` |
|
||||||
| Agent roles and effort routing | `docs/agents/routing.md` |
|
| Agent roles and effort routing | `docs/agents/routing.md` |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
|
|||||||
import { TagsPage } from '@/pages/content/TagsPage';
|
import { TagsPage } from '@/pages/content/TagsPage';
|
||||||
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
||||||
import { DaresPage } from '@/pages/content/DaresPage';
|
import { DaresPage } from '@/pages/content/DaresPage';
|
||||||
|
import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage';
|
||||||
import { UsersPage } from '@/pages/users/UsersPage';
|
import { UsersPage } from '@/pages/users/UsersPage';
|
||||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||||
|
import { CouplesPage } from '@/pages/users/CouplesPage';
|
||||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||||
@@ -20,6 +22,8 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
|||||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||||
|
import { SentDaresPage } from '@/pages/activity/SentDaresPage';
|
||||||
|
import { LiveDrawSessionsPage } from '@/pages/activity/LiveDrawSessionsPage';
|
||||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||||
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
||||||
@@ -108,6 +112,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/quiz-questions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||||
|
<QuizQuestionsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* User management */}
|
{/* User management */}
|
||||||
<Route
|
<Route
|
||||||
@@ -142,6 +154,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/couples"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||||
|
<CouplesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/activity"
|
path="/activity"
|
||||||
element={
|
element={
|
||||||
@@ -150,6 +170,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/activity/sent-dares"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||||
|
<SentDaresPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/activity/live-draw"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||||
|
<LiveDrawSessionsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* System */}
|
{/* System */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export interface StrokePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrokeSegment {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
points: StrokePoint[];
|
||||||
|
color: string;
|
||||||
|
width: number;
|
||||||
|
timestamp: number;
|
||||||
|
isEraser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STROKE_CANVAS_BACKGROUND = '#1a1a2e';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a `live_draw_sessions.strokes` array to a canvas. Points are stored
|
||||||
|
* as 0..1 fractions of the drawing surface, so this scales to whatever size
|
||||||
|
* the canvas element is laid out at.
|
||||||
|
*/
|
||||||
|
export function StrokeCanvas({ strokes }: { strokes: StrokeSegment[] }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = canvas.clientHeight;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.fillStyle = STROKE_CANVAS_BACKGROUND;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
for (const stroke of strokes) {
|
||||||
|
if (stroke.points.length === 0) continue;
|
||||||
|
ctx.strokeStyle = stroke.isEraser ? STROKE_CANVAS_BACKGROUND : stroke.color;
|
||||||
|
ctx.lineWidth = stroke.width;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const first = stroke.points[0];
|
||||||
|
ctx.moveTo(first.x * w, first.y * h);
|
||||||
|
|
||||||
|
if (stroke.points.length === 1) {
|
||||||
|
ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1);
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < stroke.points.length; i++) {
|
||||||
|
const prev = stroke.points[i - 1];
|
||||||
|
const curr = stroke.points[i];
|
||||||
|
const midX = ((prev.x + curr.x) / 2) * w;
|
||||||
|
const midY = ((prev.y + curr.y) / 2) * h;
|
||||||
|
|
||||||
|
if (i === 1) {
|
||||||
|
ctx.lineTo(midX, midY);
|
||||||
|
} else {
|
||||||
|
ctx.quadraticCurveTo(prev.x * w, prev.y * h, midX, midY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const last = stroke.points[stroke.points.length - 1];
|
||||||
|
ctx.lineTo(last.x * w, last.y * h);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}, [strokes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl overflow-hidden border" style={{ background: STROKE_CANVAS_BACKGROUND }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Tags,
|
Tags,
|
||||||
Zap,
|
Zap,
|
||||||
|
HelpCircle,
|
||||||
|
Heart,
|
||||||
|
Swords,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
@@ -68,6 +72,12 @@ const contentNav: NavItem[] = [
|
|||||||
icon: <Tags className="h-5 w-5" />,
|
icon: <Tags className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
requiredPermission: PERMISSION_KEYS.MANAGE_PACKS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Quiz Questions',
|
||||||
|
href: '/quiz-questions',
|
||||||
|
icon: <HelpCircle className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const usersNav: NavItem[] = [
|
const usersNav: NavItem[] = [
|
||||||
@@ -83,6 +93,24 @@ const usersNav: NavItem[] = [
|
|||||||
icon: <Activity className="h-5 w-5" />,
|
icon: <Activity className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Sent Dares',
|
||||||
|
href: '/activity/sent-dares',
|
||||||
|
icon: <Swords className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.VIEW_ACTIVITY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Live Draw Sessions',
|
||||||
|
href: '/activity/live-draw',
|
||||||
|
icon: <Pencil className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Couples',
|
||||||
|
href: '/couples',
|
||||||
|
icon: <Heart className="h-5 w-5" />,
|
||||||
|
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Usage Insights',
|
label: 'Usage Insights',
|
||||||
href: '/usage-insights',
|
href: '/usage-insights',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const AUDITED_TABLES = [
|
|||||||
'categories',
|
'categories',
|
||||||
'dare_packs',
|
'dare_packs',
|
||||||
'dares',
|
'dares',
|
||||||
|
'quiz_questions',
|
||||||
'profiles',
|
'profiles',
|
||||||
'couples',
|
'couples',
|
||||||
'responses',
|
'responses',
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from './couples';
|
||||||
|
|
||||||
|
describe('coupleStatusFromMemberCount', () => {
|
||||||
|
it('treats zero or one member as pending', () => {
|
||||||
|
expect(coupleStatusFromMemberCount(0)).toBe('pending');
|
||||||
|
expect(coupleStatusFromMemberCount(1)).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats two or more members as paired', () => {
|
||||||
|
expect(coupleStatusFromMemberCount(2)).toBe('paired');
|
||||||
|
expect(coupleStatusFromMemberCount(3)).toBe('paired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCoupleSearchFilter', () => {
|
||||||
|
it('returns null for an empty or blank search', () => {
|
||||||
|
expect(buildCoupleSearchFilter('')).toBeNull();
|
||||||
|
expect(buildCoupleSearchFilter(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a uuid-shaped search as an exact id filter', () => {
|
||||||
|
expect(buildCoupleSearchFilter('a1b2c3d4-1111-1111-1111-111111111111')).toEqual({
|
||||||
|
column: 'id', op: 'eq', value: 'a1b2c3d4-1111-1111-1111-111111111111',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats any other search as a partial invite code match', () => {
|
||||||
|
expect(buildCoupleSearchFilter('SUNSET42')).toEqual({
|
||||||
|
column: 'invite_code', op: 'ilike', value: '%SUNSET42%',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace before deciding the filter shape', () => {
|
||||||
|
expect(buildCoupleSearchFilter(' sunset42 ')).toEqual({
|
||||||
|
column: 'invite_code', op: 'ilike', value: '%sunset42%',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Couples do not carry an explicit "paired" flag (see apps/api/src/domains/couples/schema.ts).
|
||||||
|
// Pairing is inferred from how many profiles currently reference the couple id, the same
|
||||||
|
// approach UsersPage already uses inline for the couple badge on a profile row.
|
||||||
|
|
||||||
|
export type CoupleStatus = 'paired' | 'pending';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive whether a couple is fully paired from the number of profiles that
|
||||||
|
* reference it. A couple starts with one member (the creator) and becomes
|
||||||
|
* paired once a partner joins via invite code.
|
||||||
|
*/
|
||||||
|
export function coupleStatusFromMemberCount(memberCount: number): CoupleStatus {
|
||||||
|
return memberCount >= 2 ? 'paired' : 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export type CoupleSearchFilter =
|
||||||
|
| { column: 'id'; op: 'eq'; value: string }
|
||||||
|
| { column: 'invite_code'; op: 'ilike'; value: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the admin query filter for the couples search box. A UUID-shaped
|
||||||
|
* search term is treated as an exact id lookup; anything else is matched
|
||||||
|
* against the invite code with a case-insensitive partial match.
|
||||||
|
*/
|
||||||
|
export function buildCoupleSearchFilter(rawSearch: string): CoupleSearchFilter {
|
||||||
|
const search = rawSearch.trim();
|
||||||
|
if (!search) return null;
|
||||||
|
if (UUID_PATTERN.test(search)) {
|
||||||
|
return { column: 'id', op: 'eq', value: search };
|
||||||
|
}
|
||||||
|
return { column: 'invite_code', op: 'ilike', value: `%${search}%` };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { clampDareIntensity, parseIntensityRange } from './dareIntensity';
|
||||||
|
|
||||||
|
describe('clampDareIntensity', () => {
|
||||||
|
it('accepts values within the 1-5 range', () => {
|
||||||
|
expect(clampDareIntensity('1')).toBe(1);
|
||||||
|
expect(clampDareIntensity('3')).toBe(3);
|
||||||
|
expect(clampDareIntensity('5')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to 1 for missing, non-numeric, or out-of-range values', () => {
|
||||||
|
expect(clampDareIntensity('')).toBe(1);
|
||||||
|
expect(clampDareIntensity('abc')).toBe(1);
|
||||||
|
expect(clampDareIntensity('0')).toBe(1);
|
||||||
|
expect(clampDareIntensity('6')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a custom default when provided', () => {
|
||||||
|
expect(clampDareIntensity('', 3)).toBe(3);
|
||||||
|
expect(clampDareIntensity('9', 3)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseIntensityRange', () => {
|
||||||
|
it('allows both bounds blank', () => {
|
||||||
|
expect(parseIntensityRange('', '')).toEqual({ range: { min: null, max: null }, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses valid bounds', () => {
|
||||||
|
expect(parseIntensityRange('2', '4')).toEqual({ range: { min: 2, max: 4 }, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a minimum outside 1-5', () => {
|
||||||
|
const { error } = parseIntensityRange('0', '');
|
||||||
|
expect(error).toMatch(/Minimum intensity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a maximum outside 1-5', () => {
|
||||||
|
const { error } = parseIntensityRange('', '6');
|
||||||
|
expect(error).toMatch(/Maximum intensity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a minimum greater than the maximum', () => {
|
||||||
|
const { error } = parseIntensityRange('4', '2');
|
||||||
|
expect(error).toMatch(/cannot be greater than/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Dares and dare packs both gate on a 1-5 intensity scale (see
|
||||||
|
// apps/api/drizzle/0010_admin.sql and 0013_dares_loop.sql). These helpers keep
|
||||||
|
// the admin form validation consistent with that range.
|
||||||
|
|
||||||
|
export const MIN_DARE_INTENSITY = 1;
|
||||||
|
export const MAX_DARE_INTENSITY = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a raw form value to a valid dare intensity (1-5), falling back to 1
|
||||||
|
* when the value is missing or not a finite number in range.
|
||||||
|
*/
|
||||||
|
export function clampDareIntensity(rawValue: string, fallback: number = MIN_DARE_INTENSITY): number {
|
||||||
|
const parsed = parseInt(rawValue, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < MIN_DARE_INTENSITY || parsed > MAX_DARE_INTENSITY) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntensityRange {
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate an optional min/max intensity pair for a dare pack.
|
||||||
|
* Returns the parsed range, or an error message when the values are invalid.
|
||||||
|
*/
|
||||||
|
export function parseIntensityRange(rawMin: string, rawMax: string): { range: IntensityRange; error: string | null } {
|
||||||
|
const min = rawMin.trim() ? parseInt(rawMin, 10) : null;
|
||||||
|
const max = rawMax.trim() ? parseInt(rawMax, 10) : null;
|
||||||
|
|
||||||
|
if (min !== null && (!Number.isFinite(min) || min < MIN_DARE_INTENSITY || min > MAX_DARE_INTENSITY)) {
|
||||||
|
return { range: { min: null, max: null }, error: 'Minimum intensity must be between 1 and 5' };
|
||||||
|
}
|
||||||
|
if (max !== null && (!Number.isFinite(max) || max < MIN_DARE_INTENSITY || max > MAX_DARE_INTENSITY)) {
|
||||||
|
return { range: { min: null, max: null }, error: 'Maximum intensity must be between 1 and 5' };
|
||||||
|
}
|
||||||
|
if (min !== null && max !== null && min > max) {
|
||||||
|
return { range: { min: null, max: null }, error: 'Minimum intensity cannot be greater than maximum intensity' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { range: { min, max }, error: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { sanitizeQuizOptions, validateQuizQuestionForm } from './quizQuestions';
|
||||||
|
|
||||||
|
describe('sanitizeQuizOptions', () => {
|
||||||
|
it('trims and drops empty options', () => {
|
||||||
|
expect(sanitizeQuizOptions([' A ', '', ' ', 'B'])).toEqual(['A', 'B']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateQuizQuestionForm', () => {
|
||||||
|
const base = {
|
||||||
|
prompt_self: 'What is your favourite season?',
|
||||||
|
prompt_guess: "What is your partner's favourite season?",
|
||||||
|
options: ['Spring', 'Summer'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts a valid form', () => {
|
||||||
|
expect(validateQuizQuestionForm(base)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the self prompt', () => {
|
||||||
|
expect(validateQuizQuestionForm({ ...base, prompt_self: ' ' })).toMatch(/about yourself/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the guess prompt', () => {
|
||||||
|
expect(validateQuizQuestionForm({ ...base, prompt_guess: '' })).toMatch(/about your partner/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least two options', () => {
|
||||||
|
expect(validateQuizQuestionForm({ ...base, options: ['Only one'] })).toMatch(/At least 2 options/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows at most four options', () => {
|
||||||
|
expect(validateQuizQuestionForm({ ...base, options: ['A', 'B', 'C', 'D', 'E'] })).toMatch(/At most 4 options/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank options when counting', () => {
|
||||||
|
expect(validateQuizQuestionForm({ ...base, options: ['Spring', 'Summer', '', ''] })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Quiz question options are constrained by the database check
|
||||||
|
// `array_length(options, 1) between 2 and 4` (see
|
||||||
|
// apps/api/drizzle/0019_quiz.sql). These helpers keep the admin form
|
||||||
|
// validation consistent with that constraint.
|
||||||
|
|
||||||
|
export const MIN_QUIZ_OPTIONS = 2;
|
||||||
|
export const MAX_QUIZ_OPTIONS = 4;
|
||||||
|
|
||||||
|
export interface QuizQuestionFormValues {
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trim options and drop any that are empty. */
|
||||||
|
export function sanitizeQuizOptions(options: string[]): string[] {
|
||||||
|
return options.map((option) => option.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a quiz question form. Returns an error message, or null when the
|
||||||
|
* form is valid.
|
||||||
|
*/
|
||||||
|
export function validateQuizQuestionForm(form: QuizQuestionFormValues): string | null {
|
||||||
|
if (!form.prompt_self.trim()) return 'The "about yourself" prompt is required';
|
||||||
|
if (!form.prompt_guess.trim()) return 'The "about your partner" prompt is required';
|
||||||
|
const cleanedOptions = sanitizeQuizOptions(form.options);
|
||||||
|
if (cleanedOptions.length < MIN_QUIZ_OPTIONS) return `At least ${MIN_QUIZ_OPTIONS} options are required`;
|
||||||
|
if (cleanedOptions.length > MAX_QUIZ_OPTIONS) return `At most ${MAX_QUIZ_OPTIONS} options are allowed`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isSentDareOverdue, SENT_DARE_STATUS_LABELS } from './sentDares';
|
||||||
|
|
||||||
|
describe('SENT_DARE_STATUS_LABELS', () => {
|
||||||
|
it('has a label for every documented status', () => {
|
||||||
|
for (const status of ['pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled'] as const) {
|
||||||
|
expect(SENT_DARE_STATUS_LABELS[status].label).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSentDareOverdue', () => {
|
||||||
|
const now = new Date('2026-08-30T12:00:00Z');
|
||||||
|
|
||||||
|
it('is false when there is no expiry', () => {
|
||||||
|
expect(isSentDareOverdue('pending', null, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a terminal status even if expiry has passed', () => {
|
||||||
|
expect(isSentDareOverdue('completed', '2026-08-01T00:00:00Z', now)).toBe(false);
|
||||||
|
expect(isSentDareOverdue('expired', '2026-08-01T00:00:00Z', now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for an open status whose expiry has not passed', () => {
|
||||||
|
expect(isSentDareOverdue('active', '2026-09-01T00:00:00Z', now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for an open status whose expiry has already passed', () => {
|
||||||
|
expect(isSentDareOverdue('pending', '2026-08-01T00:00:00Z', now)).toBe(true);
|
||||||
|
expect(isSentDareOverdue('active', '2026-08-01T00:00:00Z', now)).toBe(true);
|
||||||
|
expect(isSentDareOverdue('submitted', '2026-08-01T00:00:00Z', now)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// sent_dares status lifecycle mirrors the check constraint added in
|
||||||
|
// apps/api/drizzle/0013_dares_loop.sql: pending -> active -> submitted -> completed,
|
||||||
|
// with expired/declined/cancelled as terminal side branches.
|
||||||
|
|
||||||
|
export const SENT_DARE_STATUSES = [
|
||||||
|
'pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SentDareStatus = typeof SENT_DARE_STATUSES[number];
|
||||||
|
|
||||||
|
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
|
||||||
|
export const SENT_DARE_STATUS_LABELS: Record<SentDareStatus, { label: string; variant: BadgeVariant }> = {
|
||||||
|
pending: { label: 'Pending', variant: 'secondary' },
|
||||||
|
active: { label: 'Active', variant: 'default' },
|
||||||
|
submitted: { label: 'Submitted', variant: 'default' },
|
||||||
|
completed: { label: 'Completed', variant: 'outline' },
|
||||||
|
expired: { label: 'Expired', variant: 'destructive' },
|
||||||
|
declined: { label: 'Declined', variant: 'destructive' },
|
||||||
|
cancelled: { label: 'Cancelled', variant: 'outline' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPEN_STATUSES: ReadonlySet<SentDareStatus> = new Set(['pending', 'active', 'submitted']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dare is overdue when it is still in an open state but its expiry has
|
||||||
|
* already passed. The worker sweeps these to `expired` asynchronously, so the
|
||||||
|
* admin view can lag the true status briefly; this flags that gap for support.
|
||||||
|
*/
|
||||||
|
export function isSentDareOverdue(
|
||||||
|
status: string,
|
||||||
|
expiresAt: string | null,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
if (!OPEN_STATUSES.has(status as SentDareStatus)) return false;
|
||||||
|
return new Date(expiresAt).getTime() < now.getTime();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Pencil, Eye } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { StrokeCanvas, type StrokeSegment } from '@/components/content/StrokeCanvas';
|
||||||
|
|
||||||
|
interface LiveDrawSession {
|
||||||
|
couple_id: string;
|
||||||
|
revision: number;
|
||||||
|
updated_by: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
couple_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveDrawSessionsPage() {
|
||||||
|
const [sessions, setSessions] = useState<LiveDrawSession[]>([]);
|
||||||
|
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const [selectedCoupleId, setSelectedCoupleId] = useState<string | null>(null);
|
||||||
|
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
||||||
|
const [strokesLoading, setStrokesLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchSessions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
const { data, error, count } = await adminData
|
||||||
|
.from<LiveDrawSession>('live_draw_sessions')
|
||||||
|
.select('couple_id, revision, updated_by, created_at, updated_at', { count: 'exact' })
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const rows = data || [];
|
||||||
|
setSessions(rows);
|
||||||
|
setTotal(count || 0);
|
||||||
|
|
||||||
|
const coupleIds = rows.map((row) => row.couple_id);
|
||||||
|
if (coupleIds.length > 0) {
|
||||||
|
const { data: profiles } = await adminData
|
||||||
|
.from<Profile>('profiles')
|
||||||
|
.select('id, name, email, couple_id')
|
||||||
|
.in('couple_id', coupleIds);
|
||||||
|
const grouped: Record<string, Profile[]> = {};
|
||||||
|
(profiles || []).forEach((profile) => {
|
||||||
|
if (!profile.couple_id) return;
|
||||||
|
(grouped[profile.couple_id] ??= []).push(profile);
|
||||||
|
});
|
||||||
|
setMembersByCouple(grouped);
|
||||||
|
} else {
|
||||||
|
setMembersByCouple({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load live draw sessions:', error);
|
||||||
|
toast.error('Failed to load live draw sessions');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchSessions(); }, [fetchSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, pageSize, total]);
|
||||||
|
|
||||||
|
const previewSession = async (coupleId: string) => {
|
||||||
|
setSelectedCoupleId(coupleId);
|
||||||
|
setStrokesLoading(true);
|
||||||
|
setStrokes([]);
|
||||||
|
try {
|
||||||
|
const { data, error } = await adminData
|
||||||
|
.from('live_draw_sessions')
|
||||||
|
.select('strokes')
|
||||||
|
.eq('couple_id', coupleId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
setStrokes((data?.strokes as StrokeSegment[]) || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load drawing:', error);
|
||||||
|
toast.error('Failed to load drawing');
|
||||||
|
} finally {
|
||||||
|
setStrokesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMembers = (coupleId: string) => {
|
||||||
|
const members = membersByCouple[coupleId] || [];
|
||||||
|
if (members.length === 0) return <span className="text-muted-foreground text-sm">Unknown couple</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{members.map((member) => (
|
||||||
|
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
|
||||||
|
{member.name || member.email || member.id}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Pencil className="h-8 w-8 text-primary" />
|
||||||
|
Live Draw Sessions
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Recent live-draw activity across couples, most recently updated first
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Couple</TableHead>
|
||||||
|
<TableHead>Revision</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Last Updated</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8">
|
||||||
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
No live draw sessions yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<TableRow key={session.couple_id}>
|
||||||
|
<TableCell>{renderMembers(session.couple_id)}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline">{session.revision}</Badge></TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{format(new Date(session.created_at), 'MMM d, yyyy')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{format(new Date(session.updated_at), 'MMM d, yyyy HH:mm')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => previewSession(session.couple_id)}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCoupleId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">Drawing Preview</CardTitle>
|
||||||
|
<CardDescription>Read-only snapshot of the couple's shared canvas</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{strokesLoading ? (
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
) : strokes.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Pencil className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No strokes to show</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<StrokeCanvas strokes={strokes} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Swords, Eye, MessageSquare } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { SENT_DARE_STATUS_LABELS, SENT_DARE_STATUSES, isSentDareOverdue, type SentDareStatus } from '@/lib/sentDares';
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentDare {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
dare_text_snapshot: string;
|
||||||
|
custom_dare_text: string | null;
|
||||||
|
dare_intensity_snapshot: number;
|
||||||
|
sender_id: string;
|
||||||
|
recipient_id: string;
|
||||||
|
status: string;
|
||||||
|
sent_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DareMessage {
|
||||||
|
id: string;
|
||||||
|
sender_id: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
read_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS: Array<'all' | SentDareStatus> = ['all', ...SENT_DARE_STATUSES];
|
||||||
|
|
||||||
|
export function SentDaresPage() {
|
||||||
|
const [dares, setDares] = useState<SentDare[]>([]);
|
||||||
|
const [profilesById, setProfilesById] = useState<Record<string, Profile>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | SentDareStatus>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const [selectedDare, setSelectedDare] = useState<SentDare | null>(null);
|
||||||
|
const [messages, setMessages] = useState<DareMessage[]>([]);
|
||||||
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchDares = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
let query = adminData
|
||||||
|
.from<SentDare>('sent_dares')
|
||||||
|
.select('id, couple_id, dare_text_snapshot, custom_dare_text, dare_intensity_snapshot, sender_id, recipient_id, status, sent_at, expires_at', { count: 'exact' })
|
||||||
|
.order('sent_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
query = query.eq('status', statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const rows = data || [];
|
||||||
|
setDares(rows);
|
||||||
|
setTotal(count || 0);
|
||||||
|
|
||||||
|
const userIds = [...new Set(rows.flatMap((row) => [row.sender_id, row.recipient_id]))];
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const { data: profiles } = await adminData
|
||||||
|
.from<Profile>('profiles')
|
||||||
|
.select('id, name, email')
|
||||||
|
.in('id', userIds);
|
||||||
|
setProfilesById((profiles || []).reduce((acc: Record<string, Profile>, profile) => {
|
||||||
|
acc[profile.id] = profile;
|
||||||
|
return acc;
|
||||||
|
}, {}));
|
||||||
|
} else {
|
||||||
|
setProfilesById({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load sent dares:', error);
|
||||||
|
toast.error('Failed to load sent dares');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDares(); }, [fetchDares]);
|
||||||
|
|
||||||
|
useEffect(() => { setPage(1); }, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, pageSize, total]);
|
||||||
|
|
||||||
|
const renderUserLink = (userId: string) => {
|
||||||
|
const profile = profilesById[userId];
|
||||||
|
return (
|
||||||
|
<Link to={`/users/${userId}`} className="text-primary hover:underline">
|
||||||
|
{profile?.name || profile?.email || 'Unknown user'}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = async (dare: SentDare) => {
|
||||||
|
setSelectedDare(dare);
|
||||||
|
setMessagesLoading(true);
|
||||||
|
setMessages([]);
|
||||||
|
try {
|
||||||
|
const { data, error } = await adminData
|
||||||
|
.from<DareMessage>('dare_messages')
|
||||||
|
.select('id, sender_id, content, created_at, read_at')
|
||||||
|
.eq('sent_dare_id', dare.id)
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
if (error) throw error;
|
||||||
|
setMessages(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dare messages:', error);
|
||||||
|
toast.error('Failed to load dare messages');
|
||||||
|
} finally {
|
||||||
|
setMessagesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Swords className="h-8 w-8 text-primary" />
|
||||||
|
Sent Dares
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Review the dares loop between couples for moderation and support
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as 'all' | SentDareStatus)}>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_FILTER_OPTIONS.map((status) => (
|
||||||
|
<SelectItem key={status} value={status} className="capitalize">
|
||||||
|
{status === 'all' ? 'All statuses' : SENT_DARE_STATUS_LABELS[status].label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Dare</TableHead>
|
||||||
|
<TableHead>Sender</TableHead>
|
||||||
|
<TableHead>Recipient</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Sent</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8">
|
||||||
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : dares.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
|
No sent dares found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
dares.map((dare) => {
|
||||||
|
const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus];
|
||||||
|
const overdue = isSentDareOverdue(dare.status, dare.expires_at);
|
||||||
|
return (
|
||||||
|
<TableRow key={dare.id}>
|
||||||
|
<TableCell className="max-w-[280px]">
|
||||||
|
<span className="line-clamp-2 block">{dare.custom_dare_text || dare.dare_text_snapshot}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{renderUserLink(dare.sender_id)}</TableCell>
|
||||||
|
<TableCell>{renderUserLink(dare.recipient_id)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={statusInfo?.variant ?? 'secondary'} className="capitalize">{statusInfo?.label ?? dare.status}</Badge>
|
||||||
|
{overdue && <Badge variant="destructive">Overdue</Badge>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{format(new Date(dare.sent_at), 'MMM d, yyyy HH:mm')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{dare.expires_at ? format(new Date(dare.expires_at), 'MMM d, yyyy HH:mm') : 'No expiry'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openDetail(dare)}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Messages
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!selectedDare} onOpenChange={(open) => !open && setSelectedDare(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Dare Messages
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedDare && (selectedDare.custom_dare_text || selectedDare.dare_text_snapshot)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="min-h-[120px] max-h-[400px] overflow-y-auto space-y-3 bg-muted/30 rounded-md p-4">
|
||||||
|
{messagesLoading ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground py-8">No messages for this dare</p>
|
||||||
|
) : (
|
||||||
|
messages.map((message) => (
|
||||||
|
<div key={message.id} className="bg-background border rounded p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-medium">
|
||||||
|
{selectedDare && (message.sender_id === selectedDare.sender_id ? 'Sender' : 'Recipient')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{format(new Date(message.created_at), 'MMM d, yyyy HH:mm')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, Chev
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
import type { ContentReviewStatus } from '@sauci/shared';
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
import { parseIntensityRange } from '@/lib/dareIntensity';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -54,6 +55,9 @@ interface DarePack {
|
|||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
|
min_intensity: number | null;
|
||||||
|
max_intensity: number | null;
|
||||||
|
avg_intensity: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
dare_count?: number;
|
dare_count?: number;
|
||||||
content_status?: ContentReviewStatus | null;
|
content_status?: ContentReviewStatus | null;
|
||||||
@@ -68,6 +72,8 @@ interface DarePackFormData {
|
|||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
category_id: string;
|
category_id: string;
|
||||||
|
min_intensity: string;
|
||||||
|
max_intensity: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -96,6 +102,8 @@ export function DarePacksPage() {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: categoryId ?? '',
|
category_id: categoryId ?? '',
|
||||||
|
min_intensity: '',
|
||||||
|
max_intensity: '',
|
||||||
},
|
},
|
||||||
(pack) => ({
|
(pack) => ({
|
||||||
name: pack.name,
|
name: pack.name,
|
||||||
@@ -105,6 +113,8 @@ export function DarePacksPage() {
|
|||||||
is_public: pack.is_public,
|
is_public: pack.is_public,
|
||||||
is_explicit: pack.is_explicit,
|
is_explicit: pack.is_explicit,
|
||||||
category_id: pack.category_id || '',
|
category_id: pack.category_id || '',
|
||||||
|
min_intensity: pack.min_intensity != null ? String(pack.min_intensity) : '',
|
||||||
|
max_intensity: pack.max_intensity != null ? String(pack.max_intensity) : '',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -209,6 +219,14 @@ export function DarePacksPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { range, error: intensityError } = parseIntensityRange(form.formData.min_intensity, form.formData.max_intensity);
|
||||||
|
if (intensityError) {
|
||||||
|
toast.error(intensityError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsedMin = range.min;
|
||||||
|
const parsedMax = range.max;
|
||||||
|
|
||||||
form.setSaving(true);
|
form.setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (form.editingItem) {
|
if (form.editingItem) {
|
||||||
@@ -220,6 +238,8 @@ export function DarePacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
|
min_intensity: parsedMin,
|
||||||
|
max_intensity: parsedMax,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Dare pack updated');
|
toast.success('Dare pack updated');
|
||||||
@@ -232,6 +252,8 @@ export function DarePacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
|
min_intensity: parsedMin,
|
||||||
|
max_intensity: parsedMax,
|
||||||
sort_order: totalCount,
|
sort_order: totalCount,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -410,6 +432,36 @@ export function DarePacksPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="min_intensity">Min Intensity (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="min_intensity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={form.formData.min_intensity}
|
||||||
|
onChange={(e) => form.setField('min_intensity', e.target.value)}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max_intensity">Max Intensity (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="max_intensity"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={form.formData.max_intensity}
|
||||||
|
onChange={(e) => form.setField('max_intensity', e.target.value)}
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
Gates whether this pack shows for a couple based on their intensity preference. Leave blank for no gate.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>Premium</Label>
|
<Label>Premium</Label>
|
||||||
@@ -572,6 +624,20 @@ export function DarePacksPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{(pack.min_intensity != null || pack.max_intensity != null || pack.avg_intensity != null) && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{(pack.min_intensity != null || pack.max_intensity != null) && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Intensity {pack.min_intensity ?? 1}-{pack.max_intensity ?? 5}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{pack.avg_intensity != null && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Avg {pack.avg_intensity}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { toast } from 'sonner';
|
|||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
import type { ContentReviewStatus } from '@sauci/shared';
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
import { clampDareIntensity, MAX_DARE_INTENSITY, MIN_DARE_INTENSITY } from '@/lib/dareIntensity';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -102,9 +103,11 @@ export function DaresPage() {
|
|||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
text: string;
|
text: string;
|
||||||
|
intensity: string;
|
||||||
suggested_duration_hours: string;
|
suggested_duration_hours: string;
|
||||||
}>({
|
}>({
|
||||||
text: '',
|
text: '',
|
||||||
|
intensity: '1',
|
||||||
suggested_duration_hours: '',
|
suggested_duration_hours: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,7 +169,7 @@ export function DaresPage() {
|
|||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingDare(null);
|
setEditingDare(null);
|
||||||
setFormData({ text: '', suggested_duration_hours: '' });
|
setFormData({ text: '', intensity: '1', suggested_duration_hours: '' });
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +177,7 @@ export function DaresPage() {
|
|||||||
setEditingDare(dare);
|
setEditingDare(dare);
|
||||||
setFormData({
|
setFormData({
|
||||||
text: dare.text,
|
text: dare.text,
|
||||||
|
intensity: String(dare.intensity ?? 1),
|
||||||
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -185,10 +189,13 @@ export function DaresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const intensity = clampDareIntensity(formData.intensity);
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const dareData = {
|
const dareData = {
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
|
intensity,
|
||||||
suggested_duration_hours: formData.suggested_duration_hours
|
suggested_duration_hours: formData.suggested_duration_hours
|
||||||
? parseInt(formData.suggested_duration_hours, 10)
|
? parseInt(formData.suggested_duration_hours, 10)
|
||||||
: null,
|
: null,
|
||||||
@@ -338,6 +345,31 @@ export function DaresPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="intensity">Intensity</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.intensity}
|
||||||
|
onValueChange={(value) => setFormData(d => ({ ...d, intensity: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="intensity">
|
||||||
|
<SelectValue placeholder="Select an intensity" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from(
|
||||||
|
{ length: MAX_DARE_INTENSITY - MIN_DARE_INTENSITY + 1 },
|
||||||
|
(_, i) => MIN_DARE_INTENSITY + i,
|
||||||
|
).map((level) => (
|
||||||
|
<SelectItem key={level} value={String(level)}>
|
||||||
|
{level}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
1 is mild, 5 is the most intense. Used to gate dares against a couple's intensity preference.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Suggested Duration</Label>
|
<Label>Suggested Duration</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -444,6 +476,7 @@ export function DaresPage() {
|
|||||||
<TableHead className="w-12">#</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Dare</TableHead>
|
<TableHead>Dare</TableHead>
|
||||||
<TableHead className="w-40">Catalogue</TableHead>
|
<TableHead className="w-40">Catalogue</TableHead>
|
||||||
|
<TableHead className="w-20">Intensity</TableHead>
|
||||||
<TableHead className="w-24">Duration</TableHead>
|
<TableHead className="w-24">Duration</TableHead>
|
||||||
<TableHead className="w-24">Actions</TableHead>
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -478,6 +511,11 @@ export function DaresPage() {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{dare.intensity}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{dare.suggested_duration_hours ? (
|
{dare.suggested_duration_hours ? (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Plus, Pencil, Loader2, HelpCircle, X, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { MAX_QUIZ_OPTIONS, MIN_QUIZ_OPTIONS, sanitizeQuizOptions, validateQuizQuestionForm } from '@/lib/quizQuestions';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizQuestionFormData {
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
sort_order: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveFilter = 'all' | 'active' | 'inactive';
|
||||||
|
|
||||||
|
const emptyOptions = () => ['', ''];
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function QuizQuestionsPage() {
|
||||||
|
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('all');
|
||||||
|
|
||||||
|
const form = useEntityForm<QuizQuestionFormData, QuizQuestion>(
|
||||||
|
{
|
||||||
|
prompt_self: '',
|
||||||
|
prompt_guess: '',
|
||||||
|
options: emptyOptions(),
|
||||||
|
sort_order: '0',
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
(question) => ({
|
||||||
|
prompt_self: question.prompt_self,
|
||||||
|
prompt_guess: question.prompt_guess,
|
||||||
|
options: question.options.length ? [...question.options] : emptyOptions(),
|
||||||
|
sort_order: String(question.sort_order),
|
||||||
|
is_active: question.is_active,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
let query = adminData
|
||||||
|
.from('quiz_questions')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('sort_order', { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (activeFilter === 'active') {
|
||||||
|
query = query.eq('is_active', true);
|
||||||
|
} else if (activeFilter === 'inactive') {
|
||||||
|
query = query.eq('is_active', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
setTotalCount(count || 0);
|
||||||
|
setQuestions((data as QuizQuestion[]) || []);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load quiz questions');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, activeFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [activeFilter]);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Option helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const setOption = (index: number, value: string) => {
|
||||||
|
form.setFields({
|
||||||
|
options: form.formData.options.map((opt, i) => (i === index ? value : opt)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
if (form.formData.options.length >= MAX_QUIZ_OPTIONS) return;
|
||||||
|
form.setFields({ options: [...form.formData.options, ''] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
if (form.formData.options.length <= MIN_QUIZ_OPTIONS) return;
|
||||||
|
form.setFields({ options: form.formData.options.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const validationError = validateQuizQuestionForm(form.formData);
|
||||||
|
if (validationError) {
|
||||||
|
toast.error(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedOptions = sanitizeQuizOptions(form.formData.options);
|
||||||
|
const parsedSortOrder = Number(form.formData.sort_order);
|
||||||
|
const sortOrder = Number.isFinite(parsedSortOrder) ? parsedSortOrder : 0;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
prompt_self: form.formData.prompt_self.trim(),
|
||||||
|
prompt_guess: form.formData.prompt_guess.trim(),
|
||||||
|
options: cleanedOptions,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
is_active: form.formData.is_active,
|
||||||
|
};
|
||||||
|
|
||||||
|
form.setSaving(true);
|
||||||
|
try {
|
||||||
|
if (form.editingItem) {
|
||||||
|
const { error } = await auditedAdminData.update('quiz_questions', form.editingItem.id, payload);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Quiz question updated');
|
||||||
|
} else {
|
||||||
|
const { error } = await auditedAdminData.insert('quiz_questions', payload);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Quiz question created');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.close();
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save quiz question');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
form.setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (question: QuizQuestion) => {
|
||||||
|
try {
|
||||||
|
const { error } = await auditedAdminData.update('quiz_questions', question.id, {
|
||||||
|
is_active: !question.is_active,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success(question.is_active ? 'Quiz question deactivated' : 'Quiz question activated');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update quiz question');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-9 w-64" />
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<HelpCircle className="h-8 w-8 text-purple-500" />
|
||||||
|
Quiz Questions
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{totalCount} question{totalCount !== 1 ? 's' : ''} in the couples quiz pool
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={form.dialogOpen} onOpenChange={form.setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={form.openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Question
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{form.isEditing ? 'Edit Quiz Question' : 'Add Quiz Question'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{form.isEditing
|
||||||
|
? 'Update the quiz question details below.'
|
||||||
|
: 'Add a new question to the couples quiz pool.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt_self">Prompt (about yourself)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt_self"
|
||||||
|
value={form.formData.prompt_self}
|
||||||
|
onChange={(e) => form.setField('prompt_self', e.target.value)}
|
||||||
|
placeholder="e.g., What is your idea of a perfect date night?"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Shown when a partner answers about themselves.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt_guess">Prompt (guessing about partner)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt_guess"
|
||||||
|
value={form.formData.prompt_guess}
|
||||||
|
onChange={(e) => form.setField('prompt_guess', e.target.value)}
|
||||||
|
placeholder="e.g., What is your partner's idea of a perfect date night?"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Shown when a partner guesses the other partner's answer. Same options, different phrasing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Options ({MIN_QUIZ_OPTIONS}-{MAX_QUIZ_OPTIONS})</Label>
|
||||||
|
{form.formData.options.length < MAX_QUIZ_OPTIONS && (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addOption}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Add Option
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.formData.options.map((option, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={option}
|
||||||
|
onChange={(e) => setOption(index, e.target.value)}
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
/>
|
||||||
|
{form.formData.options.length > MIN_QUIZ_OPTIONS && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
aria-label={`Remove option ${index + 1}`}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Both partners choose from the same list of options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort_order">Sort Order</Label>
|
||||||
|
<Input
|
||||||
|
id="sort_order"
|
||||||
|
type="number"
|
||||||
|
value={form.formData.sort_order}
|
||||||
|
onChange={(e) => form.setField('sort_order', e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Lower numbers are considered before higher numbers when questions have equal usage history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Active</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Inactive questions are never selected for a new quiz session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.formData.is_active}
|
||||||
|
onCheckedChange={(checked) => form.setField('is_active', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={form.close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={form.saving}>
|
||||||
|
{form.saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Label className="text-sm text-muted-foreground">Status</Label>
|
||||||
|
<Select value={activeFilter} onValueChange={(value) => setActiveFilter(value as ActiveFilter)}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="All questions" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All questions</SelectItem>
|
||||||
|
<SelectItem value="active">Active only</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quiz Questions Table */}
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<HelpCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold">No quiz questions yet</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Add questions to build the couples quiz pool
|
||||||
|
</p>
|
||||||
|
<Button onClick={form.openCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Question
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">Order</TableHead>
|
||||||
|
<TableHead>Prompt</TableHead>
|
||||||
|
<TableHead>Options</TableHead>
|
||||||
|
<TableHead className="w-24">Status</TableHead>
|
||||||
|
<TableHead className="w-32">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{questions.map((question) => (
|
||||||
|
<TableRow key={question.id}>
|
||||||
|
<TableCell className="font-medium text-muted-foreground">
|
||||||
|
{question.sort_order}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="line-clamp-2 font-medium">{question.prompt_self}</p>
|
||||||
|
<p className="line-clamp-1 text-xs text-muted-foreground">{question.prompt_guess}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{question.options.map((option, i) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-xs">
|
||||||
|
{option}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={question.is_active ? 'default' : 'secondary'}>
|
||||||
|
{question.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => form.openEdit(question)}
|
||||||
|
aria-label="Edit question"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleToggleActive(question)}
|
||||||
|
aria-label={question.is_active ? 'Deactivate question' : 'Activate question'}
|
||||||
|
title={question.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
>
|
||||||
|
{question.is_active ? (
|
||||||
|
<EyeOff className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(size);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Heart, Search, Users, Hourglass } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from '@/lib/couples';
|
||||||
|
|
||||||
|
interface Couple {
|
||||||
|
id: string;
|
||||||
|
invite_code: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
couple_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CouplesPage() {
|
||||||
|
const [couples, setCouples] = useState<Couple[]>([]);
|
||||||
|
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const fetchCouples = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * pageSize;
|
||||||
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
|
let query = adminData
|
||||||
|
.from<Couple>('couples')
|
||||||
|
.select('id, invite_code, created_at', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
const filter = buildCoupleSearchFilter(search);
|
||||||
|
if (filter) {
|
||||||
|
query = filter.op === 'eq' ? query.eq(filter.column, filter.value) : query.ilike(filter.column, filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const rows = data || [];
|
||||||
|
setCouples(rows);
|
||||||
|
setTotal(count || 0);
|
||||||
|
|
||||||
|
const coupleIds = rows.map((row) => row.id);
|
||||||
|
if (coupleIds.length > 0) {
|
||||||
|
const { data: profiles } = await adminData
|
||||||
|
.from<Profile>('profiles')
|
||||||
|
.select('id, name, email, couple_id')
|
||||||
|
.in('couple_id', coupleIds);
|
||||||
|
const grouped: Record<string, Profile[]> = {};
|
||||||
|
(profiles || []).forEach((profile) => {
|
||||||
|
if (!profile.couple_id) return;
|
||||||
|
(grouped[profile.couple_id] ??= []).push(profile);
|
||||||
|
});
|
||||||
|
setMembersByCouple(grouped);
|
||||||
|
} else {
|
||||||
|
setMembersByCouple({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load couples:', error);
|
||||||
|
toast.error('Failed to load couples');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchCouples(); }, [fetchCouples]);
|
||||||
|
useEffect(() => { setPage(1); }, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, pageSize, total]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Heart className="h-8 w-8 text-primary" />
|
||||||
|
Couples
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Look up an invite funnel by invite code or couple id
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full sm:w-72">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search invite code or couple id..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invite Code</TableHead>
|
||||||
|
<TableHead>Members</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8">
|
||||||
|
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : couples.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||||
|
{search ? 'No couples found matching your search.' : 'No couples yet.'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
couples.map((couple) => {
|
||||||
|
const members = membersByCouple[couple.id] || [];
|
||||||
|
const status = coupleStatusFromMemberCount(members.length);
|
||||||
|
return (
|
||||||
|
<TableRow key={couple.id}>
|
||||||
|
<TableCell className="font-mono">{couple.invite_code}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground text-sm">No members</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{members.map((member) => (
|
||||||
|
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
|
||||||
|
{member.name || member.email || member.id}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{status === 'paired' ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||||
|
<Users className="h-3 w-3 mr-1" />
|
||||||
|
Paired
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||||
|
<Hourglass className="h-3 w-3 mr-1" />
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(couple.created_at), 'MMM d, yyyy')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<PaginationControls
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
@@ -37,12 +37,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react';
|
import { Crown, Users, MessageCircle, ChevronRight, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil, CreditCard, Bell, Globe, BadgeCheck, HelpCircle, Swords } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
import type { AnswerType, QuestionType } from '@sauci/shared';
|
import type { AnswerType, QuestionType } from '@sauci/shared';
|
||||||
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
|
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
|
||||||
import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
import { MATCH_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
||||||
|
import { StrokeCanvas, type StrokeSegment } from '@/components/content/StrokeCanvas';
|
||||||
|
import { SENT_DARE_STATUS_LABELS, type SentDareStatus } from '@/lib/sentDares';
|
||||||
|
|
||||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
@@ -61,6 +63,7 @@ interface Profile {
|
|||||||
usage_reason: UsageReason | null;
|
usage_reason: UsageReason | null;
|
||||||
hide_nsfw: boolean | null;
|
hide_nsfw: boolean | null;
|
||||||
onboarding_completed: boolean | null;
|
onboarding_completed: boolean | null;
|
||||||
|
timezone: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||||
@@ -158,11 +161,72 @@ interface CoupleStreak {
|
|||||||
current_streak: number;
|
current_streak: number;
|
||||||
longest_streak: number;
|
longest_streak: number;
|
||||||
last_active_date: string | null;
|
last_active_date: string | null;
|
||||||
|
last_completed_date: string | null;
|
||||||
last_milestone_celebrated: number | null;
|
last_milestone_celebrated: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationPreferences {
|
||||||
|
matches_enabled: boolean;
|
||||||
|
messages_enabled: boolean;
|
||||||
|
partner_activity_enabled: boolean;
|
||||||
|
nudges_enabled: boolean;
|
||||||
|
pack_changes_enabled: boolean;
|
||||||
|
new_packs_enabled: boolean;
|
||||||
|
streak_milestones_enabled: boolean;
|
||||||
|
streak_reminders_enabled: boolean;
|
||||||
|
weekly_summary_enabled: boolean;
|
||||||
|
unpaired_reminders_enabled: boolean;
|
||||||
|
catchup_reminders_enabled: boolean;
|
||||||
|
dares_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationPreferenceLabels: Record<keyof NotificationPreferences, string> = {
|
||||||
|
matches_enabled: 'Matches',
|
||||||
|
messages_enabled: 'Messages',
|
||||||
|
partner_activity_enabled: 'Partner activity',
|
||||||
|
nudges_enabled: 'Nudges',
|
||||||
|
pack_changes_enabled: 'Pack changes',
|
||||||
|
new_packs_enabled: 'New packs',
|
||||||
|
streak_milestones_enabled: 'Streak milestones',
|
||||||
|
streak_reminders_enabled: 'Streak reminders',
|
||||||
|
weekly_summary_enabled: 'Weekly summary',
|
||||||
|
unpaired_reminders_enabled: 'Unpaired reminders',
|
||||||
|
catchup_reminders_enabled: 'Catch-up reminders',
|
||||||
|
dares_enabled: 'Dares',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: string;
|
||||||
|
store: string;
|
||||||
|
product_id: string;
|
||||||
|
status: string;
|
||||||
|
entitlement_ids: string[];
|
||||||
|
purchased_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
revenuecat_app_user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizSession {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
score_percent: number | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentDare {
|
||||||
|
id: string;
|
||||||
|
dare_text_snapshot: string;
|
||||||
|
custom_dare_text: string | null;
|
||||||
|
sender_id: string;
|
||||||
|
recipient_id: string;
|
||||||
|
status: string;
|
||||||
|
sent_at: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const matchTypeLabels = {
|
const matchTypeLabels = {
|
||||||
yes_yes: 'Both Yes!',
|
yes_yes: 'Both Yes!',
|
||||||
yes_maybe: 'Yes + Maybe',
|
yes_maybe: 'Yes + Maybe',
|
||||||
@@ -269,25 +333,7 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
|||||||
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
|
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrokePoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StrokeSegment {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
points: StrokePoint[];
|
|
||||||
color: string;
|
|
||||||
width: number;
|
|
||||||
timestamp: number;
|
|
||||||
isEraser: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CANVAS_BACKGROUND = '#1a1a2e';
|
|
||||||
|
|
||||||
function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -322,56 +368,6 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
|||||||
};
|
};
|
||||||
}, [coupleId]);
|
}, [coupleId]);
|
||||||
|
|
||||||
// Render strokes to canvas
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const w = canvas.clientWidth;
|
|
||||||
const h = canvas.clientHeight;
|
|
||||||
canvas.width = w * dpr;
|
|
||||||
canvas.height = h * dpr;
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
|
|
||||||
ctx.fillStyle = CANVAS_BACKGROUND;
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
for (const stroke of strokes) {
|
|
||||||
if (stroke.points.length === 0) continue;
|
|
||||||
ctx.strokeStyle = stroke.isEraser ? CANVAS_BACKGROUND : stroke.color;
|
|
||||||
ctx.lineWidth = stroke.width;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.beginPath();
|
|
||||||
|
|
||||||
const first = stroke.points[0];
|
|
||||||
ctx.moveTo(first.x * w, first.y * h);
|
|
||||||
|
|
||||||
if (stroke.points.length === 1) {
|
|
||||||
ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1);
|
|
||||||
} else {
|
|
||||||
for (let i = 1; i < stroke.points.length; i++) {
|
|
||||||
const prev = stroke.points[i - 1];
|
|
||||||
const curr = stroke.points[i];
|
|
||||||
const midX = ((prev.x + curr.x) / 2) * w;
|
|
||||||
const midY = ((prev.y + curr.y) / 2) * h;
|
|
||||||
|
|
||||||
if (i === 1) {
|
|
||||||
ctx.lineTo(midX, midY);
|
|
||||||
} else {
|
|
||||||
ctx.quadraticCurveTo(prev.x * w, prev.y * h, midX, midY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const last = stroke.points[stroke.points.length - 1];
|
|
||||||
ctx.lineTo(last.x * w, last.y * h);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}, [strokes]);
|
|
||||||
|
|
||||||
if (loading) return <Skeleton className="h-64 w-full" />;
|
if (loading) return <Skeleton className="h-64 w-full" />;
|
||||||
if (error) return <div className="text-sm text-red-500">Failed to load drawing: {error}</div>;
|
if (error) return <div className="text-sm text-red-500">Failed to load drawing: {error}</div>;
|
||||||
|
|
||||||
@@ -384,13 +380,8 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-xl overflow-hidden border" style={{ background: CANVAS_BACKGROUND }}>
|
<StrokeCanvas strokes={strokes} />
|
||||||
<canvas
|
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} - updates in real-time</p>
|
||||||
ref={canvasRef}
|
|
||||||
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} — updates in real-time</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -424,6 +415,10 @@ export function UserDetailPage() {
|
|||||||
const [mediaPage, setMediaPage] = useState(1);
|
const [mediaPage, setMediaPage] = useState(1);
|
||||||
const [mediaPageSize, setMediaPageSize] = useState(12);
|
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||||
const [mediaTotal, setMediaTotal] = useState(0);
|
const [mediaTotal, setMediaTotal] = useState(0);
|
||||||
|
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||||
|
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPreferences | null>(null);
|
||||||
|
const [quizSessions, setQuizSessions] = useState<QuizSession[]>([]);
|
||||||
|
const [sentDares, setSentDares] = useState<SentDare[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||||
@@ -492,6 +487,39 @@ export function UserDetailPage() {
|
|||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||||
|
|
||||||
|
// Fetch purchase history so support can see what was bought and when it lapses,
|
||||||
|
// instead of only the profile's is_premium boolean.
|
||||||
|
const { data: subscriptionsData } = await adminData
|
||||||
|
.from('subscriptions')
|
||||||
|
.select('id, store, product_id, status, entitlement_ids, purchased_at, expires_at, revenuecat_app_user_id')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('purchased_at', { ascending: false });
|
||||||
|
setSubscriptions((subscriptionsData || []) as Subscription[]);
|
||||||
|
|
||||||
|
// Fetch notification preferences (read-only visibility for support).
|
||||||
|
const { data: notificationPrefsData } = await adminData
|
||||||
|
.from('notification_preferences')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.maybeSingle();
|
||||||
|
setNotificationPrefs(notificationPrefsData);
|
||||||
|
|
||||||
|
// Fetch recent dares sent by or to this user (sent_dares has no OR support
|
||||||
|
// in the admin query builder, so this issues two filtered queries and merges).
|
||||||
|
const [sentByUser, sentToUser] = await Promise.all([
|
||||||
|
adminData.from('sent_dares')
|
||||||
|
.select('id, dare_text_snapshot, custom_dare_text, sender_id, recipient_id, status, sent_at, expires_at')
|
||||||
|
.eq('sender_id', userId).order('sent_at', { ascending: false }).limit(10),
|
||||||
|
adminData.from('sent_dares')
|
||||||
|
.select('id, dare_text_snapshot, custom_dare_text, sender_id, recipient_id, status, sent_at, expires_at')
|
||||||
|
.eq('recipient_id', userId).order('sent_at', { ascending: false }).limit(10),
|
||||||
|
]);
|
||||||
|
const daresById = new Map<string, SentDare>();
|
||||||
|
for (const row of [...(sentByUser.data || []), ...(sentToUser.data || [])] as SentDare[]) {
|
||||||
|
daresById.set(row.id, row);
|
||||||
|
}
|
||||||
|
setSentDares([...daresById.values()].sort((a, b) => new Date(b.sent_at).getTime() - new Date(a.sent_at).getTime()).slice(0, 10));
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
@@ -503,9 +531,20 @@ export function UserDetailPage() {
|
|||||||
.eq('couple_id', profileData.couple_id)
|
.eq('couple_id', profileData.couple_id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
setStreak(streakData);
|
setStreak(streakData);
|
||||||
|
|
||||||
|
// Fetch recent quiz sessions for the couple (quiz_sessions has no user_id
|
||||||
|
// column; sessions belong to the couple both partners share).
|
||||||
|
const { data: quizSessionsData } = await adminData
|
||||||
|
.from('quiz_sessions')
|
||||||
|
.select('id, status, score_percent, completed_at, created_at')
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(10);
|
||||||
|
setQuizSessions((quizSessionsData || []) as QuizSession[]);
|
||||||
} else {
|
} else {
|
||||||
setPartner(null);
|
setPartner(null);
|
||||||
setStreak(null);
|
setStreak(null);
|
||||||
|
setQuizSessions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
@@ -789,12 +828,59 @@ export function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
|
{/* Subscriptions */}
|
||||||
|
{subscriptions.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-primary" />
|
||||||
|
Subscriptions
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Purchase history from the store, independent of the is_premium flag</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Store</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Entitlements</TableHead>
|
||||||
|
<TableHead>Purchased</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>RevenueCat User</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subscriptions.map((subscription) => (
|
||||||
|
<TableRow key={subscription.id}>
|
||||||
|
<TableCell className="capitalize">{subscription.store.toLowerCase().replace('_', ' ')}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{subscription.product_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={subscription.status === 'active' ? 'default' : subscription.status === 'billing_issue' ? 'destructive' : 'secondary'} className="capitalize">
|
||||||
|
{subscription.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{subscription.entitlement_ids.join(', ') || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm">{format(new Date(subscription.purchased_at), 'MMM d, yyyy')}</TableCell>
|
||||||
|
<TableCell className="text-sm">{subscription.expires_at ? format(new Date(subscription.expires_at), 'MMM d, yyyy') : 'Never'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{subscription.revenuecat_app_user_id}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Onboarding Details */}
|
{/* Onboarding Details */}
|
||||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.hide_nsfw ? 'bg-gray-100 dark:bg-gray-800' : 'bg-amber-100 dark:bg-amber-900'}`}>{profile.hide_nsfw ? <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" /> : <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Hide NSFW</p><p className="font-medium">{profile.hide_nsfw === null ? 'Not set' : profile.hide_nsfw ? 'Yes' : 'No'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.hide_nsfw ? 'bg-gray-100 dark:bg-gray-800' : 'bg-amber-100 dark:bg-amber-900'}`}>{profile.hide_nsfw ? <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" /> : <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Hide NSFW</p><p className="font-medium">{profile.hide_nsfw === null ? 'Not set' : profile.hide_nsfw ? 'Yes' : 'No'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||||
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-teal-100 dark:bg-teal-900"><Globe className="h-4 w-4 text-teal-600 dark:text-teal-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Timezone</p><p className="font-medium">{profile.timezone || 'Not reported (UTC fallback)'}</p></div></div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
{/* Streak Info */}
|
{/* Streak Info */}
|
||||||
{profile.couple_id && streak && (
|
{profile.couple_id && streak && (
|
||||||
@@ -835,6 +921,16 @@ export function UserDetailPage() {
|
|||||||
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900">
|
||||||
|
<BadgeCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Last Completed (both partners)</p>
|
||||||
|
<p className="font-medium">{streak.last_completed_date ? format(new Date(streak.last_completed_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Determines whether the streak is alive today or tomorrow</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||||
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
@@ -848,6 +944,28 @@ export function UserDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{/* Notification Preferences */}
|
||||||
|
{notificationPrefs && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Bell className="h-5 w-5 text-primary" />
|
||||||
|
Notification Preferences
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Read-only. Configured by the user in the mobile app</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(notificationPreferenceLabels) as Array<keyof NotificationPreferences>).map((key) => (
|
||||||
|
<Badge key={key} variant={notificationPrefs[key] ? 'default' : 'secondary'} className="gap-1">
|
||||||
|
{notificationPrefs[key] ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||||
|
{notificationPreferenceLabels[key]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Feature Opt-Ins */}
|
{/* Feature Opt-Ins */}
|
||||||
{featureInterests.length > 0 && (
|
{featureInterests.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -872,6 +990,83 @@ export function UserDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{/* Quiz Sessions */}
|
||||||
|
{quizSessions.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<HelpCircle className="h-5 w-5 text-primary" />
|
||||||
|
Couples Quiz Sessions
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Recent quiz sessions for this couple</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Started</TableHead>
|
||||||
|
<TableHead>Completed</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{quizSessions.map((session) => (
|
||||||
|
<TableRow key={session.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={session.status === 'completed' ? 'default' : 'secondary'} className="capitalize">{session.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{session.score_percent !== null ? `${session.score_percent}%` : '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{format(new Date(session.created_at), 'MMM d, yyyy')}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{session.completed_at ? format(new Date(session.completed_at), 'MMM d, yyyy') : '-'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{/* Recent Dares */}
|
||||||
|
{sentDares.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Swords className="h-5 w-5 text-primary" />
|
||||||
|
Recent Dares
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Dares this user sent or received. See the Sent Dares page for the full loop and messages</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Dare</TableHead>
|
||||||
|
<TableHead>Direction</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Sent</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sentDares.map((dare) => {
|
||||||
|
const statusInfo = SENT_DARE_STATUS_LABELS[dare.status as SentDareStatus];
|
||||||
|
return (
|
||||||
|
<TableRow key={dare.id}>
|
||||||
|
<TableCell className="max-w-md line-clamp-2">{dare.custom_dare_text || dare.dare_text_snapshot}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{dare.sender_id === userId ? 'Sent' : 'Received'}</TableCell>
|
||||||
|
<TableCell><Badge variant={statusInfo?.variant ?? 'secondary'} className="capitalize">{statusInfo?.label ?? dare.status}</Badge></TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{format(new Date(dare.sent_at), 'MMM d, yyyy')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export default defineConfig({
|
|||||||
host: true,
|
host: true,
|
||||||
port: 3001,
|
port: 3001,
|
||||||
allowedHosts: ['manage.sauci.app'],
|
allowedHosts: ['manage.sauci.app'],
|
||||||
|
// The API has no CORS handling, so local development serves API calls
|
||||||
|
// same-origin and forwards them to the local API process.
|
||||||
|
proxy: {
|
||||||
|
'/v1': {
|
||||||
|
target: process.env.SAUCI_ADMIN_API_PROXY ?? 'http://127.0.0.1:3003',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
interval: 100,
|
interval: 100,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
|
||||||
|
-- answers every question twice, once about themselves and once guessing their
|
||||||
|
-- partner, and the session only scores once both partners have a complete set.
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_questions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"prompt_self" text NOT NULL UNIQUE,
|
||||||
|
"prompt_guess" text NOT NULL,
|
||||||
|
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
|
||||||
|
"sort_order" integer NOT NULL DEFAULT 0,
|
||||||
|
"is_active" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
|
||||||
|
"question_ids" uuid[] NOT NULL,
|
||||||
|
"score_percent" integer,
|
||||||
|
"completed_at" timestamptz,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
|
||||||
|
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
|
||||||
|
ON "quiz_sessions" ("couple_id", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "quiz_answers" (
|
||||||
|
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"question_id" uuid NOT NULL,
|
||||||
|
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
|
||||||
|
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("session_id", "user_id", "question_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||||
|
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
|
||||||
|
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
|
||||||
|
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
|
||||||
|
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
|
||||||
|
('How do you most like to receive love?', 'How does your partner most like to receive love?',
|
||||||
|
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
|
||||||
|
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
|
||||||
|
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
|
||||||
|
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
|
||||||
|
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
|
||||||
|
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
|
||||||
|
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
|
||||||
|
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
|
||||||
|
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
|
||||||
|
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
|
||||||
|
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
|
||||||
|
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
|
||||||
|
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
|
||||||
|
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
|
||||||
|
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
|
||||||
|
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
|
||||||
|
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
|
||||||
|
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
|
||||||
|
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
|
||||||
|
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- The seeded quiz pool of 12 questions was small enough that repeat sessions for
|
||||||
|
-- the same couple felt almost identical. Widen the pool so the never-used-first
|
||||||
|
-- selection strategy in the repository has enough fresh material to draw from.
|
||||||
|
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
|
||||||
|
('What is your ideal pet?', 'What is your partner''s ideal pet?',
|
||||||
|
ARRAY['A dog', 'A cat', 'Something low-maintenance', 'No pets, thanks'], 130),
|
||||||
|
('What is your go-to coffee or tea order?', 'What is your partner''s go-to coffee or tea order?',
|
||||||
|
ARRAY['Strong and simple', 'Something sweet and milky', 'Herbal or decaf', 'I skip it entirely'], 140),
|
||||||
|
('What feature would your dream house have?', 'What feature would your partner''s dream house have?',
|
||||||
|
ARRAY['A big kitchen', 'A garden or outdoor space', 'A cosy reading nook', 'A home cinema or games room'], 150),
|
||||||
|
('What is your favourite season?', 'What is your partner''s favourite season?',
|
||||||
|
ARRAY['Spring', 'Summer', 'Autumn', 'Winter'], 160),
|
||||||
|
('On a road trip, what role do you take?', 'On a road trip, what role does your partner take?',
|
||||||
|
ARRAY['Driver', 'Navigator', 'Playlist curator', 'Snack manager'], 170),
|
||||||
|
('When a cooking attempt goes wrong, what do you do?', 'When a cooking attempt goes wrong, what does your partner do?',
|
||||||
|
ARRAY['Laugh it off and order in', 'Try to fix it anyway', 'Start again from scratch', 'Blame the recipe'], 180),
|
||||||
|
('What is your worst phone habit?', 'What is your partner''s worst phone habit?',
|
||||||
|
ARRAY['Scrolling too long', 'Never replying quickly', 'Too many notifications on', 'Checking it during conversations'], 190),
|
||||||
|
('What makes your ideal birthday?', 'What makes your partner''s ideal birthday?',
|
||||||
|
ARRAY['A big party with friends', 'A quiet day together', 'A surprise outing', 'Just a nice meal and cake'], 200),
|
||||||
|
('How do you behave during a board game?', 'How does your partner behave during a board game?',
|
||||||
|
ARRAY['Fiercely competitive', 'Relaxed, just for fun', 'Overly strategic', 'Happy to lose as long as it is fun'], 210),
|
||||||
|
('What is your packing style before a trip?', 'What is your partner''s packing style before a trip?',
|
||||||
|
ARRAY['Packed days in advance', 'Careful list-maker', 'Last-minute scramble', 'Overpacks just in case'], 220),
|
||||||
|
('How well do you handle scary movies?', 'How well does your partner handle scary movies?',
|
||||||
|
ARRAY['Loves them, no problem', 'Watches through fingers', 'Needs the lights on after', 'Avoids them completely'], 230),
|
||||||
|
('What is your dance floor style?', 'What is your partner''s dance floor style?',
|
||||||
|
ARRAY['First one out there', 'Needs a drink first', 'Sways from the sidelines', 'Stays firmly seated'], 240),
|
||||||
|
('What would you do with a free hour to yourself?', 'What would your partner do with a free hour to themselves?',
|
||||||
|
ARRAY['Nap or rest', 'Read or watch something', 'Go for a walk or exercise', 'Tidy up or get things done'], 250),
|
||||||
|
('Which type of celebrity would you want as a dinner guest?', 'Which type of celebrity would your partner want as a dinner guest?',
|
||||||
|
ARRAY['A comedian', 'A musician', 'An athlete', 'An author or thinker'], 260),
|
||||||
|
('What childhood dream job says the most about you?', 'What childhood dream job says the most about your partner?',
|
||||||
|
ARRAY['Astronaut or explorer', 'Performer or artist', 'Something helping others', 'Something running the show'], 270),
|
||||||
|
('How would you describe your tidiness style?', 'How would you describe your partner''s tidiness style?',
|
||||||
|
ARRAY['Everything has its place', 'Tidy in bursts', 'Organised chaos', 'Relaxed about mess'], 280),
|
||||||
|
('Are you more spontaneous or a planner?', 'Is your partner more spontaneous or a planner?',
|
||||||
|
ARRAY['Fully spontaneous', 'Mostly spontaneous', 'Mostly a planner', 'Fully a planner'], 290),
|
||||||
|
('What genre do you rewatch for comfort?', 'What genre does your partner rewatch for comfort?',
|
||||||
|
ARRAY['Sitcoms', 'Feel-good films', 'Fantasy or sci-fi', 'Reality shows'], 300),
|
||||||
|
('How would you describe your social battery?', 'How would you describe your partner''s social battery?',
|
||||||
|
ARRAY['Energised by people', 'Fine in small doses', 'Drained quickly', 'Depends entirely on the day'], 310),
|
||||||
|
('What is your gift-giving style?', 'What is your partner''s gift-giving style?',
|
||||||
|
ARRAY['Thoughtful and personal', 'Practical and useful', 'Big and generous', 'Last-minute but well-meant'], 320),
|
||||||
|
('What kind of breakfast personality do you have?', 'What kind of breakfast personality does your partner have?',
|
||||||
|
ARRAY['Big cooked breakfast', 'Quick and light', 'Coffee only', 'Skips it most days'], 330),
|
||||||
|
('What weather do you prefer?', 'What weather does your partner prefer?',
|
||||||
|
ARRAY['Warm and sunny', 'Cool and crisp', 'Rainy and cosy', 'Snowy'], 340),
|
||||||
|
('If you won the lottery, what would you do first?', 'If your partner won the lottery, what would they do first?',
|
||||||
|
ARRAY['Pay off everything', 'Book a big trip', 'Treat the people close to them', 'Save it and think it over'], 350),
|
||||||
|
('When lost in a new city, what do you do?', 'When lost in a new city, what does your partner do?',
|
||||||
|
ARRAY['Ask a local for help', 'Follow a map or app closely', 'Wander until something looks familiar', 'Stay calm and retrace your steps'], 360)
|
||||||
|
ON CONFLICT ("prompt_self") DO NOTHING;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Optional proof-of-completion for dares. The sender picks a requirement
|
||||||
|
-- ('none', 'photo', 'audio') at send time for both catalogue and custom dares;
|
||||||
|
-- the recipient attaches a media object when submitting.
|
||||||
|
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_type text NOT NULL DEFAULT 'none';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_proof_type_check;
|
||||||
|
ALTER TABLE sent_dares ADD CONSTRAINT sent_dares_proof_type_check
|
||||||
|
CHECK (proof_type IN ('none', 'photo', 'audio'));
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_proof_media_idx ON sent_dares(proof_media_id) WHERE proof_media_id IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE media_objects DROP CONSTRAINT IF EXISTS media_objects_kind_check;
|
||||||
|
ALTER TABLE media_objects ADD CONSTRAINT media_objects_kind_check
|
||||||
|
CHECK (kind IN ('avatar', 'response', 'chat', 'feedback', 'dare_proof'));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Unpaired users previously hit a waiting room with no way to answer questions,
|
||||||
|
-- because responses.couple_id was NOT NULL. This migration relaxes that column so
|
||||||
|
-- a solo user can bank "sealed" answers before they have a couple. The foreign key
|
||||||
|
-- and ON DELETE CASCADE are unchanged for non-null values: once a couple is deleted,
|
||||||
|
-- the responses that belonged to it are still removed with it. Sealed answers keep
|
||||||
|
-- couple_id NULL until claimed by the application at pairing time, so couple deletion
|
||||||
|
-- has no effect on them; they are not tied to any couple's lifecycle until claimed.
|
||||||
|
ALTER TABLE "responses" ALTER COLUMN "couple_id" DROP NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- A sealed answer (couple_id IS NULL) has no couple to digest a match notification
|
||||||
|
-- for yet; the partner digest only makes sense once claim-on-pair assigns the
|
||||||
|
-- response to a couple, at which point the claim path computes matches directly.
|
||||||
|
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.couple_id IS NULL THEN RETURN NEW; END IF;
|
||||||
|
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
|
||||||
|
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
|
||||||
|
ON CONFLICT(couple_id) DO UPDATE SET
|
||||||
|
active_user_id=excluded.active_user_id,
|
||||||
|
response_count=pending_match_notifications.response_count+1,
|
||||||
|
notify_at=excluded.notify_at,
|
||||||
|
updated_at=now();
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
@@ -92,6 +92,76 @@
|
|||||||
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788168000000,
|
||||||
|
"tag": "0019_quiz",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788168060000,
|
||||||
|
"tag": "0020_quiz_question_pool",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788168090000,
|
||||||
|
"tag": "0021_dare_proof",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788168150000,
|
||||||
|
"tag": "0022_solo_sealed_answers",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
||||||
import type { ApiRepository } from './db/repository.js';
|
import type { ApiRepository } from './db/repository.js';
|
||||||
@@ -13,6 +14,10 @@ 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 type { QuizRepository } from './domains/quiz/repository.js';
|
||||||
|
import { registerQuizRoutes } from './domains/quiz/routes.js';
|
||||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
import type { CoupleService } from './domains/couples/service.js';
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
import type { PacksRepository } from './domains/packs/repository.js';
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
@@ -25,16 +30,20 @@ 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 };
|
||||||
|
|
||||||
export interface AppDependencies {
|
export interface AppDependencies {
|
||||||
auth: AuthVerifier;
|
auth: AuthVerifier;
|
||||||
repository: ApiRepository;
|
repository: ApiRepository;
|
||||||
|
corsAllowedOrigins?: string[];
|
||||||
coupleService?: CoupleService;
|
coupleService?: CoupleService;
|
||||||
packsRepository?: PacksRepository;
|
packsRepository?: PacksRepository;
|
||||||
answersRepository?: AnswersRepository;
|
answersRepository?: AnswersRepository;
|
||||||
chatRepository?: ChatRepository;
|
chatRepository?: ChatRepository;
|
||||||
|
daresRepository?: DaresRepository;
|
||||||
|
quizRepository?: QuizRepository;
|
||||||
profileSettingsRepository?: ProfileSettingsRepository;
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
accountOperationsService?: AccountOperationsService;
|
accountOperationsService?: AccountOperationsService;
|
||||||
mediaRepository?: MediaRepository;
|
mediaRepository?: MediaRepository;
|
||||||
@@ -54,6 +63,27 @@ 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 }>();
|
||||||
|
|
||||||
|
const corsAllowedOrigins = new Set(deps.corsAllowedOrigins ?? ['https://sauci.app']);
|
||||||
|
// The public web redemption form calls the API from a distinct browser origin.
|
||||||
|
// API authentication remains bearer-token based, so this does not grant cross-origin access to protected data.
|
||||||
|
app.use('*', cors({
|
||||||
|
origin: (origin) => corsAllowedOrigins.has(origin) ? origin : null,
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 +139,8 @@ 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.quizRepository) registerQuizRoutes(app, deps.quizRepository);
|
||||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||||
|
|||||||
@@ -2,11 +2,34 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional());
|
const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional());
|
||||||
const optionalUuid = z.preprocess((value) => value === '' ? undefined : value, z.string().uuid().optional());
|
const optionalUuid = z.preprocess((value) => value === '' ? undefined : value, z.string().uuid().optional());
|
||||||
|
const corsOrigins = z.string().default('https://sauci.app').transform((value, ctx) => {
|
||||||
|
const origins = value.split(',').map((origin) => origin.trim()).filter(Boolean);
|
||||||
|
if (origins.length === 0) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'CORS_ALLOWED_ORIGINS must contain at least one origin' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
for (const origin of origins) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(origin);
|
||||||
|
if (parsed.origin !== origin || parsed.username || parsed.password || parsed.pathname !== '/') {
|
||||||
|
throw new Error('not an origin');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid CORS origin: ${origin}` });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
});
|
||||||
|
|
||||||
const baseSchema = z.object({
|
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),
|
||||||
|
CORS_ALLOWED_ORIGINS: corsOrigins,
|
||||||
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(),
|
||||||
@@ -27,7 +50,9 @@ const baseSchema = z.object({
|
|||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
nodeEnv: 'development' | 'test' | 'production';
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
port: number;
|
port: number;
|
||||||
|
corsAllowedOrigins: string[];
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
|
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
|
||||||
authIssuer: string;
|
authIssuer: string;
|
||||||
authJwksUrl?: string;
|
authJwksUrl?: string;
|
||||||
authAudience: string;
|
authAudience: string;
|
||||||
@@ -80,7 +105,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
return {
|
return {
|
||||||
nodeEnv: parsed.NODE_ENV,
|
nodeEnv: parsed.NODE_ENV,
|
||||||
port: parsed.PORT,
|
port: parsed.PORT,
|
||||||
|
corsAllowedOrigins: parsed.CORS_ALLOWED_ORIGINS,
|
||||||
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,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
|
|||||||
export * from '../domains/answers/schema.js';
|
export * from '../domains/answers/schema.js';
|
||||||
export * from '../domains/chat/schema.js';
|
export * from '../domains/chat/schema.js';
|
||||||
export * from '../domains/app-data/schema.js';
|
export * from '../domains/app-data/schema.js';
|
||||||
|
export * from '../domains/quiz/schema.js';
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
|||||||
is_premium: row.isPremium,
|
is_premium: row.isPremium,
|
||||||
couple_id: row.coupleId,
|
couple_id: row.coupleId,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
|
usage_reason: row.usageReason,
|
||||||
show_explicit_content: row.showExplicitContent,
|
show_explicit_content: row.showExplicitContent,
|
||||||
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
@@ -44,10 +46,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 +60,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 +84,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 +115,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,9 +6,13 @@ 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>(),
|
||||||
|
usageReason: text('usage_reason').$type<
|
||||||
|
'improve_communication' | 'reconnect' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null
|
||||||
|
>(),
|
||||||
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
|
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
|
||||||
maxIntensity: integer('max_intensity').notNull().default(5),
|
maxIntensity: integer('max_intensity').notNull().default(5),
|
||||||
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | 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';
|
||||||
@@ -34,6 +35,12 @@ const resources: Record<string, ResourceRule> = {
|
|||||||
media_objects: { read: 'view_media' },
|
media_objects: { read: 'view_media' },
|
||||||
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
|
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
|
||||||
dares: { read: 'manage_questions', write: 'manage_questions' },
|
dares: { read: 'manage_questions', write: 'manage_questions' },
|
||||||
|
quiz_questions: { read: ['manage_questions', 'view_activity', 'view_users'], write: 'manage_questions' },
|
||||||
|
quiz_sessions: { read: ['view_users', 'view_activity'] },
|
||||||
|
quiz_answers: { read: ['view_users', 'view_activity'] },
|
||||||
|
sent_dares: { read: ['view_users', 'view_activity', 'view_chats'] },
|
||||||
|
dare_messages: { read: ['view_chats', 'view_activity'] },
|
||||||
|
notification_preferences: { read: 'view_users', idColumn: 'user_id' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function identifier(value: string): string {
|
function identifier(value: string): string {
|
||||||
@@ -139,9 +146,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 +364,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 }
|
||||||
@@ -14,24 +42,33 @@ export interface AnswersRepository {
|
|||||||
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
|
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
|
||||||
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
|
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
|
||||||
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
|
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
|
||||||
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; sealed_count?: number }>;
|
||||||
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; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* couple_id is nullable here: an unpaired user still has a profile and can answer
|
||||||
|
* questions solo, banking them as sealed answers (couple_id IS NULL on responses)
|
||||||
|
* until a partner joins. Callers that need a couple for their own operation (e.g.
|
||||||
|
* matches, streaks) get an empty/no-op result naturally because every downstream
|
||||||
|
* query is scoped by couple_id, which stays null for a solo caller.
|
||||||
|
*/
|
||||||
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]);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
|
if (!row) throw new AnswersError('no_couple', 409, 'Complete signup before answering questions');
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +78,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,12 +154,25 @@ 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 });
|
// A solo answerer has no partner, so the catch-up exemption never applies:
|
||||||
|
// every sealed answer counts against the daily limit like a fresh one.
|
||||||
|
const catchingUp = ctx.couple_id
|
||||||
|
? 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])
|
||||||
|
: { rowCount: 0 };
|
||||||
|
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]);
|
||||||
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
|
if (!ctx.couple_id) {
|
||||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
// No couple yet: bank the answer as sealed. It is claimed and reconciled
|
||||||
|
// into matches once a partner joins; see couples repository join().
|
||||||
|
const sealed = await client.query<{ count: number }>('select count(*)::int count from responses where user_id=$1 and couple_id is null',[userId]);
|
||||||
|
return { response: this.dates(response.rows[0]), match: null, sealed_count: sealed.rows[0]?.count ?? 0 };
|
||||||
|
}
|
||||||
|
const match = await this.reconcileMatch(client,userId,ctx.couple_id,q,input.answer,input.responseData ?? null,true);
|
||||||
|
await this.touchStreak(client,userId,ctx.couple_id);
|
||||||
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
|
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -139,9 +189,9 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
|
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
|
||||||
}
|
}
|
||||||
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
|
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
|
||||||
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
|
const before=existing.rows[0]; const match=ctx.couple_id?await this.reconcileMatch(client,userId,ctx.couple_id,q,input.answer,normalized,false):null;
|
||||||
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
|
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
|
||||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
if (ctx.couple_id) await this.touchStreak(client,userId,ctx.couple_id);
|
||||||
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -157,12 +207,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 +249,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 +334,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;
|
||||||
|
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
|
||||||
|
// Expiry is the one visible change no row write announces: the clock crosses
|
||||||
|
// `media_expires_at` and nothing is updated, so no timestamp moves past the
|
||||||
|
// cursor. Comparing the deadline against the window instead emits the row in
|
||||||
|
// the single poll whose `(since, now()]` contains it, which is exactly when
|
||||||
|
// the derived `media_expired` above flips.
|
||||||
|
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
|
||||||
|
or m.deleted_at > $3 or m.media_viewed_at > $3
|
||||||
|
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
|
||||||
|
|
||||||
|
const [clock, messages, removed, typing] = await Promise.all([
|
||||||
|
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
|
||||||
|
this.pool.query<MessageRecord>(
|
||||||
`select ${columns}
|
`select ${columns}
|
||||||
from messages m
|
from messages m
|
||||||
where m.match_id = $1
|
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
|
||||||
and not exists (
|
|
||||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
|
||||||
)
|
|
||||||
order by m.created_at desc, m.id desc
|
order by m.created_at desc, m.id desc
|
||||||
limit 500`,
|
limit 500`,
|
||||||
[matchId, userId],
|
since === null ? [matchId, userId] : [matchId, userId, since],
|
||||||
);
|
),
|
||||||
return result.rows.map(toMessage);
|
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,7 @@
|
|||||||
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 { calculateMatchType, type Answer } from '../answers/types.js';
|
||||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -76,6 +78,60 @@ async function profileForUpdate(client: PoolClient, userId: string): Promise<{ c
|
|||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user answering solo banks each answer with couple_id NULL ("sealed"). Once
|
||||||
|
* pairing reaches two members, every sealed answer belonging to either member is
|
||||||
|
* claimed into the couple, and any question both members now have an answer for
|
||||||
|
* gets its match computed and upserted, reusing the same match-type rules the
|
||||||
|
* live answer flow uses. This only runs from join(), the one place membership can
|
||||||
|
* go from one to two, so it only ever fires once per couple.
|
||||||
|
*/
|
||||||
|
async function claimSealedAnswers(client: PoolClient, coupleId: string): Promise<void> {
|
||||||
|
await client.query(
|
||||||
|
`update responses set couple_id = $1
|
||||||
|
where couple_id is null and user_id in (select id from profiles where couple_id = $1)`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pairs = await client.query<{
|
||||||
|
question_id: string;
|
||||||
|
question_type: string;
|
||||||
|
a_user: string;
|
||||||
|
a_answer: Answer;
|
||||||
|
a_data: Record<string, unknown> | null;
|
||||||
|
b_user: string;
|
||||||
|
b_answer: Answer;
|
||||||
|
b_data: Record<string, unknown> | null;
|
||||||
|
}>(
|
||||||
|
`select r1.question_id, q.question_type,
|
||||||
|
r1.user_id a_user, r1.answer a_answer, r1.response_data a_data,
|
||||||
|
r2.user_id b_user, r2.answer b_answer, r2.response_data b_data
|
||||||
|
from responses r1
|
||||||
|
join responses r2 on r2.question_id = r1.question_id and r2.couple_id = r1.couple_id and r2.user_id > r1.user_id
|
||||||
|
join questions q on q.id = r1.question_id
|
||||||
|
where r1.couple_id = $1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const pair of pairs.rows) {
|
||||||
|
const matchType = calculateMatchType({ id: pair.question_id, question_type: pair.question_type as never }, pair.a_answer, pair.b_answer);
|
||||||
|
if (!matchType) {
|
||||||
|
await client.query('delete from matches where couple_id = $1 and question_id = $2', [coupleId, pair.question_id]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const responseSummary = matchType === 'both_answered'
|
||||||
|
? { [pair.a_user]: pair.a_data, [pair.b_user]: pair.b_data }
|
||||||
|
: null;
|
||||||
|
await client.query(
|
||||||
|
`insert into matches(couple_id, question_id, match_type, is_new, response_summary)
|
||||||
|
values($1, $2, $3, true, $4)
|
||||||
|
on conflict(couple_id, question_id) do update set
|
||||||
|
match_type = excluded.match_type, response_summary = excluded.response_summary, is_new = true`,
|
||||||
|
[coupleId, pair.question_id, matchType, responseSummary],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -93,9 +149,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> {
|
||||||
@@ -107,7 +166,8 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
||||||
}
|
}
|
||||||
if (!profile.couple_id) return { couple: null, partner: null };
|
const sealedCount = await this.sealedCount(userId);
|
||||||
|
if (!profile.couple_id) return { couple: null, partner: null, sealed_count: sealedCount };
|
||||||
|
|
||||||
const [coupleResult, partnerResult] = await Promise.all([
|
const [coupleResult, partnerResult] = await Promise.all([
|
||||||
this.pool.query<CoupleRecord>(
|
this.pool.query<CoupleRecord>(
|
||||||
@@ -125,13 +185,22 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const couple = coupleResult.rows[0];
|
const couple = coupleResult.rows[0];
|
||||||
if (!couple) return { couple: null, partner: null };
|
if (!couple) return { couple: null, partner: null, sealed_count: sealedCount };
|
||||||
return {
|
return {
|
||||||
couple: toCouple(couple),
|
couple: toCouple(couple),
|
||||||
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
|
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
|
||||||
|
sealed_count: sealedCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sealedCount(userId: string): Promise<number> {
|
||||||
|
const result = await this.pool.query<{ count: number }>(
|
||||||
|
'select count(*)::int count from responses where user_id = $1 and couple_id is null',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
|
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
|
||||||
return transaction(this.pool, async (client) => {
|
return transaction(this.pool, async (client) => {
|
||||||
const profile = await profileForUpdate(client, userId);
|
const profile = await profileForUpdate(client, userId);
|
||||||
@@ -179,6 +248,7 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [couple.id, userId]);
|
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [couple.id, userId]);
|
||||||
|
await claimSealedAnswers(client, couple.id);
|
||||||
return toCouple(couple);
|
return toCouple(couple);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -199,6 +269,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@ import type { Couple, Profile } from '@sauci/shared';
|
|||||||
export interface CoupleStateResponse {
|
export interface CoupleStateResponse {
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
partner: Profile | null;
|
partner: Profile | null;
|
||||||
|
/** Count of this user's banked answers with no couple yet (couple_id IS NULL). */
|
||||||
|
sealed_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoupleMutationResponse {
|
export interface CoupleMutationResponse {
|
||||||
|
|||||||
@@ -0,0 +1,552 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/** Sender-chosen proof of completion; 'none' means a plain "I did it" suffices. */
|
||||||
|
export type DareProofType = 'none' | 'photo' | 'audio';
|
||||||
|
|
||||||
|
export const DARE_PROOF_TYPES: readonly DareProofType[] = ['none', 'photo', 'audio'];
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
proof_type: DareProofType;
|
||||||
|
proof_media_id: 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;
|
||||||
|
proof_type?: DareProofType | 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;
|
||||||
|
proof_type: DareProofType;
|
||||||
|
proof_media_id: 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, proofMediaId?: string | null): 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, proof_type, proof_media_id,
|
||||||
|
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,
|
||||||
|
proof_type: row.proof_type,
|
||||||
|
proof_media_id: row.proof_media_id,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofType = input.proof_type ?? 'none';
|
||||||
|
if (!DARE_PROOF_TYPES.includes(proofType)) {
|
||||||
|
throw new DaresError('invalid_proof_type', 'Proof must be none, photo, or audio', 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, proof_type, expires_at
|
||||||
|
) values (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
case when $11::integer is null then null else now() + ($11::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, proofType, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submission enforces the sender's proof requirement: a proof-required dare
|
||||||
|
* cannot be marked done without media of the matching type. Proof is also
|
||||||
|
* accepted voluntarily on 'none' dares. Media ownership, kind, and couple are
|
||||||
|
* re-checked here so a submit cannot reference someone else's upload.
|
||||||
|
*/
|
||||||
|
async submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const existing = await client.query<{
|
||||||
|
sender_id: string; recipient_id: string; status: DareStatus;
|
||||||
|
proof_type: DareProofType; couple_id: string;
|
||||||
|
}>(
|
||||||
|
'select sender_id, recipient_id, status, proof_type, couple_id from sent_dares where id = $1 for update',
|
||||||
|
[dareId],
|
||||||
|
);
|
||||||
|
const dare = existing.rows[0];
|
||||||
|
if (!dare || (dare.sender_id !== userId && dare.recipient_id !== userId)) {
|
||||||
|
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||||
|
}
|
||||||
|
if (dare.recipient_id !== userId) {
|
||||||
|
throw new DaresError('not_permitted', 'Only the recipient can do that', 403);
|
||||||
|
}
|
||||||
|
if (dare.status !== 'active') {
|
||||||
|
throw new DaresError('invalid_transition', `A ${dare.status} dare cannot change to submitted`, 409);
|
||||||
|
}
|
||||||
|
if (dare.proof_type !== 'none' && !proofMediaId) {
|
||||||
|
throw new DaresError('proof_required', `This dare requires ${dare.proof_type} proof`, 400);
|
||||||
|
}
|
||||||
|
if (proofMediaId) {
|
||||||
|
const media = await client.query<{ kind: string; mime_type: string; owner_id: string; couple_id: string | null }>(
|
||||||
|
'select kind, mime_type, owner_id, couple_id from media_objects where id = $1 and deleted_at is null',
|
||||||
|
[proofMediaId],
|
||||||
|
);
|
||||||
|
const proof = media.rows[0];
|
||||||
|
if (!proof || proof.owner_id !== userId || proof.kind !== 'dare_proof' || proof.couple_id !== dare.couple_id) {
|
||||||
|
throw new DaresError('proof_not_found', 'Proof media not found', 404);
|
||||||
|
}
|
||||||
|
const category = dare.proof_type === 'none'
|
||||||
|
? (proof.mime_type.startsWith('image/') || proof.mime_type.startsWith('audio/'))
|
||||||
|
: proof.mime_type.startsWith(dare.proof_type === 'photo' ? 'image/' : 'audio/');
|
||||||
|
if (!category) {
|
||||||
|
throw new DaresError('proof_type_mismatch', `This dare requires ${dare.proof_type} proof`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await client.query<SentDareRecord>(
|
||||||
|
`update sent_dares
|
||||||
|
set status = 'submitted', submitted_at = now(), proof_media_id = $2::uuid
|
||||||
|
where id = $1
|
||||||
|
returning ${BARE_COLUMNS}`,
|
||||||
|
[dareId, proofMediaId ?? null],
|
||||||
|
);
|
||||||
|
return toSentDare(result.rows[0]!, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,125 @@
|
|||||||
|
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(),
|
||||||
|
proof_type: z.enum(['none', 'photo', 'audio']).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();
|
||||||
|
|
||||||
|
const submitBody = z.object({ proof_media_id: id.nullish() }).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);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/dares/:dareId/submit', 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);
|
||||||
|
// Body is optional: dares without a proof requirement submit with no payload.
|
||||||
|
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!body.success) return c.json(error('invalid_proof', 'proof_media_id must be a valid media ID'), 400);
|
||||||
|
const response = await result(() =>
|
||||||
|
repository.submit(c.get('identity').id, dareId.data, body.data.proof_media_id ?? null));
|
||||||
|
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 [
|
||||||
|
['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();
|
||||||
@@ -37,7 +39,7 @@ export class PostgresMediaRepository implements MediaRepository {
|
|||||||
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
|
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
|
||||||
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
|
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
|
||||||
const coupleId = profile.rows[0].couple_id;
|
const coupleId = profile.rows[0].couple_id;
|
||||||
if ((kind === 'response' || kind === 'chat') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
if ((kind === 'response' || kind === 'chat' || kind === 'dare_proof') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
||||||
if (kind === 'response') {
|
if (kind === 'response') {
|
||||||
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
|
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
|
||||||
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
|
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
|
||||||
@@ -82,7 +84,7 @@ export class PostgresMediaRepository implements MediaRepository {
|
|||||||
from media_objects mo join profiles viewer on viewer.id=$1
|
from media_objects mo join profiles viewer on viewer.id=$1
|
||||||
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
|
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
|
||||||
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
|
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
|
||||||
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat','dare_proof') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
||||||
[userId, mediaId],
|
[userId, mediaId],
|
||||||
);
|
);
|
||||||
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
|
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
|
||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,9 @@ const allowed: Record<MediaKind, ReadonlySet<string>> = {
|
|||||||
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||||
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
|
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
|
||||||
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||||
|
dare_proof: new Set(['image/jpeg', 'image/png', 'image/webp', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||||
};
|
};
|
||||||
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 };
|
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024, dare_proof: 25*1024*1024 };
|
||||||
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
|
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
|
||||||
|
|
||||||
export class FilesystemMediaStorage {
|
export class FilesystemMediaStorage {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const;
|
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback', 'dare_proof'] as const;
|
||||||
export type MediaKind = typeof mediaKinds[number];
|
export type MediaKind = typeof mediaKinds[number];
|
||||||
|
|
||||||
export interface MediaObject {
|
export interface MediaObject {
|
||||||
|
|||||||
@@ -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}>(
|
||||||
@@ -120,9 +132,14 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
and exists(select 1 from responses r where r.couple_id=p.couple_id and r.created_at>$1::timestamptz-interval '7 days')
|
and exists(select 1 from responses r where r.couple_id=p.couple_id and r.created_at>$1::timestamptz-interval '7 days')
|
||||||
group by p.id on conflict(dedupe_key) do nothing`, [now]);
|
group by p.id on conflict(dedupe_key) do nothing`, [now]);
|
||||||
|
|
||||||
|
// Sealed answers are the earned-value hook for the nudge: a solo answerer who
|
||||||
|
// has already banked answers gets told what pairing unlocks for them, instead
|
||||||
|
// of the generic invite copy that applies before they have answered anything.
|
||||||
const unpaired=await client.query(
|
const unpaired=await client.query(
|
||||||
`with eligible as (
|
`with eligible as (
|
||||||
select p.id,p.couple_id is not null has_invite from profiles p left join notification_preferences np on np.user_id=p.id
|
select p.id,p.couple_id is not null has_invite,
|
||||||
|
(select count(*)::int from responses r where r.user_id=p.id and r.couple_id is null) sealed_count
|
||||||
|
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||||
where extract(hour from $1::timestamptz)>=18 and p.push_token is not null
|
where extract(hour from $1::timestamptz)>=18 and p.push_token is not null
|
||||||
and (p.couple_id is null or (select count(*) from profiles member where member.couple_id=p.couple_id)<2)
|
and (p.couple_id is null or (select count(*) from profiles member where member.couple_id=p.couple_id)<2)
|
||||||
and coalesce(np.unpaired_reminders_enabled,true)
|
and coalesce(np.unpaired_reminders_enabled,true)
|
||||||
@@ -131,19 +148,85 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
), queued as (
|
), queued as (
|
||||||
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||||
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
|
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
|
||||||
jsonb_build_object('title','Sauci','body',case when e.has_invite
|
jsonb_build_object('title','Sauci','body',case
|
||||||
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
when e.sealed_count>0 then 'You have '||e.sealed_count||' sealed answer'||(case when e.sealed_count=1 then '' else 's' end)||' waiting. Invite your partner to unlock what you said about them.'
|
||||||
|
when e.has_invite then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
||||||
else 'Ready to get started? Create an invite code and share it with your partner!' end,
|
else 'Ready to get started? Create an invite code and share it with your partner!' end,
|
||||||
'data',jsonb_build_object('type','unpaired_reminder'))
|
'data',jsonb_build_object('type','unpaired_reminder','sealed_count',e.sealed_count))
|
||||||
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 +285,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 +296,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 +354,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,7 +30,13 @@ 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(),
|
||||||
|
scheduledReleaseAt: timestamp('scheduled_release_at', { withTimezone: true }),
|
||||||
|
releaseNotified: boolean('release_notified').notNull().default(false),
|
||||||
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
||||||
|
|
||||||
export const questions = pgTable('questions', {
|
export const questions = pgTable('questions', {
|
||||||
@@ -42,6 +52,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)]);
|
||||||
|
|
||||||
@@ -49,7 +63,9 @@ export const responses = pgTable('responses', {
|
|||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }),
|
questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }),
|
||||||
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
// Nullable: an unpaired user's answer is banked as a sealed answer with no couple
|
||||||
|
// yet. It is claimed into the couple at pairing time; see the answers repository.
|
||||||
|
coupleId: uuid('couple_id').references(() => couples.id, { onDelete: 'cascade' }),
|
||||||
answer: text('answer').notNull(),
|
answer: text('answer').notNull(),
|
||||||
responseData: jsonb('response_data'),
|
responseData: jsonb('response_data'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
@@ -64,3 +80,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 {
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
import { computeQuizScore, type QuizAnswerPair } from './service.js';
|
||||||
|
import {
|
||||||
|
QuizError,
|
||||||
|
type QuizAnswerInput,
|
||||||
|
type QuizQuestionSummary,
|
||||||
|
type QuizResultPayload,
|
||||||
|
type QuizSessionPayload,
|
||||||
|
type QuizSessionStatus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
interface QuizQuestionRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
prompt_self: string;
|
||||||
|
prompt_guess: string;
|
||||||
|
options: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizSessionRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
status: QuizSessionStatus;
|
||||||
|
question_ids: string[];
|
||||||
|
score_percent: number | null;
|
||||||
|
completed_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizAnswerRow extends QueryResultRow {
|
||||||
|
user_id: string;
|
||||||
|
question_id: string;
|
||||||
|
self_index: number;
|
||||||
|
guess_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizRepository {
|
||||||
|
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
|
||||||
|
currentSession(userId: string): Promise<QuizSessionPayload | null>;
|
||||||
|
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
|
||||||
|
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUESTIONS_PER_SESSION = 10;
|
||||||
|
|
||||||
|
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const result = await work(client);
|
||||||
|
await client.query('commit');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
|
||||||
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresQuizRepository implements QuizRepository {
|
||||||
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
|
constructor(connection: DatabaseConnection) {
|
||||||
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
|
||||||
|
const result = await client.query<{ couple_id: string | null }>(
|
||||||
|
'select couple_id from profiles where id = $1',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.couple_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
|
||||||
|
const result = await client.query<{ id: string }>(
|
||||||
|
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
|
||||||
|
[coupleId, userId],
|
||||||
|
);
|
||||||
|
return result.rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
|
||||||
|
const result = await client.query<QuizQuestionRow>(
|
||||||
|
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
|
||||||
|
[questionIds],
|
||||||
|
);
|
||||||
|
return orderByIds(result.rows, questionIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildPayload(
|
||||||
|
client: Pool | PoolClient,
|
||||||
|
session: QuizSessionRow,
|
||||||
|
userId: string,
|
||||||
|
): Promise<QuizSessionPayload> {
|
||||||
|
const questions = await this.questionsFor(client, session.question_ids);
|
||||||
|
const [myAnswers, counts] = await Promise.all([
|
||||||
|
client.query<QuizAnswerRow>(
|
||||||
|
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
|
||||||
|
[session.id, userId],
|
||||||
|
),
|
||||||
|
client.query<{ user_id: string; count: string }>(
|
||||||
|
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||||
|
[session.id],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const coupleId = session.couple_id;
|
||||||
|
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||||
|
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||||
|
const questionCount = session.question_ids.length;
|
||||||
|
|
||||||
|
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
|
||||||
|
const orderedAnswers: QuizAnswerInput[] = session.question_ids
|
||||||
|
.map((questionId) => answersByQuestion.get(questionId))
|
||||||
|
.filter((row): row is QuizAnswerRow => row !== undefined)
|
||||||
|
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
|
||||||
|
|
||||||
|
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
prompt_self: row.prompt_self,
|
||||||
|
prompt_guess: row.prompt_guess,
|
||||||
|
options: row.options,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
status: session.status,
|
||||||
|
created_at: session.created_at.toISOString(),
|
||||||
|
completed_at: session.completed_at?.toISOString() ?? null,
|
||||||
|
score_percent: session.score_percent,
|
||||||
|
questions: questionSummaries,
|
||||||
|
my_answers: orderedAnswers,
|
||||||
|
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
|
||||||
|
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const coupleId = await this.coupleOf(client, userId);
|
||||||
|
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
|
||||||
|
|
||||||
|
const members = await client.query<{ count: string }>(
|
||||||
|
'select count(*)::text as count from profiles where couple_id = $1',
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
if (Number(members.rows[0]?.count ?? 0) < 2) {
|
||||||
|
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await client.query<QuizSessionRow>(
|
||||||
|
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer questions this couple has never had in any prior session (completed or
|
||||||
|
// active), then the least-recently-used ones, with a random tiebreak within each
|
||||||
|
// group so repeat quizzes stay varied instead of always reusing the same pool.
|
||||||
|
const questions = await client.query<{ id: string }>(
|
||||||
|
`select q.id
|
||||||
|
from quiz_questions q
|
||||||
|
left join (
|
||||||
|
select question_id, max(created_at) as last_used
|
||||||
|
from quiz_sessions, unnest(question_ids) as question_id
|
||||||
|
where couple_id = $1
|
||||||
|
group by question_id
|
||||||
|
) usage on usage.question_id = q.id
|
||||||
|
where q.is_active = true
|
||||||
|
order by (usage.last_used is not null), usage.last_used asc, random()
|
||||||
|
limit $2`,
|
||||||
|
[coupleId, QUESTIONS_PER_SESSION],
|
||||||
|
);
|
||||||
|
const questionIds = questions.rows.map((row) => row.id);
|
||||||
|
|
||||||
|
// The partial unique index (one active session per couple) is the source of
|
||||||
|
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
|
||||||
|
// lets the loser fall through to reading the winner's row instead of erroring.
|
||||||
|
const inserted = await client.query<QuizSessionRow>(
|
||||||
|
`insert into quiz_sessions (couple_id, question_ids)
|
||||||
|
values ($1, $2::uuid[])
|
||||||
|
on conflict (couple_id) where status = 'active' do nothing
|
||||||
|
returning *`,
|
||||||
|
[coupleId, questionIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
let sessionRow = inserted.rows[0];
|
||||||
|
let created = true;
|
||||||
|
if (!sessionRow) {
|
||||||
|
created = false;
|
||||||
|
const raced = await client.query<QuizSessionRow>(
|
||||||
|
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
sessionRow = raced.rows[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session: await this.buildPayload(client, sessionRow, userId), created };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
|
||||||
|
const coupleId = await this.coupleOf(this.pool, userId);
|
||||||
|
if (!coupleId) return null;
|
||||||
|
const result = await this.pool.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
|
||||||
|
[coupleId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return this.buildPayload(this.pool, row, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const sessionResult = await client.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where id = $1 for update',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
const coupleId = await this.coupleOf(client, userId);
|
||||||
|
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||||
|
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
}
|
||||||
|
if (session.status !== 'active') {
|
||||||
|
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionIds = new Set(session.question_ids);
|
||||||
|
const answeredIds = new Set(answers.map((answer) => answer.question_id));
|
||||||
|
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
for (const id of answeredIds) {
|
||||||
|
if (!questionIds.has(id)) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions = await this.questionsFor(client, session.question_ids);
|
||||||
|
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
|
||||||
|
for (const answer of answers) {
|
||||||
|
const options = optionsByQuestion.get(answer.question_id);
|
||||||
|
if (!options) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
answer.self_index < 0 || answer.self_index >= options.length ||
|
||||||
|
answer.guess_index < 0 || answer.guess_index >= options.length
|
||||||
|
) {
|
||||||
|
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const answer of answers) {
|
||||||
|
await client.query(
|
||||||
|
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
|
||||||
|
values ($1, $2, $3, $4, $5)
|
||||||
|
on conflict (session_id, user_id, question_id)
|
||||||
|
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
|
||||||
|
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = await this.partnerOf(client, coupleId, userId);
|
||||||
|
let updatedSession = session;
|
||||||
|
if (partnerId) {
|
||||||
|
const counts = await client.query<{ user_id: string; count: string }>(
|
||||||
|
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
|
||||||
|
const questionCount = session.question_ids.length;
|
||||||
|
const bothComplete =
|
||||||
|
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
|
||||||
|
|
||||||
|
if (bothComplete) {
|
||||||
|
const allAnswers = await client.query<QuizAnswerRow>(
|
||||||
|
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||||
|
for (const row of allAnswers.rows) {
|
||||||
|
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||||
|
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||||
|
}
|
||||||
|
const mine = byUser.get(userId)!;
|
||||||
|
const theirs = byUser.get(partnerId)!;
|
||||||
|
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
|
||||||
|
questionId,
|
||||||
|
aSelfIndex: mine.get(questionId)!.self_index,
|
||||||
|
aGuessIndex: mine.get(questionId)!.guess_index,
|
||||||
|
bSelfIndex: theirs.get(questionId)!.self_index,
|
||||||
|
bGuessIndex: theirs.get(questionId)!.guess_index,
|
||||||
|
}));
|
||||||
|
const { scorePercent } = computeQuizScore(pairs);
|
||||||
|
|
||||||
|
const completed = await client.query<QuizSessionRow>(
|
||||||
|
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
|
||||||
|
where id = $1
|
||||||
|
returning *`,
|
||||||
|
[sessionId, scorePercent],
|
||||||
|
);
|
||||||
|
updatedSession = completed.rows[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildPayload(client, updatedSession, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
|
||||||
|
const sessionResult = await this.pool.query<QuizSessionRow>(
|
||||||
|
'select * from quiz_sessions where id = $1',
|
||||||
|
[sessionId],
|
||||||
|
);
|
||||||
|
const session = sessionResult.rows[0];
|
||||||
|
const coupleId = await this.coupleOf(this.pool, userId);
|
||||||
|
if (!session || !coupleId || session.couple_id !== coupleId) {
|
||||||
|
throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
}
|
||||||
|
if (session.status !== 'completed') {
|
||||||
|
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
|
||||||
|
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
|
||||||
|
|
||||||
|
const [questions, allAnswers] = await Promise.all([
|
||||||
|
this.questionsFor(this.pool, session.question_ids),
|
||||||
|
this.pool.query<QuizAnswerRow>(
|
||||||
|
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
|
||||||
|
[sessionId],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
|
||||||
|
for (const row of allAnswers.rows) {
|
||||||
|
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
|
||||||
|
byUser.get(row.user_id)!.set(row.question_id, row);
|
||||||
|
}
|
||||||
|
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
|
||||||
|
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
|
||||||
|
|
||||||
|
const questionsPayload = questions.map((question) => {
|
||||||
|
const my = mine.get(question.id);
|
||||||
|
const partner = theirs.get(question.id);
|
||||||
|
const mySelf = my?.self_index ?? 0;
|
||||||
|
const myGuess = my?.guess_index ?? 0;
|
||||||
|
const partnerSelf = partner?.self_index ?? 0;
|
||||||
|
const partnerGuess = partner?.guess_index ?? 0;
|
||||||
|
return {
|
||||||
|
question_id: question.id,
|
||||||
|
prompt_self: question.prompt_self,
|
||||||
|
prompt_guess: question.prompt_guess,
|
||||||
|
options: question.options,
|
||||||
|
my_self_index: mySelf,
|
||||||
|
my_guess_index: myGuess,
|
||||||
|
partner_self_index: partnerSelf,
|
||||||
|
partner_guess_index: partnerGuess,
|
||||||
|
i_guessed_right: myGuess === partnerSelf,
|
||||||
|
partner_guessed_right: partnerGuess === mySelf,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
score_percent: session.score_percent ?? 0,
|
||||||
|
completed_at: session.completed_at?.toISOString() ?? null,
|
||||||
|
questions: questionsPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ApiErrorResponse } from '@sauci/shared';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
|
import { QuizError, type QuizAnswerInput } from './types.js';
|
||||||
|
import type { QuizRepository } from './repository.js';
|
||||||
|
|
||||||
|
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
|
|
||||||
|
const id = z.string().uuid();
|
||||||
|
|
||||||
|
const answerEntry = z
|
||||||
|
.object({
|
||||||
|
question_id: id,
|
||||||
|
self_index: z.number().int().min(0),
|
||||||
|
guess_index: z.number().int().min(0),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
|
||||||
|
|
||||||
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
|
return { error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function result<T>(operation: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return { ok: true as const, value: await operation() };
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof QuizError) return { ok: false as const, cause };
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
|
||||||
|
app.post('/v1/quiz/sessions', async (c) => {
|
||||||
|
const response = await result(() => repository.startSession(c.get('identity').id));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/quiz/sessions/current', async (c) => {
|
||||||
|
const response = await result(() => repository.currentSession(c.get('identity').id));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
|
||||||
|
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||||
|
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||||
|
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
|
||||||
|
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
|
||||||
|
const answers: QuizAnswerInput[] = body.data.answers;
|
||||||
|
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json({ session: response.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
|
||||||
|
const sessionId = id.safeParse(c.req.param('sessionId'));
|
||||||
|
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
|
||||||
|
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
|
||||||
|
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
|
return c.json(response.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { couples } from '../couples/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
|
||||||
|
* same question so a session can ask each partner about themselves and about the
|
||||||
|
* other without a second row.
|
||||||
|
*/
|
||||||
|
export const quizQuestions = pgTable('quiz_questions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
promptSelf: text('prompt_self').notNull().unique(),
|
||||||
|
promptGuess: text('prompt_guess').notNull(),
|
||||||
|
options: text('options').array().notNull(),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const quizSessions = pgTable('quiz_sessions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||||
|
status: text('status').notNull().default('active'),
|
||||||
|
questionIds: uuid('question_ids').array().notNull(),
|
||||||
|
scorePercent: integer('score_percent'),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
|
||||||
|
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
|
||||||
|
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const quizAnswers = pgTable('quiz_answers', {
|
||||||
|
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
|
||||||
|
userId: uuid('user_id').notNull(),
|
||||||
|
questionId: uuid('question_id').notNull(),
|
||||||
|
selfIndex: integer('self_index').notNull(),
|
||||||
|
guessIndex: integer('guess_index').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
|
||||||
|
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
|
||||||
|
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
|
||||||
|
export type QuizSessionRow = typeof quizSessions.$inferSelect;
|
||||||
|
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
|
||||||
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user