Compare commits

Author SHA1 Message Date
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
224 changed files with 16729 additions and 2632 deletions

No files matched your search

@@ -0,0 +1,39 @@
---
id: 2026-08-30-ios-extension-version-verification
date: 2026-08-30
status: promoted
scope: iOS release artifact verification
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA
destination: .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 archive warning described an intermediate state and was not authoritative
for the exported package. Direct IPA inspection showed both the app and
`LiveDrawWidget.appex` at version 1.0.6 build 45.
## Remediation
For every iOS release, inspect version and build values in 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 packaged plists. `codesign --verify --deep --strict` passed for the app, and
the verified IPA SHA-256 was
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`.
+9 -7
View File
@@ -1,12 +1,14 @@
name = "implementation" name = "implementation"
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known." description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
model = "gpt-5.6-terra" model = "gpt-5.6-terra"
developer_instructions = """ developer_instructions = """
Read the root and nearest scoped AGENTS.md files plus the applicable repository Read the root and nearest scoped AGENTS.md files plus the applicable repository
skill. Implement only the delegated objective and preserve unrelated work. Update skill. Own the continuous execution unit: perform scoped discovery,
maintained contract documentation and add focused tests when the contract changes. implementation, focused tests, and iteration in this handoff. Implement only the
Run the required focused checks and return changed files, exact results, delegated objective and preserve unrelated work. Update maintained contract
assumptions, and blockers. Stop for missing authority or a materially ambiguous documentation and add focused tests when the contract changes. Run the required
product choice. Do not expand scope or perform external, destructive, production, focused checks and return changed files, exact results, assumptions, and blockers.
release, or provider writes unless the task explicitly authorizes that action. 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.
""" """
+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.
+121
View File
@@ -0,0 +1,121 @@
---
name: generate-release
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
---
# Generate release
Create a reproducible release whose tag identifies the reviewed release source
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
[references/release-notes-voice.md](references/release-notes-voice.md) before
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
before accepting a build.
A request to generate or create a release authorizes the scoped version edits,
release notes, local builds, release commits, and one local annotated tag. It
does not authorize pushing commits or tags, uploading binaries, submitting a
store release, changing signing credentials, or regenerating native projects.
## Defaults
- Build both iOS and Android production binaries unless the user narrows the
platforms.
- Increment the patch version unless the user specifies `minor`, `major`, or an
exact SemVer version.
- Use `v<version>` for the annotated Git tag.
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
binaries, credentials, logs, or build directories.
## Preflight
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
toolchains, EAS authentication, and signing availability without changing them.
Require all of the following:
- a named branch, normally `main` for a production release;
- a clean starting worktree with `HEAD` equal to its fetched upstream;
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
- consistent public and OTA runtime versions across `app.json`, explicit
`app.config.js` runtime, Android, and every iOS target
(`scripts/set_version.mjs --check`);
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
- the configured production API/Auth/RevenueCat environment, without printing
secret values.
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.
After each build:
1. Inspect the worktree before doing anything else. Accept only the documented
EAS build-number changes in `app.json`, that platform's native version source,
and the iOS widget plist when applicable. Treat any implementation, resource,
dependency, or native-project regeneration as a failed build.
2. Independently inspect the binary for package/bundle identifier, public
version, platform build number, production endpoint configuration where
inspectable, and signing identity/fingerprint. Require the embedded public
version to equal the requested version.
3. Record the verified platform build number in the new release-note entry.
4. Commit only the expected metadata and release-note update as
`chore(<platform>): record v<version> build <number>`. The worktree must be
clean before starting the next platform build.
Do not accept a successful EAS exit code as proof of a valid artifact. If either
requested platform fails, do not tag a partial release.
## Tag and prove the result
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
artifact exists, has a SHA-256 digest, passed independent version/signature
inspection, and was produced from the release commit chain. Require a clean
worktree, then create the annotated local tag `v<version>` at `HEAD`.
Read back the tag object and prove:
- the tag resolves to `HEAD`;
- the tag version equals all public-version sources and embedded artifacts;
- recorded build numbers equal the embedded platform build numbers;
- no binary or ignored build output entered the commit;
- the release commits descend from the fetched starting branch commit;
- for a two-platform release, the Android artifact's source commit differs from
the final tag only by the later verified iOS build metadata.
Report the branch, tag, commit, release-note summary, artifact paths, versions,
build numbers, signatures, SHA-256 digests, checks run, and any environmental
limitations. State explicitly that the tag and commits remain local unless a
separate authorized push was completed.
@@ -0,0 +1,4 @@
interface:
display_name: "Generate Release"
short_description: "Bump, build, note, and tag Sauci releases"
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
@@ -0,0 +1,39 @@
# Release artifact verification
Use the strongest locally available platform tools and record the commands and
results. Never print provisioning profiles, keys, tokens, or environment values.
## Android AAB
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
or Android SDK `apkanalyzer`, and verify:
- package `com.sauci.app`;
- `versionName` equals the release version;
- `versionCode` equals the recorded Android build number.
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
signing certificate SHA-1 to the production fingerprint documented in
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
the artifact SHA-256 with `shasum -a 256`.
## iOS IPA
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
- `CFBundleIdentifier` is `com.sauci.app`;
- `CFBundleShortVersionString` equals the release version;
- `CFBundleVersion` equals the recorded iOS build number.
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
to inspect and verify signing without exposing the embedded profile. Calculate the
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
extraction directory after inspection.
## Source correspondence
For both platforms, retain the build command, artifact path, build completion
time, and digest in the final report. Compare the post-build tracked diff with the
documented allowed version files. A binary with the right version but unexpected
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
@@ -0,0 +1,51 @@
# Sauci release-note voice
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
couples app about private connection and shared curiosity: supportive without
being clinical, polished without sounding corporate, and intimate without being
graphic or presumptuous.
## Shape
Use a short opening sentence followed by three to six compact bullets. Lead with
the change that most improves a couple's experience. Combine related work into
one benefit rather than mirroring the commit list. Include a brief closing line
only when it adds warmth or useful context.
In `docs/release-notes.md`, use:
```markdown
## v1.2.3 — 29 August 2026
_iOS build 44 · Android build 48_
A little more ease, clarity, and connection in every check-in.
- **Closer conversations** — Find the prompts that feel right for both of you.
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
- **More control** — Clearer choices help keep your shared space comfortable.
```
Adapt the words to the evidence; do not reuse this copy when those claims are not
present in the release. If only one platform was requested, record only its build.
## Language
- Address the couple's experience with `you`, `your partner`, `together`, or
`your shared space` where natural.
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
`keep conversations flowing`, `stay in control`.
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
fix a relationship.
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
`revolutionary`, `game-changing`, `massive`, or `best ever`.
- Explain a fix as the experience restored, not the defect mechanics. Name a
security or privacy change plainly when users need to understand it.
## Evidence boundary
Release notes describe shipped behavior only. Do not claim a feature from a plan,
flagged-off path, migration, test, or commit title without confirming the runtime
path in the diff and release configuration. Do not expose internal service names,
database details, moderation logic, security-sensitive implementation, or content
that the product intentionally keeps private between partners.
+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}`);
+47 -8
View File
@@ -10,14 +10,53 @@ 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.
Before a build from the current upstream branch, fetch, require a clean worktree, Before a build from the current upstream branch, fetch, require a clean worktree,
and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android and prove `HEAD` matches its upstream. The `generate-release` skill may instead
projects, so routine release builds must package those checked-in sources. Do not build a clean, scoped release-preparation commit that descends directly from that
run `expo prebuild` first; native regeneration is a separate reviewed change. fetched upstream; between its multi-platform builds it commits only independently
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
so routine release builds must package those checked-in sources. Do not run
`expo prebuild` first; native regeneration is a separate reviewed change.
Use the package scripts in `apps/mobile/package.json`; they create artifacts only. Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
Never infer upload or submission authority from build authorization. Store upload Never infer upload or submission authority from build authorization. Store upload
uses a separately authorized command. Preserve platform signing and local EAS uses a separately authorized command.
versioning. After a build, verify that tracked changes are limited to the expected
version metadata, then independently read the artifact version and signature. ## Upload authorization gate
Report the artifact, version/build number, upload result when authorized, and
read-back state. After an artifact passes verification, stop immediately before the external
upload and ask the user for confirmation. The request must identify:
- the platform, exact artifact path, bundle/package identifier, public version,
build number, signature identity, and SHA-256 digest;
- the exact destination and resulting state: Google Play internal-track draft or
App Store Connect/TestFlight processing;
- that the upload does not authorize a public rollout or App Review submission.
For iOS, inspect the current App Store Connect automatic-distribution behavior
before asking. If the build would be distributed automatically, name the exact
TestFlight groups or testers in the request and include that effect in the fresh
authorization. If the effect cannot be established, stop; changing an automatic-
distribution setting also requires separate authorization.
Even when the original request includes building, releasing, uploading, or
submitting, require this fresh confirmation once the exact verified artifact and
destination are known. End the request with: `Upload this verified artifact to
<destination> now?` A clear affirmative response authorizes one upload of that
artifact to that destination, plus only any automatic TestFlight distribution
explicitly disclosed in the request. Run `eas submit` with the explicit
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
select `--latest`. If the result is ambiguous, inspect EAS and the store before
doing anything else, do not retry, and ask again before another upload.
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
and the store consoles. A later public rollout, App Review submission, or
additional TestFlight distribution requires another fresh authorization.
Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently
read the artifact version and signature. 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 result when authorized, and store read-back state.
+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.
+6
View File
@@ -1,5 +1,7 @@
node_modules node_modules
**/node_modules **/node_modules
.next
**/.next
.git .git
.gitignore .gitignore
*.md *.md
@@ -23,11 +25,15 @@ 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
+3 -2
View File
@@ -58,8 +58,9 @@ e2e/playwright-report/
.playwright-mcp/ .playwright-mcp/
# Misc # Misc
*.pem *.pem
.cache/ !apps/mobile/certs/certificate.pem
.cache/
.temp/ .temp/
.tmp/ .tmp/
*.tmp *.tmp
+13 -3
View File
@@ -25,11 +25,21 @@ MCP service in one npm/Turborepo workspace.
and prove the relevant compatibility contracts still hold. Preserve deployed and prove the relevant compatibility contracts still hold. Preserve deployed
interfaces and supported client behavior; repository checks alone do not interfaces and supported client behavior; repository checks alone do not
prove a live change is safe. prove a live change is safe.
10. Scope work to the task. Route bounded implementation through the project 10. The Sol primary agent owns scope, risk classification, acceptance criteria,
`implementation` agent when delegation is useful; the primary agent retains progress steering, final diff and evidence review, and the final answer.
acceptance, review, and final verification. Do not delegate trivial edits. 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 11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`. `docs/agents/routing.md`.
12. Agents must not use em dash characters in text they add to this repository.
## Repository map ## Repository map
+36
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 };
}
+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();
}
@@ -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,
+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 $$;
+29 -1
View File
@@ -134,6 +134,34 @@
"when": 1788081480000, "when": 1788081480000,
"tag": "0018_cutover_source_parity", "tag": "0018_cutover_source_parity",
"breakpoints": true "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
} }
] ]
} }
+15
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';
@@ -15,6 +16,8 @@ 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 type { DaresRepository } from './domains/dares/repository.js';
import { registerDareRoutes } from './domains/dares/routes.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';
@@ -34,11 +37,13 @@ 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; daresRepository?: DaresRepository;
quizRepository?: QuizRepository;
profileSettingsRepository?: ProfileSettingsRepository; profileSettingsRepository?: ProfileSettingsRepository;
accountOperationsService?: AccountOperationsService; accountOperationsService?: AccountOperationsService;
mediaRepository?: MediaRepository; mediaRepository?: MediaRepository;
@@ -58,6 +63,15 @@ 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) => { app.use('*', async (c, next) => {
const startedAt = performance.now(); const startedAt = performance.now();
try { try {
@@ -126,6 +140,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
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.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);
+22
View File
@@ -2,10 +2,30 @@ 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_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_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
@@ -30,6 +50,7 @@ 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 }; databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
authIssuer: string; authIssuer: string;
@@ -84,6 +105,7 @@ 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: { databasePool: {
max: parsed.DATABASE_POOL_MAX, max: parsed.DATABASE_POOL_MAX,
+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';
+5 -1
View File
@@ -32,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(),
@@ -83,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();
+3
View File
@@ -10,6 +10,9 @@ export const profiles = pgTable('profiles', {
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>(),
+6
View File
@@ -35,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 {
+24 -7
View File
@@ -42,7 +42,7 @@ 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 | null; hasMore: boolean }>; matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
@@ -58,10 +58,17 @@ export class PostgresAnswersRepository implements AnswersRepository {
private zones: Promise<Set<string>> | null = null; private zones: Promise<Set<string>> | null = null;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } 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;
} }
@@ -147,15 +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 catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]); // 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) { 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 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 };
}); });
} }
@@ -172,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}:{}) };
}); });
} }
+68 -2
View File
@@ -1,6 +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 { 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 {
@@ -77,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 {
@@ -111,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>(
@@ -129,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);
@@ -183,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);
}); });
} }
+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 {
+76 -8
View File
@@ -4,6 +4,11 @@ import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../d
export type DareStatus = export type DareStatus =
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled'; | '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. */ /** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
export const FREE_WEEKLY_SEND_LIMIT = 3; export const FREE_WEEKLY_SEND_LIMIT = 3;
@@ -45,6 +50,8 @@ export interface SentDare {
direction: 'incoming' | 'outgoing'; direction: 'incoming' | 'outgoing';
status: DareStatus; status: DareStatus;
sender_notes: string | null; sender_notes: string | null;
proof_type: DareProofType;
proof_media_id: string | null;
sent_at: string; sent_at: string;
accepted_at: string | null; accepted_at: string | null;
submitted_at: string | null; submitted_at: string | null;
@@ -79,6 +86,7 @@ export interface SendDareInput {
custom_dare_intensity?: number | null; custom_dare_intensity?: number | null;
duration_hours?: number | null; duration_hours?: number | null;
sender_notes?: string | null; sender_notes?: string | null;
proof_type?: DareProofType | null;
} }
export class DaresError extends Error { export class DaresError extends Error {
@@ -111,6 +119,8 @@ interface SentDareRecord extends QueryResultRow {
recipient_id: string; recipient_id: string;
status: DareStatus; status: DareStatus;
sender_notes: string | null; sender_notes: string | null;
proof_type: DareProofType;
proof_media_id: string | null;
sent_at: Date; sent_at: Date;
accepted_at: Date | null; accepted_at: Date | null;
submitted_at: Date | null; submitted_at: Date | null;
@@ -124,7 +134,7 @@ export interface DaresRepository {
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>; listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
send(userId: string, input: SendDareInput): Promise<SentDare>; send(userId: string, input: SendDareInput): Promise<SentDare>;
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>; respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
submit(userId: string, dareId: string): Promise<SentDare>; submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare>;
complete(userId: string, dareId: string): Promise<SentDare>; complete(userId: string, dareId: string): Promise<SentDare>;
cancel(userId: string, dareId: string): Promise<SentDare>; cancel(userId: string, dareId: string): Promise<SentDare>;
stats(userId: string): Promise<DareStats>; stats(userId: string): Promise<DareStats>;
@@ -132,7 +142,7 @@ export interface DaresRepository {
} }
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot, const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
custom_dare_text, sender_id, recipient_id, status, sender_notes, 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`; sent_at, accepted_at, submitted_at, completed_at, expires_at`;
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', '); const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
@@ -172,6 +182,8 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing', direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
status: row.status, status: row.status,
sender_notes: row.sender_notes, sender_notes: row.sender_notes,
proof_type: row.proof_type,
proof_media_id: row.proof_media_id,
sent_at: row.sent_at.toISOString(), sent_at: row.sent_at.toISOString(),
accepted_at: iso(row.accepted_at), accepted_at: iso(row.accepted_at),
submitted_at: iso(row.submitted_at), submitted_at: iso(row.submitted_at),
@@ -323,6 +335,11 @@ export class PostgresDaresRepository implements DaresRepository {
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400); 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 text: string;
let intensity: number; let intensity: number;
let dareId: string | null = null; let dareId: string | null = null;
@@ -376,15 +393,15 @@ export class PostgresDaresRepository implements DaresRepository {
`insert into sent_dares ( `insert into sent_dares (
couple_id, dare_id, custom_dare_text, custom_dare_intensity, couple_id, dare_id, custom_dare_text, custom_dare_intensity,
dare_text_snapshot, dare_intensity_snapshot, dare_text_snapshot, dare_intensity_snapshot,
sender_id, recipient_id, sender_notes, expires_at sender_id, recipient_id, sender_notes, proof_type, expires_at
) values ( ) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end case when $11::integer is null then null else now() + ($11::integer * interval '1 hour') end
) )
returning ${BARE_COLUMNS}`, returning ${BARE_COLUMNS}`,
[ [
context.couple_id, dareId, customText, customIntensity, text, intensity, context.couple_id, dareId, customText, customIntensity, text, intensity,
userId, context.partner_id, input.sender_notes?.trim() || null, duration, userId, context.partner_id, input.sender_notes?.trim() || null, proofType, duration,
], ],
); );
return toSentDare(result.rows[0]!, userId); return toSentDare(result.rows[0]!, userId);
@@ -438,8 +455,59 @@ export class PostgresDaresRepository implements DaresRepository {
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null); : this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
} }
async submit(userId: string, dareId: string): Promise<SentDare> { /**
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at'); * 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> { async complete(userId: string, dareId: string): Promise<SentDare> {
+16 -1
View File
@@ -15,6 +15,7 @@ const sendBody = z
custom_dare_intensity: z.number().int().min(1).max(5).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(), 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(), sender_notes: z.string().trim().max(500).nullish(),
proof_type: z.enum(['none', 'photo', 'audio']).nullish(),
}) })
.strict() .strict()
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), { .refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
@@ -23,6 +24,8 @@ const sendBody = z
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict(); 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 { function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } }; return { error: { code, message } };
} }
@@ -93,8 +96,20 @@ export function registerDareRoutes(app: DaresApp, repository: DaresRepository):
: c.json(error(response.cause.code, response.cause.message), response.cause.status); : 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 [ for (const [path, method] of [
['submit', 'submit'],
['complete', 'complete'], ['complete', 'complete'],
['cancel', 'cancel'], ['cancel', 'cancel'],
] as const) { ] as const) {
+2 -2
View File
@@ -39,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]);
@@ -84,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);
+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 {
+10 -4
View File
@@ -132,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)
@@ -143,10 +148,11 @@ 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]);
+5 -1
View File
@@ -35,6 +35,8 @@ export const questionPacks = pgTable('question_packs', {
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), 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', {
@@ -61,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(),
+389
View File
@@ -0,0 +1,389 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { computeQuizScore, type QuizAnswerPair } from './service.js';
import {
QuizError,
type QuizAnswerInput,
type QuizQuestionSummary,
type QuizResultPayload,
type QuizSessionPayload,
type QuizSessionStatus,
} from './types.js';
interface QuizQuestionRow extends QueryResultRow {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
interface QuizSessionRow extends QueryResultRow {
id: string;
couple_id: string;
status: QuizSessionStatus;
question_ids: string[];
score_percent: number | null;
completed_at: Date | null;
created_at: Date;
}
interface QuizAnswerRow extends QueryResultRow {
user_id: string;
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizRepository {
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
currentSession(userId: string): Promise<QuizSessionPayload | null>;
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
close(): Promise<void>;
}
const QUESTIONS_PER_SESSION = 10;
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await work(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
}
export class PostgresQuizRepository implements QuizRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
const result = await client.query<{ couple_id: string | null }>(
'select couple_id from profiles where id = $1',
[userId],
);
return result.rows[0]?.couple_id ?? null;
}
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
const result = await client.query<{ id: string }>(
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
[coupleId, userId],
);
return result.rows[0]?.id ?? null;
}
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
const result = await client.query<QuizQuestionRow>(
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
[questionIds],
);
return orderByIds(result.rows, questionIds);
}
private async buildPayload(
client: Pool | PoolClient,
session: QuizSessionRow,
userId: string,
): Promise<QuizSessionPayload> {
const questions = await this.questionsFor(client, session.question_ids);
const [myAnswers, counts] = await Promise.all([
client.query<QuizAnswerRow>(
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
[session.id, userId],
),
client.query<{ user_id: string; count: string }>(
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
[session.id],
),
]);
const coupleId = session.couple_id;
const partnerId = await this.partnerOf(client, coupleId, userId);
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
const questionCount = session.question_ids.length;
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
const orderedAnswers: QuizAnswerInput[] = session.question_ids
.map((questionId) => answersByQuestion.get(questionId))
.filter((row): row is QuizAnswerRow => row !== undefined)
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
id: row.id,
prompt_self: row.prompt_self,
prompt_guess: row.prompt_guess,
options: row.options,
}));
return {
id: session.id,
status: session.status,
created_at: session.created_at.toISOString(),
completed_at: session.completed_at?.toISOString() ?? null,
score_percent: session.score_percent,
questions: questionSummaries,
my_answers: orderedAnswers,
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
};
}
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
return transaction(this.pool, async (client) => {
const coupleId = await this.coupleOf(client, userId);
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
const members = await client.query<{ count: string }>(
'select count(*)::text as count from profiles where couple_id = $1',
[coupleId],
);
if (Number(members.rows[0]?.count ?? 0) < 2) {
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
}
const existing = await client.query<QuizSessionRow>(
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
[coupleId],
);
if (existing.rows[0]) {
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
}
// Prefer questions this couple has never had in any prior session (completed or
// active), then the least-recently-used ones, with a random tiebreak within each
// group so repeat quizzes stay varied instead of always reusing the same pool.
const questions = await client.query<{ id: string }>(
`select q.id
from quiz_questions q
left join (
select question_id, max(created_at) as last_used
from quiz_sessions, unnest(question_ids) as question_id
where couple_id = $1
group by question_id
) usage on usage.question_id = q.id
where q.is_active = true
order by (usage.last_used is not null), usage.last_used asc, random()
limit $2`,
[coupleId, QUESTIONS_PER_SESSION],
);
const questionIds = questions.rows.map((row) => row.id);
// The partial unique index (one active session per couple) is the source of
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
// lets the loser fall through to reading the winner's row instead of erroring.
const inserted = await client.query<QuizSessionRow>(
`insert into quiz_sessions (couple_id, question_ids)
values ($1, $2::uuid[])
on conflict (couple_id) where status = 'active' do nothing
returning *`,
[coupleId, questionIds],
);
let sessionRow = inserted.rows[0];
let created = true;
if (!sessionRow) {
created = false;
const raced = await client.query<QuizSessionRow>(
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
[coupleId],
);
sessionRow = raced.rows[0]!;
}
return { session: await this.buildPayload(client, sessionRow, userId), created };
});
}
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
const coupleId = await this.coupleOf(this.pool, userId);
if (!coupleId) return null;
const result = await this.pool.query<QuizSessionRow>(
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
[coupleId],
);
const row = result.rows[0];
if (!row) return null;
return this.buildPayload(this.pool, row, userId);
}
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
return transaction(this.pool, async (client) => {
const sessionResult = await client.query<QuizSessionRow>(
'select * from quiz_sessions where id = $1 for update',
[sessionId],
);
const session = sessionResult.rows[0];
const coupleId = await this.coupleOf(client, userId);
if (!session || !coupleId || session.couple_id !== coupleId) {
throw new QuizError('session_not_found', 'Quiz session not found', 404);
}
if (session.status !== 'active') {
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
}
const questionIds = new Set(session.question_ids);
const answeredIds = new Set(answers.map((answer) => answer.question_id));
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
for (const id of answeredIds) {
if (!questionIds.has(id)) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
}
const questions = await this.questionsFor(client, session.question_ids);
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
for (const answer of answers) {
const options = optionsByQuestion.get(answer.question_id);
if (!options) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
if (
answer.self_index < 0 || answer.self_index >= options.length ||
answer.guess_index < 0 || answer.guess_index >= options.length
) {
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
}
}
for (const answer of answers) {
await client.query(
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
values ($1, $2, $3, $4, $5)
on conflict (session_id, user_id, question_id)
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
);
}
const partnerId = await this.partnerOf(client, coupleId, userId);
let updatedSession = session;
if (partnerId) {
const counts = await client.query<{ user_id: string; count: string }>(
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
[sessionId],
);
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
const questionCount = session.question_ids.length;
const bothComplete =
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
if (bothComplete) {
const allAnswers = await client.query<QuizAnswerRow>(
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
[sessionId],
);
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
for (const row of allAnswers.rows) {
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
byUser.get(row.user_id)!.set(row.question_id, row);
}
const mine = byUser.get(userId)!;
const theirs = byUser.get(partnerId)!;
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
questionId,
aSelfIndex: mine.get(questionId)!.self_index,
aGuessIndex: mine.get(questionId)!.guess_index,
bSelfIndex: theirs.get(questionId)!.self_index,
bGuessIndex: theirs.get(questionId)!.guess_index,
}));
const { scorePercent } = computeQuizScore(pairs);
const completed = await client.query<QuizSessionRow>(
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
where id = $1
returning *`,
[sessionId, scorePercent],
);
updatedSession = completed.rows[0]!;
}
}
return this.buildPayload(client, updatedSession, userId);
});
}
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
const sessionResult = await this.pool.query<QuizSessionRow>(
'select * from quiz_sessions where id = $1',
[sessionId],
);
const session = sessionResult.rows[0];
const coupleId = await this.coupleOf(this.pool, userId);
if (!session || !coupleId || session.couple_id !== coupleId) {
throw new QuizError('session_not_found', 'Quiz session not found', 404);
}
if (session.status !== 'completed') {
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
}
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
const [questions, allAnswers] = await Promise.all([
this.questionsFor(this.pool, session.question_ids),
this.pool.query<QuizAnswerRow>(
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
[sessionId],
),
]);
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
for (const row of allAnswers.rows) {
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
byUser.get(row.user_id)!.set(row.question_id, row);
}
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
const questionsPayload = questions.map((question) => {
const my = mine.get(question.id);
const partner = theirs.get(question.id);
const mySelf = my?.self_index ?? 0;
const myGuess = my?.guess_index ?? 0;
const partnerSelf = partner?.self_index ?? 0;
const partnerGuess = partner?.guess_index ?? 0;
return {
question_id: question.id,
prompt_self: question.prompt_self,
prompt_guess: question.prompt_guess,
options: question.options,
my_self_index: mySelf,
my_guess_index: myGuess,
partner_self_index: partnerSelf,
partner_guess_index: partnerGuess,
i_guessed_right: myGuess === partnerSelf,
partner_guessed_right: partnerGuess === mySelf,
};
});
return {
score_percent: session.score_percent ?? 0,
completed_at: session.completed_at?.toISOString() ?? null,
questions: questionsPayload,
};
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { QuizError, type QuizAnswerInput } from './types.js';
import type { QuizRepository } from './repository.js';
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const answerEntry = z
.object({
question_id: id,
self_index: z.number().int().min(0),
guess_index: z.number().int().min(0),
})
.strict();
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof QuizError) return { ok: false as const, cause };
throw cause;
}
}
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
app.post('/v1/quiz/sessions', async (c) => {
const response = await result(() => repository.startSession(c.get('identity').id));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
});
app.get('/v1/quiz/sessions/current', async (c) => {
const response = await result(() => repository.currentSession(c.get('identity').id));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value });
});
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
const sessionId = id.safeParse(c.req.param('sessionId'));
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
const answers: QuizAnswerInput[] = body.data.answers;
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value });
});
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
const sessionId = id.safeParse(c.req.param('sessionId'));
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json(response.value);
});
}
+51
View File
@@ -0,0 +1,51 @@
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { couples } from '../couples/schema.js';
/**
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
* same question so a session can ask each partner about themselves and about the
* other without a second row.
*/
export const quizQuestions = pgTable('quiz_questions', {
id: uuid('id').primaryKey().defaultRandom(),
promptSelf: text('prompt_self').notNull().unique(),
promptGuess: text('prompt_guess').notNull(),
options: text('options').array().notNull(),
sortOrder: integer('sort_order').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
]);
export const quizSessions = pgTable('quiz_sessions', {
id: uuid('id').primaryKey().defaultRandom(),
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('active'),
questionIds: uuid('question_ids').array().notNull(),
scorePercent: integer('score_percent'),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
]);
export const quizAnswers = pgTable('quiz_answers', {
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
questionId: uuid('question_id').notNull(),
selfIndex: integer('self_index').notNull(),
guessIndex: integer('guess_index').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
]);
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
export type QuizSessionRow = typeof quizSessions.$inferSelect;
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
+41
View File
@@ -0,0 +1,41 @@
/**
* One partner's guess of the other's self-answer, for a single question. Scoring
* always runs both directions: A guessing B and B guessing A each count separately,
* so a couple who only ever gets half the guesses right lands at 50%, not 100%.
*/
export interface QuizAnswerPair {
questionId: string;
aSelfIndex: number;
aGuessIndex: number;
bSelfIndex: number;
bGuessIndex: number;
}
export interface QuizScoreQuestionResult extends QuizAnswerPair {
aGuessedRight: boolean;
bGuessedRight: boolean;
}
export interface QuizScoreResult {
scorePercent: number;
perQuestion: QuizScoreQuestionResult[];
}
/**
* Pure scoring function for a completed quiz session. Each question contributes
* up to two hits (partner A's guess about B, and B's guess about A), so the
* denominator is twice the question count.
*/
export function computeQuizScore(pairs: QuizAnswerPair[]): QuizScoreResult {
const perQuestion = pairs.map((pair) => ({
...pair,
aGuessedRight: pair.aGuessIndex === pair.bSelfIndex,
bGuessedRight: pair.bGuessIndex === pair.aSelfIndex,
}));
const hits = perQuestion.reduce(
(sum, question) => sum + (question.aGuessedRight ? 1 : 0) + (question.bGuessedRight ? 1 : 0),
0,
);
const scorePercent = pairs.length === 0 ? 0 : Math.round((100 * hits) / (2 * pairs.length));
return { scorePercent, perQuestion };
}
+64
View File
@@ -0,0 +1,64 @@
export type QuizSessionStatus = 'active' | 'completed';
export type QuizErrorCode =
| 'no_couple'
| 'partner_required'
| 'session_not_found'
| 'session_completed'
| 'invalid_answers'
| 'session_not_completed';
export class QuizError extends Error {
constructor(
readonly code: QuizErrorCode,
message: string,
readonly status: 400 | 404 | 409,
) {
super(message);
this.name = 'QuizError';
}
}
export interface QuizAnswerInput {
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizQuestionSummary {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
export interface QuizSessionPayload {
id: string;
status: QuizSessionStatus;
created_at: string;
completed_at: string | null;
score_percent: number | null;
questions: QuizQuestionSummary[];
my_answers: QuizAnswerInput[];
partner_completed: boolean;
i_completed: boolean;
}
export interface QuizResultQuestion {
question_id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
my_self_index: number;
my_guess_index: number;
partner_self_index: number;
partner_guess_index: number;
i_guessed_right: boolean;
partner_guessed_right: boolean;
}
export interface QuizResultPayload {
score_percent: number;
completed_at: string | null;
questions: QuizResultQuestion[];
}
+4
View File
@@ -20,6 +20,7 @@ import {
import { PostgresAnswersRepository } from './domains/answers/repository.js'; import { PostgresAnswersRepository } from './domains/answers/repository.js';
import { PostgresChatRepository } from './domains/chat/repository.js'; import { PostgresChatRepository } from './domains/chat/repository.js';
import { PostgresDaresRepository } from './domains/dares/repository.js'; import { PostgresDaresRepository } from './domains/dares/repository.js';
import { PostgresQuizRepository } from './domains/quiz/repository.js';
import { PostgresCoupleRepository } from './domains/couples/repository.js'; import { PostgresCoupleRepository } from './domains/couples/repository.js';
import { CoupleService } from './domains/couples/service.js'; import { CoupleService } from './domains/couples/service.js';
import { PostgresPacksRepository } from './domains/packs/repository.js'; import { PostgresPacksRepository } from './domains/packs/repository.js';
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
const answersRepository = new PostgresAnswersRepository(pool); const answersRepository = new PostgresAnswersRepository(pool);
const chatRepository = new PostgresChatRepository(pool); const chatRepository = new PostgresChatRepository(pool);
const daresRepository = new PostgresDaresRepository(pool); const daresRepository = new PostgresDaresRepository(pool);
const quizRepository = new PostgresQuizRepository(pool);
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool); const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool); const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
const accountOperationsService = new AccountOperationsService( const accountOperationsService = new AccountOperationsService(
@@ -63,11 +65,13 @@ const adminRepository = new PostgresAdminRepository(pool, {
const app = createApp({ const app = createApp({
auth: authVerifier, auth: authVerifier,
repository, repository,
corsAllowedOrigins: config.corsAllowedOrigins,
coupleService: new CoupleService(coupleRepository), coupleService: new CoupleService(coupleRepository),
packsRepository, packsRepository,
answersRepository, answersRepository,
chatRepository, chatRepository,
daresRepository, daresRepository,
quizRepository,
profileSettingsRepository, profileSettingsRepository,
accountOperationsService, accountOperationsService,
mediaRepository, mediaRepository,
+51 -1
View File
@@ -21,13 +21,14 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi
const responseQuestion = '88888888-8888-4888-8888-888888888888'; const responseQuestion = '88888888-8888-4888-8888-888888888888';
const responseId = '99999999-9999-4999-8999-999999999999'; const responseId = '99999999-9999-4999-8999-999999999999';
const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const quizCouple = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
let pool: Pool; let repository: PostgresAdminRepository; let pool: Pool; let repository: PostgresAdminRepository;
beforeAll(async () => { beforeAll(async () => {
await adminPool.query(`create schema "${schema}"`); await adminPool.query(`create schema "${schema}"`);
const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${schema}`); const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() }); repository = new PostgresAdminRepository(isolated.toString()); pool = new Pool({ connectionString: isolated.toString() }); repository = new PostgresAdminRepository(isolated.toString());
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql']) { for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql','0011_question_pack_average_precision.sql','0012_catalogue_field_parity.sql','0013_dares_loop.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql','0018_cutover_source_parity.sql','0019_quiz.sql','0020_quiz_question_pool.sql']) {
const migration = await readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8'); const migration = await readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8');
for (const statement of migration.split('--> statement-breakpoint')) if (statement.trim()) await pool.query(statement); for (const statement of migration.split('--> statement-breakpoint')) if (statement.trim()) await pool.query(statement);
} }
@@ -161,6 +162,55 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi
await expect(repository.responseMedia(responseViewer, responseId)).rejects.toMatchObject({ code: 'response_media_not_found' }); await expect(repository.responseMedia(responseViewer, responseId)).rejects.toMatchObject({ code: 'response_media_not_found' });
}); });
it('exposes writable quiz content and read-only quiz activity resources', async () => {
await pool.query('insert into couples(id,invite_code) values($1,$2)', [quizCouple, 'QUIZADMN']);
await pool.query("update admin_users set permissions=array_append(permissions,'manage_questions') where user_id=$1", [creatorUser]);
const creator = await repository.principal(creatorUser);
const [question] = await repository.insert(creator, 'quiz_questions', [{
prompt_self: 'How do you show affection?', prompt_guess: 'How does your partner show affection?', options: ['Touch', 'Words'],
}]);
expect(question).toMatchObject({ prompt_self: 'How do you show affection?' });
const principal = await repository.principal(superUser);
const listed = await repository.query(principal, 'quiz_questions', { filters: [{ column: 'id', op: 'eq', value: question!.id }] });
expect(listed.count).toBe(1);
expect((await repository.query(principal, 'quiz_sessions', {})).count).toBe(0);
await expect(repository.insert(principal, 'quiz_sessions', [{
couple_id: quizCouple, status: 'active', question_ids: [question!.id],
}])).rejects.toMatchObject({ code: 'forbidden' });
});
it('reads sent dares for user support visibility without granting write access', async () => {
await pool.query(
`insert into sent_dares(couple_id,custom_dare_text,dare_text_snapshot,dare_intensity_snapshot,sender_id,recipient_id,status)
values($1,'Give a compliment','Give a compliment',2,$2,$3,'pending')`,
[quizCouple, creatorUser, moderatorUser],
);
const principal = await repository.principal(superUser);
const result = await repository.query(principal, 'sent_dares', {});
expect(result.count).toBe(1);
expect(result.rows[0]).toMatchObject({ dare_text_snapshot: 'Give a compliment' });
await expect(repository.insert(principal, 'sent_dares', [{
couple_id: quizCouple, custom_dare_text: 'x', dare_text_snapshot: 'x',
dare_intensity_snapshot: 1, sender_id: creatorUser, recipient_id: moderatorUser, status: 'pending',
}])).rejects.toMatchObject({ code: 'forbidden' });
});
it('reads notification preferences for user support visibility without granting write access', async () => {
await pool.query(
"insert into notification_preferences(user_id,matches_enabled,dares_enabled) values($1,true,false)",
[creatorUser],
);
const principal = await repository.principal(superUser);
const result = await repository.query(principal, 'notification_preferences', {
filters: [{ column: 'user_id', op: 'eq', value: creatorUser }],
});
expect(result).toMatchObject({ count: 1, rows: [expect.objectContaining({ matches_enabled: true, dares_enabled: false })] });
await expect(repository.insert(principal, 'notification_preferences', [{
user_id: moderatorUser,
}])).rejects.toMatchObject({ code: 'forbidden' });
});
it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => { it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => {
const tables = await pool.query<{ table_name: string }>( const tables = await pool.query<{ table_name: string }>(
`select table_name from information_schema.tables where table_schema=current_schema() `select table_name from information_schema.tables where table_schema=current_schema()
+23 -2
View File
@@ -5,8 +5,8 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'; import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost'); const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository; describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc',solo='dddddddd-dddd-4ddd-8ddd-dddddddddddd',soloQ1='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',soloQ2='ffffffff-ffff-4fff-8fff-ffffffffffff';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql','0022_solo_sealed_answers.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into profiles(id,gender,max_intensity) values($1,null,5)",[solo]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null),($8,$2,'Solo one',2,null),($9,$2,'Solo two',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ,soloQ1,soloQ2]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);}); it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});}); it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);}); it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
@@ -109,4 +109,25 @@ it('buckets the window on the user local day and falls back to UTC',async()=>{aw
const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected"); const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected");
for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}}); for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}});
it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);}); it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);});
describe('solo answering before a couple exists',()=>{
it('recommends questions to an unpaired user instead of a no_couple error',async()=>{
const questions=await repo.recommended(solo);
expect(questions.map(x=>x.id)).toContain(soloQ1);
});
it('banks a solo answer with no couple, skips match reconciliation, and reports the sealed count',async()=>{
const first=await repo.submit(solo,{questionId:soloQ1,answer:'yes'});
expect(first.match).toBeNull();
expect(first.sealed_count).toBe(1);
const stored=await pool.query('select couple_id from responses where user_id=$1 and question_id=$2',[solo,soloQ1]);
expect(stored.rows[0].couple_id).toBeNull();
const second=await repo.submit(solo,{questionId:soloQ2,answer:'maybe'});
expect(second.match).toBeNull();
expect(second.sealed_count).toBe(2);
});
it('lets a solo answerer edit a sealed answer without a couple to reconcile against',async()=>{
await expect(repo.update(solo,{questionId:soloQ1,answer:'no',responseData:undefined,confirmDeleteMatch:false})).resolves.toMatchObject({success:true});
});
});
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`); isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolatedUrl.toString() }); pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresRepository(isolatedUrl.toString()); repository = new PostgresRepository(isolatedUrl.toString());
const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0014_daily_limit_local_reset.sql'] const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0005_profile_settings.sql','0014_daily_limit_local_reset.sql']
.map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n'); .map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n');
for (const statement of migration.split('--> statement-breakpoint')) { for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement); if (statement.trim()) await pool.query(statement);
+29
View File
@@ -81,6 +81,35 @@ describe('Sauci API', () => {
expect(repository.ready).toHaveBeenCalledOnce(); expect(repository.ready).toHaveBeenCalledOnce();
}); });
it('allows browser preflight requests only from configured web origins', async () => {
const app = createApp({
auth: verifier(),
repository: new MemoryRepository(),
corsAllowedOrigins: ['http://127.0.0.1:3010', 'https://sauci.app'],
});
const response = await app.request('/public/v1/redemptions', {
method: 'OPTIONS',
headers: {
origin: 'http://127.0.0.1:3010',
'access-control-request-method': 'POST',
'access-control-request-headers': 'content-type',
},
});
expect(response.status).toBe(204);
expect(response.headers.get('access-control-allow-origin')).toBe('http://127.0.0.1:3010');
expect(response.headers.get('access-control-allow-methods')).toContain('POST');
const rejected = await app.request('/public/v1/redemptions', {
method: 'OPTIONS',
headers: {
origin: 'https://unapproved.example',
'access-control-request-method': 'POST',
},
});
expect(rejected.headers.get('access-control-allow-origin')).toBeNull();
});
it('fails closed when the bearer token is missing or invalid', async () => { it('fails closed when the bearer token is missing or invalid', async () => {
const app = createApp({ auth: verifier(), repository: new MemoryRepository() }); const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
expect((await app.request('/v1/me')).status).toBe(401); expect((await app.request('/v1/me')).status).toBe(401);
@@ -26,7 +26,13 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
pool = new Pool({ connectionString: isolatedUrl.toString() }); pool = new Pool({ connectionString: isolatedUrl.toString() });
repository = new PostgresCoupleRepository(isolatedUrl.toString()); repository = new PostgresCoupleRepository(isolatedUrl.toString());
for (const migrationName of ['0000_identity_and_feature_interests.sql', '0001_couples.sql']) { for (const migrationName of [
'0000_identity_and_feature_interests.sql',
'0001_couples.sql',
'0002_packs_catalog_progress.sql',
'0003_answers_matches.sql',
'0022_solo_sealed_answers.sql',
]) {
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8'); const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
for (const statement of migration.split('--> statement-breakpoint')) { for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement); if (statement.trim()) await pool.query(statement);
@@ -57,8 +63,71 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
await expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' }); await expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' });
await repository.cancel(alice); await repository.cancel(alice);
await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null }); await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null }); await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0); expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
}); });
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const question = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
await pool.query(`insert into profiles(id, name) values ($1, 'Solo')`, [solo]);
await pool.query(`insert into question_packs(id, name) values ($1, 'Pack')`, [pack]);
await pool.query(`insert into questions(id, pack_id, text) values ($1, $2, 'Question')`, [question, pack]);
await pool.query(
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')`,
[randomUUID(), solo, question],
);
await expect(repository.getState(solo)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
});
it('claims both members\' sealed answers and computes matches when the second member joins', async () => {
const coupleId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
const dana = '44444444-4444-4444-8444-444444444444';
const erin = '55555555-5555-4555-8555-555555555555';
const pack = '66666666-6666-4666-8666-666666666666';
const yesYesQuestion = '77777777-7777-4777-8777-777777777777';
const yesNoQuestion = '88888888-8888-4888-8888-888888888888';
const soloOnlyQuestion = '99999999-9999-4999-8999-999999999999';
await pool.query(`insert into profiles(id, name) values ($1, 'Dana'), ($2, 'Erin')`, [dana, erin]);
await pool.query(`insert into question_packs(id, name) values ($1, 'Claim pack')`, [pack]);
await pool.query(
`insert into questions(id, pack_id, text) values ($1, $2, 'Yes yes'), ($3, $2, 'Yes no'), ($4, $2, 'Solo only')`,
[yesYesQuestion, pack, yesNoQuestion, soloOnlyQuestion],
);
// Both answer the same question before pairing (sealed, couple_id null). Dana
// and Erin agree on one question (yes/yes), disagree on another (yes/no), and
// Dana alone answers a third, which has no match to compute.
const insertResponse = (userId: string, questionId: string, answer: 'yes' | 'no' | 'maybe') =>
pool.query(
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, $4)`,
[randomUUID(), userId, questionId, answer],
);
await insertResponse(dana, yesYesQuestion, 'yes');
await insertResponse(erin, yesYesQuestion, 'yes');
await insertResponse(dana, yesNoQuestion, 'yes');
await insertResponse(erin, yesNoQuestion, 'no');
await insertResponse(dana, soloOnlyQuestion, 'maybe');
await repository.create(dana, coupleId, 'CLAIM123');
await repository.join(erin, 'CLAIM123');
const claimed = await pool.query<{ couple_id: string | null; count: number }>(
'select couple_id, count(*)::int count from responses where user_id in ($1,$2) group by couple_id',
[dana, erin],
);
expect(claimed.rows).toEqual([{ couple_id: coupleId, count: 5 }]);
// Yes/yes produces a match; yes/no produces none; the solo-only answer has no
// partner response yet, so only one match is computed from the claim.
const matches = await pool.query<{ question_id: string; match_type: string }>(
'select question_id, match_type from matches where couple_id=$1 order by question_id',
[coupleId],
);
expect(matches.rows).toEqual([{ question_id: yesYesQuestion, match_type: 'yes_yes' }]);
});
}); });
+2 -2
View File
@@ -14,7 +14,7 @@ const identity: AuthIdentity = {
function setup() { function setup() {
const repository: CoupleRepository = { const repository: CoupleRepository = {
getState: vi.fn(async () => ({ couple: null, partner: null })), getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async (_userId, coupleId, inviteCode) => ({ create: vi.fn(async (_userId, coupleId, inviteCode) => ({
id: coupleId, id: coupleId,
invite_code: inviteCode, invite_code: inviteCode,
@@ -43,7 +43,7 @@ describe('couple routes', () => {
const response = await app.request('/v1/couple'); const response = await app.request('/v1/couple');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ couple: null, partner: null }); expect(await response.json()).toEqual({ couple: null, partner: null, sealed_count: 0 });
expect(repository.getState).toHaveBeenCalledWith(identity.id); expect(repository.getState).toHaveBeenCalledWith(identity.id);
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ const couple: Couple = {
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository { function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
return { return {
getState: vi.fn(async () => ({ couple: null, partner: null })), getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
create: vi.fn(async () => couple), create: vi.fn(async () => couple),
join: vi.fn(async () => couple), join: vi.fn(async () => couple),
cancel: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined),
+52
View File
@@ -196,6 +196,58 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming'); expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
}); });
async function insertProof(owner: string, coupleId: string | null, mime: string, kind = 'dare_proof') {
const id = randomUUID();
await pool.query(
'insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,$4,$5,$6,100)',
[id, owner, coupleId, kind, `${kind}/test/${id}.bin`, mime],
);
return id;
}
it('stores the proof requirement and defaults to none', async () => {
const plain = await repo.send(sender, { dare_id: freeDare });
expect(plain).toMatchObject({ proof_type: 'none', proof_media_id: null });
const proofed = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
expect(proofed.proof_type).toBe('photo');
await expect(repo.send(sender, { dare_id: freeDare, proof_type: 'video' as never }))
.rejects.toMatchObject({ code: 'invalid_proof_type' });
});
it('requires matching proof media before a proofed dare can be submitted', async () => {
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
await repo.respond(recipient, dare.id, 'accept');
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'proof_required' });
const audio = await insertProof(recipient, couple, 'audio/mp4');
await expect(repo.submit(recipient, dare.id, audio)).rejects.toMatchObject({ code: 'proof_type_mismatch' });
const photo = await insertProof(recipient, couple, 'image/jpeg');
const submitted = await repo.submit(recipient, dare.id, photo);
expect(submitted).toMatchObject({ status: 'submitted', proof_media_id: photo });
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
});
it('refuses proof media the recipient does not own or that is not dare proof', async () => {
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
await repo.respond(recipient, dare.id, 'accept');
const foreign = await insertProof(outsider, otherCouple, 'image/jpeg');
await expect(repo.submit(recipient, dare.id, foreign)).rejects.toMatchObject({ code: 'proof_not_found' });
const wrongKind = await insertProof(recipient, couple, 'image/jpeg', 'chat');
await expect(repo.submit(recipient, dare.id, wrongKind)).rejects.toMatchObject({ code: 'proof_not_found' });
const senderOwned = await insertProof(sender, couple, 'image/jpeg');
await expect(repo.submit(recipient, dare.id, senderOwned)).rejects.toMatchObject({ code: 'proof_not_found' });
});
it('accepts voluntary proof on dares that did not require any', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await repo.respond(recipient, dare.id, 'accept');
const photo = await insertProof(recipient, couple, 'image/jpeg');
const submitted = await repo.submit(recipient, dare.id, photo);
expect(submitted).toMatchObject({ status: 'submitted', proof_type: 'none', proof_media_id: photo });
});
it('rejects durations outside the offered presets', async () => { it('rejects durations outside the offered presets', async () => {
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' }); await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
}); });
+20 -2
View File
@@ -13,10 +13,12 @@ const catalogueDareId = '55555555-5555-4555-8555-555555555555';
const dare: SentDare = { const dare: SentDare = {
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId, id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner, text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
direction: 'outgoing', status: 'pending', sender_notes: null, direction: 'outgoing', status: 'pending', sender_notes: null, proof_type: 'none', proof_media_id: null,
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null, sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
}; };
const proofMediaId = '77777777-7777-4777-8777-777777777777';
function setup(overrides: Partial<DaresRepository> = {}) { function setup(overrides: Partial<DaresRepository> = {}) {
const repository: DaresRepository = { const repository: DaresRepository = {
getCatalog: vi.fn(async () => ({ getCatalog: vi.fn(async () => ({
@@ -91,7 +93,7 @@ describe('dare routes', () => {
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' }); await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline'); expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
await post(app, `/v1/dares/${dareId}/submit`); await post(app, `/v1/dares/${dareId}/submit`);
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId); expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, null);
await post(app, `/v1/dares/${dareId}/complete`); await post(app, `/v1/dares/${dareId}/complete`);
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId); expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/cancel`); await post(app, `/v1/dares/${dareId}/cancel`);
@@ -109,6 +111,22 @@ describe('dare routes', () => {
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409); expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
}); });
it('accepts a proof requirement on send and rejects unknown ones', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'photo' });
expect(response.status).toBe(201);
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ proof_type: 'photo' }));
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'video' })).status).toBe(400);
});
it('passes proof media through on submit and validates its shape', async () => {
const { app, repository } = setup();
await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: proofMediaId });
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, proofMediaId);
expect((await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: 'not-a-uuid' })).status).toBe(400);
expect((await post(app, `/v1/dares/${dareId}/submit`, { extra_field: true })).status).toBe(400);
});
it('exposes the duration presets the send sheet offers', async () => { it('exposes the duration presets the send sheet offers', async () => {
const { app } = setup(); const { app } = setup();
const response = await app.request('/v1/dares/durations'); const response = await app.request('/v1/dares/durations');
+17 -1
View File
@@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository; const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
beforeAll(async()=>{ beforeAll(async()=>{
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString()); await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){ for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql','0022_solo_sealed_answers.sql']){
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement); const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
} }
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']); await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
@@ -59,6 +59,22 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key=$1',[`discord:couple_paired:${inviteCouple}`])).rows[0].count).toBe(1); expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key=$1',[`discord:couple_paired:${inviteCouple}`])).rows[0].count).toBe(1);
}); });
it('escalates the unpaired nudge copy with the sealed answer count',async()=>{
const solo=randomUUID(),secondQuestion=randomUUID();
await pool.query(`insert into profiles(id,name,email,push_token,onboarding_completed) values($1,'Solo','solo@test','token-solo',false)`,[solo]);
await pool.query('insert into questions(id,pack_id,text) values($1,$2,$3)',[secondQuestion,pack,'Second question']);
await pool.query(`insert into responses(id,user_id,question_id,couple_id,answer) values($1,$2,$3,null,'yes'),($4,$2,$5,null,'maybe')`,
[randomUUID(),solo,question,randomUUID(),secondQuestion]);
await repo.produce(new Date('2026-08-28T18:00:00.000Z'),100);
const body=(await pool.query<{body:string;data:{sealed_count:number}}>(
`select payload->>'body' body,payload->'data' data from operations_outbox where dedupe_key=$1`,
[`unpaired:2026-08-28:${solo}`],
)).rows[0];
expect(body?.body).toContain('2 sealed answers waiting');
expect(body?.data).toMatchObject({sealed_count:2});
});
it('atomically releases packs, celebrates milestones, and produces a single digest',async()=>{ it('atomically releases packs, celebrates milestones, and produces a single digest',async()=>{
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,streak_celebrated_at) values($1,7,7,0) on conflict(couple_id) do update set current_streak=7,streak_celebrated_at=0`,[couple]); await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,streak_celebrated_at) values($1,7,7,0) on conflict(couple_id) do update set current_streak=7,streak_celebrated_at=0`,[couple]);
const result=await repo.produce(new Date(Date.now()+10*60*1000),100); const result=await repo.produce(new Date(Date.now()+10*60*1000),100);
+250
View File
@@ -0,0 +1,250 @@
import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { PostgresQuizRepository } from '../src/domains/quiz/repository.js';
const url = process.env.DATABASE_URL;
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
if (url && !local) throw new Error('Quiz integration tests only permit localhost');
describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const admin = new Pool({ connectionString: url });
const schema = `quiz_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repo: PostgresQuizRepository;
const userA = '11111111-1111-4111-8111-111111111111';
const userB = '22222222-2222-4222-8222-222222222222';
const soloUser = '33333333-3333-4333-8333-333333333333';
const outsider = '44444444-4444-4444-8444-444444444444';
const couple = '55555555-5555-4555-8555-555555555555';
const soloCouple = '66666666-6666-4666-8666-666666666666';
const otherCouple = '77777777-7777-4777-8777-777777777777';
const userC = '88888888-8888-4888-8888-888888888888';
beforeAll(async () => {
await admin.query(`create schema "${schema}"`);
const isolated = new URL(url!);
isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() });
repo = new PostgresQuizRepository(isolated.toString());
const dir = new URL('../drizzle/', import.meta.url);
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
for (const name of files) {
const sql = await readFile(new URL(name, dir), 'utf8');
for (const statement of sql.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
}
await pool.query(
'insert into couples(id,invite_code) values($1,$2),($3,$4),($5,$6)',
[couple, 'QUIZAAA1', soloCouple, 'QUIZAAA2', otherCouple, 'QUIZAAA3'],
);
await pool.query(
`insert into profiles(id,couple_id) values
($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`,
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
);
});
beforeEach(async () => {
await pool.query('delete from quiz_answers');
await pool.query('delete from quiz_sessions');
});
afterAll(async () => {
await repo.close();
await pool.end();
await admin.query(`drop schema "${schema}" cascade`);
await admin.end();
});
it('refuses to start a quiz without a couple or without a partner', async () => {
await expect(repo.startSession(randomUUID())).rejects.toMatchObject({ code: 'no_couple' });
await expect(repo.startSession(soloUser)).rejects.toMatchObject({ code: 'partner_required' });
});
it('starts a session with ten active questions and returns it idempotently', async () => {
const first = await repo.startSession(userA);
expect(first.created).toBe(true);
expect(first.session.status).toBe('active');
expect(first.session.questions).toHaveLength(10);
expect(first.session.score_percent).toBeNull();
expect(first.session.my_answers).toEqual([]);
expect(first.session.i_completed).toBe(false);
expect(first.session.partner_completed).toBe(false);
const second = await repo.startSession(userA);
expect(second.created).toBe(false);
expect(second.session.id).toBe(first.session.id);
// The partner starting also resolves to the same active session.
const partnerView = await repo.startSession(userB);
expect(partnerView.created).toBe(false);
expect(partnerView.session.id).toBe(first.session.id);
});
it('walks the full happy path: both partners answer, session completes, score is exact', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
expect(questionIds).toHaveLength(10);
// Self-answers alternate 0/1 per question so a guess cannot accidentally match
// by coincidence. A guesses B's self-answer correctly on the first 6 questions
// and wrong on the rest; B guesses A's self-answer correctly on the first 3
// questions and wrong on the rest.
const bSelf = questionIds.map((_, index) => index % 2);
const aSelf = questionIds.map((_, index) => 1 - (index % 2));
const aGuess = bSelf.map((value, index) => (index < 6 ? value : 1 - value));
const bGuess = aSelf.map((value, index) => (index < 3 ? value : 1 - value));
const aAnswers = questionIds.map((questionId, index) => ({
question_id: questionId,
self_index: aSelf[index]!,
guess_index: aGuess[index]!,
}));
const bAnswers = questionIds.map((questionId, index) => ({
question_id: questionId,
self_index: bSelf[index]!,
guess_index: bGuess[index]!,
}));
const afterA = await repo.submitAnswers(userA, session.id, aAnswers);
expect(afterA.status).toBe('active');
expect(afterA.i_completed).toBe(true);
expect(afterA.partner_completed).toBe(false);
expect(afterA.my_answers).toHaveLength(10);
const afterB = await repo.submitAnswers(userB, session.id, bAnswers);
expect(afterB.status).toBe('completed');
expect(afterB.completed_at).not.toBeNull();
expect(afterB.i_completed).toBe(true);
expect(afterB.partner_completed).toBe(true);
// A guessed right (guess=1 matches B's self=1) on questions 0-5: 6 hits.
// B guessed right (guess=0 matches A's self=0) on questions 0-2: 3 hits.
// Total 9 hits out of 20 possible = 45%.
expect(afterB.score_percent).toBe(45);
const result = await repo.result(userA, session.id);
expect(result.score_percent).toBe(45);
expect(result.completed_at).not.toBeNull();
expect(result.questions).toHaveLength(10);
expect(result.questions[0]).toMatchObject({
my_self_index: aSelf[0],
my_guess_index: aGuess[0],
partner_self_index: bSelf[0],
partner_guess_index: bGuess[0],
i_guessed_right: true,
partner_guessed_right: true,
});
expect(result.questions[9]).toMatchObject({
i_guessed_right: false,
partner_guessed_right: false,
});
// The partner's result view mirrors the same score with roles swapped.
const partnerResult = await repo.result(userB, session.id);
expect(partnerResult.score_percent).toBe(45);
});
it('rejects an incomplete or mismatched answer set', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
await expect(
repo.submitAnswers(userA, session.id, [
{ question_id: questionIds[0]!, self_index: 0, guess_index: 0 },
]),
).rejects.toMatchObject({ code: 'invalid_answers' });
await expect(
repo.submitAnswers(userA, session.id, [
...questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })),
{ question_id: randomUUID(), self_index: 0, guess_index: 0 },
]),
).rejects.toMatchObject({ code: 'invalid_answers' });
const options = session.questions[0]!.options;
await expect(
repo.submitAnswers(userA, session.id, questionIds.map((id) => ({
question_id: id,
self_index: options.length,
guess_index: 0,
}))),
).rejects.toMatchObject({ code: 'invalid_answers' });
});
it('isolates sessions to their own couple', async () => {
const { session } = await repo.startSession(userA);
await expect(repo.submitAnswers(outsider, session.id, [])).rejects.toMatchObject({ code: 'session_not_found' });
await expect(repo.result(outsider, session.id)).rejects.toMatchObject({ code: 'session_not_found' });
expect(await repo.currentSession(outsider)).toBeNull();
});
it('blocks the answers endpoint once the session has completed', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
const answers = questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, session.id, answers);
const completed = await repo.submitAnswers(userB, session.id, answers);
expect(completed.status).toBe('completed');
await expect(repo.submitAnswers(userA, session.id, answers)).rejects.toMatchObject({ code: 'session_completed' });
});
it('blocks the result endpoint before the session has completed', async () => {
const { session } = await repo.startSession(userA);
await expect(repo.result(userA, session.id)).rejects.toMatchObject({ code: 'session_not_completed' });
});
it('returns the couple\'s most recent session from currentSession', async () => {
expect(await repo.currentSession(userA)).toBeNull();
const { session } = await repo.startSession(userA);
const current = await repo.currentSession(userB);
expect(current?.id).toBe(session.id);
});
it('draws the next session from never-used questions once a session completes', async () => {
const { session: first } = await repo.startSession(userA);
const firstQuestionIds = first.questions.map((question) => question.id);
expect(firstQuestionIds).toHaveLength(10);
const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, first.id, answers);
const completed = await repo.submitAnswers(userB, first.id, answers);
expect(completed.status).toBe('completed');
const { session: second, created } = await repo.startSession(userA);
expect(created).toBe(true);
const secondQuestionIds = second.questions.map((question) => question.id);
expect(secondQuestionIds).toHaveLength(10);
// The seeded pool has 36 active questions, so the never-used-first preference
// guarantees the second session's questions are wholly disjoint from the first.
const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id));
expect(overlap).toEqual([]);
});
it('scopes selection to each couple independently', async () => {
const { session: coupleOneFirst } = await repo.startSession(userA);
const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id);
const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, coupleOneFirst.id, answers);
await repo.submitAnswers(userB, coupleOneFirst.id, answers);
// A different couple with no prior sessions should still be able to draw a
// fresh set of 10 questions regardless of what the first couple has used,
// since the never-used-for-them preference is scoped per couple.
const { session: otherSession, created } = await repo.startSession(outsider);
expect(created).toBe(true);
expect(otherSession.questions).toHaveLength(10);
const partnerView = await repo.startSession(userC);
expect(partnerView.created).toBe(false);
expect(partnerView.session.id).toBe(otherSession.id);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { QuizRepository } from '../src/domains/quiz/repository.js';
import { registerQuizRoutes } from '../src/domains/quiz/routes.js';
import { QuizError, type QuizSessionPayload } from '../src/domains/quiz/types.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const sessionId = '22222222-2222-4222-8222-222222222222';
const questionId = '33333333-3333-4333-8333-333333333333';
const session: QuizSessionPayload = {
id: sessionId,
status: 'active',
created_at: '2026-08-27T00:00:00.000Z',
completed_at: null,
score_percent: null,
questions: [
{ id: questionId, prompt_self: 'Self prompt', prompt_guess: 'Guess prompt', options: ['A', 'B'] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
};
function setup(overrides: Partial<QuizRepository> = {}) {
const repository: QuizRepository = {
startSession: vi.fn(async () => ({ session, created: true })),
currentSession: vi.fn(async () => session),
submitAnswers: vi.fn(async () => session),
result: vi.fn(async () => ({
score_percent: 100,
completed_at: '2026-08-27T00:00:00.000Z',
questions: [],
})),
close: vi.fn(async () => undefined),
...overrides,
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerQuizRoutes(app, repository);
return { app, repository };
}
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('quiz routes', () => {
it('starts a session and returns 201 when a new one is created', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ session });
expect(repository.startSession).toHaveBeenCalledWith(identity.id);
});
it('returns 200 when starting resolves to an existing active session', async () => {
const { app } = setup({ startSession: vi.fn(async () => ({ session, created: false })) });
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(200);
});
it('maps no_couple and partner_required to 409', async () => {
const { app } = setup({
startSession: vi.fn(async () => { throw new QuizError('no_couple', 'Join a couple first', 409); }),
});
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: 'no_couple' } });
});
it('returns the current session, including null when none exists', async () => {
const { app } = setup({ currentSession: vi.fn(async () => null) });
const response = await app.request('/v1/quiz/sessions/current');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ session: null });
});
it('rejects a malformed session id on the answers route', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/quiz/sessions/not-a-uuid/answers', {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
});
expect(response.status).toBe(404);
expect(repository.submitAnswers).not.toHaveBeenCalled();
});
it('rejects an empty or malformed answer body', async () => {
const { app, repository } = setup();
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers: [] })).status).toBe(400);
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {})).status).toBe(400);
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1, extra: true }],
})).status).toBe(400);
expect(repository.submitAnswers).not.toHaveBeenCalled();
});
it('submits valid answers and surfaces the updated session', async () => {
const { app, repository } = setup();
const answers = [{ question_id: questionId, self_index: 0, guess_index: 1 }];
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers });
expect(response.status).toBe(200);
expect(repository.submitAnswers).toHaveBeenCalledWith(identity.id, sessionId, answers);
});
it('maps session errors to their status codes', async () => {
const { app } = setup({
submitAnswers: vi.fn(async () => { throw new QuizError('session_completed', 'Already finished', 409); }),
});
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: 'session_completed' } });
});
it('returns the result payload for a completed session', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ score_percent: 100 });
expect(repository.result).toHaveBeenCalledWith(identity.id, sessionId);
});
it('maps session_not_completed to 409 on the result route', async () => {
const { app } = setup({
result: vi.fn(async () => { throw new QuizError('session_not_completed', 'Not finished yet', 409); }),
});
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
expect(response.status).toBe(409);
});
it('rejects a malformed session id on the result route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/quiz/sessions/not-a-uuid/result');
expect(response.status).toBe(404);
expect(repository.result).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { computeQuizScore, type QuizAnswerPair } from '../src/domains/quiz/service.js';
function pair(overrides: Partial<QuizAnswerPair> = {}): QuizAnswerPair {
return {
questionId: 'q1',
aSelfIndex: 0,
aGuessIndex: 0,
bSelfIndex: 0,
bGuessIndex: 0,
...overrides,
};
}
describe('computeQuizScore', () => {
it('scores 100% when every guess matches the partner self-answer in both directions', () => {
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 1 }),
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 3, bGuessIndex: 0 }),
];
const { scorePercent } = computeQuizScore(pairs);
expect(scorePercent).toBe(100);
});
it('scores 0% when nobody guesses their partner correctly', () => {
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 2, bGuessIndex: 3 }),
];
expect(computeQuizScore(pairs).scorePercent).toBe(0);
});
it('counts each direction independently, so a half-right couple lands at 50%', () => {
// A guesses B correctly, but B guesses A incorrectly: one hit out of two possible.
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 0 }),
];
const result = computeQuizScore(pairs);
expect(result.scorePercent).toBe(50);
expect(result.perQuestion[0]).toMatchObject({ aGuessedRight: true, bGuessedRight: false });
});
it('rounds to the nearest whole percent', () => {
// 1 hit out of 6 possible (3 questions * 2 directions) = 16.67%, rounds to 17.
const pairs: QuizAnswerPair[] = [
// aGuess (0) matches bSelf (0): one hit. bGuess (3) does not match aSelf (1).
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 0, bGuessIndex: 3 }),
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
pair({ questionId: 'q3', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
];
expect(computeQuizScore(pairs).scorePercent).toBe(17);
});
it('returns 0% for an empty question set instead of dividing by zero', () => {
expect(computeQuizScore([]).scorePercent).toBe(0);
});
});
+16 -10
View File
@@ -13,13 +13,19 @@ EXPO_PUBLIC_API_URL=http://127.0.0.1:3003
# invoking shell. Do not put those credentials in this Expo environment file or # invoking shell. Do not put those credentials in this Expo environment file or
# commit them. The test user must be disposable, onboarding-complete, and have a # commit them. The test user must be disposable, onboarding-complete, and have a
# matching profile in the standalone non-production PostgreSQL database. # matching profile in the standalone non-production PostgreSQL database.
# Sentry error tracking (optional - get DSN from https://sentry.io) # Facebook App ID for Instagram Stories sharing (optional). Instagram requires
EXPO_PUBLIC_SENTRY_DSN= # a registered Facebook App ID for the Stories share flow; without it the
# Instagram share button falls back to the system share sheet.
# RevenueCat (for in-app subscriptions) # https://developers.facebook.com/docs/instagram/sharing-to-stories/
# Get your API keys from https://app.revenuecat.com EXPO_PUBLIC_FACEBOOK_APP_ID=
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY= # Sentry error tracking (optional - get DSN from https://sentry.io)
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set) EXPO_PUBLIC_SENTRY_DSN=
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
# RevenueCat (for in-app subscriptions)
# Get your API keys from https://app.revenuecat.com
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set)
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
+52 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 47 versionCode 49
versionName "1.0.5" versionName "1.0.7"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
signingConfigs { signingConfigs {
@@ -182,3 +182,53 @@ dependencies {
apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics' apply plugin: 'com.google.firebase.crashlytics'
// Reanimated 4.1.7 imports Worklets from its pre-AGP-8 CMake output path,
// while AGP 8.14 writes release outputs below intermediates/cxx/RelWithDebInfo.
// Keep the checked-in Android project compatible with the supported SDK 54
// dependency set by materializing that one legacy input after Worklets builds.
def workletsReleaseAbis = ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"]
def syncWorkletsReleaseCmakeInputs = tasks.register("syncWorkletsReleaseCmakeInputs") {
dependsOn(":react-native-worklets:externalNativeBuildRelease")
doLast {
def workletsIntermediates = file("${rootDir}/../../../node_modules/react-native-worklets/android/build/intermediates")
workletsReleaseAbis.each { abi ->
def libraries = fileTree(workletsIntermediates) {
include "cxx/RelWithDebInfo/**/obj/${abi}/libworklets.so"
}.files
if (libraries.size() != 1) {
throw new GradleException("Expected exactly one Worklets release library for ${abi}, found ${libraries.size()}")
}
def source = libraries.iterator().next()
def destination = file("${workletsIntermediates}/cmake/release/obj/${abi}/libworklets.so")
destination.parentFile.mkdirs()
java.nio.file.Files.deleteIfExists(destination.toPath())
source.withInputStream { input ->
destination.withOutputStream { output -> output << input }
}
if (destination.length() != source.length()) {
throw new GradleException("Failed to copy Worklets release library for ${abi}")
}
}
}
}
gradle.projectsEvaluated {
def reanimatedProject = findProject(":react-native-reanimated")
reanimatedProject?.tasks?.configureEach { task ->
if (task.name == "externalNativeBuildRelease" || task.name == "prefabReleasePackage") {
task.dependsOn(syncWorkletsReleaseCmakeInputs)
}
}
tasks.configureEach { task ->
if (task.name.startsWith("configureCMakeRelWithDebInfo")) {
task.dependsOn(
":react-native-worklets:prefabReleasePackage",
":react-native-reanimated:prefabReleasePackage",
":shopify_react-native-skia:prefabReleasePackage",
)
}
}
}
@@ -3,8 +3,8 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="com.android.vending.BILLING"/> <uses-permission android:name="com.android.vending.BILLING"/>
<queries> <queries>
<package android:name="com.instagram.android"/>
<intent> <intent>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
@@ -26,9 +27,14 @@
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/> <meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/> <meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/> <meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/> <meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----&#xA;MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV&#xA;BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG&#xA;A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac&#xA;8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx&#xA;SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW&#xA;SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn&#xA;JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV&#xA;hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW&#xA;VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E&#xA;DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl&#xA;2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT&#xA;MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07&#xA;JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo&#xA;JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5&#xA;3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY&#xA;Ug==&#xA;-----END CERTIFICATE-----&#xA;"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;keyid&quot;:&quot;main&quot;,&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;}"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.apps.jbrunton.co.uk/manifest"/>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;production&quot;,&quot;expo-app-id&quot;:&quot;9a66f3c2-fda7-416f-87e2-6db833b32e9d&quot;,&quot;xprem-branch&quot;:&quot;&quot;}"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/> <service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
@@ -43,6 +49,12 @@
<data android:scheme="com.sauci.app"/> <data android:scheme="com.sauci.app"/>
<data android:scheme="exp+sauci"/> <data android:scheme="exp+sauci"/>
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true" data-generated="true">
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https" android:host="sauci.app" android:pathPrefix="/join"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity> </activity>
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw"> <receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
<intent-filter> <intent-filter>
@@ -3,4 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string> <string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
<string name="expo_runtime_version">1.0.7</string>
</resources> </resources>
+106
View File
@@ -0,0 +1,106 @@
// Dynamic Expo config.
//
// `app.json` stays the static source of truth for everything that is stable
// across environments, including the `version`, `ios.buildNumber`, and
// `android.versionCode` fields that EAS rewrites during a release build. This
// file only layers the over-the-air update configuration on top, because the
// XPREM update server selects a release channel from a build-time request
// header and that value differs per EAS build profile.
//
// See `docs/mobile-ota.md`.
/* global __dirname */
const fs = require('fs');
const path = require('path');
const appJson = require('./app.json');
/** Base URL of the self-hosted XPREM update server. */
const OTA_SERVER_URL = 'https://ota.apps.jbrunton.co.uk';
/** XPREM manifest endpoint. XPREM serves the Expo Updates protocol here. */
const OTA_MANIFEST_URL = `${OTA_SERVER_URL}/manifest`;
/**
* XPREM application UUID for Sauci, shown in the XPREM dashboard under App
* Info. Not a secret; overridable for pointing a build at another app.
*/
const OTA_APP_ID = process.env.XPREM_APP_ID || '9a66f3c2-fda7-416f-87e2-6db833b32e9d';
/** Public code-signing certificate downloaded from the XPREM dashboard. */
const CODE_SIGNING_CERTIFICATE_PATH = './certs/certificate.pem';
/** Marker string present only in the committed placeholder certificate. */
const CERTIFICATE_PLACEHOLDER_MARKER = 'XPREM-PLACEHOLDER-CERTIFICATE';
/**
* Release channel bound to the build. XPREM maps a channel to a branch server
* side, so a channel can be repointed at a different branch without rebuilding.
* Set per EAS build profile in `eas.json` and exported before `eoas publish`.
*/
const releaseChannel = process.env.RELEASE_CHANNEL || 'development';
/**
* Code signing is skipped for local development, where the packager serves the
* bundle directly and no signed manifest exists:
* DISABLE_CODE_SIGNING=1 npx expo start --dev-client
*/
const codeSigningDisabled = Boolean(process.env.DISABLE_CODE_SIGNING);
if (releaseChannel === 'production' && codeSigningDisabled) {
throw new Error(
'RELEASE_CHANNEL=production does not permit DISABLE_CODE_SIGNING. ' +
'Production builds must embed the XPREM code-signing certificate and metadata.',
);
}
function assertUpdateConfigIsReal() {
const certificatePath = path.join(__dirname, CODE_SIGNING_CERTIFICATE_PATH);
const certificate = fs.existsSync(certificatePath)
? fs.readFileSync(certificatePath, 'utf8')
: '';
if (!certificate || certificate.includes(CERTIFICATE_PLACEHOLDER_MARKER)) {
throw new Error(
`RELEASE_CHANNEL=${releaseChannel} requires the real XPREM code-signing ` +
`certificate at apps/mobile/${CODE_SIGNING_CERTIFICATE_PATH}. ` +
'Download it from the XPREM dashboard App Info page. See docs/mobile-ota.md.',
);
}
}
// A build or publish always names its channel explicitly. Fail loudly there
// rather than shipping a build that can never validate a signed manifest.
if (process.env.RELEASE_CHANNEL) {
assertUpdateConfigIsReal();
}
module.exports = () => ({
...appJson.expo,
// Keep this explicit because EOAS validates a primitive runtime string before
// upload. The release version script updates it atomically with app.json and
// the checked-in native runtime values.
runtimeVersion: '1.0.7',
updates: {
enabled: true,
url: OTA_MANIFEST_URL,
checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 5000,
codeSigningCertificate: codeSigningDisabled
? undefined
: CODE_SIGNING_CERTIFICATE_PATH,
codeSigningMetadata: codeSigningDisabled
? undefined
: { keyid: 'main', alg: 'rsa-v1_5-sha256' },
// expo-updates only sends headers that were declared at build time, so
// every header XPREM may need has to be present here even when empty.
requestHeaders: {
'expo-channel-name': releaseChannel,
'expo-app-id': OTA_APP_ID,
// Branch surfing: lets a non-production build be pointed at another
// branch at runtime. Empty means the channel mapping decides.
'xprem-branch': '',
},
},
});
+43 -4
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Sauci", "name": "Sauci",
"slug": "sauci", "slug": "sauci",
"version": "1.0.5", "version": "1.0.7",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"scheme": "app.sauci", "scheme": "app.sauci",
@@ -16,12 +16,18 @@
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.sauci.app", "bundleIdentifier": "com.sauci.app",
"associatedDomains": [
"applinks:sauci.app"
],
"config": {
"usesNonExemptEncryption": false
},
"entitlements": { "entitlements": {
"com.apple.security.application-groups": [ "com.apple.security.application-groups": [
"group.com.sauci.app" "group.com.sauci.app"
] ]
}, },
"buildNumber": "43", "buildNumber": "46",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist", "googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": { "infoPlist": {
@@ -183,7 +189,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 47, "versionCode": 49,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
@@ -191,6 +197,23 @@
"android.permission.USE_BIOMETRIC", "android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT", "android.permission.USE_FINGERPRINT",
"com.android.vending.BILLING" "com.android.vending.BILLING"
],
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "sauci.app",
"pathPrefix": "/join"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
] ]
}, },
"web": { "web": {
@@ -208,6 +231,10 @@
"buildToolsVersion": "36.0.0", "buildToolsVersion": "36.0.0",
"compileSdkVersion": 36, "compileSdkVersion": 36,
"targetSdkVersion": 36, "targetSdkVersion": 36,
"blockedPermissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
],
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }", "extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
"useLegacyPackaging": false, "useLegacyPackaging": false,
"manifestPlaceholders": { "manifestPlaceholders": {
@@ -271,7 +298,19 @@
} }
], ],
"./plugins/withWidgetExported.js", "./plugins/withWidgetExported.js",
"./plugins/withNativeWidget.js" "./plugins/withNativeWidget.js",
[
"react-native-share",
{
"ios": [
"instagram",
"instagram-stories"
],
"android": [
"com.instagram.android"
]
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
+70 -10
View File
@@ -1,16 +1,19 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native'; import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated'; import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import * as Haptics from 'expo-haptics';
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui'; import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall'; import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard, QuizTile } from '../../src/components/discovery';
import { StreakDisplay } from '../../src/components/StreakDisplay'; import { StreakDisplay } from '../../src/components/StreakDisplay';
import { colors, spacing, typography, radius } from '../../src/theme'; import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types'; import type { QuestionPack, Category } from '../../src/types';
import { pickRecommendedPacks, RECOMMENDED_PACKS_LABEL } from '../../src/features/home/recommendedPacks';
const MAX_CONTENT_WIDTH = 600; const MAX_CONTENT_WIDTH = 600;
@@ -69,14 +72,21 @@ function groupPacksByCategory(
export default function DiscoveryScreen() { export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore(); const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore(); const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
const { pendingQuestions, fetchPendingQuestions } = useMatchStore();
const { subscription } = useSubscriptionStore(); const { subscription } = useSubscriptionStore();
const router = useRouter();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [headerHeight, setHeaderHeight] = useState(130); const [headerHeight, setHeaderHeight] = useState(130);
const lastPromptImpression = useRef<string | null>(null);
// Check all sources of premium access: user flag, partner flag, or active subscription // Check all sources of premium access: user flag, partner flag, or active subscription
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser; const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const pendingQuestionIds = useMemo(
() => pendingQuestions.map(question => question.id).join(','),
[pendingQuestions],
);
// Load the catalog once. Returning to this tab shows what is cached; answering a // Load the catalog once. Returning to this tab shows what is cached; answering a
// question invalidates it, and StreakDisplay owns the streak, so neither needs a // question invalidates it, and StreakDisplay owns the streak, so neither needs a
@@ -84,7 +94,17 @@ export default function DiscoveryScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void ensurePacksLoaded(); void ensurePacksLoaded();
}, [ensurePacksLoaded]) void fetchPendingQuestions();
if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) {
Events.pendingQuestionsPromptViewed(pendingQuestions.length);
lastPromptImpression.current = pendingQuestionIds;
}
return () => {
lastPromptImpression.current = null;
};
}, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length])
); );
// Group packs by category // Group packs by category
@@ -93,9 +113,28 @@ export default function DiscoveryScreen() {
[packs, categories] [packs, categories]
); );
// A small, cheap personalization: highlight packs that match what the user
// said brought them to Sauci during onboarding, ahead of the full catalog.
const recommendedPacks = useMemo(
() => pickRecommendedPacks(packs, categories, user?.usage_reason ?? null),
[packs, categories, user?.usage_reason]
);
// Check if user has no packs enabled (empty state) // Check if user has no packs enabled (empty state)
const hasNoPacks = packs.length === 0; const hasNoPacks = packs.length === 0;
const handlePendingQuestionsPress = useCallback(() => {
const firstQuestion = pendingQuestions[0];
if (!firstQuestion) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Events.pendingQuestionsPromptTapped(pendingQuestions.length);
router.push({
pathname: '/(app)/swipe',
params: { mode: 'pending', startQuestionId: firstQuestion.question.id },
});
}, [pendingQuestions, router]);
return ( return (
<GradientBackground> <GradientBackground>
<View <View
@@ -117,16 +156,37 @@ export default function DiscoveryScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}> <View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
<PendingQuestionsCard
questions={pendingQuestions}
partnerName={partner?.name}
onPress={handlePendingQuestionsPress}
delay={50}
/>
{/* Shared streak - hidden entirely for couples with nothing running */} {/* Shared streak - hidden entirely for couples with nothing running */}
<View style={styles.streakSlot}> <View style={styles.streakSlot}>
<StreakDisplay delay={50} /> <StreakDisplay delay={100} />
</View> </View>
{/* Live Draw Activity Tile */} {/* Live Draw Activity Tile */}
<LiveDrawTile delay={100} /> <LiveDrawTile delay={150} />
{/* Dares */} {/* Dares */}
<DaresTile delay={150} /> <DaresTile delay={200} />
{/* Quiz */}
<QuizTile delay={225} />
{/* Personalized pick based on the onboarding usage reason */}
{recommendedPacks.length > 0 && (
<ContentRow
title={RECOMMENDED_PACKS_LABEL}
packs={recommendedPacks}
isPremiumUser={isPremiumUser}
delay={240}
onShowPaywall={() => setShowPaywall(true)}
/>
)}
{/* Content Rows by Category */} {/* Content Rows by Category */}
{packsByCategory.map((group, index) => ( {packsByCategory.map((group, index) => (
@@ -136,7 +196,7 @@ export default function DiscoveryScreen() {
packs={group.packs} packs={group.packs}
isPremiumUser={isPremiumUser} isPremiumUser={isPremiumUser}
categoryId={group.category.id} categoryId={group.category.id}
delay={200 + index * 100} delay={250 + index * 100}
getPackProgress={getPackProgress} getPackProgress={getPackProgress}
onShowPaywall={() => setShowPaywall(true)} onShowPaywall={() => setShowPaywall(true)}
/> />
+42 -30
View File
@@ -27,14 +27,14 @@ import { GlassButton } from '../../src/components/ui/GlassButton';
import { Paywall } from '../../src/components/paywall'; import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme'; import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store'; import { useAuthStore } from '../../src/store';
import { profileSettingsApi } from '../../src/lib/profileSettingsApi'; import { profileSettingsApi } from '../../src/lib/profileSettingsApi';
import { getProfileError } from '../../src/lib/errors'; import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen'; import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker'; import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding'; import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import type { Gender } from '../../src/types'; import type { Gender, UsageReason } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' }, { value: 'male', label: 'Male', icon: 'male' },
@@ -43,17 +43,15 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' }, { value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
]; ];
type UsageReason = 'improve_communication' | 'reconnect' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' }, { value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' },
{ value: 'reconnect', label: 'Feel more connected', icon: 'flame' }, { value: 'reconnect', label: 'Feel more connected', icon: 'flame' },
{ value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' }, { value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' },
{ value: 'have_fun', label: 'Have fun together', icon: 'happy' }, { value: 'have_fun', label: 'Have fun together', icon: 'happy' },
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
]; ];
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications'; type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
@@ -94,6 +92,13 @@ export default function OnboardingScreen() {
Events.onboardingStageComplete('avatar'); Events.onboardingStageComplete('avatar');
}; };
const handleAvatarSkip = () => {
setError(null);
setStage('name');
Events.avatarSkipped();
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => { const handleNameSubmit = () => {
if (!name.trim()) { if (!name.trim()) {
setError('Please enter your name'); setError('Please enter your name');
@@ -129,9 +134,9 @@ export default function OnboardingScreen() {
setError('Please select what brings you to Sauci'); setError('Please select what brings you to Sauci');
return; return;
} }
setStage('notifications'); setStage('notifications');
Events.onboardingStageComplete('purpose'); Events.onboardingStageComplete('purpose');
}; };
const handleEnableNotifications = async () => { const handleEnableNotifications = async () => {
setIsLoading(true); setIsLoading(true);
@@ -169,26 +174,26 @@ export default function OnboardingScreen() {
} }
} }
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"]; const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = { const updateData = {
name: name.trim(), name: name.trim(),
gender, gender,
usage_reason: usageReason, usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue // Preserve the safest legacy profile values. Catalogue
// eligibility is enforced centrally by reviewed status. // eligibility is enforced centrally by reviewed status.
max_intensity: 2 as const, max_intensity: 2 as const,
show_explicit_content: false, show_explicit_content: false,
hide_nsfw: true, hide_nsfw: true,
onboarding_completed: true, onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION, onboarding_version: REQUIRED_ONBOARDING_VERSION,
}; };
await profileSettingsApi.updateProfile(updateData); await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) { if (avatarUrl) {
updatedFields.push("avatar_url"); updatedFields.push("avatar_url");
} }
console.log('[Onboarding] Profile updated successfully, fetching user...'); console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser(); await fetchUser();
@@ -280,6 +285,13 @@ export default function OnboardingScreen() {
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg"> <GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
Continue Continue
</GlassButton> </GlassButton>
<Pressable
style={styles.skipButton}
onPress={handleAvatarSkip}
testID="avatar-skip-button"
>
<Text style={styles.skipText}>Skip for now</Text>
</Pressable>
</View> </View>
</Animated.View> </Animated.View>
); );
@@ -569,7 +581,7 @@ export default function OnboardingScreen() {
style={styles.backButton} style={styles.backButton}
onPress={() => { onPress={() => {
setError(null); setError(null);
if (stage === 'notifications') setStage('purpose'); if (stage === 'notifications') setStage('purpose');
else if (stage === 'purpose') setStage('gender'); else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name'); else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar'); else if (stage === 'name') setStage('avatar');
@@ -585,7 +597,7 @@ export default function OnboardingScreen() {
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View> </View>
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
</Animated.View> </Animated.View>
+198 -19
View File
@@ -16,45 +16,115 @@ import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useAuthStore } from "../../src/store"; import { useAuthStore, useMatchStore } from "../../src/store";
import { coupleApi } from "../../src/lib/coupleApi"; import { coupleApi } from "../../src/lib/coupleApi";
import { getPairingError } from "../../src/lib/errors"; import { getPairingError } from "../../src/lib/errors";
import { buildInviteShareMessage } from "../../src/lib/inviteShareCopy";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
import { router } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
export default function PairingScreen() { export default function PairingScreen() {
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore(); const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
const params = useLocalSearchParams<{ code?: string }>();
const [inviteCode, setInviteCode] = useState(""); const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [wasPrefilled, setWasPrefilled] = useState(false);
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
const [prefillAttempted, setPrefillAttempted] = useState(false);
// Redirect if already paired // Redirect if already paired. Pairing claims both members' sealed answers and
// computes matches server-side, so the matches store must refetch here too:
// whichever partner lands on this redirect first would otherwise show stale,
// pre-pairing matches until some unrelated screen happened to refresh it.
useEffect(() => { useEffect(() => {
if (couple && partner) { if (couple && partner) {
void useMatchStore.getState().fetchMatches(true);
router.replace("/(app)"); router.replace("/(app)");
} }
}, [couple, partner]); }, [couple, partner]);
// Poll the standalone API while waiting for a partner. Realtime delivery can // One-time prefill: apply a code carried by a join link (route param), then
// be added later without coupling product data back to Supabase. // a stashed code from before sign-in, and only otherwise offer a clipboard
useEffect(() => { // match. Never auto-submits; the user still taps "Join Partner".
if (!couple || partner) return; useEffect(() => {
if (couple) return; // only relevant to the unpaired join flow
if (prefillAttempted) return;
let cancelled = false;
const applyPrefill = async (code: string) => {
if (cancelled) return;
setInviteCode(code);
setWasPrefilled(true);
Events.pairingCodePrefilled();
};
(async () => {
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
if (isValidInviteCode(routeCode)) {
await applyPrefill(routeCode);
await clearPendingInviteCode();
if (!cancelled) setPrefillAttempted(true);
return;
}
const stashedCode = await getPendingInviteCode();
if (stashedCode) {
await applyPrefill(stashedCode);
await clearPendingInviteCode();
if (!cancelled) setPrefillAttempted(true);
return;
}
const clipboardCode = await checkClipboardForInviteCode();
if (clipboardCode && !cancelled) {
setClipboardOfferCode(clipboardCode);
}
if (!cancelled) setPrefillAttempted(true);
})();
return () => {
cancelled = true;
};
}, [couple, params.code, prefillAttempted]);
const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return;
setInviteCode(clipboardOfferCode);
setWasPrefilled(true);
Events.inviteLinkOpened("clipboard");
Events.pairingCodePrefilled();
setClipboardOfferCode(null);
};
const dismissClipboardOffer = () => {
setClipboardOfferCode(null);
};
// Poll the standalone API while waiting for a partner. Realtime delivery can
// be added later without coupling product data back to Supabase.
useEffect(() => {
if (!couple || partner) return;
// Poll every 5 seconds as fallback // Poll every 5 seconds as fallback
const pollInterval = setInterval(() => { const pollInterval = setInterval(() => {
fetchCouple(); fetchCouple();
}, 5000); }, 5000);
return () => { return () => {
clearInterval(pollInterval); clearInterval(pollInterval);
}; };
}, [couple, partner, fetchCouple]); }, [couple, partner, fetchCouple]);
const handleCreateCouple = async () => { const handleCreateCouple = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await coupleApi.create(); await coupleApi.create();
await fetchUser(); // Refresh user to get couple_id await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data await fetchCouple(); // Fetch couple data
@@ -76,10 +146,13 @@ export default function PairingScreen() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await coupleApi.join(sanitizedCode); await coupleApi.join(sanitizedCode);
await fetchUser(); // Refresh user to get couple_id await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data await fetchCouple(); // Fetch couple data
// Joining claims both members' sealed answers and computes matches
// server-side, so the joiner needs a fresh matches list too.
void useMatchStore.getState().fetchMatches(true);
Events.coupleJoined(); Events.coupleJoined();
Alert.alert("Success", "You are now paired!", [ Alert.alert("Success", "You are now paired!", [
@@ -100,8 +173,13 @@ export default function PairingScreen() {
} }
}; };
const handleInviteCodeChange = (value: string) => {
setWasPrefilled(false);
setInviteCode(value);
};
const shareMessage = couple?.invite_code const shareMessage = couple?.invite_code
? `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}` ? buildInviteShareMessage(couple.invite_code, sealedCount)
: ""; : "";
const shareCode = async () => { const shareCode = async () => {
@@ -144,7 +222,7 @@ export default function PairingScreen() {
onPress: async () => { onPress: async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await coupleApi.cancel(); await coupleApi.cancel();
await fetchUser(); await fetchUser();
await fetchCouple(); await fetchCouple();
@@ -209,6 +287,21 @@ export default function PairingScreen() {
</Text> </Text>
</Animated.View> </Animated.View>
{/* Earned value: sealed answers already banked while unpaired */}
{sealedCount > 0 && (
<Animated.View
entering={FadeInDown.delay(250).duration(500)}
style={styles.section}
>
<GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
They will unlock the moment your partner joins.
</Text>
</GlassCard>
</Animated.View>
)}
{/* Code Card */} {/* Code Card */}
<Animated.View <Animated.View
entering={FadeInDown.delay(300).duration(500)} entering={FadeInDown.delay(300).duration(500)}
@@ -338,6 +431,37 @@ export default function PairingScreen() {
</Text> </Text>
</Animated.View> </Animated.View>
{/* Clipboard invite code offer */}
{clipboardOfferCode && (
<Animated.View
entering={FadeInDown.duration(400)}
style={styles.section}
>
<View style={styles.clipboardOffer} testID="clipboard-invite-offer">
<Ionicons name="clipboard-outline" size={18} color={colors.primary} />
<Text style={styles.clipboardOfferText}>
Use code {clipboardOfferCode} from your clipboard?
</Text>
<View style={styles.clipboardOfferActions}>
<TouchableOpacity
onPress={dismissClipboardOffer}
style={styles.clipboardOfferButton}
testID="clipboard-invite-offer-dismiss"
>
<Text style={styles.clipboardOfferDismissText}>Not now</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={acceptClipboardOffer}
style={[styles.clipboardOfferButton, styles.clipboardOfferButtonPrimary]}
testID="clipboard-invite-offer-accept"
>
<Text style={styles.clipboardOfferAcceptText}>Use code</Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
)}
{/* Join Card */} {/* Join Card */}
<Animated.View <Animated.View
entering={FadeInDown.delay(300).duration(500)} entering={FadeInDown.delay(300).duration(500)}
@@ -346,14 +470,20 @@ export default function PairingScreen() {
<Text style={styles.sectionTitle}>Have a code?</Text> <Text style={styles.sectionTitle}>Have a code?</Text>
<GlassCard> <GlassCard>
<TextInput <TextInput
style={styles.input} style={[styles.input, wasPrefilled && styles.inputPrefilled]}
placeholder="Enter invite code" placeholder="Enter invite code"
placeholderTextColor={colors.textTertiary} placeholderTextColor={colors.textTertiary}
value={inviteCode} value={inviteCode}
onChangeText={setInviteCode} onChangeText={handleInviteCodeChange}
autoCapitalize="characters" autoCapitalize="characters"
maxLength={8} maxLength={8}
testID="invite-code-input"
/> />
{wasPrefilled && (
<Text style={styles.prefilledCaption}>
Code applied from your invite link
</Text>
)}
<GlassButton <GlassButton
onPress={handleJoinCouple} onPress={handleJoinCouple}
disabled={isSubmitting} disabled={isSubmitting}
@@ -436,6 +566,50 @@ const styles = StyleSheet.create({
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
inputPrefilled: {
borderColor: colors.primary,
},
prefilledCaption: {
...typography.caption1,
color: colors.primary,
textAlign: "center",
marginTop: -spacing.sm,
marginBottom: spacing.md,
},
clipboardOffer: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
gap: spacing.sm,
},
clipboardOfferText: {
...typography.subhead,
color: colors.text,
},
clipboardOfferActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: spacing.sm,
},
clipboardOfferButton: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
clipboardOfferButtonPrimary: {
backgroundColor: colors.primaryLight,
},
clipboardOfferDismissText: {
...typography.subhead,
color: colors.textTertiary,
},
clipboardOfferAcceptText: {
...typography.subhead,
color: colors.primary,
fontWeight: "600",
},
// ... // ...
dividerLine: { dividerLine: {
flex: 1, flex: 1,
@@ -494,6 +668,11 @@ const styles = StyleSheet.create({
textAlign: "center", textAlign: "center",
maxWidth: 280, maxWidth: 280,
}, },
sealedCountText: {
...typography.body,
color: colors.text,
textAlign: "center",
},
section: { section: {
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
+5 -232
View File
@@ -1,232 +1,5 @@
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native"; import { QuizScreen } from "../../src/features/quiz";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated"; export default function QuizRoute() {
import { GradientBackground } from "../../src/components/ui"; return <QuizScreen />;
import { colors, spacing, radius, typography, animations } from "../../src/theme"; }
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
const ACCENT_COLOR = colors.premium.rose;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export default function QuizScreen() {
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
useFeatureInterest("quiz");
const scale = useSharedValue(1);
const animatedButtonStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>Quiz</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Learn what makes each other tick</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Deepen your bond</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Track your journey</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text>
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
+35 -20
View File
@@ -43,9 +43,12 @@ const GestureHandlerRootView = Platform.OS === "web"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking"; import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { authClient } from "../src/lib/authClient"; import { authClient } from "../src/lib/authClient";
import { colors, spacing, radius, typography } from "../src/theme"; import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen"; import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
import { parseInviteLinkCode } from "../src/lib/inviteLink";
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
import { needsOnboarding } from "../src/constants/onboarding";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -112,10 +115,24 @@ export default function RootLayout() {
// Fetch user on mount // Fetch user on mount
fetchUser(); fetchUser();
// Handle initial URL if app was opened via deep link // Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => { const handleDeepLink = async (url: string | null) => {
console.log('[DeepLink] handleDeepLink called with URL:', url); if (!url) return;
if (!url) return;
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
// are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
}
return;
}
try { try {
// Parse the URL to extract auth parameters // Parse the URL to extract auth parameters
@@ -148,7 +165,7 @@ export default function RootLayout() {
if (accessToken && refreshToken) { if (accessToken && refreshToken) {
// OAuth flow - set session directly // OAuth flow - set session directly
console.log('[DeepLink] Setting session with tokens'); console.log('[DeepLink] Setting session with tokens');
const { data, error } = await authClient.auth.setSession({ const { data, error } = await authClient.auth.setSession({
access_token: accessToken, access_token: accessToken,
refresh_token: refreshToken, refresh_token: refreshToken,
}); });
@@ -160,7 +177,7 @@ export default function RootLayout() {
} else if (tokenHash && type) { } else if (tokenHash && type) {
// Magic link / email verification flow - verify the OTP // Magic link / email verification flow - verify the OTP
console.log('[DeepLink] Verifying OTP with token_hash and type:', type); console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
const { data, error } = await authClient.auth.verifyOtp({ const { data, error } = await authClient.auth.verifyOtp({
token_hash: tokenHash, token_hash: tokenHash,
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email', type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
}); });
@@ -174,26 +191,24 @@ export default function RootLayout() {
} else { } else {
// Fallback for web or other scenarios // Fallback for web or other scenarios
console.log('[DeepLink] No auth params found, falling back to getSession'); console.log('[DeepLink] No auth params found, falling back to getSession');
await authClient.auth.getSession(); await authClient.auth.getSession();
} }
} catch (error) { } catch (error) {
console.error('[DeepLink] Error handling deep link:', error); console.error('[DeepLink] Error handling deep link:', error);
// Still try to get session as fallback // Still try to get session as fallback
await authClient.auth.getSession(); await authClient.auth.getSession();
} }
}; };
// Get initial URL // Get initial URL
Linking.getInitialURL().then((url) => { Linking.getInitialURL().then((url) => {
console.log('[DeepLink] getInitialURL returned:', url); handleDeepLink(url);
handleDeepLink(url); });
});
// Listen for new deep links while app is open // Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => { const linkingSubscription = Linking.addEventListener("url", (event) => {
console.log('[DeepLink] URL event listener triggered with:', event.url); handleDeepLink(event.url);
handleDeepLink(event.url); });
});
// Set up push notification response listener (for taps while app is running) // Set up push notification response listener (for taps while app is running)
const notificationSubscription = setupNotificationResponseListener(); const notificationSubscription = setupNotificationResponseListener();
@@ -206,7 +221,7 @@ export default function RootLayout() {
}); });
// Listen for auth changes // Listen for auth changes
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange( const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
async (event, session) => { async (event, session) => {
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id); console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") { if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
+43
View File
@@ -1,10 +1,33 @@
import { useEffect, useState } from "react";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native"; import { View, ActivityIndicator, StyleSheet } from "react-native";
import { needsOnboarding } from "../src/constants/onboarding"; import { needsOnboarding } from "../src/constants/onboarding";
import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
export default function Index() { export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore(); const { isAuthenticated, isLoading, user } = useAuthStore();
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
const [hasPendingInvite, setHasPendingInvite] = useState(false);
// Check once, after auth resolves, whether an invite code is waiting to be
// applied (e.g. the user tapped a join link before signing in).
useEffect(() => {
if (isLoading || !isAuthenticated || user?.couple_id) {
return;
}
let cancelled = false;
getPendingInviteCode().then((code) => {
if (cancelled) return;
setHasPendingInvite(!!code);
setPendingCodeChecked(true);
});
return () => {
cancelled = true;
};
}, [isLoading, isAuthenticated, user?.couple_id]);
// Show loading state while auth is being determined // Show loading state while auth is being determined
if (isLoading) { if (isLoading) {
@@ -25,6 +48,26 @@ export default function Index() {
return <Redirect href="/(app)/onboarding" />; return <Redirect href="/(app)/onboarding" />;
} }
// A couple already exists for this user; no invite hand-off is relevant.
if (user?.couple_id) {
return <Redirect href="/(app)" />;
}
// Wait for the one-time pending invite code check before deciding the
// route, so a stashed code from a join link is applied without an extra
// trip through the home screen.
if (!pendingCodeChecked) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
</View>
);
}
if (hasPendingInvite) {
return <Redirect href="/(app)/pairing" />;
}
// Go to home/dashboard - users can pair from within the app // Go to home/dashboard - users can pair from within the app
// No forced redirect to pairing for users without a couple // No forced redirect to pairing for users without a couple
return <Redirect href="/(app)" />; return <Redirect href="/(app)" />;
+1
View File
@@ -2,5 +2,6 @@ module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: ['babel-preset-expo'], presets: ['babel-preset-expo'],
plugins: ['react-native-worklets/plugin'],
}; };
}; };
+6
View File
@@ -0,0 +1,6 @@
# The update-signing private key belongs to the XPREM server, never to the app.
private-key.pem
*.key
# The public certificate must be committed despite the root *.pem rule: the
# expo-updates plugin embeds it into every build.
!certificate.pem
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
+18 -3
View File
@@ -7,19 +7,24 @@
"development": { "development": {
"node": "20.19.4", "node": "20.19.4",
"developmentClient": true, "developmentClient": true,
"distribution": "internal" "distribution": "internal",
"env": {
"RELEASE_CHANNEL": "development"
}
}, },
"preview": { "preview": {
"node": "20.19.4", "node": "20.19.4",
"distribution": "internal", "distribution": "internal",
"env": { "env": {
"EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app" "EXPO_PUBLIC_API_URL": "https://api.preprod.sauci.app",
"RELEASE_CHANNEL": "staging"
} }
}, },
"production": { "production": {
"node": "20.19.4", "node": "20.19.4",
"autoIncrement": true, "autoIncrement": true,
"env": { "env": {
"RELEASE_CHANNEL": "production",
"EXPO_PUBLIC_API_URL": "https://api.sauci.app", "EXPO_PUBLIC_API_URL": "https://api.sauci.app",
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co", "EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI", "EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNramNya2pwbWhxaGl1Y2lmdWt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjcxMDc1ODEsImV4cCI6MjA4MjY4MzU4MX0.7FA_RmFXUjlmZF96O9xlAUfxHrQrSIZcJclNReLL4lI",
@@ -32,6 +37,16 @@
} }
}, },
"submit": { "submit": {
"production": {} "production": {
"android": {
"track": "internal",
"releaseStatus": "draft",
"changesNotSentForReview": true
},
"ios": {
"ascAppId": "6757159885",
"appleTeamId": "BXVPBATDZY"
}
}
} }
} }
+2480 -1292
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"expo.jsEngine": "hermes", "expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "false", "newArchEnabled": "true",
"ios.useFrameworks": "static", "ios.useFrameworks": "static",
"apple.extraPods": "[]", "apple.extraPods": "[]",
"apple.ccacheEnabled": "false", "apple.ccacheEnabled": "false",
+21 -16
View File
@@ -7,15 +7,14 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; }; 5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; };
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; }; 7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; }; CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; }; FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; };
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; }; XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; };
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; }; XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
@@ -50,11 +49,8 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; }; 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; }; 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; }; A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
@@ -64,6 +60,7 @@
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; }; DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Sauci/AppDelegate.swift; sourceTree = "<group>"; };
F744EAD4ABA54509966475D6 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; }; F744EAD4ABA54509966475D6 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -126,11 +123,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BB2F792B24A3F905000567C9 /* Supporting */, BB2F792B24A3F905000567C9 /* Supporting */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */, F11748412D0307B40044C1D9 /* AppDelegate.swift */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */, A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */,
F744EAD4ABA54509966475D6 /* noop-file.swift */, F744EAD4ABA54509966475D6 /* noop-file.swift */,
@@ -421,6 +416,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
@@ -455,6 +451,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
@@ -497,11 +494,16 @@
inputFileListPaths = ( inputFileListPaths = (
); );
inputPaths = ( inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/Sauci/Sauci.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-Sauci/expo-configure-project.sh",
); );
name = "[Expo] Configure project"; name = "[Expo] Configure project";
outputFileListPaths = ( outputFileListPaths = (
); );
outputPaths = ( outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
@@ -527,8 +529,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */, 5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
); );
@@ -569,7 +570,7 @@
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.7;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -598,7 +599,7 @@
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.7;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -671,6 +672,8 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
); );
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
@@ -738,6 +741,8 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", "${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
); );
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
@@ -767,7 +772,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 41; CURRENT_PROJECT_VERSION = 46;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -777,7 +782,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.7;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
@@ -803,7 +808,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41; CURRENT_PROJECT_VERSION = 46;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -813,7 +818,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5; MARKETING_VERSION = 1.0.7;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
-7
View File
@@ -1,7 +0,0 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
#import <Expo/Expo.h>
@interface AppDelegate : EXAppDelegateWrapper
@end
-66
View File
@@ -1,66 +0,0 @@
#import "AppDelegate.h"
#import <Firebase/Firebase.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-ecd111c37e49fdd1ed6354203cd6b1e2a38cccda
[FIRApp configure];
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end
+75
View File
@@ -0,0 +1,75 @@
import Expo
import FirebaseCore
import React
import ReactAppDependencyProvider
@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = ExpoReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
bindReactNativeFactory(factory)
#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
FirebaseApp.configure()
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
#endif
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
super.application(app, open: url, options: options)
|| RCTLinkingManager.application(app, open: url, options: options)
}
public override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
let result = RCTLinkingManager.application(
application,
continue: userActivity,
restorationHandler: restorationHandler)
return super.application(
application,
continue: userActivity,
restorationHandler: restorationHandler) || result
}
}
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
bridge.bundleURL ?? bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings()
.jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
+11 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.5</string> <string>1.0.7</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -47,9 +47,16 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>43</string> <string>46</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key> <key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/> <false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>instagram</string>
<string>instagram-stories</string>
</array>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
@@ -75,6 +82,8 @@
<array> <array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array> </array>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>
+5 -1
View File
@@ -8,9 +8,13 @@
<array> <array>
<string>Default</string> <string>Default</string>
</array> </array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:sauci.app</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.sauci.app</string> <string>group.com.sauci.app</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+42 -2
View File
@@ -4,9 +4,49 @@
<dict> <dict>
<key>EXUpdatesCheckOnLaunch</key> <key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string> <string>ALWAYS</string>
<key>EXUpdatesCodeSigningCertificate</key>
<string>-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
</string>
<key>EXUpdatesCodeSigningMetadata</key>
<dict>
<key>keyid</key>
<string>main</string>
<key>alg</key>
<string>rsa-v1_5-sha256</string>
</dict>
<key>EXUpdatesEnabled</key> <key>EXUpdatesEnabled</key>
<false/> <true/>
<key>EXUpdatesLaunchWaitMs</key> <key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer> <integer>5000</integer>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>production</string>
<key>expo-app-id</key>
<string>9a66f3c2-fda7-416f-87e2-6db833b32e9d</string>
<key>xprem-branch</key>
<string/>
</dict>
<key>EXUpdatesRuntimeVersion</key>
<string>1.0.7</string>
<key>EXUpdatesURL</key>
<string>https://ota.apps.jbrunton.co.uk/manifest</string>
</dict> </dict>
</plist> </plist>
-10
View File
@@ -1,10 +0,0 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
+12 -1
View File
@@ -18,7 +18,15 @@
"release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local", "release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local", "release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
"release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local", "release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
"release:android:upload": "bundle exec fastlane android upload" "release:android:upload": "bundle exec fastlane android upload",
"ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight",
"ota:preflight:production": "node scripts/ota-publish.mjs production --preflight",
"ota:publish:staging": "node scripts/ota-publish.mjs staging",
"ota:publish:production": "node scripts/ota-publish.mjs production",
"test:ota-publish": "node --test scripts/ota-publish.test.mjs",
"ota:rollback": "eoas rollback",
"ota:doctor:staging": "node scripts/ota-publish.mjs staging --doctor",
"ota:doctor:production": "node scripts/ota-publish.mjs production --doctor"
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^0.1.6", "@bacons/apple-targets": "^0.1.6",
@@ -62,6 +70,7 @@
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8", "expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.20",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@@ -72,6 +81,7 @@
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-share": "^12.3.1",
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^4.0.3", "react-native-view-shot": "^4.0.3",
@@ -87,6 +97,7 @@
"@types/react": "~19.1.10", "@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eoas": "^3.1.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more