182 Commits
Author SHA1 Message Date
jjbrunton ea038c9863 merge: add sex fun market strategy 2026-09-05 22:55:52 +01:00
jjbrunton a431adad89 docs(product): add sex fun market strategy 2026-09-05 22:55:10 +01:00
jjbrunton 860986f900 Merge pull request #32 from jjbrunton/task/dare-catalogue-150
feat(dares): seed 150-item catalogue
2026-09-05 22:35:28 +01:00
jjbrunton 439f506fff feat(dares): seed 150-item catalogue 2026-09-05 22:30:39 +01:00
jjbrunton d998e32047 merge: require upfront OTA assessment 2026-09-05 22:15:42 +01:00
jjbrunton 7edd957547 docs(agents): require upfront OTA assessment 2026-09-05 22:15:29 +01:00
jjbrunton fdaa8c47bb merge: add full question catalogue draft 2026-09-05 21:41:20 +01:00
jjbrunton 04328fb3a8 fix(catalogue): integrate reviewed question sources 2026-09-05 21:36:20 +01:00
jjbrunton f373e3beb4 docs(catalogue): add full live catalogue draft 2026-09-05 21:03:46 +01:00
jjbrunton 583d38eb8c merge(staging): preserve main admin fixes 2026-09-05 20:51:32 +01:00
jjbrunton d604cd7a11 fix(catalogue): avoid literal em dash in validator 2026-09-05 20:28:26 +01:00
jjbrunton ddf1e33a4e merge: add play-safe spicy question draft 2026-09-05 20:27:55 +01:00
jjbrunton da1c310738 docs(catalogue): trim draft source whitespace 2026-09-05 20:27:31 +01:00
jjbrunton 5c8c9cc9a5 docs(catalogue): finalise spicy question draft 2026-09-05 20:24:27 +01:00
jjbrunton c297f03b56 docs(catalogue): add audio concept source 2026-09-05 20:16:34 +01:00
jjbrunton c79d993cf5 docs(catalogue): preserve text answer concepts 2026-09-05 20:14:08 +01:00
jjbrunton b5e6b70d54 docs(catalogue): repair spicy swipe source 2026-09-05 20:07:14 +01:00
jjbrunton 2a2cbe9e85 docs(catalogue): add concept set b source 2026-09-05 19:59:02 +01:00
jjbrunton eb54aa4daa docs(questions): add concept set a swipe drafts 2026-09-05 19:57:48 +01:00
jjbrunton e509ac732f docs(catalogue): replace swipe draft concepts 2026-09-05 19:45:51 +01:00
jjbrunton a72fe9c06d docs(catalogue): expand draft pack themes 2026-09-05 19:36:52 +01:00
jjbrunton 450c527d1e docs(question-types): document interaction contract 2026-09-05 19:33:17 +01:00
jjbrunton 6c7219c741 docs(catalogue): refine spicy draft copy 2026-09-05 19:31:57 +01:00
jjbrunton b02a105e10 docs(catalogue): add play-safe spicy draft 2026-09-05 19:29:05 +01:00
jjbrunton f4687f95fe Merge pull request #29 from jjbrunton/staging
fix(admin): prevent blank dashboard after login
2026-09-05 17:12:45 +01:00
jjbrunton 53a48ab6d5 fix(admin): use feature interest composite key 2026-09-05 17:02:07 +01:00
jjbrunton a6233d0175 fix(admin): render standalone feature interests 2026-09-05 16:57:57 +01:00
jjbrunton c776859183 Merge pull request #28 from jjbrunton/staging
Promote staging to main: Apple review onboarding fixes and v1.0.8 build 49
2026-09-01 16:16:37 +01:00
jjbrunton cc12f5f0ff Merge remote-tracking branch 'origin/main' into sync/staging-backmerge 2026-09-01 16:10:11 +01:00
jjbrunton 3659cc8c2f chore(ios): record v1.0.8 build 49 2026-09-01 14:08:05 +01:00
jjbrunton 4b227ad62c chore(mobile): prepare iOS build 48 2026-09-01 13:47:47 +01:00
jjbrunton 7c8d91c89c fix(mobile): address Apple review onboarding flows 2026-09-01 09:42:48 +01:00
jjbrunton fe1248c59d Merge pull request #25 from jjbrunton/staging
Staging
2026-08-31 20:56:43 +01:00
jjbrunton 6883befff3 fix(mobile): persist matches tutorial dismissal 2026-08-31 19:39:33 +01:00
jjbrunton a06f1b69e8 Merge pull request #27 from jjbrunton/hotfix/admin-cors-origin
fix(api): allow the admin console origin through CORS
2026-08-31 18:22:23 +01:00
jjbruntonandClaude Opus 5 3a019e62d3 fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:26 +01:00
jjbruntonandClaude Opus 5 636811e66a fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:10 +01:00
jjbrunton 84712a950e Merge pull request #26 from jjbrunton/hotfix/admin-docker-context
fix(admin): restore admin docker build context, allow hotfix branches into main
2026-08-31 18:08:31 +01:00
jjbruntonandClaude Opus 5 d14869d745 ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 f1573f4906 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 d825e3d80c ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:34 +01:00
jjbruntonandClaude Opus 5 13094267a6 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:56:41 +01:00
jjbruntonandClaude Fable 5 ecc31a13a2 feat(release): enforce manifest and Info.plist release invariants
Extend release-preflight to reject an Android manifest missing the
READ_MEDIA_IMAGES/READ_MEDIA_VIDEO tools:node="remove" attributes and
an iOS Info.plist that regained dev-launcher-only keys, both
regressions introduced silently by expo prebuild during the SDK 54 to
56 upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:04 +01:00
jjbruntonandClaude Fable 5 7a76936829 docs(agents): promote v1.0.8 release learnings
Record three confirmed v1.0.8 failure modes (expo prebuild native
regressions, npm 10/11 lockfile divergence, local EAS build
preconditions) and promote each into docs/releasing.md, a new
docs/development/dependencies.md, and the release-preflight script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:02 +01:00
jjbrunton 83df3b45f8 Merge pull request #23 from jjbrunton/staging
Release v1.0.8: Expo SDK 56 and EAS Observe
2026-08-31 15:47:29 +01:00
jjbruntonandClaude Fable 5 9609a9084b fix(deps): pin @react-native/metro-config to end npm 10/11 lock divergence
react-native-worklets depends on @react-native/metro-config with a
wildcard range; npm 11 (local) resolves and hoists 0.87.x where npm 10
(CI runners and Docker images) requires 0.85.3 at the same position, so
every lockfile regeneration broke one side. Pin 0.85.3 via the root
overrides and regenerate: npm ci now passes on npm 10.8.2 linux/amd64
(SWC, rollup, and lightningcss platform binaries all install) and on
local npm 11, with verify:fast green on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:43:49 +01:00
jjbruntonandClaude Fable 5 90cde4aca4 fix(web): pin Next SWC binaries and regenerate lockfile with CI's npm
Since the SDK 56 dependency churn, arborist nests next under apps/web and
npm 10 skipped its platform-optional SWC binaries on the x64 CI runner,
breaking next build. Pin the linux and darwin binaries as direct
optionalDependencies of @sauci/web (bump alongside next) and regenerate
the lockfile from scratch with npm 10.8.2 to match CI. Verified in a
linux/amd64 node:20.19.4 container: npm ci installs the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:29:01 +01:00
jjbruntonandClaude Fable 5 ecff9e2ec8 fix(deps): regenerate lockfile with npm 10 for CI compatibility
Today's dependency changes left orphaned @react-native 0.87.x entries
that npm 10's ci validation rejects; npm 11 tolerated them locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:02:09 +01:00
jjbrunton ffc655f576 Merge remote-tracking branch 'origin/main' into staging 2026-08-31 14:59:44 +01:00
jjbruntonandClaude Fable 5 d8bedead0c chore(ios): sync widget Xcode build number to 47
EAS auto-increment updates the Info.plists but not the widget target's
CURRENT_PROJECT_VERSION; the shipped widget binary already carries 47
from its Info.plist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:59:02 +01:00
jjbruntonandClaude Fable 5 6c3a181613 chore(android): record v1.0.8 build 51
AAB sha256 4893ee6f64f90bb1b2e1f13c6b088cbe75103337fbc101751da7076224778c29
verified: com.sauci.app 1.0.8 (51), production upload key, four ABIs,
Worklets/Reanimated/Skia libraries, no broad media read permissions,
production OTA endpoint, channel, and code-signing certificate. Supersedes
the build 50 artifact, which predated the skia 2.6.9 bump and the
expo-av migration and was quarantined unshipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:58:10 +01:00
jjbruntonandClaude Fable 5 d8ff39a2f3 chore(ios): record v1.0.8 build 47
IPA sha256 760517ccf474d496c940c3f84825f4852306d840d913090610a77dc24450ee36
verified: com.sauci.app and com.sauci.app.LiveDrawWidget both 1.0.8 (47),
team BXVPBATDZY, associated domains, shared app group, production OTA
runtime, endpoint, and code-signing certificate, no dev-launcher keys.
Also syncs the tracked Podfile.lock to the archive's resolution (expo-av
removed; ExpoVideo, ExpoAudio, ExpoSystemUI added; skia 2.6.9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:46:44 +01:00
jjbruntonandClaude Fable 5 b8cb2addff fix(mobile): migrate expo-av to expo-video and expo-audio
expo-av has no SDK 56 compatible release, and its EXEventEmitter.h
interface was removed from expo-modules-core, so the iOS release
archive fails compiling Pods/EXAV. Move video playback to
expo-video's useVideoPlayer/VideoView, and audio playback and
recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder,
preserving existing controls, mute/loop/resize behavior, and the
recording audio session configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:35:51 +01:00
jjbruntonandClaude Fable 5 5e28117662 fix(ios): bump react-native-skia to 2.6.9 for the base64 header fix
2.6.2 fails to archive under the SDK 56 toolchain: base64.cpp includes
third_party/base64.h, which the podspec header glob no longer resolves.
2.6.9 includes the header relative to its own directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:15:42 +01:00
jjbruntonandClaude Fable 5 196428f796 chore(android): record v1.0.8 build 50
AAB sha256 29c9bee3949e51b027c558637c04eefec96bf7f177151cb9571ca2a289ffee4e
verified: com.sauci.app 1.0.8 (50), production upload key, four ABIs,
Worklets/Reanimated/Skia libraries, no broad media read permissions,
production OTA endpoint, channel, and code-signing certificate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:07:58 +01:00
jjbruntonandClaude Fable 5 6bd04a6c06 fix(ios): keep dev-launcher keys out of the release Info.plist
The SDK 56 prebuild added NSBonjourServices, RCTMetroPort, and the Expo
Dev Launcher NSLocalNetworkUsageDescription to the tracked Info.plist.
v1.0.7 shipped without them and the production app has no local-network
feature to declare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:43:56 +01:00
jjbruntonandClaude Fable 5 7aca39aa8f fix(android): restore blocked media permission removals in manifest
The SDK 56 prebuild dropped the tools:node=remove entries for
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the merged release manifest
regained permissions the Play media policy forbids; Sauci uses the system
photo picker. Restore the removals shipped in v1.0.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:42:44 +01:00
jjbruntonandClaude Fable 5 79233f58aa fix(android): raise Gradle daemon memory for RN 0.85 toolchain
The prebuild-regenerated gradle.properties restored stock 2GB heap and
512MB Metaspace limits; KSP and CMake configuration under SDK 56 exhaust
Metaspace and fail the release build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:19:41 +01:00
jjbruntonandClaude Fable 5 c9e8835810 fix(mobile): scope Expo exports to native platforms
A bare expo export (used by the OTA preflight and by eoas publish) also
exported the unused web target, whose static render breaks under SDK 56.
XPREM serves only iOS and Android bundles and the website is apps/web, so
declare the Expo app native-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:12:18 +01:00
jjbruntonandClaude Fable 5 ff8f99f418 chore(release): prepare v1.0.8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:07:13 +01:00
jjbruntonandClaude Fable 5 604eec3322 fix(ios): restore LiveDrawWidget identity after apple-targets 5 regeneration
The SDK 56 prebuild derived the widget bundle id from its folder name
(com.sauci.app.widget) and pointed CODE_SIGN_ENTITLEMENTS at the ignored
ios/.targets cache. Restore the shipped com.sauci.app.LiveDrawWidget
identity, the tracked entitlements file, and pin bundleIdentifier in the
target config so future prebuilds keep it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:07:13 +01:00
jjbrunton dbf548c7bd Merge remote-tracking branch 'origin/staging' into staging
# Conflicts:
#	docs/releasing.md
2026-08-31 11:54:50 +01:00
jjbrunton 4973f2c631 Merge remote-tracking branch 'origin/staging' into staging
# Conflicts:
#	.codex/skills/release-mobile/SKILL.md
#	apps/mobile/package.json
#	docs/releasing.md
2026-08-31 11:34:33 +01:00
jjbruntonandClaude Fable 5 2b05e50312 feat(release): add fastlane ship lanes and end-to-end release pipeline docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:32:32 +01:00
jjbruntonandClaude Opus 5 26312e5a58 docs(release): a release request carries through to rollout
The upload execution policy stopped an agent at upload: App Review
submission and rollout each needed their own explicit authorization, so a
request to ship a release ended with an artifact sitting in App Store
Connect and a question back to the user. That contradicted the harness
standard in documentation/standards/react-native.md, which has always
authorized the full pipeline, and it is the wrong default: every step from
here is recoverable. A submission can be cancelled while it waits for
review, a phased release paused, a staged rollout halted.

A release request now authorizes build, verification, upload, App Review
submission, and rollout for the platforms it names.

What still needs its own authorization is anything that is not part of
shipping the requested release: widening TestFlight distribution to new
groups, and re-uploading after an ambiguous upload result. The existing
automatic-TestFlight-distribution check before an iOS upload is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:31:08 +01:00
jjbrunton 66c25849e9 Merge remote-tracking branch 'origin/staging' into task/expo-56-observe
# Conflicts:
#	docs/releasing.md
2026-08-31 11:15:09 +01:00
jjbruntonandClaude Fable 5 5c4a4608b1 fix(mobile): record xprem_js_crash only for fatal JS errors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:13:55 +01:00
jjbruntonandClaude Fable 5 659cee76b6 feat(mobile): add expo-observe update health tracking
Install expo-observe (56.0.29, matching the SDK 56 line) and configure
its endpoint under expo.extra.eas.observe in app.json, alongside the
existing EAS projectId. Leave dispatchInDebug and sampleRate at their
defaults.

Log an app_started custom event once per cold start from the root
layout's existing "run once" startup effect, and install a global JS
error handler that logs xprem_js_crash and then always delegates to
whatever handler was previously registered, so existing Crashlytics
reporting is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:43 +01:00
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00
jjbruntonandClaude Opus 5 aa062c9fd0 fix(mobile): make ota-publish actually run under a path with spaces
The entrypoint guard compared process.argv[1] against
new URL(import.meta.url).pathname. A URL pathname percent-encodes spaces, so
in any checkout whose absolute path contains one the comparison never matched
and main() never ran.

Every ota:preflight:*, ota:publish:*, and ota:doctor:* script then exited 0
having done absolutely nothing, which is indistinguishable from success. A
publish that silently no-ops is worse than one that fails: it reports a
shipped update that never left the machine.

Compare resolved filesystem paths instead.

Verified from a checkout whose path contains a space: before the change
`node scripts/ota-publish.mjs production --preflight` exited 0 with no
output; after it runs a real export for both platforms and prints the
preflight confirmation. Argument validation still rejects unknown targets and
unsupported EOAS arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:09:38 +01:00
jjbrunton 5947220fc6 feat(release): add mobile build preflight 2026-08-31 10:18:20 +01:00
jjbrunton 8bf93f372b docs(release): authorize scoped store uploads 2026-08-31 10:08:03 +01:00
jjbrunton 2f05e9ae87 Merge pull request #21 from jjbrunton/staging
chore(release): publish v1.0.7 metadata
2026-08-31 01:29:58 +01:00
jjbrunton 3fc6f8aa1a chore(ios): record v1.0.7 build 46 2026-08-31 01:20:45 +01:00
jjbrunton 02a7349899 chore(android): record v1.0.7 build 49 2026-08-31 00:37:41 +01:00
jjbrunton 8f70dbcc00 fix(mobile): include OTA certificate in builds 2026-08-31 00:16:39 +01:00
jjbrunton ac80d6b614 chore(release): prepare v1.0.7 2026-08-31 00:02:15 +01:00
jjbrunton 056bb62d04 Merge pull request #20 from jjbrunton/staging
release: promote v1.0.7
2026-08-30 23:52:58 +01:00
jjbrunton 80ec1663f9 merge: fix android release native inputs 2026-08-30 23:48:17 +01:00
jjbrunton 8ff05c050b fix(mobile): order android release native inputs
Materialize the Worklets CMake input expected by Reanimated and order\nPrefab producers before the app release CMake configuration.
2026-08-30 23:47:05 +01:00
jjbrunton 549419892d fix(ota): propagate profile env to EOAS 2026-08-30 22:07:38 +01:00
jjbrunton a46b8b95e6 Merge commit '267f3c3cf9e5acf6087a51bc4f81141f8285fb76' into staging 2026-08-30 21:42:44 +01:00
jjbrunton e54f6d31ed fix(admin): forward local Auth settings 2026-08-30 21:40:09 +01:00
jjbrunton 267f3c3cf9 fix(ota): use explicit Expo runtime version 2026-08-30 21:37:28 +01:00
jjbrunton 02aa6e4a75 security(web): upgrade Next to 15.5.24 2026-08-30 21:14:33 +01:00
jjbrunton cf19d23d8a fix(e2e): align browser harness with local stack 2026-08-30 21:00:12 +01:00
jjbrunton db5980c9bd fix(mobile): prepare v1.0.7 OTA release 2026-08-30 20:41:08 +01:00
jjbruntonandClaude Fable 5 2664791233 Merge self-hosted XPREM OTA updates wiring into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:19:07 +01:00
jjbruntonandClaude Fable 5 f37673cad7 feat(mobile): finalize XPREM OTA config with real app id and certificate
Server registration is complete: app id 9a66f3c2-fda7-416f-87e2-6db833b32e9d,
real code-signing certificate (public half), staging/production channels
mapped server-side. Drops the app-id placeholder guard (cert guard stays) and
re-includes certificate.pem past the root *.pem ignore rule, which had been
silently excluding it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:16:12 +01:00
jjbruntonandClaude Fable 5 0ada812294 docs(mobile): document the XPREM over-the-air update flow
Add docs/mobile-ota.md covering how OTA works here, the branch and channel
model, the appVersion runtime version policy and its store-build rule, the
eoas publish commands per channel, both rollback paths, the pending native
prebuild, and the exact placeholders the server deployment still has to fill.

Index it and cross-link it from the release guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:51 +01:00
jjbruntonandClaude Fable 5 f4db567267 feat(mobile): wire expo-updates to the self-hosted XPREM OTA server
Install expo-updates and the eoas CLI, and configure the client against
https://ota.apps.jbrunton.co.uk.

app.json stays the static source of truth for version, buildNumber, and
versionCode, which EAS rewrites during a release build. A new app.config.js
spreads it and layers on the updates block, because XPREM selects a release
channel from a build-time request header and that value differs per EAS
build profile.

- updates.url points at the XPREM manifest endpoint, /manifest
- runtimeVersion uses the appVersion policy, so the runtime version is the
  store-visible expo.version and a native change requires a store build
- expo-channel-name comes from RELEASE_CHANNEL, set per EAS build profile:
  development, staging (preview profile), and production
- expo-app-id and an empty xprem-branch header are declared at build time,
  since expo-updates only sends headers that existed when the build was made
- code signing uses keyid main and rsa-v1_5-sha256 against a certificate at
  certs/certificate.pem, skipped when DISABLE_CODE_SIGNING is set so a dev
  client can run against the packager

The XPREM app id and the code-signing certificate are still placeholders
pending the update server deployment. The config throws a descriptive error
whenever RELEASE_CHANNEL is set while either is unresolved, so a
misconfigured build fails at config resolution rather than shipping a client
that can never validate a manifest.

The native projects are Expo CNG output and are left untouched: the
expo-updates config plugin embeds the real app id and certificate contents,
so they are regenerated as a reviewed source change once both values land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:51 +01:00
jjbruntonandClaude Fable 5 d298a33656 feat(admin): proxy /v1 API calls through the local admin dev server
The API has no CORS handling, so local admin development serves API calls
same-origin and forwards them to the local API process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:12:23 +01:00
jjbruntonandClaude Fable 5 af4e1d0cf6 test(api): include profile settings migration in identity integration schema
The profile select now includes usage_reason, which 0005_profile_settings.sql adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:09:29 +01:00
jjbruntonandClaude Fable 5 9e6e4612f5 fix(db): journal 0021_dare_proof and renumber solo sealed answers migration to 0022
The dare proof migration file landed on staging without a journal entry, so the
drizzle migrator would never apply it. Register it in the journal and move the
solo sealed answers migration to 0022 to keep filenames and journal order aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:52:58 +01:00
jjbrunton 7d392e756d Merge solo sealed answers that unlock on pairing into staging 2026-08-30 19:49:51 +01:00
jjbrunton 3c08963a70 Merge skippable onboarding avatar with first-match prompt and usage-reason pack picks into staging 2026-08-30 19:49:23 +01:00
jjbrunton d86d57d5c2 Merge branded question share card into staging 2026-08-30 19:38:16 +01:00
jjbruntonandClaude Fable 5 57501230cd feat(share): branded question share card with share analytics
Replace the inline ShareableCard in SharePreviewModal with a designed
QuestionShareCard: Sauci logo + wordmark header, pack badge, and a
branded sauci.app footer pill on the captured story image. The card
deliberately never carries the couple join code - it is a private
pairing credential, not a referral link (a regression test enforces
this). Text-share fallbacks now carry a full https://sauci.app link,
and all three share paths fire a new question_shared analytics event
with the destination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:38:09 +01:00
jjbruntonandClaude Fable 5 986d0b21cb Merge dare proof of completion into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:37:08 +01:00
jjbruntonandClaude Fable 5 504eddaf68 feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare,
catalogue or custom. The recipient captures and uploads the proof as a
new couple-scoped 'dare_proof' media kind and attaches it on submit;
the server enforces ownership, kind, couple, and MIME-type match.
The proof renders inline on the dare card for the sender to review
before confirming completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:36:11 +01:00
jjbrunton f52b8536be Merge admin portal coverage for quiz, dares loop, subscriptions, couples, and live draw into staging 2026-08-30 19:35:22 +01:00
jjbruntonandClaude Fable 5 09255da7db feat(pairing): solo sealed answers that unlock on pairing
Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:34:42 +01:00
jjbrunton 8e83cc0d85 feat(admin): add support views for dares, quiz, subscriptions, couples, and live draw
Give support read-only visibility into purchase history, streak locality
fields, notification preferences, the dares loop, couples quiz sessions,
invite-code lookup, and recent live-draw activity, all backed by resources
already allow-listed in the admin repository.
2026-08-30 19:21:56 +01:00
jjbrunton 8955e49aae Merge remote-tracking branch 'origin/staging' into task/peaceful-grothendieck-95a3aa 2026-08-30 19:19:19 +01:00
jjbruntonandClaude Fable 5 04822043ae fix(share): attach captured card image when sharing to Instagram Stories
Both share modals opened the instagram-stories:// scheme without ever
placing the captured PNG on the pasteboard, so Instagram opened an empty
story. Replace the manual scheme launch with react-native-share
shareSingle(INSTAGRAM_STORIES), which writes the image to UIPasteboard
under com.instagram.sharedSticker.backgroundImage on iOS and uses the
ADD_TO_STORY intent on Android. Gate the flow on the required Facebook
App ID (EXPO_PUBLIC_FACEBOOK_APP_ID) and Instagram being installed, with
fallback to the system share sheet, and remove the dead base64 reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:19:01 +01:00
jjbrunton eab73e6314 feat(admin): manage quiz questions and dare intensity
Add a Quiz Questions content page (list/filter/create/edit/activate-
deactivate, no delete since quiz_answers reference questions by id),
route it under manage_questions, and add a sidebar entry.

Fix DaresPage so intensity (1-5) is editable and shown in the table;
previously every admin-created dare silently defaulted to intensity 1.

Add authored min/max intensity fields to DarePacksPage: no trigger or
worker in apps/api computes these columns, so they are admin-set
thresholds gating pack visibility, not derived aggregates. avg_intensity
is displayed read-only since nothing writes to it and it is not used
for filtering.

Extract dare intensity and quiz option validation into small lib
modules with focused unit tests.
2026-08-30 19:13:08 +01:00
jjbruntonandClaude Fable 5 fd8981fa28 feat(onboarding): skippable avatar with first-match prompt and usage-reason pack picks
Lets new users skip the onboarding avatar step and instead defers the ask
to a lightweight, dismissible prompt shown once after the couple's first
match. Also personalizes the home screen's first pack recommendation using
the usage_reason already collected during onboarding, which now round-trips
through GET /v1/me.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:12:56 +01:00
jjbrunton 25b7c6238e feat(admin-api): expose notification_preferences as read-only
Resolve the earlier open question by mapping the profile-settings
domain's notification toggles to the existing notification_preferences
table (primary key user_id, no sensitive columns). Read-only, gated on
view_users, with a test covering read access and rejected writes.
2026-08-30 19:04:54 +01:00
jjbrunton 77f2718fdd feat(admin-api): expose quiz, dares loop, and profile settings resources
Extend the admin allow-list so upcoming admin screens can reach quiz
content and activity, sent dares, and dare chat messages. Also fix a
schema drift where question_packs.scheduled_release_at and
release_notified existed in the database but were missing from the
drizzle TS schema, which could have let a future generate propose
dropping them.

Note: no profile_settings table exists in the schema. The
profile-settings domain reads and writes the existing profiles and
notification_preferences tables, so that entry was intentionally
omitted pending a product decision on which table it should map to.
2026-08-30 19:03:26 +01:00
jjbruntonandClaude Fable 5 f22d4a3649 Merge quiz question variety into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:22:10 +01:00
jjbruntonandClaude Fable 5 45d24911fb feat(quiz): variety in quiz question selection
Expand the seeded pool from 12 to 36 questions (migration 0020) and
select 10 per session preferring questions the couple has never seen,
then least recently used, with random tiebreaks, so repeat quizzes stop
serving the same set.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:58:43 +01:00
jjbrunton 5564f9bbab Merge iOS CocoaPods lock sync into staging 2026-08-30 17:54:38 +01:00
jjbrunton c972b1c236 fix(ios): synchronize CocoaPods lock checksums 2026-08-30 17:54:30 +01:00
jjbrunton 30aa7bc386 Merge iOS CocoaPods UTF-8 fix into staging 2026-08-30 17:35:30 +01:00
jjbrunton 8bbb0e8cea fix(dev-local): set UTF-8 for native builds 2026-08-30 17:35:18 +01:00
jjbrunton fbdb2a8fff Merge task/agent-fast-lane into staging 2026-08-30 17:34:04 +01:00
jjbrunton bdfe8b7171 perf(agents): streamline Terra execution
Keep Sol responsible for scope, steering, and final acceptance while assigning one continuous discovery, implementation, testing, and iteration unit to Terra. Route additional reviewers and runtime verification by risk and avoid redundant verification passes.
2026-08-30 17:33:48 +01:00
jjbrunton 2cbe9e9c55 chore(release): promote photo picker fix
Promote the verified Android photo-picker policy fix from staging to main after target-branch and pull-request checks passed.
2026-08-30 17:31:02 +01:00
jjbrunton 8d9a3f4f1c Merge local stack single-command fix into staging 2026-08-30 17:26:21 +01:00
jjbrunton 476e4c845f fix(dev-local): harden one-command startup 2026-08-30 17:25:30 +01:00
jjbrunton 3d53d001b7 Merge task/android-photo-picker into main 2026-08-30 17:16:08 +01:00
jjbrunton 8658fc38fe fix(android): use system photo picker
Block broad image and video read permissions in Expo config and the checked-in Android manifest. Keep picker access scoped to user-selected media and gallery saves write-only. Add regression coverage and document the release-manifest invariant.
2026-08-30 17:15:57 +01:00
jjbrunton 24de9af4cd fix(web): add account deletion page (#16)
Add the public deletion resource and repair the monorepo web container build contract.
2026-08-30 16:23:30 +01:00
jjbrunton 5e581efe51 Merge web container fix into staging 2026-08-30 16:19:20 +01:00
jjbrunton 12c6b8bcc9 fix(web): build from monorepo context 2026-08-30 16:18:55 +01:00
jjbrunton 599be22eb1 Merge task/google-account-deletion into staging 2026-08-30 16:09:59 +01:00
jjbrunton 9514835bf2 fix(web): add account deletion page 2026-08-30 16:07:19 +01:00
jjbrunton 65bd1bbd6b chore(release): record iOS build 45
chore(release): record iOS build 45
2026-08-30 10:51:43 +01:00
jjbrunton e0813ba5a3 docs(release): verify iOS extension versions
docs(release): verify iOS extension versions
2026-08-30 10:43:45 +01:00
jjbrunton f7c686d5cf docs(release): verify iOS extension versions 2026-08-30 10:39:33 +01:00
jjbrunton 314664ecd6 chore(ios): record v1.0.6 build 45
chore(ios): record v1.0.6 build 45
2026-08-30 10:37:43 +01:00
jjbrunton 11f89b1345 chore(ios): record v1.0.6 build 45 2026-08-30 10:33:16 +01:00
jjbrunton 2481956c11 chore(agents): prohibit em dashes 2026-08-30 10:17:37 +01:00
jjbrunton 8804e121b6 chore(release): promote iOS compliance fix
chore(release): promote iOS compliance fix
2026-08-30 10:15:27 +01:00
jjbrunton 7b3de48dbd Merge pull request #11 from jjbrunton/task/ios-export-compliance
chore(ios): declare exempt encryption
2026-08-30 10:07:24 +01:00
jjbrunton 5ebf57bdbf Merge branch 'staging' into task/ios-export-compliance 2026-08-30 10:07:10 +01:00
jjbrunton 089e629021 docs(agents): delegate substantive repository tasks 2026-08-30 10:06:37 +01:00
jjbrunton 58b70710db chore(ios): declare exempt encryption 2026-08-30 09:58:26 +01:00
jjbrunton d645d83c47 chore(ios): record v1.0.6 build 44 2026-08-30 01:29:55 +01:00
jjbrunton 179f912170 chore(android): record v1.0.6 build 48 2026-08-30 01:05:13 +01:00
jjbrunton a604f82110 chore(release): prepare v1.0.6 2026-08-30 00:46:46 +01:00
jjbrunton ae8e72ddb5 Merge pull request #10 from jjbrunton/staging
chore(release): complete mobile release configuration
2026-08-30 00:41:09 +01:00
jjbrunton 2e4cddd1c8 fix(mobile): restore iOS SDK 54 runtime 2026-08-30 00:29:45 +01:00
jjbrunton 440f224095 docs(release): require upload confirmation 2026-08-30 00:28:14 +01:00
jjbrunton 22871d037c chore(release): configure store submissions
Route Android uploads to an internal draft and bind iOS submissions to the verified App Store Connect app. Document explicit-path local artifact submission and preserve separate release authorization.
2026-08-30 00:13:40 +01:00
jjbrunton fd38b303f0 Merge pull request #9 from jjbrunton/staging
fix(api): keep auth sync timestamps monotonic
2026-08-29 23:57:39 +01:00
jjbrunton 266233a473 fix(api): keep auth sync timestamps monotonic
Use PostgreSQL's clock for both the initial auth sync timestamp and later identity refreshes so host clock skew cannot move the marker backwards.
2026-08-29 23:45:39 +01:00
jjbrunton 387b242aea Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
2026-08-29 23:34:30 +01:00
jjbrunton 2bc69c2790 feat(mobile): surface pending questions
Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior.
2026-08-29 23:24:19 +01:00
jjbrunton 9da44217f7 feat(release): add generation workflow
Add a guarded mobile release skill that synchronizes public versions, writes on-brand notes, verifies signed iOS and Android artifacts, and tags the verified release commit.
2026-08-29 23:22:35 +01:00
jjbrunton 5f77b06044 test(coverage): enforce API and app floors
Measure untouched mobile source, add regression coverage for native media paths and account operations, and enforce ratcheting API and mobile thresholds in required CI checks.
2026-08-29 22:50:07 +01:00
jjbrunton 5c973c4ac5 perf(api): bound worker pools and add telemetry
Share one configured PostgreSQL pool across worker repositories and make API and worker connection budgets explicit.

Add privacy-safe aggregate request, query, pool, sync, and outbox telemetry, a loopback-only representative load fixture, bounded worker shutdown, and focused regression and integration coverage.

Document the database-wide budget formula and defer pg_stat_statements activation to a reviewed migration and planned restart.
2026-08-29 22:43:27 +01:00
jjbrunton a13bbc3f65 docs(agents): require published completion 2026-08-29 21:59:50 +01:00
jjbrunton 10c4ae8d84 docs(agents): require isolated implementation 2026-08-29 21:58:13 +01:00
jjbrunton ac4a05d5ed chore(staging): reconcile main ancestry 2026-08-29 21:49:46 +01:00
jjbrunton c7ee8a4851 perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
2026-08-29 21:47:27 +01:00
jjbrunton 9957fa43f2 feat(agents): add Claude delegation skill
Add a repository-owned Codex skill and helper for bounded Claude Code execution, model and effort selection, resumable follow-ups, and independent acceptance checks.
2026-08-29 18:29:29 +01:00
jjbrunton 502452c9e4 Merge pull request #4 from jjbrunton/staging
chore(release): promote staging to production
2026-08-29 00:12:03 +01:00
jjbrunton e6bb368bc8 Merge pull request #7 from jjbrunton/task/production-cutover-import
feat(api): harden production data import
2026-08-29 00:08:15 +01:00
jjbrunton aedba2a480 feat(api): harden production data import
Preserve live Dokploy state while importing legacy Supabase data. Add schema parity, safe relationship reconciliation, storage quarantine, and production-shaped integration coverage.
2026-08-29 00:04:11 +01:00
jjbrunton 78900a24fb ci(staging): enable direct verification
Allow agents to run required checks manually against an exact commit before pushing it directly to staging, while retaining protected status checks.
2026-08-28 20:41:18 +01:00
jjbrunton 03443fe898 chore(staging): integrate verified changes
Combine the Android API 36 release, dares loop, RevenueCat documentation correction, and daily engagement features. Resolve migration ordering and preserve both dares expiry and streak reminder operations.
2026-08-28 20:36:05 +01:00
jjbrunton b60c788362 feat(engagement): localize limits and streaks
Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
2026-08-28 20:28:31 +01:00
jjbrunton 61ec3692b7 chore(android): target API 36 with Expo 54
Upgrade the mobile native toolchain and Expo dependencies for Android 16 compatibility while preserving web and admin runtime isolation.
2026-08-28 20:28:31 +01:00
jjbrunton 1482305810 Merge remote-tracking branch 'origin/staging' into task/android-api-36 2026-08-28 20:22:16 +01:00
jjbrunton 8b37569818 Merge pull request #5 from jjbrunton/feat/dares-loop
feat(dares): ship the dares loop to mobile
2026-08-28 20:18:11 +01:00
jjbrunton 2178750d07 chore(android): bump version code to 47 2026-08-28 20:17:19 +01:00
jjbrunton 028e21e54c Merge origin/staging (Expo 54 / Android API 36) into feat/dares-loop 2026-08-28 20:12:55 +01:00
jjbrunton b24a402b4c Merge pull request #3 from jjbrunton/task/android-api-36
chore(android): target API 36 with Expo 54
2026-08-28 20:09:20 +01:00
jjbruntonandClaude Opus 5 ce0b188776 feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done,
confirm, and get a push notification on every transition.

Backend (apps/api):
- New `dares` domain with couple-shared premium entitlement, a free weekly
  send allowance, and server-enforced actor/state rules on every transition.
- `submitted` status so the recipient reports completion and the sender
  confirms, rather than the sender guessing.
- Notifications ride the existing operations outbox via a trigger on
  `sent_dares`. Push copy is generic; store compliance forbids dare text in a
  preview and a test asserts no payload leaks it.
- Worker expiry sweep now covers `pending` as well as `active`/`submitted`;
  an invitation nobody opened previously stayed open forever.

Fixes two latent defects:
- Deleting any dare that had been sent aborted with a check violation, because
  `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check`
  required it. Dare text is now snapshotted at send time, which also keeps a
  couple's history stable across catalogue edits.
- The free send allowance was raceable: concurrent requests all read the same
  count and every one succeeded. The count and insert are now serialized on
  the sender's profile row.

Also ports `content_status` review metadata to the API catalogue, which had no
store-safety gating at all, and mirrors the review decision already enforced on
the legacy catalogue.

Mobile: dares screen (active/send/history), send sheet with duration presets,
home tile badging incoming dares, paywall on both proactive and 402 paths.

Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against
local PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:03:11 +01:00
jjbrunton f35114dc77 chore(android): target API 36 with Expo 54
Upgrade the mobile runtime and tracked Android project to compile and target API 36. Align Expo modules, React Native, Node, Gradle, and Android build tooling while preserving the custom widget sources.\n\nAdapt file-system, notification, animation, test, and Metro compatibility for the SDK 54 runtime.
2026-08-28 20:01:15 +01:00
jjbruntonandClaude Opus 5 aacb4f8731 docs(subscription): correct stale Android RevenueCat status
The Platform Support section claimed Android was "not yet implemented".
Android has full native SDK support: getApiKey() selects the Android key,
com.android.vending.BILLING is declared in app.json, the paywall has no
platform gating, and the webhook passes event.store through unchanged so
PLAY_STORE events need no separate handling.

Also flag the entitlement ID mismatch between the "pro" fallback in
revenuecat.ts and the "Sauci Pro" value set in eas.json. A build profile
missing that variable yields isProUser: false with no error, which
presents as churn rather than as misconfiguration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:26:53 +01:00
jjbrunton 1be54175e4 Merge pull request #2 from jjbrunton/staging
chore(release): promote mobile build hardening
2026-08-28 18:44:58 +01:00
jjbrunton a7470310ae Merge pull request #1 from jjbrunton/task/mobile-release-hardening
chore(release): harden mobile build workflow
2026-08-28 18:39:14 +01:00
jjbrunton 3acadeff7c chore(release): harden mobile build workflow
Promote the reproduced release lessons into harness enforcement: keep tracked native regeneration separate from artifact builds, separate Android build and upload authorization, use stable login readiness in Maestro, and retain the iOS 43 and Android 46 version increments.
2026-08-28 18:34:00 +01:00
468 changed files with 57257 additions and 17184 deletions

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`.
@@ -0,0 +1,40 @@
---
id: 2026-09-05-question-type-contract-drift
date: 2026-09-05
status: promoted
scope: question interaction contract documentation
evidence: npm run lint:harness and npm run learn:check
destination: docs/question-types.md, scripts/lint-harness.mjs, AGENTS.md, and .codex/skills/question-pack/SKILL.md
review_after: 2026-12-05
---
## Symptom
Maintained documentation did not identify the implemented question interaction
types, their response payloads, or their matching semantics. The only detailed
reference was a historical implementation plan, so content and product work
could not rely on maintained documentation.
## Reproduction
Compare `QuestionType` in `packages/shared/src/types/index.ts` and the question
renderer and answer matching code with `docs/index.md`, `docs/schema.md`, and
`docs/question-selection.md`.
## Cause
The interaction contract was added to code without a maintained documentation
owner or a verification check linking the document to the canonical union.
## Remediation
Create `docs/question-types.md` as the maintained interaction contract. Require
agents and the question-pack workflow to update it with type or semantic
changes. Add a harness invariant that compares its type marker to the shared
`QuestionType` union.
## Evidence
`npm run lint:harness` reads the shared union and the documentation marker and
fails when their ordered type lists differ. `npm run learn:check` validates this
promoted learning record and its destination metadata.
+1 -1
View File
@@ -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.
+11
View File
@@ -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.
"""
+12
View File
@@ -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.
"""
+14
View File
@@ -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.
"""
+11
View File
@@ -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.
"""
+12
View File
@@ -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.
"""
+11
View File
@@ -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.
"""
+11
View File
@@ -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.
"""
+5
View File
@@ -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"
+73
View File
@@ -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
+19 -13
View File
@@ -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.
+140
View File
@@ -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
View File
@@ -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}`);
+5
View File
@@ -7,6 +7,11 @@ description: Create or review Sauci question-pack content and its local migratio
Inspect existing packs before generating to avoid duplication. Inspect existing packs before generating to avoid duplication.
Before creating or editing content that uses a question interaction type, read
`docs/question-types.md`. If the type, response payload, configuration, or
matching semantics change, update that maintained contract and run
`npm run lint:harness` in the same task.
- Write proposals, not interview questions; use gender-neutral “your partner”. - Write proposals, not interview questions; use gender-neutral “your partner”.
- Avoid time-bound words such as tonight, now, or today. - Avoid time-bound words such as tonight, now, or today.
- Symmetric activities have no partner text. - Symmetric activities have no partner text.
+137 -5
View File
@@ -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.
+20 -11
View File
@@ -5,25 +5,34 @@ description: Prove a completed Sauci engineering task works before a PR. Use whe
# Verify Sauci work # Verify Sauci work
Run locally on a non-default branch with the implementation committed. Run locally on a non-default branch with the implementation committed. Use an
independent verifier for user-facing, cross-boundary, or high-risk runtime
changes. Documentation, mechanical configuration, isolated tests, and low-risk
package-local changes may use focused deterministic checks without a verifier.
1. Start the stack once with `scripts/dev-local.sh up`; reset fixtures first when 1. Package-local deterministic checks do not need a running stack. For runtime
the task changes durable state. Web is `http://127.0.0.1:3000`, admin is verification, reuse a compatible running local stack when available; otherwise
`http://127.0.0.1:3001`, and MCP health is `http://127.0.0.1:3002/health`. start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
2. Give a fresh read-only verifier the acceptance criteria. It drives browser first when the task changes durable state. Web is `http://127.0.0.1:3000`,
admin is `http://127.0.0.1:3001`, and MCP health is
`http://127.0.0.1:3002/health`.
2. When independent runtime verification is required, give a fresh read-only
verifier the acceptance criteria. It drives browser
surfaces with the repository browser capability, native surfaces with Maestro surfaces with the repository browser capability, native surfaces with Maestro
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`. described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
3. The verifier writes screenshots, traces, video, or response evidence under 3. The verifier writes screenshots, traces, video, or response evidence under
`evidence/` and returns only expected, observed, evidence paths, and `evidence/` and returns only expected, observed, evidence paths, and
`works|broken`. It never edits code. `works|broken`. It never edits code.
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and 4. If independent verification is broken, fix and use a fresh verifier. Stop
report the unresolved evidence. after three failed rounds and report the unresolved evidence.
5. After the task works, run `npm run verify:fast`, affected E2E, and 5. Run focused checks for the affected change. Run affected E2E when the runtime
`npm run verify:full` when local Supabase/Deno are available. Never weaken an path requires it. Run `npm run verify:full` when its prerequisites are
assertion just to pass. available; it already runs `npm run verify:fast`, so do not run both in
sequence. Never weaken an assertion just to pass.
6. Open a PR only after both task verification and regression checks pass. Upload 6. Open a PR only after both task verification and regression checks pass. Upload
evidence to the established CI artifact or `pr-evidence` prerelease and embed evidence to the established CI artifact or `pr-evidence` prerelease and embed
the screenshot plus a video/trace link in the PR body. the screenshot plus a video/trace link in the PR body.
A green test suite without an independently observed task outcome is incomplete. A green test suite without an independently observed outcome is incomplete when
independent runtime verification is required.
+14
View File
@@ -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
+15 -4
View File
@@ -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
+1
View File
@@ -59,6 +59,7 @@ e2e/playwright-report/
# Misc # Misc
*.pem *.pem
!apps/mobile/certs/certificate.pem
.cache/ .cache/
.temp/ .temp/
.tmp/ .tmp/
+1 -1
View File
@@ -1 +1 @@
20.10.0 20.19.4
+32 -1
View File
@@ -17,8 +17,33 @@ 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.
13. Before beginning implementation, tell the user whether the proposed change
is likely deliverable through an Expo OTA update, likely requires a new
native/store build, or has no applicable OTA path, with a brief reason. If
uncertain, state that uncertainty and name the validation needed.
## Repository map ## Repository map
@@ -36,6 +61,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 +80,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` |
@@ -63,6 +91,9 @@ environment limitation; do not call it a passing product check.
## Documentation discipline ## Documentation discipline
Update the maintained doc when a contract, command, port, or invariant changes. Update the maintained doc when a contract, command, port, or invariant changes.
When question interaction types, response payloads, configuration, or match
semantics change, update `docs/question-types.md` in the same change and run
`npm run lint:harness` to prove its type list matches the shared contract.
Plans and historical notes are evidence, not current architecture. A repeated Plans and historical notes are evidence, not current architecture. A repeated
lesson becomes permanent only after it is reproduced and promoted into a doc, lesson becomes permanent only after it is reproduced and promoted into a doc,
skill, fixture, or executable check. skill, fixture, or executable check.
+2 -2
View File
@@ -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",
+36
View File
@@ -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',
+39
View File
@@ -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%',
});
});
});
+35
View File
@@ -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}%` };
}
+47
View File
@@ -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/);
});
});
+44
View File
@@ -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,20 @@
import { describe, expect, it } from 'vitest';
import { featureInterestKey, formatFeatureName, toRecentFeatureInterest } from './featureInterests';
describe('toRecentFeatureInterest', () => {
it('preserves the standalone API feature field for dashboard rendering', () => {
const interest = toRecentFeatureInterest({
user_id: 'user-1',
feature: 'better_chat',
created_at: '2026-09-05T12:00:00.000Z',
});
expect(interest).toEqual({
user_id: 'user-1',
feature: 'better_chat',
created_at: '2026-09-05T12:00:00.000Z',
});
expect(formatFeatureName(interest.feature)).toBe('Better Chat');
expect(featureInterestKey(interest)).toBe('user-1:better_chat');
});
});
+34
View File
@@ -0,0 +1,34 @@
export interface FeatureInterestRow {
user_id: string;
feature: string;
created_at: string;
}
export interface RecentFeatureInterest extends FeatureInterestRow {
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
}
export function toRecentFeatureInterest(row: FeatureInterestRow): RecentFeatureInterest {
return {
user_id: row.user_id,
feature: row.feature,
created_at: row.created_at,
};
}
export function featureInterestKey(row: Pick<FeatureInterestRow, 'user_id' | 'feature'>): string {
return `${row.user_id}:${row.feature}`;
}
export function formatFeatureName(feature: string): string {
return feature
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
+40
View File
@@ -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();
});
});
+31
View File
@@ -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;
}
+33
View File
@@ -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);
});
});
+38
View File
@@ -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();
}
+8 -3
View File
@@ -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>
+11 -30
View File
@@ -10,36 +10,22 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recha
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns'; import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import {
formatFeatureName,
featureInterestKey,
toRecentFeatureInterest,
type FeatureInterestRow,
type RecentFeatureInterest,
} from '@/lib/featureInterests';
type FeatureInterestChartPoint = { date: string; count: number }; type FeatureInterestChartPoint = { date: string; count: number };
type RecentFeatureInterest = {
id: string;
user_id: string;
feature_name: string;
created_at: string;
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
};
type FeatureInterestCountsRow = { type FeatureInterestCountsRow = {
feature_name: string; feature_name: string;
opt_in_count: number; opt_in_count: number;
opt_in_count_last_7_days: number; opt_in_count_last_7_days: number;
}; };
function formatFeatureName(featureName: string) {
return featureName
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function DashboardPage() { export function DashboardPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS); const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
@@ -95,7 +81,7 @@ export function DashboardPage() {
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString()) ? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
canViewUsers canViewUsers
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10) ? adminData.from<FeatureInterestRow>('feature_interests').select('user_id, feature, created_at').order('created_at', { ascending: false }).limit(10)
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
]); ]);
@@ -158,12 +144,7 @@ export function DashboardPage() {
})) }))
); );
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({ let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(toRecentFeatureInterest);
id: item.id,
user_id: item.user_id,
feature_name: item.feature_name,
created_at: item.created_at,
}));
if (canViewUsers && recentActivity.length > 0) { if (canViewUsers && recentActivity.length > 0) {
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id))); const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
@@ -579,14 +560,14 @@ export function DashboardPage() {
if (item.profile?.name && item.profile.email) { if (item.profile?.name && item.profile.email) {
subtitleParts.push(item.profile.email); subtitleParts.push(item.profile.email);
} }
subtitleParts.push(formatFeatureName(item.feature_name)); subtitleParts.push(formatFeatureName(item.feature));
const fallbackChar = (item.profile?.name || item.profile?.email || 'U') const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
.charAt(0) .charAt(0)
.toUpperCase(); .toUpperCase();
return ( return (
<div key={item.id} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3"> <div key={featureInterestKey(item)} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={item.profile?.avatar_url || undefined} /> <AvatarImage src={item.profile?.avatar_url || undefined} />
@@ -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 -1
View File
@@ -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>
);
}
+200
View File
@@ -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>
);
}
+272 -77
View File
@@ -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>
+8
View File
@@ -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,
+9
View File
@@ -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.
+223
View File
@@ -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()
);
+65
View File
@@ -0,0 +1,65 @@
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
-- answers every question twice, once about themselves and once guessing their
-- partner, and the session only scores once both partners have a complete set.
CREATE TABLE IF NOT EXISTS "quiz_questions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"prompt_self" text NOT NULL UNIQUE,
"prompt_guess" text NOT NULL,
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
"sort_order" integer NOT NULL DEFAULT 0,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
"question_ids" uuid[] NOT NULL,
"score_percent" integer,
"completed_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
ON "quiz_sessions" ("couple_id", "created_at" DESC);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "quiz_answers" (
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL,
"question_id" uuid NOT NULL,
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("session_id", "user_id", "question_id")
);
--> statement-breakpoint
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
('How do you most like to receive love?', 'How does your partner most like to receive love?',
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
ON CONFLICT ("prompt_self") DO NOTHING;
@@ -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;
+16
View File
@@ -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 $$;
@@ -0,0 +1,317 @@
-- Initial editorial dare catalogue. Catalogue IDs are deterministic so the seed
-- is replay-safe. Only rows that passed the universal store-safety review are
-- public and `allowed`; all other editorial inventory remains fail-closed.
WITH seed (
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason
) AS (VALUES
('1f5e0000-0000-4000-8000-100000000001', 'Little Things', 'Small gestures that make your partner feel seen.', '💌', false, true, false, 10, 1, 1, 1.00, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000002', 'Make Me Laugh', 'Silly challenges for a shared laugh.', '😄', false, true, false, 20, 1, 2, 1.33, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000003', 'Sweet & Romantic', 'Thoughtful dares for feeling close.', '💕', false, true, false, 30, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual romantic content'),
('1f5e0000-0000-4000-8000-100000000004', 'Surprise Me', 'Plan a little surprise for your partner.', '🎁', true, true, false, 40, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000005', 'Flirty Messages', 'Private messages with a little spark.', '💬', true, false, false, 50, 2, 3, 2.47, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-100000000006', 'Long-Distance Heat', 'Private dares for partners apart.', '📱', true, false, true, 60, 2, 3, 2.67, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000007', 'Risky Photos & Audio', 'Private photo and audio dares.', '📸', true, false, true, 70, 3, 4, 3.53, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000008', 'Hands On', 'Private in-person intimacy dares.', '🫶', true, false, true, 80, 3, 4, 3.40, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000009', 'After Dark', 'Private adult intimacy dares.', '🌙', true, false, true, 90, 4, 4, 4.00, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000010', 'Power Play', 'Private adult power-play dares.', '🔒', true, false, true, 100, 5, 5, 5.00, 'unreviewed', 'Private mature editorial inventory')
), upsert AS (
INSERT INTO dare_packs (
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason,
content_reviewed_at
)
SELECT id::uuid, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity::numeric(3, 2),
content_status::content_review_status, content_review_reason,
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
FROM seed
ON CONFLICT (id) DO UPDATE SET id = dare_packs.id
WHERE (dare_packs.name, dare_packs.description, dare_packs.icon,
dare_packs.is_premium, dare_packs.is_public, dare_packs.is_explicit,
dare_packs.sort_order, dare_packs.category_id, dare_packs.min_intensity,
dare_packs.max_intensity, dare_packs.avg_intensity, dare_packs.content_status,
dare_packs.content_review_reason, dare_packs.content_reviewed_at,
dare_packs.content_reviewed_by)
IS NOT DISTINCT FROM
(EXCLUDED.name, EXCLUDED.description, EXCLUDED.icon, EXCLUDED.is_premium,
EXCLUDED.is_public, EXCLUDED.is_explicit, EXCLUDED.sort_order,
EXCLUDED.category_id, EXCLUDED.min_intensity, EXCLUDED.max_intensity,
EXCLUDED.avg_intensity, EXCLUDED.content_status,
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
EXCLUDED.content_reviewed_by)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
WITH seed (id, pack_id, text, intensity, content_status, content_review_reason) AS (VALUES
-- Little Things
('1f5e0000-0000-4000-8000-000000000001', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a photo of something that reminded you of them.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000002', '1f5e0000-0000-4000-8000-100000000001', 'Hide a short note somewhere your partner will unexpectedly find it.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000003', '1f5e0000-0000-4000-8000-100000000001', 'Bring your partner their favourite snack without asking what they want.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000004', '1f5e0000-0000-4000-8000-100000000001', 'Take over the household job your partner hates most.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000005', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a drink exactly how they like it.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000006', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a compliment that has nothing to do with their appearance.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000007', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a proper uninterrupted hug.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000008', '1f5e0000-0000-4000-8000-100000000001', 'Choose a shared photo and tell your partner why you still love that moment.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000009', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a three-song playlist for their current mood.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000010', '1f5e0000-0000-4000-8000-100000000001', 'Leave your partner a voice note they can replay when they are having a bad day.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000011', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a homemade voucher for one chore-free evening.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000012', '1f5e0000-0000-4000-8000-100000000001', 'Put your phone away and give your partner your full attention for thirty minutes.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000013', '1f5e0000-0000-4000-8000-100000000001', 'Tell your partner about the first moment you realised they were special.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000014', '1f5e0000-0000-4000-8000-100000000001', 'Fix one small annoyance your partner has been putting off.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000015', '1f5e0000-0000-4000-8000-100000000001', 'Let your partner choose what you watch, play, or listen to together.', 1, 'allowed', 'Reviewed with Little Things pack'),
-- Make Me Laugh
('1f5e0000-0000-4000-8000-000000000016', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner the worst selfie you can take.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000017', '1f5e0000-0000-4000-8000-100000000002', 'Record a ten-second impression of your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000018', '1f5e0000-0000-4000-8000-100000000002', 'Draw your partner from memory and send them the result.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000019', '1f5e0000-0000-4000-8000-100000000002', 'Make a meme about your relationship.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000020', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner your cheesiest pickup line.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000021', '1f5e0000-0000-4000-8000-100000000002', 'Invent a ridiculous new nickname for your partner and use it until the dare expires.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000022', '1f5e0000-0000-4000-8000-100000000002', 'Record a dramatic advert explaining why someone should date your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000023', '1f5e0000-0000-4000-8000-100000000002', 'Tell your partner a painfully bad joke without laughing.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000024', '1f5e0000-0000-4000-8000-100000000002', 'Recreate one of your partner''s photos as badly as possible.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000025', '1f5e0000-0000-4000-8000-100000000002', 'Describe your relationship using only five emojis.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000026', '1f5e0000-0000-4000-8000-100000000002', 'Perform a thirty-second dance chosen by your partner.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000027', '1f5e0000-0000-4000-8000-100000000002', 'Wear a ridiculous accessory until your partner releases you from the dare.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000028', '1f5e0000-0000-4000-8000-100000000002', 'Serenade your partner with the least romantic song you can find.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000029', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner an embarrassing but harmless confession.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000030', '1f5e0000-0000-4000-8000-100000000002', 'Act out how you behaved on your first date.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
-- Sweet & Romantic
('1f5e0000-0000-4000-8000-000000000031', '1f5e0000-0000-4000-8000-100000000003', 'Write your partner a love note by hand.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000032', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a song that says what you struggle to put into words.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000033', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner three small things they do that make your life better.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000034', '1f5e0000-0000-4000-8000-100000000003', 'Plan an at-home date and keep the details secret.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000035', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a slow dance without waiting for the perfect song.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000036', '1f5e0000-0000-4000-8000-100000000003', 'Recreate one detail from your first date together.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000037', '1f5e0000-0000-4000-8000-100000000003', 'Kiss your partner like you have not seen them for a month.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000038', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a voice note describing your favourite memory of them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000039', '1f5e0000-0000-4000-8000-100000000003', 'Prepare your partner''s side of the bed exactly how they like it.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000040', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner five kisses, each in a different place.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000041', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner the quality you find most attractive in them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000042', '1f5e0000-0000-4000-8000-100000000003', 'Plan a mystery date and give your partner only one clue.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000043', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a compliment they will still remember next week.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000044', '1f5e0000-0000-4000-8000-100000000003', 'Read your partner a message you sent during the early days of your relationship.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000045', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner ten minutes of affection without looking at either of your phones.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
-- Surprise Me
('1f5e0000-0000-4000-8000-000000000046', '1f5e0000-0000-4000-8000-100000000004', 'Leave your partner''s favourite treat somewhere they will discover it.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000047', '1f5e0000-0000-4000-8000-100000000004', 'Complete one job your partner expects to do themselves.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000048', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner an unexpected food delivery.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000049', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a mystery date and tell your partner only when to be ready.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000050', '1f5e0000-0000-4000-8000-100000000004', 'Hide three clues leading your partner to a small surprise.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000051', '1f5e0000-0000-4000-8000-100000000004', 'Take your partner somewhere neither of you has visited before.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000052', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner flowers or their preferred equivalent for no particular reason.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000053', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner coordinates and ask them to meet you there.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000054', '1f5e0000-0000-4000-8000-100000000004', 'Create a playlist that reveals what you have planned.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000055', '1f5e0000-0000-4000-8000-100000000004', 'Recreate your partner''s ideal lazy morning.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000056', '1f5e0000-0000-4000-8000-100000000004', 'Put together a date using only things already in your home.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000057', '1f5e0000-0000-4000-8000-100000000004', 'Choose an outfit for your partner from clothes they already own.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000058', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner an envelope they are not allowed to open until the dare expires.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000059', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a surprise based on something your partner mentioned weeks ago.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000060', '1f5e0000-0000-4000-8000-100000000004', 'Turn an ordinary meal into a date without warning your partner first.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
-- Flirty Messages
('1f5e0000-0000-4000-8000-000000000061', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three messages that become progressively flirtier.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000062', '1f5e0000-0000-4000-8000-100000000005', 'Record yourself saying your partner''s name in the most seductive way you can.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000063', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a selfie wearing something you know they like.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000064', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner where you want them to kiss you.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000065', '1f5e0000-0000-4000-8000-100000000005', 'Describe the first thing you notice when your partner enters a room.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000066', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a compliment you would not say in front of anyone else.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000067', '1f5e0000-0000-4000-8000-100000000005', 'Invent a secret phrase that means you want your partner.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000068', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a message designed to make them blush.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000069', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner what you would do if you were alone together.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000070', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three words designed to distract them.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000071', '1f5e0000-0000-4000-8000-100000000005', 'Record a voice note telling your partner what you want later.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000072', '1f5e0000-0000-4000-8000-100000000005', 'Send a close-up photo and make your partner guess what they are looking at.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000073', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner which item of their clothing you most want to remove.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000074', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner one instruction to follow when you next meet.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000075', '1f5e0000-0000-4000-8000-100000000005', 'Describe one fantasy in a single sentence without using vague words.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
-- Long-Distance Heat
('1f5e0000-0000-4000-8000-000000000076', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo showing exactly what you are doing.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000077', '1f5e0000-0000-4000-8000-100000000006', 'Record a goodnight message your partner will want to replay.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000078', '1f5e0000-0000-4000-8000-100000000006', 'Plan a video-call date and send your partner a dress code.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000079', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo of the empty space where you wish they were.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000080', '1f5e0000-0000-4000-8000-100000000006', 'Order your partner a surprise they can enjoy during your next call.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000081', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a risky selfie within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000082', '1f5e0000-0000-4000-8000-100000000006', 'Record a voice note telling your partner exactly how much you miss their touch.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000083', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo from bed without adding any explanation.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000084', '1f5e0000-0000-4000-8000-100000000006', 'Describe what you would do if your partner appeared beside you.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000085', '1f5e0000-0000-4000-8000-100000000006', 'Give your partner a private fashion show over video.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000086', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a sequence of photos that gradually reveal more.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000087', '1f5e0000-0000-4000-8000-100000000006', 'Record yourself whispering what you want your partner to do when you reunite.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000088', '1f5e0000-0000-4000-8000-100000000006', 'Let your partner choose what you wear during your next private call.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000089', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo they can use as their private wallpaper.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000090', '1f5e0000-0000-4000-8000-100000000006', 'End your next call with a promise your partner can hold you to.', 3, 'unreviewed', 'Private mature editorial inventory'),
-- Risky Photos & Audio
('1f5e0000-0000-4000-8000-000000000091', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo captioned "Only for you."', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000092', '1f5e0000-0000-4000-8000-100000000007', 'Take a silhouette photo that leaves your partner guessing.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000093', '1f5e0000-0000-4000-8000-100000000007', 'Record a voice note describing what you are wearing.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000094', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo that reveals just enough to make them curious.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000095', '1f5e0000-0000-4000-8000-100000000007', 'Take a photo from your partner''s favourite angle.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000096', '1f5e0000-0000-4000-8000-100000000007', 'Record a risky audio clip for your partner within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000097', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo showing what you plan to wear underneath your clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000098', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a topless photo they have not seen before.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000099', '1f5e0000-0000-4000-8000-100000000007', 'Record yourself describing exactly what you want your partner to do to you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000100', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a nude using lighting or shadows creatively.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000101', '1f5e0000-0000-4000-8000-100000000007', 'Record the sound you make when your partner touches you properly.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000102', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a short striptease video.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000103', '1f5e0000-0000-4000-8000-100000000007', 'Take a nude photo without showing your face.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000104', '1f5e0000-0000-4000-8000-100000000007', 'Record a video showing your partner how badly you want them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000105', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner the most explicit photo you are both comfortable keeping.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- Hands On
('1f5e0000-0000-4000-8000-000000000106', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a ten-minute massage.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000107', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner in five places without kissing their lips.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000108', '1f5e0000-0000-4000-8000-100000000008', 'Trace your fingertips over your partner''s body for five minutes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000109', '1f5e0000-0000-4000-8000-100000000008', 'Sit on your partner''s lap and kiss them slowly.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000110', '1f5e0000-0000-4000-8000-100000000008', 'Blindfold your partner and feed them something they enjoy.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000111', '1f5e0000-0000-4000-8000-100000000008', 'Let your partner choose where you kiss them.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000112', '1f5e0000-0000-4000-8000-100000000008', 'Tease your partner for ten minutes without touching beneath their clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000113', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a massage and stop exactly when they want more.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000114', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner''s neck until they ask you to stop.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000115', '1f5e0000-0000-4000-8000-100000000008', 'Remove one item of your partner''s clothing using only one hand.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000116', '1f5e0000-0000-4000-8000-100000000008', 'Guide your partner''s hand to where you want it.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000117', '1f5e0000-0000-4000-8000-100000000008', 'Undress your partner as slowly as possible.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000118', '1f5e0000-0000-4000-8000-100000000008', 'Join your partner in the shower.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000119', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your way down your partner''s body and stop just before reaching their most sensitive spot.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000120', '1f5e0000-0000-4000-8000-100000000008', 'Pleasure your partner for ten minutes without letting them return the favour.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- After Dark
('1f5e0000-0000-4000-8000-000000000121', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly what you want, then do it together if they want it too.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000122', '1f5e0000-0000-4000-8000-100000000009', 'Give your partner oral sex.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000123', '1f5e0000-0000-4000-8000-100000000009', 'Have sex in a position chosen by your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000124', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate while your partner watches.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000125', '1f5e0000-0000-4000-8000-100000000009', 'Watch your partner masturbate without touching them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000126', '1f5e0000-0000-4000-8000-100000000009', 'Use a sex toy on your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000127', '1f5e0000-0000-4000-8000-100000000009', 'Let your partner use a sex toy on you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000128', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate together while watching each other.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000129', '1f5e0000-0000-4000-8000-100000000009', 'Blindfold your partner and pleasure them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000130', '1f5e0000-0000-4000-8000-100000000009', 'Have sex without completely removing your clothes.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000131', '1f5e0000-0000-4000-8000-100000000009', 'Take your partner somewhere private for a quickie.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000132', '1f5e0000-0000-4000-8000-100000000009', 'Act out a fantasy your partner has previously shared with you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000133', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly how to touch you while they do it.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000134', '1f5e0000-0000-4000-8000-100000000009', 'Spend twenty minutes pleasuring each other without penetration.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000135', '1f5e0000-0000-4000-8000-100000000009', 'Make your partner orgasm without using your hands.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- Power Play
('1f5e0000-0000-4000-8000-000000000136', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner three rules to follow until the dare expires.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000137', '1f5e0000-0000-4000-8000-100000000010', 'Kneel in front of your partner and wait for their first instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000138', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner restrain your wrists using an agreed quick-release restraint.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000139', '1f5e0000-0000-4000-8000-100000000010', 'Wear a blindfold and follow three instructions from your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000140', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner an agreed number of strikes using an agreed implement and intensity.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000141', '1f5e0000-0000-4000-8000-100000000010', 'Act as your partner''s submissive for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000142', '1f5e0000-0000-4000-8000-100000000010', 'Take control of your partner for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000143', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner choose your outfit, position, and posture.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000144', '1f5e0000-0000-4000-8000-100000000010', 'Send your partner a voice note promising to obey one agreed instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000145', '1f5e0000-0000-4000-8000-100000000010', 'Edge your partner and stop before they orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000146', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner decide when you are allowed to orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000147', '1f5e0000-0000-4000-8000-100000000010', 'Wear a collar or agreed symbol of submission for your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000148', '1f5e0000-0000-4000-8000-100000000010', 'Serve your partner without speaking for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000149', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner control a remote toy while you remain somewhere private.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000150', '1f5e0000-0000-4000-8000-100000000010', 'Finish the dare by giving your partner the aftercare they ask for.', 5, 'unreviewed', 'Private mature editorial inventory')
), upsert AS (
INSERT INTO dares (
id, pack_id, text, intensity, content_status, content_review_reason,
content_reviewed_at
)
SELECT id::uuid, pack_id::uuid, text, intensity,
content_status::content_review_status, content_review_reason,
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
FROM seed
ON CONFLICT (id) DO UPDATE SET id = dares.id
WHERE (dares.pack_id, dares.text, dares.intensity, dares.suggested_duration_hours,
dares.content_status, dares.content_review_reason, dares.content_reviewed_at,
dares.content_reviewed_by)
IS NOT DISTINCT FROM
(EXCLUDED.pack_id, EXCLUDED.text, EXCLUDED.intensity,
EXCLUDED.suggested_duration_hours, EXCLUDED.content_status,
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
EXCLUDED.content_reviewed_by)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
-- A migration has no authenticated human reviewer. Record the system seed as the
-- actor-less, timestamped review decision the schema explicitly permits rather than
-- fabricating a profile UUID. The entries remain durable audit provenance.
WITH seed (id, entity_type, entity_id, previous_status, new_status, reason, created_at) AS (
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dare_packs'::content_entity_type, id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, content_review_reason,
'2026-09-05T16:00:00Z'::timestamptz
FROM dare_packs
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
UNION ALL
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dares'::content_entity_type, id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, content_review_reason,
'2026-09-05T16:00:00Z'::timestamptz
FROM dares
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
), upsert AS (
INSERT INTO content_reviews (
id, entity_type, entity_id, previous_status, new_status, reason, changed_by, created_at
)
SELECT id::uuid, entity_type::content_entity_type, entity_id::uuid,
previous_status::content_review_status, new_status::content_review_status,
reason, null::uuid, created_at::timestamptz
FROM seed
ON CONFLICT (id) DO UPDATE SET id = content_reviews.id
WHERE (content_reviews.entity_type, content_reviews.entity_id,
content_reviews.previous_status, content_reviews.new_status,
content_reviews.reason, content_reviews.changed_by,
content_reviews.created_at)
IS NOT DISTINCT FROM
(EXCLUDED.entity_type, EXCLUDED.entity_id, EXCLUDED.previous_status,
EXCLUDED.new_status, EXCLUDED.reason, EXCLUDED.changed_by,
EXCLUDED.created_at)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM content_reviews r
WHERE (r.entity_type = 'dare_packs' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid AND '1f5e0000-0000-4000-8000-100000000004'::uuid)
OR (r.entity_type = 'dares' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid AND '1f5e0000-0000-4000-8000-000000000060'::uuid)
GROUP BY r.entity_type, r.entity_id
HAVING count(*) <> 1
) THEN
RAISE EXCEPTION 'catalogue seed review provenance is not one-to-one';
END IF;
IF EXISTS (
WITH expected (id, entity_type, entity_id, reason) AS (
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dare_packs'::content_entity_type, id, content_review_reason
FROM dare_packs
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
UNION ALL
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dares'::content_entity_type, id, content_review_reason
FROM dares
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
)
SELECT 1
FROM expected e
LEFT JOIN content_reviews r
ON r.entity_type = e.entity_type AND r.entity_id = e.entity_id
WHERE (r.id, r.previous_status, r.new_status, r.reason, r.changed_by,
r.created_at)
IS DISTINCT FROM
(e.id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, e.reason, null::uuid,
'2026-09-05T16:00:00Z'::timestamptz)
) THEN
RAISE EXCEPTION 'catalogue seed review provenance does not match the system seed';
END IF;
END $$;
+77
View File
@@ -92,6 +92,83 @@
"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
},
{
"idx": 23,
"version": "7",
"when": 1788624000000,
"tag": "0023_dare_catalogue_150",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -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",
+32
View File
@@ -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);
+31
View File
@@ -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,
+1
View File
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
export * from '../domains/answers/schema.js'; export * from '../domains/answers/schema.js';
export * from '../domains/chat/schema.js'; export * from '../domains/chat/schema.js';
export * from '../domains/app-data/schema.js'; export * from '../domains/app-data/schema.js';
export * from '../domains/quiz/schema.js';
+52
View File
@@ -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();
}
+26 -4
View File
@@ -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);
} }
} }
+4
View File
@@ -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);
} }
} }
+11 -3
View File
@@ -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); }
} }
+149 -22
View File
@@ -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);}
} }
+129 -2
View File
@@ -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); }
} }
+8
View File
@@ -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));
+7 -3
View File
@@ -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);
} }
} }
+111 -17
View File
@@ -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);
} }
} }
+12 -2
View File
@@ -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) => {
+75 -5
View File
@@ -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);
} }
} }
+2
View File
@@ -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 {
+552
View File
@@ -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);
}
}
+125
View File
@@ -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);
});
}
}
+79
View File
@@ -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)]);
+6 -4
View File
@@ -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); }
} }
+2 -1
View File
@@ -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 -1
View File
@@ -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 {
+116 -8
View File
@@ -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) {
+6 -1
View File
@@ -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};
} }
} }
+2
View File
@@ -48,4 +48,6 @@ export interface ProducerSummary {
weeklySummaries: number; weeklySummaries: number;
unpairedReminders: number; unpairedReminders: number;
catchupReminders: number; catchupReminders: number;
streakReminders: number;
daresExpired: number;
} }
+7 -3
View File
@@ -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);
} }
} }
+31 -1
View File
@@ -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),
]);
Loaded 100 of 468 files, more files were not shown because too many files have changed in this diff. Show more