Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:26:53 +01:00
jjbrunton 1be54175e4 Merge pull request #2 from jjbrunton/staging
chore(release): promote mobile build hardening
2026-08-28 18:44:58 +01:00
jjbrunton a7470310ae Merge pull request #1 from jjbrunton/task/mobile-release-hardening
chore(release): harden mobile build workflow
2026-08-28 18:39:14 +01:00
jjbrunton 3acadeff7c chore(release): harden mobile build workflow
Promote the reproduced release lessons into harness enforcement: keep tracked native regeneration separate from artifact builds, separate Android build and upload authorization, use stable login readiness in Maestro, and retain the iOS 43 and Android 46 version increments.
2026-08-28 18:34:00 +01:00
jjbrunton 91544629fa fix(worker): keep polling process alive 2026-08-28 15:11:12 +01:00
jjbrunton 32f5214751 fix(backend): allow deferred provider integrations 2026-08-28 15:00:57 +01:00
jjbrunton 96882642c7 fix(ci): start healthcheck-free worker separately 2026-08-28 14:56:53 +01:00
jjbrunton a455325147 ci(deploy): establish staged Dokploy promotion 2026-08-28 14:54:12 +01:00
jjbrunton 2dad7aa352 feat(admin): serve response media from standalone API 2026-08-28 14:54:07 +01:00
jjbrunton be20391788 Merge remote-tracking branch 'origin/master' into task/self-hosted-backend
# Conflicts:
#	apps/mobile/app/(app)/onboarding.tsx
#	apps/mobile/src/__tests__/stores/packsStore.test.ts
#	apps/mobile/src/features/profile/hooks/useProfileSettings.ts
#	apps/mobile/src/store/packsStore.ts
2026-08-28 14:44:51 +01:00
jjbrunton 006761d3b0 feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
2026-08-28 14:42:11 +01:00
jjbrunton 7a0de45482 feat(mobile): align store-safe experience
Remove client-side adult-content preferences and badges so the centrally reviewed catalogue is the single visibility boundary.\n\nUse neutral relationship-focused copy across onboarding, discovery, paywall, and login surfaces, and retain biometric controls under App Lock for both native platforms.
2026-08-28 14:36:33 +01:00
jjbrunton 72e031eb84 fix(content): keep catalogue migration portable
Classify every environment through the immutable allowlist and archive unknown concurrent additions. Remove the remote-project dependency while retaining fail-closed postconditions.
2026-08-28 13:24:43 +01:00
jjbrunton 2ab976ec42 fix(content): scope production catalogue guard
Keep exact audited-count assertions for the production project while allowing local and non-production fixture catalogues to be classified by the same immutable allowlist.
2026-08-28 13:21:35 +01:00
jjbrunton c0650ed5b7 feat(content): enforce reviewed catalogue
Hide archived and unreviewed catalogue content from existing clients through RLS and guarded recommendation RPCs. Reject stale writes, preserve historical rows, and seed reviewed defaults when the cutover removes a couple's full active selection.
2026-08-28 13:15:33 +01:00
jjbrunton 94c76535a4 feat(content): add catalogue review metadata
Add reversible review states, audit history, and super-admin controls for catalogue entities without changing current client visibility.

Stage 30 high-risk production packs for archival review, cap generated content at non-explicit language, and preserve a checksummed pre-change production catalogue snapshot.
2026-08-28 11:32:55 +01:00
584 changed files with 92317 additions and 13579 deletions

No files matched your search

@@ -0,0 +1,37 @@
---
id: 2026-08-28-maestro-stable-login-readiness
date: 2026-08-28
status: promoted
scope: native Auth-shell acceptance flows
evidence: iOS simulator login-shell acceptance on current main
destination: e2e/maestro
review_after: 2027-02-28
---
## Symptom
The credential-free native smoke failed even though the current app reached the
login screen and displayed its required controls.
## Reproduction
Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
for the retired tagline `Explore intimacy together`; the app displayed updated
marketing copy while `Sauci` and `Password` were present.
## Cause
Login readiness was coupled to mutable marketing copy rather than a stable
required control.
## Remediation
Wait for the password control that defines login readiness, while retaining the
brand and password assertions. Apply the same readiness condition to the
authenticated and credential-free flows.
## Evidence
Simulator read-back confirmed the current login shell exposes `Sauci` and
`Password`. The maintained Maestro flows now wait for that stable control rather
than the tagline.
@@ -0,0 +1,40 @@
---
id: 2026-08-28-mobile-release-source-integrity
date: 2026-08-28
status: promoted
scope: mobile release build and upload workflow
evidence: clean-source local EAS builds for iOS 43 and Android 46
destination: scripts/lint-harness.mjs
review_after: 2027-02-28
---
## Symptom
A build-only request initially regenerated tracked native projects. The generated
Android widget provider replaced the checked-in implementation, and the Android
release script would also have uploaded after building.
## Reproduction
Run the former mobile release scripts or run `expo prebuild` before a local EAS
build, then inspect `git diff`. The reproduced diff included the Android widget
provider, widget XML, and Xcode project rather than version metadata alone.
## Cause
The release scripts combined three independently reviewed operations: native
regeneration, artifact creation, and store upload. Sauci already tracks native
projects, and EAS skips prebuild when those directories exist.
## Remediation
Keep native regeneration explicit, make release scripts artifact-only, expose
upload separately, and lint the scripts so those responsibilities cannot be
silently recombined.
## Evidence
Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
built successfully from the clean tracked sources; the post-build worktree
contained only the four expected build-number files. `npm run lint:harness`
enforces the release-script boundary.
@@ -0,0 +1,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`.
+11
View File
@@ -0,0 +1,11 @@
name = "architecture_reviewer"
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Evaluate the delegated decision against current system boundaries, data flow,
migration path, operability, reversibility, and verification. Prefer the smallest
coherent change. Return explicit trade-offs, risks, and a recommendation with
file-backed evidence. Do not edit files or review unrelated local refactors.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "database_reviewer"
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
model = "gpt-5.6-terra"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Read the applicable AGENTS.md, database migration skill, and migration safety
documentation. Review only the delegated diff for migration tracking,
idempotency, authorization and tenant isolation, portability, rollback impact,
types, and integration coverage. Never mutate a database or edit files. Return
prioritized line-specific findings with concrete evidence and counterevidence.
"""
+14
View File
@@ -0,0 +1,14 @@
name = "implementation"
description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
model = "gpt-5.6-terra"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files plus the applicable repository
skill. Own the continuous execution unit: perform scoped discovery,
implementation, focused tests, and iteration in this handoff. Implement only the
delegated objective and preserve unrelated work. Update maintained contract
documentation and add focused tests when the contract changes. Run the required
focused checks and return changed files, exact results, assumptions, and blockers.
Stop for missing authority or a materially ambiguous product choice. Do not expand
scope or perform external, destructive, production, release, or provider writes
unless the task explicitly authorizes that action.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "repo_scout"
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
model = "gpt-5.6-luna"
model_reasoning_effort = "low"
sandbox_mode = "read-only"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
and uncertainties. Do not edit files, make design decisions, or propose broad
refactors. Do not claim runtime behavior you did not observe.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "security_reviewer"
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Perform a scoped threat-boundary review of the delegated change. Trace callers
through authentication and authorization to the data sink. Prioritize exploitable
correctness and security risks, include counterevidence, and cite exact files and
lines. Do not edit files or expand into a repository-wide audit unless explicitly
requested.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "test_writer"
description = "Focused Sauci regression and contract-test author for already-defined behavior."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and testing documentation. Test the intended
observable contract at the lowest useful layer while preserving real auth,
authorization, tenant, and couple boundaries. Do not weaken assertions to
accommodate a failure. Run the focused test and return files changed, exact
commands, results, and any product defect exposed by the test.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "verifier"
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
exercise the delegated acceptance criteria against the running non-production
product. You may write generated proof only under evidence/. Do not edit source,
tests, configuration, or acceptance criteria. Return expected behavior, observed
behavior, evidence paths, environment limitations, and a works or broken verdict.
"""
+5
View File
@@ -0,0 +1,5 @@
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-terra"
default_subagent_reasoning_effort = "medium"
+73
View File
@@ -0,0 +1,73 @@
---
name: delegate-to-claude
description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review.
---
# Delegate to Claude Code
Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance.
## Preconditions
1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory.
2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order.
3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has.
## Work order
Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include:
- role: executor or read-only reviewer;
- exact objective and boundaries;
- working directory and relevant files;
- repository instructions Claude must read;
- allowed side effects and forbidden actions;
- required checks and expected evidence;
- requested return format: findings or changed files, commands run, results, and blockers.
The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient.
## Run
From the target repository directory:
```bash
/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json
```
The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode:
```bash
CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json
```
Optional environment variables:
- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`.
- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`.
- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode.
- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary.
The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access.
Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox.
For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run:
```bash
scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json
```
If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate.
## Accept or iterate
Claude's summary is evidence to inspect, not proof. Codex must:
1. inspect the actual diff or artifacts;
2. run proportionate independent checks;
3. compare the result with the original scope and repository rules;
4. send a focused follow-up with concrete failures when iteration is needed;
5. report Claude's contribution separately from Codex's verification.
Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change.
@@ -0,0 +1,4 @@
interface:
display_name: "Delegate to Claude Code"
short_description: "Delegate scoped work to Claude Code"
default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result."
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
die() {
printf 'claude_delegate: %s\n' "$*" >&2
exit 1
}
usage() {
cat >&2 <<'EOF'
usage:
claude_delegate.sh check
claude_delegate.sh run <prompt-file> [result-json]
claude_delegate.sh resume <session-id> <prompt-file> [result-json]
EOF
exit 2
}
command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH"
if [[ "${1:-}" == "check" ]]; then
claude --version
auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status"
[[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated"
printf 'authentication: logged in\n'
exit 0
fi
mode="${1:-}"
case "$mode" in
run)
[[ $# -ge 2 && $# -le 3 ]] || usage
prompt_file="$2"
result_file="${3:-}"
resume_id=""
;;
resume)
[[ $# -ge 3 && $# -le 4 ]] || usage
resume_id="$2"
prompt_file="$3"
result_file="${4:-}"
[[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID"
;;
*) usage ;;
esac
[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file"
[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file"
prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")"
effort="${CLAUDE_DELEGATE_EFFORT:-medium}"
permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}"
case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac
case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;;
*) die "invalid permission mode: $permission_mode" ;;
esac
model="${CLAUDE_DELEGATE_MODEL:-sonnet}"
args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome)
[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD")
[[ -n "$resume_id" ]] && args+=(--resume "$resume_id")
if [[ -n "$result_file" ]]; then
result_dir="$(dirname "$result_file")"
[[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir"
claude "${args[@]}" "$prompt" | tee "$result_file"
else
claude "${args[@]}" "$prompt"
fi
+19 -13
View File
@@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services.
| Service | Port | | Service | Port |
|---|---:| |---|---:|
| web | 3000 | | PostgreSQL | 54320 |
| admin | 3001 | | API | 3003 |
| MCP | 3002 | | web | 3000 (overridable with `SAUCI_WEB_PORT`) |
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
| Expo Metro | 8081 | | Expo Metro | 8081 |
| Supabase API / DB / Studio | 54321 / 54322 / 54323 |
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
the repository Supabase CLI dependency. and the configured hosted Auth credentials where an authenticated client needs them.
- `scripts/dev-local.sh up` — idempotently start infra and services. - `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
- `status`, `logs <service>`, `restart <service>`, `attach` — inspect. - `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
- `reset` — local-only database reset plus deterministic fixtures. - `reset`: local-only database reset plus deterministic fixtures.
- `ios` / `android` — launch the native app against local Supabase. - `e2e`: start or safely reuse the local stack, validate local endpoints, and
- `down`; `down --all` — stop services, optionally infrastructure. run Playwright with launcher-derived URLs and a loopback PostgreSQL fixture.
- `ios` / `android`: launch the native app against the standalone local API and
configured hosted Auth project.
- `down`; `down --all`: stop services, optionally infrastructure.
If a port is occupied, use `status` and inspect its owning process before stopping Hosted Supabase provides Auth only. Local E2E never starts or writes to a
anything. If a tmux window exited, read its logs before restarting it. Supabase data plane. If a port is occupied, use `status` and inspect its owning
process before stopping anything. If a tmux window exited, read its logs before
restarting it.
+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}`);
+51 -4
View File
@@ -9,7 +9,54 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
scoped instructions. Confirm target platform and release profile. Run repository scoped instructions. Confirm target platform and release profile. Run repository
verification plus a native acceptance pass before starting a release build. verification plus a native acceptance pass before starting a release build.
Use the package scripts in `apps/mobile/package.json`; preserve platform signing Before a build from the current upstream branch, fetch, require a clean worktree,
and versioning. Building or uploading to a store is an external mutation: obtain and prove `HEAD` matches its upstream. The `generate-release` skill may instead
explicit authorization for the requested target, then report the build artifact, build a clean, scoped release-preparation commit that descends directly from that
version/build number, upload result, and read-back state. fetched upstream; between its multi-platform builds it commits only independently
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
so routine release builds must package those checked-in sources. Do not run
`expo prebuild` first; native regeneration is a separate reviewed change.
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
Never infer upload or submission authority from build authorization. Store upload
uses a separately authorized command.
## Upload authorization gate
After an artifact passes verification, stop immediately before the external
upload and ask the user for confirmation. The request must identify:
- the platform, exact artifact path, bundle/package identifier, public version,
build number, signature identity, and SHA-256 digest;
- the exact destination and resulting state: Google Play internal-track draft or
App Store Connect/TestFlight processing;
- that the upload does not authorize a public rollout or App Review submission.
For iOS, inspect the current App Store Connect automatic-distribution behavior
before asking. If the build would be distributed automatically, name the exact
TestFlight groups or testers in the request and include that effect in the fresh
authorization. If the effect cannot be established, stop; changing an automatic-
distribution setting also requires separate authorization.
Even when the original request includes building, releasing, uploading, or
submitting, require this fresh confirmation once the exact verified artifact and
destination are known. End the request with: `Upload this verified artifact to
<destination> now?` A clear affirmative response authorizes one upload of that
artifact to that destination, plus only any automatic TestFlight distribution
explicitly disclosed in the request. Run `eas submit` with the explicit
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
select `--latest`. If the result is ambiguous, inspect EAS and the store before
doing anything else, do not retry, and ask again before another upload.
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
and the store consoles. A later public rollout, App Review submission, or
additional TestFlight distribution requires another fresh authorization.
Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently
read the artifact version and signature. 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.
+29 -2
View File
@@ -1,14 +1,41 @@
node_modules node_modules
**/node_modules
.next
**/.next
.git .git
.gitignore .gitignore
*.md *.md
.env* .env*
.DS_Store .DS_Store
dist dist
**/dist
.expo .expo
android **/.expo
ios **/android
**/ios
*.log *.log
.turbo .turbo
**/.turbo
coverage coverage
.nyc_output .nyc_output
apps/admin
apps/mcp
!apps/mcp
!apps/mcp/**
apps/mobile
apps/supabase
apps/web
!apps/web
!apps/web/**
e2e
evidence
etc
infra
scripts
!scripts
!scripts/fix-hoisting.js
terraform
.agents
.claude
.codex
.ralph
-215
View File
@@ -1,215 +0,0 @@
name: Deploy Supabase to Production
on:
workflow_dispatch:
inputs:
deploy_production:
description: Promote the verified change to production after non-production
required: false
type: boolean
default: false
deploy_infrastructure:
description: Apply Terraform as part of this manual deployment
required: false
type: boolean
default: false
push:
branches:
- master
paths:
- 'apps/supabase/**'
- 'terraform/**'
- '.github/workflows/deploy-supabase.yml'
jobs:
changes:
name: Detect infrastructure changes
runs-on: ubuntu-latest
outputs:
infrastructure: ${{ steps.infrastructure.outputs.changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: infrastructure
name: Check Terraform scope
env:
BEFORE_SHA: ${{ github.event.before }}
CURRENT_SHA: ${{ github.sha }}
DEPLOY_INFRASTRUCTURE: ${{ inputs.deploy_infrastructure }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "changed=${DEPLOY_INFRASTRUCTURE:-false}" >> "$GITHUB_OUTPUT"
elif git diff --quiet "$BEFORE_SHA" "$CURRENT_SHA" -- terraform/; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
quality:
name: Verify deployable source
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run verify:fast
- run: npm run build
deploy-database:
name: Deploy Migrations & Functions
runs-on: ubuntu-latest
needs: quality
environment: ${{ matrix.environment }}
strategy:
max-parallel: 1
matrix:
environment: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.deploy_production && '["non-prod","prod"]' || '["non-prod"]') }}
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ${{ matrix.environment == 'prod' && 'ckjcrkjpmhqhiucifukx' || 'itbzhrvlgvdmzbnhzhyx' }}
ENVIRONMENT_NAME: ${{ matrix.environment }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest
- name: Link to ${{ matrix.environment }} Project
run: supabase link --project-ref $SUPABASE_PROJECT_REF --workdir apps
- name: Apply Database Migrations (${{ matrix.environment }})
run: supabase db push --workdir apps --include-all
- name: Sync Edge Function Secrets (${{ matrix.environment }})
env:
# Add secrets from GitHub Secrets
REVENUECAT_WEBHOOK_SECRET: ${{ secrets.REVENUECAT_WEBHOOK_SECRET }}
REVENUECAT_API_KEY: ${{ secrets.REVENUECAT_API_KEY }}
REVENUECAT_ENTITLEMENT_ID: ${{ secrets.REVENUECAT_ENTITLEMENT_ID }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ADMIN_PRIVATE_KEY_JWK: ${{ secrets.ADMIN_PRIVATE_KEY_JWK }}
ADMIN_KEYS_JSON: ${{ secrets.ADMIN_KEYS_JSON }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
# Sync secrets to Supabase (only if they're set in GitHub)
secrets_to_set=""
[ -n "$REVENUECAT_WEBHOOK_SECRET" ] && secrets_to_set="$secrets_to_set REVENUECAT_WEBHOOK_SECRET=$REVENUECAT_WEBHOOK_SECRET"
[ -n "$REVENUECAT_API_KEY" ] && secrets_to_set="$secrets_to_set REVENUECAT_API_KEY=$REVENUECAT_API_KEY"
[ -n "$REVENUECAT_ENTITLEMENT_ID" ] && secrets_to_set="$secrets_to_set REVENUECAT_ENTITLEMENT_ID=$REVENUECAT_ENTITLEMENT_ID"
[ -n "$OPENROUTER_API_KEY" ] && secrets_to_set="$secrets_to_set OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
[ -n "$ADMIN_PRIVATE_KEY_JWK" ] && secrets_to_set="$secrets_to_set ADMIN_PRIVATE_KEY_JWK=$ADMIN_PRIVATE_KEY_JWK"
[ -n "$ADMIN_KEYS_JSON" ] && secrets_to_set="$secrets_to_set ADMIN_KEYS_JSON=$ADMIN_KEYS_JSON"
[ -n "$DISCORD_WEBHOOK_URL" ] && secrets_to_set="$secrets_to_set DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL"
if [ -n "$secrets_to_set" ]; then
echo "Syncing secrets to Supabase ($ENVIRONMENT_NAME)..."
supabase secrets set $secrets_to_set --project-ref $SUPABASE_PROJECT_REF
else
echo "No secrets to sync"
fi
- name: Deploy Edge Functions (${{ matrix.environment }})
run: |
# Deploy all edge functions (excluding _shared directory)
for dir in apps/supabase/functions/*/; do
function_name=$(basename "$dir")
# Skip _shared directory (it's a shared module, not a function)
if [ "$function_name" = "_shared" ]; then
echo "Skipping _shared (shared module)"
continue
fi
echo "Deploying function: $function_name to $ENVIRONMENT_NAME"
supabase functions deploy "$function_name" --no-verify-jwt --project-ref $SUPABASE_PROJECT_REF --workdir apps
done
- name: Remove Deleted Functions (${{ matrix.environment }})
run: |
# Get list of functions deployed on Supabase (skip header lines, extract slug column)
deployed=$(supabase functions list --project-ref $SUPABASE_PROJECT_REF | grep -E '^[a-z]' | awk '{print $1}')
# Get list of functions in the repo (excluding _shared)
local_functions=$(ls -d apps/supabase/functions/*/ 2>/dev/null | xargs -I {} basename {} | grep -v '^_shared$' || true)
# Delete any deployed functions that no longer exist in the repo
for func in $deployed; do
# Skip empty or invalid function names
[ -z "$func" ] && continue
[[ "$func" =~ ^[^a-z] ]] && continue
if ! echo "$local_functions" | grep -q "^${func}$"; then
echo "Deleting removed function from $ENVIRONMENT_NAME: $func"
supabase functions delete "$func" --project-ref $SUPABASE_PROJECT_REF
fi
done
deploy-infrastructure:
name: Deploy Infrastructure (Terraform)
runs-on: ubuntu-latest
needs: [quality, changes]
if: needs.changes.outputs.infrastructure == 'true'
env:
TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6"
# Non-Production Environment
- name: Terraform Init (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform init
- name: Terraform Format Check
working-directory: terraform/modules/supabase
run: terraform fmt -check
- name: Terraform Validate (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform validate
- name: Terraform Plan (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform plan -out=tfplan
- name: Terraform Apply (Non-Prod)
working-directory: terraform/environments/non-prod
run: terraform apply -auto-approve tfplan
# Production Environment
- name: Terraform Init (Prod)
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
working-directory: terraform/environments/prod
run: terraform init
- name: Terraform Validate (Prod)
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
working-directory: terraform/environments/prod
run: terraform validate
- name: Terraform Plan (Prod)
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
working-directory: terraform/environments/prod
run: terraform plan -out=tfplan
- name: Terraform Apply (Prod)
if: github.event_name == 'workflow_dispatch' && inputs.deploy_production
working-directory: terraform/environments/prod
run: terraform apply -auto-approve tfplan
+56 -45
View File
@@ -1,15 +1,41 @@
name: Verify name: Verify
on: on:
workflow_dispatch:
pull_request: pull_request:
push: push:
branches: [master] branches: [staging, main]
concurrency: concurrency:
group: verify-${{ github.ref }} group: verify-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
branch-policy:
runs-on: ubuntu-latest
steps:
- name: Enforce promotion flow
if: github.event_name == 'pull_request'
env:
BASE_BRANCH: ${{ github.base_ref }}
HEAD_BRANCH: ${{ github.head_ref }}
run: |
if [ "$BASE_BRANCH" = "master" ]; then
echo "master is frozen; target staging or main" >&2
exit 1
fi
if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then
echo "Only staging may be promoted to main" >&2
exit 1
fi
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
echo "Feature pull requests target staging; promotions target main" >&2
exit 1
fi
- name: Confirm direct verification
if: github.event_name != 'pull_request'
run: echo "Direct branch verification requested for $GITHUB_SHA"
static: static:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -21,58 +47,43 @@ jobs:
- run: npm ci - run: npm ci
- run: npm run learn:check - run: npm run learn:check
- run: npm run verify:fast - run: npm run verify:fast
- name: Enforce API and mobile coverage floors
run: npm run test:coverage
- run: npm run build - run: npm run build
full-stack: backend-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 15
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: sauci_test
POSTGRES_USER: sauci
POSTGRES_PASSWORD: sauci_ci_only
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U sauci -d sauci_test"
--health-interval 5s
--health-timeout 3s
--health-retries 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps chromium - name: Run standalone backend integration tests
- name: Start local Supabase
env: env:
GOOGLE_CLIENT_ID: local DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
GOOGLE_CLIENT_SECRET: local run: npm run test:integration:coverage -w @sauci/api
APPLE_CLIENT_ID: local
APPLE_CLIENT_SECRET: local container-contract:
run: npx supabase start --workdir apps runs-on: ubuntu-latest
- name: Reset and seed timeout-minutes: 20
run: | steps:
npx supabase db reset --workdir apps - uses: actions/checkout@v4
scripts/with-local-supabase-env.sh tests node scripts/seed-e2e.mjs - name: Verify production Compose contract
- name: Start application services run: infra/backend/verify-production.sh
run: |
npx supabase functions serve --workdir apps > /tmp/sauci-functions.log 2>&1 &
scripts/with-local-supabase-env.sh web npm run dev -w @sauci/web -- --port 3000 > /tmp/sauci-web.log 2>&1 &
scripts/with-local-supabase-env.sh admin npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001 > /tmp/sauci-admin.log 2>&1 &
scripts/with-local-supabase-env.sh mcp npm run dev -w sauci-mcp-server > /tmp/sauci-mcp.log 2>&1 &
- name: Wait for services
run: |
for url in http://127.0.0.1:3000 http://127.0.0.1:3001/login http://127.0.0.1:3002/health; do
for attempt in $(seq 1 60); do
curl --fail --silent "$url" >/dev/null && break
sleep 2
done
curl --fail --silent "$url" >/dev/null
done
- run: npm run test:supabase
- run: npm run verify:e2e
- name: Upload E2E proof
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-proof
path: |
e2e/test-results
e2e/playwright-report
/tmp/sauci-*.log
if-no-files-found: ignore
retention-days: 14
+3
View File
@@ -55,9 +55,11 @@ evidence/
e2e/.auth/ e2e/.auth/
e2e/test-results/ e2e/test-results/
e2e/playwright-report/ e2e/playwright-report/
.playwright-mcp/
# Misc # Misc
*.pem *.pem
!apps/mobile/certs/certificate.pem
.cache/ .cache/
.temp/ .temp/
.tmp/ .tmp/
@@ -70,3 +72,4 @@ nul
# Agent runtime evidence (maintained lessons remain tracked) # Agent runtime evidence (maintained lessons remain tracked)
.ralph/activity.log .ralph/activity.log
.ralph/runs/ .ralph/runs/
.migration-state/
+1 -1
View File
@@ -1 +1 @@
20.10.0 20.19.4
+34 -9
View File
@@ -1,23 +1,45 @@
# Sauci agent map # Sauci agent map
Sauci is a couples connection product: an Expo mobile app, React admin, Next.js Sauci is a couples connection product: an Expo mobile app, React admin, Next.js
web app, Supabase backend, and an internal MCP service in one npm/Turborepo web app, standalone Node/PostgreSQL backend, hosted Supabase Auth, and an internal
workspace. MCP service in one npm/Turborepo workspace.
## Golden rules ## Golden rules
1. Read the nearest `AGENTS.md` before changing a scoped area. 1. Read the nearest `AGENTS.md` before changing a scoped area.
2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth. 2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth.
3. All schema changes are local files in `apps/supabase/migrations/` created with 3. Product-data schema changes are committed SQL files in `apps/api/drizzle/`.
`npm run db:migration:new -- <name>`. Never use remote `apply_migration`, never Hosted Supabase owns Auth only; do not deploy product-data migrations or Edge
execute DDL through MCP SQL tools, and use those SQL tools for `SELECT` only. Functions there. Never execute remote DDL through MCP or provider tools.
4. Never target production from local development or E2E. Local tests must fail 4. Never target production from local development or E2E. Database integration
closed when a configured Supabase URL is not localhost. tests must fail closed unless `DATABASE_URL` resolves to loopback. Authenticated
native acceptance may use only the designated non-production Auth project.
5. Read `apps/mobile/DESIGN.md` before mobile UI work. 5. Read `apps/mobile/DESIGN.md` before mobile UI work.
6. Keep shared package exports type-only and route them through `src/index.ts`. 6. Keep shared package exports type-only and route them through `src/index.ts`.
7. Do not commit secrets, generated evidence, local logs, or runtime state. 7. Do not commit secrets, generated evidence, local logs, or runtime state.
8. Scope work to the task. Use high-effort reviewers only for the risk triggers in 8. Implement in a task-specific git worktree created from current `staging`,
not in the shared `staging` checkout. After the work is verified, commit the
scoped changes, merge the completed task branch back into local `staging`,
push `staging`, and prove local and remote commit parity.
9. Before merging, identify every deployed app or service the change can affect
and prove the relevant compatibility contracts still hold. Preserve deployed
interfaces and supported client behavior; repository checks alone do not
prove a live change is safe.
10. The Sol primary agent owns scope, risk classification, acceptance criteria,
progress steering, final diff and evidence review, and the final answer.
Delegate every substantive repository task to a project agent. Each
substantive implementation must use one Terra `implementation` agent as a
coherent execution unit, normally combining scoped discovery, implementation,
focused tests, and iteration. "Substantive" includes repository exploration,
multi-file reasoning, implementation, test execution, or contract/risk
analysis. Add a `repo_scout`, reviewer, or verifier only for separable work or
the triggers in `docs/agents/routing.md`; do not duplicate the implementation
agent's detailed exploration or checks without a concrete reason. Skip
delegation only for trivial conversation, a single obvious lookup, or a
mechanical one-line edit whose coordination cost exceeds the work.
11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`. `docs/agents/routing.md`.
12. Agents must not use em dash characters in text they add to this repository.
## Repository map ## Repository map
@@ -26,7 +48,8 @@ workspace.
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` | | `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
| `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` | | `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` |
| `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` | | `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` |
| `apps/supabase` | migrations, functions, local backend | `apps/supabase/AGENTS.md` | | `apps/api` | standalone API, worker, PostgreSQL migrations | repository rules |
| `apps/supabase` | legacy data-plane history; hosted Auth configuration only | `apps/supabase/AGENTS.md` |
| `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` | | `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` |
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` | | `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
| `e2e` | cross-application Playwright flows | `e2e/README.md` | | `e2e` | cross-application Playwright flows | `e2e/README.md` |
@@ -34,6 +57,8 @@ workspace.
## Workflows ## Workflows
- Implementation: create a task worktree from current `staging`; commit verified
work, merge it back to local `staging`, push `staging`, and verify remote parity.
- Bootstrap: `npm ci` - Bootstrap: `npm ci`
- Full local stack: `scripts/dev-local.sh up` - Full local stack: `scripts/dev-local.sh up`
- Fast checks: `npm run verify:fast` - Fast checks: `npm run verify:fast`
+5 -2
View File
@@ -1,5 +1,8 @@
VITE_SUPABASE_URL=your_supabase_url # Hosted Supabase Auth public settings only. Admin product data and mutations go
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key # through VITE_API_URL; never expose a service-role or admin service token here.
VITE_SUPABASE_URL=your_nonproduction_supabase_auth_url
VITE_SUPABASE_ANON_KEY=your_nonproduction_supabase_anon_key
VITE_API_URL=http://127.0.0.1:3003
VITE_OPENAI_API_KEY=your_openai_api_key VITE_OPENAI_API_KEY=your_openai_api_key
VITE_OPENROUTER_API_KEY=your_openrouter_api_key VITE_OPENROUTER_API_KEY=your_openrouter_api_key
+2
View File
@@ -12,6 +12,7 @@ RUN npm run build -w @sauci/shared
# Build admin app with env vars # Build admin app with env vars
ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY ARG VITE_SUPABASE_ANON_KEY
ARG VITE_API_URL
ARG VITE_OPENAI_API_KEY ARG VITE_OPENAI_API_KEY
ARG VITE_OPENROUTER_API_KEY ARG VITE_OPENROUTER_API_KEY
ARG VITE_AI_MODEL ARG VITE_AI_MODEL
@@ -24,6 +25,7 @@ ARG VITE_COUNCIL_REVIEWER_MODEL
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY
ENV VITE_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY ENV VITE_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY
ENV VITE_AI_MODEL=$VITE_AI_MODEL ENV VITE_AI_MODEL=$VITE_AI_MODEL
+1
View File
@@ -6,6 +6,7 @@ services:
args: args:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL} - VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY} - VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_API_URL=${VITE_API_URL}
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY} - VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY} - VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
- VITE_AI_MODEL=${VITE_AI_MODEL} - VITE_AI_MODEL=${VITE_AI_MODEL}
+1
View File
@@ -8,6 +8,7 @@ services:
environment: environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL} - VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY} - VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
- VITE_API_URL=${VITE_API_URL:-http://127.0.0.1:3003}
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY} - VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY} - VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
- VITE_AI_MODEL=${VITE_AI_MODEL} - VITE_AI_MODEL=${VITE_AI_MODEL}
+2 -2
View File
@@ -44,8 +44,8 @@
"tailwind-merge": "^2.2.0" "tailwind-merge": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.0", "@types/react": "~19.1.10",
"@types/react-dom": "^18.2.0", "@types/react-dom": "~19.1.7",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.32", "postcss": "^8.4.32",
+37 -1
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
@@ -195,7 +231,7 @@ function App() {
<Route <Route
path="/flagged-messages" path="/flagged-messages"
element={ element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}> <ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
<FlaggedMessagesPage /> <FlaggedMessagesPage />
</ProtectedRoute> </ProtectedRoute>
} }
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import type { ContentReviewStatus } from '@sauci/shared';
import { Archive, CheckCircle2, ClipboardCheck, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useAuth } from '@/contexts/AuthContext';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
CONTENT_STATUS_LABELS,
type ReviewableContentTable,
updateContentReviewStatus,
} from '@/services/contentReviews';
interface ContentReviewControlProps {
table: ReviewableContentTable;
entityId: string;
entityLabel: string;
status?: ContentReviewStatus | null;
reason?: string | null;
onChanged: () => void | Promise<void>;
compact?: boolean;
}
const badgeClasses: Record<ContentReviewStatus, string> = {
unreviewed: 'border-amber-500 text-amber-500',
allowed: 'border-green-500 text-green-500',
archived: 'border-slate-500 text-slate-400',
};
const statusIcons: Record<ContentReviewStatus, typeof ClipboardCheck> = {
unreviewed: ClipboardCheck,
allowed: CheckCircle2,
archived: Archive,
};
export function ContentReviewControl({
table,
entityId,
entityLabel,
status,
reason,
onChanged,
compact = false,
}: ContentReviewControlProps) {
const { isSuperAdmin } = useAuth();
const resolvedStatus = status ?? 'unreviewed';
const StatusIcon = statusIcons[resolvedStatus];
const [open, setOpen] = useState(false);
const [nextStatus, setNextStatus] = useState<ContentReviewStatus>(resolvedStatus);
const [nextReason, setNextReason] = useState(reason ?? '');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setNextStatus(resolvedStatus);
setNextReason(reason ?? '');
}
}, [open, reason, resolvedStatus]);
const save = async () => {
if (!nextReason.trim()) {
toast.error('A review reason is required');
return;
}
setSaving(true);
try {
await updateContentReviewStatus(table, entityId, nextStatus, nextReason);
toast.success(`${entityLabel} marked ${CONTENT_STATUS_LABELS[nextStatus].toLowerCase()}`);
setOpen(false);
await onChanged();
} catch (error) {
toast.error('Failed to update catalogue status');
console.error(error);
} finally {
setSaving(false);
}
};
return (
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${badgeClasses[resolvedStatus]} ${compact ? 'text-[10px] px-1.5 py-0' : ''}`}
title={reason || 'No review reason recorded'}
>
<StatusIcon className="mr-1 h-3 w-3" />
{CONTENT_STATUS_LABELS[resolvedStatus]}
</Badge>
{isSuperAdmin && (
<>
<Button
type="button"
variant="ghost"
size={compact ? 'sm' : 'default'}
className={compact ? 'h-7 px-2 text-xs' : undefined}
onClick={() => setOpen(true)}
aria-label={`Review catalogue status for ${entityLabel}`}
>
Review
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Review catalogue status</DialogTitle>
<DialogDescription>
This records a reversible editorial decision for {entityLabel}.
Current customer apps do not filter this status yet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor={`content-status-${entityId}`}>Status</Label>
<Select
value={nextStatus}
onValueChange={(value) => {
const selectedStatus = value as ContentReviewStatus;
setNextStatus(selectedStatus);
if (selectedStatus !== resolvedStatus) setNextReason('');
}}
>
<SelectTrigger id={`content-status-${entityId}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unreviewed">Needs review</SelectItem>
<SelectItem value="allowed">Allowed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`content-reason-${entityId}`}>Reason</Label>
<Textarea
id={`content-reason-${entityId}`}
value={nextReason}
onChange={(event) => setNextReason(event.target.value)}
placeholder="Explain why this content is allowed, archived, or needs another review"
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={save} disabled={saving || !nextReason.trim()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save decision
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
);
}
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2, Tags, Check, Plus, X } from 'lucide-react'; import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai'; import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface ExtractTopicsDialogProps { interface ExtractTopicsDialogProps {
@@ -37,7 +37,7 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
setAnalyzing(true); setAnalyzing(true);
try { try {
// Fetch questions for this pack // Fetch questions for this pack
const { data: questions, error: questionsError } = await supabase const { data: questions, error: questionsError } = await adminData
.from('questions') .from('questions')
.select('text') .select('text')
.eq('pack_id', pack.id); .eq('pack_id', pack.id);
@@ -50,7 +50,7 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
} }
// Fetch existing topics // Fetch existing topics
const { data: existingTopics, error: topicsError } = await supabase const { data: existingTopics, error: topicsError } = await adminData
.from('topics') .from('topics')
.select('id, name') .select('id, name')
.order('name'); .order('name');
@@ -93,7 +93,7 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
.map(t => t.existingTopicId!); .map(t => t.existingTopicId!);
if (newTopics.length > 0) { if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase const { data: createdTopics, error: createError } = await adminData
.from('topics') .from('topics')
.insert(newTopics.map(t => ({ name: t.name }))) .insert(newTopics.map(t => ({ name: t.name })))
.select('id'); .select('id');
@@ -106,14 +106,14 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
} }
// Remove existing pack_topics for this pack // Remove existing pack_topics for this pack
await supabase await adminData
.from('pack_topics') .from('pack_topics')
.delete() .delete()
.eq('pack_id', pack.id); .eq('pack_id', pack.id);
// Insert new pack_topics relationships // Insert new pack_topics relationships
if (existingTopicIds.length > 0) { if (existingTopicIds.length > 0) {
const { error: linkError } = await supabase const { error: linkError } = await adminData
.from('pack_topics') .from('pack_topics')
.insert(existingTopicIds.map(topicId => ({ .insert(existingTopicIds.map(topicId => ({
pack_id: pack.id, pack_id: pack.id,
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react'; import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai'; import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Define local compatible interface // Define local compatible interface
@@ -111,7 +111,7 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
// Apply updates in parallel with audit logging // Apply updates in parallel with audit logging
await Promise.all(updates.map(update => await Promise.all(updates.map(update =>
auditedSupabase.update('questions', update.id, { auditedAdminData.update('questions', update.id, {
allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null, allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null, target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
}) })
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react'; import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai'; import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface Question { interface Question {
@@ -62,7 +62,7 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
// Apply updates in parallel with audit logging // Apply updates in parallel with audit logging
await Promise.all(updates.map(update => await Promise.all(updates.map(update =>
auditedSupabase.update('questions', update.id, { auditedAdminData.update('questions', update.id, {
text: update.suggested_text, text: update.suggested_text,
partner_text: update.suggested_partner_text, partner_text: update.suggested_partner_text,
}) })
@@ -3,8 +3,7 @@ import type { AnswerType, QuestionType } from '@sauci/shared';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Image as ImageIcon, Music2 } from 'lucide-react'; import { Image as ImageIcon, Music2 } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext'; import { getAdminResponseMedia } from '@/lib/adminApi';
import { supabaseConfig } from '@/config';
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses'; import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
interface ResponseAnswerProps { interface ResponseAnswerProps {
@@ -62,38 +61,17 @@ function ProtectedResponseMedia({
mediaType: 'photo' | 'audio'; mediaType: 'photo' | 'audio';
label: string; label: string;
}) { }) {
const { session } = useAuth();
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!session?.access_token) {
setError('Sign in to load media');
return;
}
let cancelled = false; let cancelled = false;
let objectUrl: string | null = null; let objectUrl: string | null = null;
const load = async () => { const load = async () => {
try { try {
setError(null); setError(null);
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, { objectUrl = URL.createObjectURL(await getAdminResponseMedia(responseId));
method: 'POST',
headers: {
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ responseId }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body?.error || `Failed to load media (${response.status})`);
}
objectUrl = URL.createObjectURL(await response.blob());
if (!cancelled) setUrl(objectUrl); if (!cancelled) setUrl(objectUrl);
} catch (loadError) { } catch (loadError) {
if (!cancelled) { if (!cancelled) {
@@ -108,7 +86,7 @@ function ProtectedResponseMedia({
cancelled = true; cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [responseId, session?.access_token]); }, [responseId]);
if (error) { if (error) {
return ( return (
@@ -22,8 +22,8 @@ import {
DeletionAnalysis, DeletionAnalysis,
PropsAnalysis, PropsAnalysis,
} from '@/lib/openai'; } from '@/lib/openai';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner'; import { toast } from 'sonner';
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely'; type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
@@ -101,7 +101,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
if (existingProps.length > 0) return existingProps; if (existingProps.length > 0) return existingProps;
try { try {
const { data, error } = await supabase const { data, error } = await adminData
.from('questions') .from('questions')
.select('required_props') .select('required_props')
.not('required_props', 'is', null) .not('required_props', 'is', null)
@@ -252,12 +252,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
// Apply updates/deletions in parallel with audit logging // Apply updates/deletions in parallel with audit logging
const operations: Promise<unknown>[] = updates.map(update => const operations: Promise<unknown>[] = updates.map(update =>
auditedSupabase.update('questions', update.id, update.data) auditedAdminData.update('questions', update.id, update.data)
); );
if (deleteIds.length > 0) { if (deleteIds.length > 0) {
operations.push( operations.push(
auditedSupabase.deleteMany('questions', deleteIds).then(result => { auditedAdminData.deleteMany('questions', deleteIds).then(result => {
if (result.error) throw result.error; if (result.error) throw result.error;
}) })
); );
@@ -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>
);
}
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link, useLocation, useParams } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react'; import { ChevronRight, Home } from 'lucide-react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
interface BreadcrumbItem { interface BreadcrumbItem {
label: string; label: string;
@@ -32,7 +32,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
if (params.packId && !params.categoryId) { if (params.packId && !params.categoryId) {
setLoading(true); setLoading(true);
try { try {
const { data } = await supabase const { data } = await adminData
.from('question_packs') .from('question_packs')
.select('category_id, categories(name)') .select('category_id, categories(name)')
.eq('id', params.packId) .eq('id', params.packId)
@@ -63,7 +63,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
if (params.userId) { if (params.userId) {
setLoading(true); setLoading(true);
try { try {
const { data } = await supabase const { data } = await adminData
.from('profiles') .from('profiles')
.select('id, name') .select('id, name')
.eq('id', params.userId) .eq('id', params.userId)
+29 -1
View File
@@ -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',
@@ -108,7 +136,7 @@ const systemNav: NavItem[] = [
label: 'Flagged Messages', label: 'Flagged Messages',
href: '/flagged-messages', href: '/flagged-messages',
icon: <AlertTriangle className="h-5 w-5" />, icon: <AlertTriangle className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG, requiredPermission: PERMISSION_KEYS.VIEW_CHATS,
}, },
{ {
label: 'AI Settings', label: 'AI Settings',
+9 -1
View File
@@ -5,7 +5,15 @@ export const supabaseConfig = {
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY, anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
}; };
export const supabase = createClient( export const authClient = createClient(
supabaseConfig.url, supabaseConfig.url,
supabaseConfig.anonKey supabaseConfig.anonKey
); );
const configuredApiUrl = import.meta.env.VITE_API_URL;
if (!configuredApiUrl) {
throw new Error('Missing required environment variable: VITE_API_URL');
}
export const apiUrl = configuredApiUrl.replace(/\/$/, '');
+22 -21
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { supabase } from '../config'; import { authClient } from '../config';
import { adminRequest } from '@/lib/adminApi';
import type { User, Session } from '@supabase/supabase-js'; import type { User, Session } from '@supabase/supabase-js';
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig'; import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
@@ -80,23 +81,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
// Get initial session // Get initial session
supabase.auth.getSession().then(({ data: { session } }) => { authClient.auth.getSession().then(async ({ data: { session } }) => {
setSession(session); setSession(session);
setUser(session?.user ?? null); setUser(session?.user ?? null);
// Restore permissions from localStorage if (session) {
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY); try {
if (storedPermissions) { const currentPermissions = await adminRequest<AdminPermissions>('/v1/admin/me');
setPermissions(JSON.parse(storedPermissions)); setPermissions(currentPermissions);
// Preload AI config if user has an existing session (non-blocking) localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(currentPermissions));
preloadAiConfig().catch(console.error); preloadAiConfig().catch(console.error);
} catch {
setPermissions(null);
localStorage.removeItem(PERMISSIONS_KEY);
}
} }
setLoading(false); setLoading(false);
}); });
// Listen for auth changes // Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = authClient.auth.onAuthStateChange(
(_event, session) => { (_event, session) => {
setSession(session); setSession(session);
setUser(session?.user ?? null); setUser(session?.user ?? null);
@@ -112,7 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({ const { error } = await authClient.auth.signInWithPassword({
email, email,
password, password,
}); });
@@ -122,20 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
// Fetch admin role and permissions // Fetch admin role and permissions
const { data: adminData, error: adminError } = await supabase let adminData: AdminPermissions;
.from('admin_users') try {
.select('role, permissions') adminData = await adminRequest<AdminPermissions>('/v1/admin/me');
.eq('user_id', data.user.id) } catch {
.single(); await authClient.auth.signOut();
if (adminError || !adminData) {
await supabase.auth.signOut();
throw new Error('Access denied. You are not an administrator.'); throw new Error('Access denied. You are not an administrator.');
} }
// Store permissions from database // Store permissions from database
const perms: AdminPermissions = { const perms: AdminPermissions = {
role: adminData.role as AdminRole, role: adminData.role,
permissions: (adminData.permissions as string[]) || [], permissions: (adminData.permissions as string[]) || [],
}; };
@@ -150,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem(PERMISSIONS_KEY); localStorage.removeItem(PERMISSIONS_KEY);
setPermissions(null); setPermissions(null);
clearAiConfigCache(); clearAiConfigCache();
await supabase.auth.signOut(); await authClient.auth.signOut();
}; };
const isSuperAdmin = permissions?.role === 'super_admin'; const isSuperAdmin = permissions?.role === 'super_admin';
+5 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
export interface CouncilGenerator { export interface CouncilGenerator {
model: string; model: string;
@@ -74,7 +74,7 @@ export function useAiConfig(): UseAiConfigReturn {
setError(null); setError(null);
try { try {
const { data, error: fetchError } = await supabase const { data, error: fetchError } = await adminData
.from('ai_config') .from('ai_config')
.select('*') .select('*')
.limit(1) .limit(1)
@@ -82,7 +82,7 @@ export function useAiConfig(): UseAiConfigReturn {
if (fetchError) { if (fetchError) {
// If user doesn't have access (not super admin), that's expected // If user doesn't have access (not super admin), that's expected
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) { if (fetchError.message.includes('permission')) {
setError('Access denied. Super admin privileges required.'); setError('Access denied. Super admin privileges required.');
} else { } else {
setError(fetchError.message); setError(fetchError.message);
@@ -110,7 +110,7 @@ export function useAiConfig(): UseAiConfigReturn {
} }
try { try {
const { error: updateError } = await supabase const { error: updateError } = await adminData
.from('ai_config') .from('ai_config')
.update(updates) .update(updates)
.eq('id', config.id); .eq('id', config.id);
@@ -160,7 +160,7 @@ export async function preloadAiConfig(): Promise<AiConfig | null> {
} }
try { try {
const { data } = await supabase const { data } = await adminData
.from('ai_config') .from('ai_config')
.select('*') .select('*')
.limit(1) .limit(1)
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
export interface AppConfig { export interface AppConfig {
id: string; id: string;
@@ -40,7 +40,7 @@ export function useAppConfig(): UseAppConfigReturn {
setError(null); setError(null);
try { try {
const { data, error: fetchError } = await supabase const { data, error: fetchError } = await adminData
.from('app_config') .from('app_config')
.select('*') .select('*')
.limit(1) .limit(1)
@@ -71,7 +71,7 @@ export function useAppConfig(): UseAppConfigReturn {
} }
try { try {
const { error: updateError } = await supabase const { error: updateError } = await adminData
.from('app_config') .from('app_config')
.update(updates) .update(updates)
.eq('id', config.id); .eq('id', config.id);
@@ -1,10 +1,13 @@
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
// Tables to audit // Tables to audit
const AUDITED_TABLES = [ const AUDITED_TABLES = [
'question_packs', 'question_packs',
'questions', 'questions',
'categories', 'categories',
'dare_packs',
'dares',
'quiz_questions',
'profiles', 'profiles',
'couples', 'couples',
'responses', 'responses',
@@ -26,23 +29,10 @@ async function logAction(
oldValues?: Record<string, unknown> | null, oldValues?: Record<string, unknown> | null,
newValues?: Record<string, unknown> | null newValues?: Record<string, unknown> | null
): Promise<void> { ): Promise<void> {
if (!AUDITED_TABLES.includes(tableName)) return; // The standalone admin API writes the mutation and audit record in one
// transaction. Keeping this no-op preserves the existing call sites while
try { // avoiding duplicate or client-forgeable audit entries.
const { error } = await supabase.rpc('log_admin_action', { void tableName; void action; void recordId; void oldValues; void newValues;
p_table_name: tableName,
p_action: action,
p_record_id: recordId,
p_old_values: oldValues || null,
p_new_values: newValues || null,
});
if (error) {
console.error('Failed to log audit action:', error);
}
} catch (error) {
console.error('Failed to log audit action:', error);
// Don't throw - logging failure shouldn't block the operation
}
} }
interface InsertResult<T> { interface InsertResult<T> {
@@ -56,9 +46,9 @@ interface SingleResult<T> {
} }
/** /**
* Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs * Admin API mutations. The server writes each mutation and audit record atomically.
*/ */
export const auditedSupabase = { export const auditedAdminData = {
/** /**
* Insert records with audit logging * Insert records with audit logging
*/ */
@@ -68,7 +58,7 @@ export const auditedSupabase = {
): Promise<InsertResult<T>> { ): Promise<InsertResult<T>> {
const recordArray = Array.isArray(records) ? records : [records]; const recordArray = Array.isArray(records) ? records : [records];
const { data, error } = await supabase const { data, error } = await adminData
.from(table) .from(table)
.insert(recordArray) .insert(recordArray)
.select(); .select();
@@ -94,7 +84,7 @@ export const auditedSupabase = {
// Fetch old values before update // Fetch old values before update
let oldValues: Record<string, unknown> | null = null; let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) { if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase const { data: oldData } = await adminData
.from(table) .from(table)
.select('*') .select('*')
.eq('id', id) .eq('id', id)
@@ -102,7 +92,7 @@ export const auditedSupabase = {
oldValues = oldData; oldValues = oldData;
} }
const { data, error } = await supabase const { data, error } = await adminData
.from(table) .from(table)
.update(updates) .update(updates)
.eq('id', id) .eq('id', id)
@@ -127,14 +117,14 @@ export const auditedSupabase = {
// Fetch old values before update // Fetch old values before update
let oldRecords: Record<string, unknown>[] = []; let oldRecords: Record<string, unknown>[] = [];
if (AUDITED_TABLES.includes(table)) { if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase const { data: oldData } = await adminData
.from(table) .from(table)
.select('*') .select('*')
.eq(filter.column, filter.value); .eq(filter.column, filter.value);
oldRecords = oldData || []; oldRecords = oldData || [];
} }
const { data, error } = await supabase const { data, error } = await adminData
.from(table) .from(table)
.update(updates) .update(updates)
.eq(filter.column, filter.value) .eq(filter.column, filter.value)
@@ -161,7 +151,7 @@ export const auditedSupabase = {
// Fetch old values before delete // Fetch old values before delete
let oldValues: Record<string, unknown> | null = null; let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) { if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase const { data: oldData } = await adminData
.from(table) .from(table)
.select('*') .select('*')
.eq('id', id) .eq('id', id)
@@ -169,7 +159,7 @@ export const auditedSupabase = {
oldValues = oldData; oldValues = oldData;
} }
const { error } = await supabase const { error } = await adminData
.from(table) .from(table)
.delete() .delete()
.eq('id', id); .eq('id', id);
@@ -191,14 +181,14 @@ export const auditedSupabase = {
// Fetch old values before delete // Fetch old values before delete
let oldRecords: Record<string, unknown>[] = []; let oldRecords: Record<string, unknown>[] = [];
if (AUDITED_TABLES.includes(table)) { if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase const { data: oldData } = await adminData
.from(table) .from(table)
.select('*') .select('*')
.in('id', ids); .in('id', ids);
oldRecords = oldData || []; oldRecords = oldData || [];
} }
const { error } = await supabase const { error } = await adminData
.from(table) .from(table)
.delete() .delete()
.in('id', ids); .in('id', ids);
@@ -224,7 +214,7 @@ export const auditedSupabase = {
// Fetch old values before delete // Fetch old values before delete
let oldValues: Record<string, unknown> | null = null; let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(table)) { if (AUDITED_TABLES.includes(table)) {
const { data: oldData } = await supabase const { data: oldData } = await adminData
.from(table) .from(table)
.select('*') .select('*')
.eq(field, value) .eq(field, value)
@@ -232,7 +222,7 @@ export const auditedSupabase = {
oldValues = oldData; oldValues = oldData;
} }
const { error } = await supabase const { error } = await adminData
.from(table) .from(table)
.delete() .delete()
.eq(field, value); .eq(field, value);
+12 -12
View File
@@ -1,14 +1,14 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { toast } from 'sonner'; import { toast } from 'sonner';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type SupabaseFilterFn = (query: any) => any; type AdminFilterFn = (query: any) => any;
interface UseCrudOperationsOptions<T> { interface UseCrudOperationsOptions<T> {
/** /**
* The Supabase table name * The standalone admin API resource name
*/ */
table: string; table: string;
@@ -41,7 +41,7 @@ interface UseCrudOperationsOptions<T> {
} }
/** /**
* Generic CRUD operations hook for Supabase tables * Generic CRUD operations hook for standalone admin API resources
* Reduces boilerplate for data fetching and mutations across pages. * Reduces boilerplate for data fetching and mutations across pages.
* *
* @example * @example
@@ -90,13 +90,13 @@ export function useCrudOperations<T extends { id: string }>({
* @param filter Optional filter function to modify the query * @param filter Optional filter function to modify the query
*/ */
const fetchAll = useCallback(async ( const fetchAll = useCallback(async (
filter?: SupabaseFilterFn filter?: AdminFilterFn
): Promise<T[]> => { ): Promise<T[]> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = supabase.from(table).select(select); let query: any = adminData.from(table).select(select);
if (filter) { if (filter) {
query = filter(query); query = filter(query);
@@ -131,7 +131,7 @@ export function useCrudOperations<T extends { id: string }>({
*/ */
const fetchById = useCallback(async (id: string): Promise<T | null> => { const fetchById = useCallback(async (id: string): Promise<T | null> => {
try { try {
const { data, error: fetchError } = await supabase const { data, error: fetchError } = await adminData
.from(table) .from(table)
.select(select) .select(select)
.eq('id', id) .eq('id', id)
@@ -158,7 +158,7 @@ export function useCrudOperations<T extends { id: string }>({
const { refetch = true, silent = false } = options; const { refetch = true, silent = false } = options;
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any); const { data: created, error: createError } = await auditedAdminData.insert(table, data as any);
if (createError) throw createError; if (createError) throw createError;
@@ -170,7 +170,7 @@ export function useCrudOperations<T extends { id: string }>({
await fetchAll(); await fetchAll();
} }
// auditedSupabase.insert returns T[] | null, get first element // auditedAdminData.insert returns T[] | null, get first element
return created && created.length > 0 ? (created[0] as T) : null; return created && created.length > 0 ? (created[0] as T) : null;
} catch (err) { } catch (err) {
if (!silent) { if (!silent) {
@@ -194,7 +194,7 @@ export function useCrudOperations<T extends { id: string }>({
): Promise<boolean> => { ): Promise<boolean> => {
const { refetch = true, silent = false } = options; const { refetch = true, silent = false } = options;
try { try {
const { error: updateError } = await auditedSupabase.update(table, id, data); const { error: updateError } = await auditedAdminData.update(table, id, data);
if (updateError) throw updateError; if (updateError) throw updateError;
@@ -233,7 +233,7 @@ export function useCrudOperations<T extends { id: string }>({
} }
try { try {
const { error: deleteError } = await auditedSupabase.delete(table, id); const { error: deleteError } = await auditedAdminData.delete(table, id);
if (deleteError) throw deleteError; if (deleteError) throw deleteError;
+73 -224
View File
@@ -1,243 +1,92 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { supabase } from '@/config';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { RealtimeChannel } from '@supabase/supabase-js'; import { queryAdminRows, type AdminFilter } from '@/lib/adminApi';
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED'; export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
export interface UseRealtimeSubscriptionOptions<T> { export interface UseRealtimeSubscriptionOptions<T> {
table: string; table: string; schema?: string; event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; filter?: string;
schema?: string; onInsert?: (payload: T) => void; onUpdate?: (payload: { old: T; new: T }) => void; onDelete?: (payload: T) => void;
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; insertToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
filter?: string; updateToast?: { enabled: boolean; message: string | ((payload: { old: T; new: T }) => string); type?: 'success' | 'info' | 'warning' };
onInsert?: (payload: T) => void; deleteToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
onUpdate?: (payload: { old: T; new: T }) => void; debounceMs?: number; enabled?: boolean; pollIntervalMs?: number;
onDelete?: (payload: T) => void; }
insertToast?: { export interface UseRealtimeSubscriptionReturn { status: SubscriptionStatus; error: Error | null }
enabled: boolean;
message: string | ((payload: T) => string); const DEFAULT_POLL_INTERVAL_MS = 10_000;
type?: 'success' | 'info' | 'warning'; const MIN_POLL_INTERVAL_MS = 5_000;
};
updateToast?: { function parseFilter(filter?: string): AdminFilter[] | undefined {
enabled: boolean; if (!filter) return undefined;
message: string | ((payload: { old: T; new: T }) => string); const match = /^([a-z_][a-z0-9_]*)=(eq|neq|gte|lte)\.(.+)$/.exec(filter);
type?: 'success' | 'info' | 'warning'; return match ? [{ column: match[1], op: match[2] as AdminFilter['op'], value: match[3] }] : undefined;
};
deleteToast?: {
enabled: boolean;
message: string | ((payload: T) => string);
type?: 'success' | 'info' | 'warning';
};
debounceMs?: number;
enabled?: boolean;
} }
export interface UseRealtimeSubscriptionReturn { function rowKey(row: unknown, index: number): string {
status: SubscriptionStatus; if (row && typeof row === 'object') {
error: Error | null; const record = row as Record<string, unknown>;
const key = record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}`;
if (key) return String(key);
}
return String(index);
} }
const MAX_RETRIES = 3; export function useRealtimeSubscription<T>(options: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionReturn {
const { table, event = '*', filter, onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast,
export function useRealtimeSubscription<T>( debounceMs = 0, enabled = true, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = options;
options: UseRealtimeSubscriptionOptions<T>
): UseRealtimeSubscriptionReturn {
const {
table,
schema = 'public',
event = '*',
filter,
onInsert,
onUpdate,
onDelete,
insertToast,
updateToast,
deleteToast,
debounceMs = 0,
enabled = true,
} = options;
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED'); const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null); const snapshotRef = useRef<Map<string, { value: T; json: string }> | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const callbacksRef = useRef({ onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast });
const retryCountRef = useRef(0); callbacksRef.current = { onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast };
const isMountedRef = useRef(true); const emit = useCallback((callback: () => void) => {
if (debounceMs <= 0) callback(); else window.setTimeout(callback, debounceMs);
}, [debounceMs]);
// Use refs for callbacks and toast options to avoid re-subscription on every render
const onInsertRef = useRef(onInsert);
const onUpdateRef = useRef(onUpdate);
const onDeleteRef = useRef(onDelete);
const insertToastRef = useRef(insertToast);
const updateToastRef = useRef(updateToast);
const deleteToastRef = useRef(deleteToast);
// Keep refs up to date
useEffect(() => { useEffect(() => {
onInsertRef.current = onInsert; if (!enabled) { setStatus('CLOSED'); return; }
onUpdateRef.current = onUpdate; let cancelled = false;
onDeleteRef.current = onDelete; let timer: number | undefined;
insertToastRef.current = insertToast; const interval = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
updateToastRef.current = updateToast; const poll = async () => {
deleteToastRef.current = deleteToast; if (document.visibilityState === 'hidden') { timer = window.setTimeout(poll, interval); return; }
}); try {
setStatus((current) => current === 'SUBSCRIBED' ? current : 'SUBSCRIBING');
// Debounced callback wrapper const { rows } = await queryAdminRows<T>(table, { filters: parseFilter(filter), limit: 500 });
const debouncedCallback = useCallback( if (cancelled) return;
(callback: () => void) => { const next = new Map(rows.map((row, index) => [rowKey(row, index), { value: row, json: JSON.stringify(row) }]));
if (debounceTimerRef.current) { const previous = snapshotRef.current;
clearTimeout(debounceTimerRef.current); if (previous) {
} if (event === '*' || event === 'INSERT') for (const [key, entry] of next) if (!previous.has(key)) {
if (debounceMs > 0) { const config = callbacksRef.current;
debounceTimerRef.current = setTimeout(() => { if (config.insertToast?.enabled) toast[config.insertToast.type || 'info'](typeof config.insertToast.message === 'function' ? config.insertToast.message(entry.value) : config.insertToast.message);
if (isMountedRef.current) { if (config.onInsert) emit(() => config.onInsert!(entry.value));
callback();
} }
}, debounceMs); if (event === '*' || event === 'UPDATE') for (const [key, entry] of next) {
} else { const old = previous.get(key);
callback(); if (old && old.json !== entry.json) {
} const payload = { old: old.value, new: entry.value };
}, const config = callbacksRef.current;
[debounceMs] if (config.updateToast?.enabled) toast[config.updateToast.type || 'info'](typeof config.updateToast.message === 'function' ? config.updateToast.message(payload) : config.updateToast.message);
); if (config.onUpdate) emit(() => config.onUpdate!(payload));
// Handle incoming changes - use refs to avoid dependency changes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleChange = useCallback(
(payload: { eventType: string; new: any; old: any }) => {
if (!isMountedRef.current) return;
const eventType = payload.eventType;
if (eventType === 'INSERT') {
const newRecord = payload.new as T;
if (insertToastRef.current?.enabled) {
const message =
typeof insertToastRef.current.message === 'function'
? insertToastRef.current.message(newRecord)
: insertToastRef.current.message;
toast[insertToastRef.current.type || 'info'](message);
}
if (onInsertRef.current) {
debouncedCallback(() => onInsertRef.current!(newRecord));
}
}
if (eventType === 'UPDATE') {
const updatePayload = { old: payload.old as T, new: payload.new as T };
if (updateToastRef.current?.enabled) {
const message =
typeof updateToastRef.current.message === 'function'
? updateToastRef.current.message(updatePayload)
: updateToastRef.current.message;
toast[updateToastRef.current.type || 'info'](message);
}
if (onUpdateRef.current) {
debouncedCallback(() => onUpdateRef.current!(updatePayload));
}
}
if (eventType === 'DELETE') {
const deletedRecord = payload.old as T;
if (deleteToastRef.current?.enabled) {
const message =
typeof deleteToastRef.current.message === 'function'
? deleteToastRef.current.message(deletedRecord)
: deleteToastRef.current.message;
toast[deleteToastRef.current.type || 'info'](message);
}
if (onDeleteRef.current) {
debouncedCallback(() => onDeleteRef.current!(deletedRecord));
}
}
},
[debouncedCallback]
);
useEffect(() => {
isMountedRef.current = true;
if (!enabled) {
setStatus('CLOSED');
return;
}
const subscribe = () => {
// Create unique channel name
const channelName = `admin-${table}-${filter || 'all'}-${Date.now()}`;
// Build subscription config
const subscriptionConfig: {
event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
schema: string;
table: string;
filter?: string;
} = {
event,
schema,
table,
};
if (filter) {
subscriptionConfig.filter = filter;
}
setStatus('SUBSCRIBING');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channel = (supabase.channel(channelName) as any)
.on('postgres_changes', subscriptionConfig, handleChange)
.subscribe((subscriptionStatus: string) => {
if (!isMountedRef.current) return;
if (subscriptionStatus === 'SUBSCRIBED') {
setStatus('SUBSCRIBED');
setError(null);
retryCountRef.current = 0;
} else if (subscriptionStatus === 'CHANNEL_ERROR') {
setStatus('CHANNEL_ERROR');
setError(new Error(`Failed to subscribe to ${table}`));
// Auto-retry with exponential backoff
if (retryCountRef.current < MAX_RETRIES) {
const timeout = Math.pow(2, retryCountRef.current) * 1000;
retryCountRef.current++;
setTimeout(() => {
if (isMountedRef.current && channelRef.current) {
supabase.removeChannel(channelRef.current);
subscribe();
}
}, timeout);
} }
} else if (subscriptionStatus === 'TIMED_OUT') {
setStatus('TIMED_OUT');
setError(new Error(`Subscription to ${table} timed out`));
} else if (subscriptionStatus === 'CLOSED') {
setStatus('CLOSED');
} }
}); if (event === '*' || event === 'DELETE') for (const [key, entry] of previous) if (!next.has(key)) {
const config = callbacksRef.current;
channelRef.current = channel; if (config.deleteToast?.enabled) toast[config.deleteToast.type || 'info'](typeof config.deleteToast.message === 'function' ? config.deleteToast.message(entry.value) : config.deleteToast.message);
}; if (config.onDelete) emit(() => config.onDelete!(entry.value));
}
subscribe(); }
snapshotRef.current = next;
return () => { setStatus('SUBSCRIBED'); setError(null);
isMountedRef.current = false; } catch (cause) {
if (debounceTimerRef.current) { if (!cancelled) { setStatus('CHANNEL_ERROR'); setError(cause instanceof Error ? cause : new Error(String(cause))); }
clearTimeout(debounceTimerRef.current); } finally {
} if (!cancelled) timer = window.setTimeout(poll, interval);
if (channelRef.current) {
supabase.removeChannel(channelRef.current);
} }
}; };
}, [enabled, table, schema, event, filter, handleChange]); void poll();
return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); snapshotRef.current = null; };
}, [emit, enabled, event, filter, pollIntervalMs, table]);
return { status, error }; return { status, error };
} }
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() }));
vi.mock('@/config', () => ({
apiUrl: 'http://127.0.0.1:3003',
authClient: { auth: { getSession } },
}));
import { AdminApiError, adminRequest, getAdminResponseMedia, queryAdminRows } from './adminApi';
describe('admin API transport', () => {
beforeEach(() => {
vi.restoreAllMocks();
getSession.mockResolvedValue({ data: { session: { access_token: 'hosted-auth-token' } }, error: null });
});
it('sends the hosted Auth bearer token to the standalone API', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ rows: [], count: 0 }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
await queryAdminRows('profiles', { limit: 25 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://127.0.0.1:3003/v1/admin/query/profiles');
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer hosted-auth-token');
expect(init?.body).toBe(JSON.stringify({ limit: 25 }));
});
it('fails closed when there is no authenticated session', async () => {
getSession.mockResolvedValue({ data: { session: null }, error: null });
await expect(adminRequest('/v1/admin/me')).rejects.toMatchObject({ status: 401 } satisfies Partial<AdminApiError>);
});
it('surfaces the API error message', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ error: { message: 'Administrator access denied' } }), {
status: 403, headers: { 'content-type': 'application/json' },
}));
await expect(adminRequest('/v1/admin/me')).rejects.toThrow('Administrator access denied');
});
it('loads protected response media from the standalone API with hosted Auth', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('response-media', {
status: 200, headers: { 'content-type': 'image/jpeg' },
}));
const responseId = '11111111-1111-4111-8111-111111111111';
const blob = await getAdminResponseMedia(responseId);
expect(await blob.text()).toBe('response-media');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`http://127.0.0.1:3003/v1/admin/responses/${responseId}/media`);
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer hosted-auth-token');
expect(init?.body).toBeUndefined();
});
});
+258
View File
@@ -0,0 +1,258 @@
import { apiUrl, authClient } from '@/config';
export class AdminApiError extends Error {
constructor(message: string, readonly status: number, readonly details?: unknown) {
super(message);
this.name = 'AdminApiError';
}
}
type RequestOptions = Omit<RequestInit, 'body'> & { body?: unknown };
async function accessToken(): Promise<string> {
const { data, error } = await authClient.auth.getSession();
const token = data.session?.access_token;
if (error || !token) throw new AdminApiError('An authenticated admin session is required', 401, error);
return token;
}
export async function adminRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const token = await accessToken();
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
headers.set('Authorization', `Bearer ${token}`);
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
if (body !== undefined) headers.set('Content-Type', 'application/json');
const response = await fetch(`${apiUrl}${path.startsWith('/') ? path : `/${path}`}`, { ...options, headers, body });
const text = await response.text();
let parsed: unknown;
try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; }
if (!response.ok) {
const nested = parsed && typeof parsed === 'object' && 'error' in parsed ? (parsed as { error: unknown }).error : undefined;
const message = nested && typeof nested === 'object' && 'message' in nested
? String((nested as { message: unknown }).message)
: `Admin API request failed with status ${response.status}`;
throw new AdminApiError(message, response.status, parsed);
}
return parsed as T;
}
export async function adminBinaryRequest(path: string, options: RequestOptions = {}): Promise<Blob> {
const token = await accessToken();
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${token}`);
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
if (body !== undefined) headers.set('Content-Type', 'application/json');
const response = await fetch(`${apiUrl}${path.startsWith('/') ? path : `/${path}`}`, { ...options, headers, body });
if (!response.ok) throw new AdminApiError(`Admin media request failed with status ${response.status}`, response.status);
return response.blob();
}
export function getAdminResponseMedia(responseId: string): Promise<Blob> {
return adminBinaryRequest(`/v1/admin/responses/${encodeURIComponent(responseId)}/media`);
}
export type AdminFilter = { column: string; op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike'; value: unknown };
export type AdminQuery = {
filters?: AdminFilter[];
order?: { column: string; ascending?: boolean };
limit?: number;
offset?: number;
};
export async function queryAdminRows<T>(resource: string, query: AdminQuery = {}): Promise<{ rows: T[]; count: number }> {
return adminRequest(`/v1/admin/query/${encodeURIComponent(resource)}`, { method: 'POST', body: query });
}
function uniqueValues(rows: Record<string, unknown>[], key: string): unknown[] {
return [...new Set(rows.map((row) => row[key]).filter((value) => value !== null && value !== undefined))];
}
async function relatedRows(resource: string, column: string, values: unknown[]): Promise<Record<string, unknown>[]> {
if (values.length === 0) return [];
return (await queryAdminRows<Record<string, unknown>>(resource, {
filters: [{ column, op: 'in', value: values }], limit: 500,
})).rows;
}
async function hydrateAdminRows(resource: string, selection: string, input: any[]): Promise<any[]> {
const rows = input as Record<string, unknown>[];
if (rows.length === 0) return input;
if (resource === 'question_packs' && selection.includes('categor')) {
const categories = await relatedRows('categories', 'id', uniqueValues(rows, 'category_id'));
const byId = new Map(categories.map((row) => [row.id, row]));
rows.forEach((row) => { row.category = byId.get(row.category_id) ?? null; });
}
if (resource === 'pack_topics' && selection.includes('topics')) {
const topics = await relatedRows('topics', 'id', uniqueValues(rows, 'topic_id'));
const byId = new Map(topics.map((row) => [row.id, row]));
rows.forEach((row) => { row.topics = byId.get(row.topic_id) ?? null; });
}
if (resource === 'feedback' && selection.includes('profile')) {
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'user_id'));
const byId = new Map(profiles.map((row) => [row.id, row]));
rows.forEach((row) => { row.profile = byId.get(row.user_id) ?? null; });
}
if (resource === 'message_reports') {
if (selection.includes('message:')) {
const messages = await relatedRows('messages', 'id', uniqueValues(rows, 'message_id'));
const byId = new Map(messages.map((row) => [row.id, row]));
rows.forEach((row) => { row.message = byId.get(row.message_id) ?? null; });
}
if (selection.includes('reporter_profile')) {
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'reporter_id'));
const byId = new Map(profiles.map((row) => [row.id, row]));
rows.forEach((row) => { row.reporter_profile = byId.get(row.reporter_id) ?? null; });
}
}
if (resource === 'responses') {
if (selection.includes('profile:')) {
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'user_id'));
const byId = new Map(profiles.map((row) => [row.id, row]));
rows.forEach((row) => { row.profile = byId.get(row.user_id) ?? null; });
}
if (selection.includes('question:')) {
const questions = await relatedRows('questions', 'id', uniqueValues(rows, 'question_id'));
if (selection.includes('pack:question_packs')) {
const packs = await relatedRows('question_packs', 'id', uniqueValues(questions, 'pack_id'));
const packsById = new Map(packs.map((row) => [row.id, row]));
questions.forEach((row) => { row.pack = packsById.get(row.pack_id) ?? null; });
}
const byId = new Map(questions.map((row) => [row.id, row]));
rows.forEach((row) => { row.question = byId.get(row.question_id) ?? null; });
}
}
if (resource === 'matches' && selection.includes('question:')) {
const questions = await relatedRows('questions', 'id', uniqueValues(rows, 'question_id'));
const byId = new Map(questions.map((row) => [row.id, row]));
rows.forEach((row) => { row.question = byId.get(row.question_id) ?? null; });
}
if (resource === 'messages' && selection.includes('match:')) {
const matches = await relatedRows('matches', 'id', uniqueValues(rows, 'match_id'));
const questions = await relatedRows('questions', 'id', uniqueValues(matches, 'question_id'));
const questionsById = new Map(questions.map((row) => [row.id, row]));
matches.forEach((row) => { row.question = questionsById.get(row.question_id) ?? null; });
const byId = new Map(matches.map((row) => [row.id, row]));
rows.forEach((row) => { row.match = byId.get(row.match_id) ?? null; });
}
return input;
}
type QueryResult<T> = { data: T | null; error: Error | null; count: number | null };
type QueryMode = 'many' | 'single' | 'maybeSingle';
class AdminQueryBuilder<T = any> implements PromiseLike<QueryResult<T[]>> {
private readonly filters: AdminFilter[] = [];
private ordering?: { column: string; ascending?: boolean };
private maxRows?: number;
private offset?: number;
private head = false;
private selection = '*';
private orFilters?: AdminFilter[];
private mode: QueryMode = 'many';
private mutation?: { type: 'insert'; records: Record<string, unknown>[] } | { type: 'update'; values: Record<string, unknown> } | { type: 'delete' };
constructor(private readonly resource: string) {}
select(columns = '*', options?: { count?: 'exact'; head?: boolean }): this {
this.selection = columns;
this.head = options?.head === true;
return this;
}
eq(column: string, value: unknown): this { this.filters.push({ column, op: 'eq', value }); return this; }
neq(column: string, value: unknown): this { this.filters.push({ column, op: 'neq', value }); return this; }
in(column: string, value: unknown[]): this { this.filters.push({ column, op: 'in', value }); return this; }
is(column: string, value: unknown): this { this.filters.push({ column, op: 'is', value }); return this; }
gte(column: string, value: unknown): this { this.filters.push({ column, op: 'gte', value }); return this; }
lte(column: string, value: unknown): this { this.filters.push({ column, op: 'lte', value }); return this; }
ilike(column: string, value: string): this { this.filters.push({ column, op: 'ilike', value }); return this; }
or(expression: string): this {
this.orFilters = expression.split(',').map((part) => {
const match = /^([a-z_][a-z0-9_]*)\.ilike\.(.+)$/.exec(part);
if (!match) throw new Error('Unsupported admin OR filter');
return { column: match[1], op: 'ilike' as const, value: match[2] };
});
return this;
}
not(column: string, operator: string, value: unknown): this {
if (operator !== 'is' || value !== null) throw new Error(`Unsupported admin filter: not ${operator}`);
this.filters.push({ column, op: 'neq', value: null });
return this;
}
order(column: string, options?: { ascending?: boolean }): this { this.ordering = { column, ascending: options?.ascending }; return this; }
limit(value: number): this { this.maxRows = value; return this; }
range(from: number, to: number): this { this.offset = from; this.maxRows = to - from + 1; return this; }
single<TResult = T>(): PromiseLike<QueryResult<TResult>> { this.mode = 'single'; return this as unknown as PromiseLike<QueryResult<TResult>>; }
maybeSingle<TResult = T>(): PromiseLike<QueryResult<TResult>> { this.mode = 'maybeSingle'; return this as unknown as PromiseLike<QueryResult<TResult>>; }
insert(records: Record<string, unknown> | Record<string, unknown>[]): this {
this.mutation = { type: 'insert', records: Array.isArray(records) ? records : [records] };
return this;
}
update(values: Record<string, unknown>): this { this.mutation = { type: 'update', values }; return this; }
delete(): this { this.mutation = { type: 'delete' }; return this; }
then<TResult1 = QueryResult<T[]>, TResult2 = never>(
onfulfilled?: ((value: QueryResult<T[]>) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected);
}
private async execute(): Promise<QueryResult<T[]>> {
try {
if (this.mutation?.type === 'insert') {
const result = await adminRequest<{ rows: T[] }>(`/v1/admin/data/${this.resource}`, { method: 'POST', body: { records: this.mutation.records } });
return this.shape(result.rows, result.rows.length);
}
if (this.mutation) {
const matching = await queryAdminRows<Record<string, unknown>>(this.resource, { filters: this.filters, limit: 500 });
const ids = [...new Set(matching.rows.map((row) => String(row.id ?? row.pack_id ?? '')).filter(Boolean))];
if (this.mutation.type === 'update') {
const rows = await Promise.all(ids.map((id) => adminRequest<{ row: T }>(`/v1/admin/data/${this.resource}/${encodeURIComponent(id)}`, { method: 'PATCH', body: { values: this.mutation!.type === 'update' ? this.mutation!.values : {} } }).then((value) => value.row)));
return this.shape(rows, rows.length);
}
await Promise.all(ids.map((id) => adminRequest(`/v1/admin/data/${this.resource}/${encodeURIComponent(id)}`, { method: 'DELETE' })));
return this.shape([], ids.length);
}
const result = this.orFilters?.length
? await this.executeOrQuery()
: await queryAdminRows<T>(this.resource, { filters: this.filters, order: this.ordering, limit: this.maxRows, offset: this.offset });
if (!this.head) result.rows = await hydrateAdminRows(this.resource, this.selection, result.rows);
return this.shape(result.rows, result.count);
} catch (cause) {
return { data: null, error: cause instanceof Error ? cause : new Error(String(cause)), count: null };
}
}
private async executeOrQuery(): Promise<{ rows: T[]; count: number }> {
const results = await Promise.all(this.orFilters!.map((filter) => queryAdminRows<T>(this.resource, {
filters: [...this.filters, filter], order: this.ordering, limit: 500,
})));
const unique = new Map<string, T>();
for (const result of results) result.rows.forEach((row, index) => {
const record = row as Record<string, unknown>;
unique.set(String(record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}:${index}`), row);
});
let rows = [...unique.values()];
if (this.offset) rows = rows.slice(this.offset);
if (this.maxRows) rows = rows.slice(0, this.maxRows);
return { rows, count: unique.size };
}
private shape(rows: T[], count: number): QueryResult<T[]> {
if (this.head) return { data: null, error: null, count };
if (this.mode === 'single') {
if (rows.length !== 1) return { data: null, error: new Error(`Expected one ${this.resource} record, received ${rows.length}`), count };
return { data: rows[0] as unknown as T[], error: null, count };
}
if (this.mode === 'maybeSingle') {
if (rows.length > 1) return { data: null, error: new Error(`Expected at most one ${this.resource} record, received ${rows.length}`), count };
return { data: (rows[0] ?? null) as unknown as T[], error: null, count };
}
return { data: rows, error: null, count };
}
}
export const adminData = {
from<T = any>(resource: string): AdminQueryBuilder<T> { return new AdminQueryBuilder<T>(resource); },
};
+5 -5
View File
@@ -23,8 +23,8 @@ export async function analyzeQuestionDeletions(
})); }));
const toneInstruction = isExplicit const toneInstruction = isExplicit
? 'CONTENT: EXPLICIT pack. Adult content is allowed but must be consensual, safe, and clear. Avoid coercion, minors, illegal acts, or unsafe suggestions.' ? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity.'
: 'CONTENT: NON-EXPLICIT pack. Do NOT include explicit sexual acts, crude language, or NSFW content. If the core action is sexual, it should be removed, not rewritten.'; : 'Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity rather than rewriting them into disguised euphemisms.';
const prompt = `<task> const prompt = `<task>
Identify questions that should be DELETED instead of edited. Identify questions that should be DELETED instead of edited.
@@ -38,7 +38,7 @@ ${toneInstruction}
<deletion_categories> <deletion_categories>
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates. - duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
- redundant: minor variation with no added value - redundant: minor variation with no added value
- off-tone: violates explicit vs non-explicit rules - off-tone: violates the universal catalogue safety ceiling or the pack's intended tone
- unsafe: coercion, minors, illegal acts, or consent violations - unsafe: coercion, minors, illegal acts, or consent violations
- too-vague: not actionable or not a clear proposal - too-vague: not actionable or not a clear proposal
- broken: grammar/structure too broken to repair without inventing content - broken: grammar/structure too broken to repair without inventing content
@@ -73,8 +73,8 @@ Only return questions that should be deleted.
</output_format>`; </output_format>`;
const systemMessage = isExplicit const systemMessage = isExplicit
? 'You are a strict content quality reviewer for an adult couples app. Remove only what should be deleted. Always respond with valid JSON only.' ? 'You are a strict content safety reviewer for a couples connection app. A legacy explicit flag never permits sexual content. Mark content outside the universal safety ceiling for deletion. Always respond with valid JSON only.'
: 'You are a strict content quality reviewer for a couples app. Remove only what should be deleted. Always respond with valid JSON only.'; : 'You are a strict content safety reviewer for a couples connection app. Mark sexual or explicit content for deletion. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel('fix'), model: getModel('fix'),
+8 -8
View File
@@ -9,16 +9,16 @@ export interface ImageSafetyAnalysis {
} }
const SAFETY_ANALYSIS_PROMPT = `<task> const SAFETY_ANALYSIS_PROMPT = `<task>
Analyze this image for content safety in the context of an adult couples intimacy app. Analyze this image for content safety in the context of a couples connection app.
</task> </task>
<context> <context>
This app is for ADULT couples (25-45) to explore intimacy. Adult content between consenting adults is expected and acceptable. The concern is inappropriate content like violence, minors, or illegal activity. This app has one universal mobile catalogue. Explicit or sexually gratifying imagery is not eligible for publication, even when it depicts consenting adults.
</context> </context>
<categories> <categories>
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults) - SAFE: Non-explicit relationship, romance, lifestyle, or abstract imagery appropriate for the universal catalogue
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review - ADULT_CONTENT: Sexual, sexually gratifying, fetish, explicit-anatomy, or stimulation-focused imagery; not appropriate for publication
- VIOLENCE: Gore, weapons, abuse, non-consensual acts - VIOLENCE: Gore, weapons, abuse, non-consensual acts
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable - MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
- ILLEGAL: Drug use, criminal activity, non-consensual content - ILLEGAL: Drug use, criminal activity, non-consensual content
@@ -27,9 +27,9 @@ This app is for ADULT couples (25-45) to explore intimacy. Adult content between
<severity_levels> <severity_levels>
- none: No safety concerns at all - none: No safety concerns at all
- low: Minor flag, likely fine but worth noting - low: Minor flag, needs human review before publication
- medium: Should be reviewed by admin - medium: Not eligible without a documented human safety decision
- high: Likely inappropriate, requires immediate review - high: Inappropriate for publication and should be archived
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL) - critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
</severity_levels> </severity_levels>
@@ -40,7 +40,7 @@ Return a JSON object:
"severity": "none"|"low"|"medium"|"high"|"critical", "severity": "none"|"low"|"medium"|"high"|"critical",
"description": "Brief factual description of what's shown (max 50 words)", "description": "Brief factual description of what's shown (max 50 words)",
"concerns": ["List of specific concerns if any, empty array if none"], "concerns": ["List of specific concerns if any, empty array if none"],
"isAppropriate": true|false // false for VIOLENCE, MINORS, ILLEGAL or high/critical severity "isAppropriate": true|false // false for ADULT_CONTENT, VIOLENCE, MINORS, ILLEGAL, UNCERTAIN, or any flagged severity
} }
</output_format>`; </output_format>`;
+11 -14
View File
@@ -22,18 +22,17 @@ export async function analyzeQuestionText(
})); }));
const toneInstruction = isExplicit const toneInstruction = isExplicit
? 'TONE: This is an EXPLICIT pack with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT keep crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s the name), "Edge until they beg". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.' ? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Replace explicit wording with a high-level proposal about curiosity, trust, anticipation, comfort, or relationship dynamics. If that would invent a materially different proposal, omit it so deletion review can archive it.'
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.'; : 'Keep language romantic, playful, flirty, or daring without naming or describing sexual content. Replace explicit wording with a safe high-level proposal, or omit it when that would change the proposal materially.';
// Use different examples based on explicit/non-explicit
const symmetricExamples = isExplicit const symmetricExamples = isExplicit
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"' ? 'GOOD SAFE REPLACEMENTS: "Explore a new dynamic together", "Share a private desire in your own words", "Try something daring together"'
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"'; : 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
const asymmetricExamples = isExplicit const asymmetricExamples = isExplicit
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage"). ? `text (The Doer): Active safe proposal (e.g., "Let your partner take the lead").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner"). - partner_text (The Receiver): Reciprocal safe proposal (e.g., "Take the lead with your partner").
- GOOD: "Spank your partner" / "Be spanked by your partner"` - GOOD: "Plan a bold surprise for your partner" / "Let your partner plan a bold surprise"`
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner"). : `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner"). - partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`; - GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
@@ -66,17 +65,15 @@ Cards should be "Proposals" for specific actions, NOT interview questions.
Partner text rules: Partner text rules:
- Describe RECEIVER's experience clearly - Describe RECEIVER's experience clearly
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING: - Keep both perspectives inside the same universal safety ceiling
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
* NOT: "Moan for your partner" (sounds forced, not natural)
</question_types> </question_types>
<rules> <rules>
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about 1. Preserve the core relationship intent only when it is catalogue-safe
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals 2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..." 3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
4. Use "your partner" instead of "me", "you", "him", "her" 4. Use "your partner" instead of "me", "you", "him", "her"
5. NEVER mix anatomically incompatible activities in same question 5. Keep allowed catalogue proposals anatomy-neutral by default
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited 6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill" 7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
8. Skip questions that are ALREADY well-phrased 8. Skip questions that are ALREADY well-phrased
@@ -102,8 +99,8 @@ Only include questions that need text improvement.
</output_format>`; </output_format>`;
const systemMessage = isExplicit const systemMessage = isExplicit
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.' ? 'You are a content editor for a couples connection app. A legacy explicit flag never overrides the universal safety ceiling. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.'
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.'; : 'You are a professional content editor for a couples connection app. Improve phrasing while never outputting sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel('fix'), model: getModel('fix'),
+36 -38
View File
@@ -47,7 +47,7 @@ export function getCouncilConfig(): CouncilConfig {
} }
/** /**
* Initialize AI config by preloading from Supabase * Initialize AI config by preloading from the standalone admin API
* Call this on app startup to ensure config is available * Call this on app startup to ensure config is available
*/ */
export async function initializeAiConfig(): Promise<AiConfig | null> { export async function initializeAiConfig(): Promise<AiConfig | null> {
@@ -62,9 +62,9 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
export const TONE_LEVELS = [ export const TONE_LEVELS = [
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' }, { level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' }, { level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' }, { level: 3, label: 'Playful', description: 'Flirty anticipation, novelty & playful connection' },
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' }, { level: 4, label: 'Bold', description: 'Trust, leadership & suggestive relationship dynamics' },
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' }, { level: 5, label: 'Daring', description: 'Comfort zones, control & user-led curiosity' },
] as const; ] as const;
// ============================================================================= // =============================================================================
@@ -92,30 +92,26 @@ REVIEW CRITERIA - Score each 1-10:
- Partner text (if present) clearly describes receiver's experience - Partner text (if present) clearly describes receiver's experience
- No confusing or ambiguous phrasing - No confusing or ambiguous phrasing
4. ANATOMICAL CONSISTENCY: 4. CATALOGUE SAFETY:
- No mixed male/female anatomy in alternatives - REJECT named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
- BAD: "Finger or give your partner a handjob" - REJECT sexual instructions disguised with euphemisms
- GOOD: Pick one activity - ALLOW non-graphic romance, anticipation, trust, leadership, following, novelty, and user-led curiosity
- The highest tone changes emotional boldness, never the explicitness ceiling
5. PARTNER TEXT QUALITY (if applicable): 5. PARTNER TEXT QUALITY (if applicable):
- Engaging and enticing, not clinical - Engaging and enticing, not clinical
- BAD: "Receive oral from your partner" - Describes the reciprocal perspective without adding sexual detail
- GOOD: "Let your partner pleasure you with their mouth" - Preserves the same safe proposal and emotional intensity
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
6. COUPLE TARGETING (flag if incorrect): 6. COUPLE TARGETING (flag if incorrect):
- Default: ALL couples (null) unless explicit anatomical requirement - Default: ALL couples (null)
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis - Safe catalogue proposals should not require anatomical targeting
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina - Flag unnecessary or exclusionary targeting
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
- Flag if targeting seems too restrictive or missing needed restrictions
7. INITIATOR TARGETING (for asymmetric questions only): 7. INITIATOR TARGETING (for asymmetric questions only):
- Default: null (anyone can initiate) unless explicit anatomical requirement - Default: null (anyone can initiate)
- Set initiator based on WHO DOES the action in "text" field - Set initiator only when a legitimate non-anatomical product rule requires it
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum) - Flag unnecessary targeting
- "Give your partner a massage" -> initiator: null (anyone can give)
- Flag if initiator seems incorrectly assigned
`; `;
// ============================================================================= // =============================================================================
@@ -125,10 +121,10 @@ REVIEW CRITERIA - Score each 1-10:
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = { export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.', 1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
2: 'INTIMACY LEVEL: WARM. Focus on creating romantic moments, sensual but non-sexual touch, and building anticipation. Examples: "Slow dance in the living room", "Candlelit bath together (non-sexual)", "Extended kissing sessions", "Sensual massage with oils". Romantic atmosphere & affectionate touch.', 2: 'INTIMACY LEVEL: WARM. Focus on romantic moments, affectionate touch, and anticipation. Examples: "Slow dance in the living room", "Plan a surprise date", "Share a longer kiss", "Give your partner a relaxing massage". Romantic atmosphere without sexual instructions.',
3: 'INTIMACY LEVEL: PLAYFUL. Focus on sexual touch without penetration, playful experimentation, and building arousal. Examples: "Mutual masturbation", "Oral sex", "Light roleplay (doctor/patient)", "Using basic toys together". Light sexual exploration & sensual discovery.', 3: 'INTIMACY LEVEL: PLAYFUL. Focus on flirtation, novelty, private signals, playful rules, and keeping a partner guessing. Examples: "Create a private signal together", "Plan a playful surprise", "Choose a bold outfit for your partner". Suggestive relationship energy without sexual acts.',
4: 'INTIMACY LEVEL: STEAMY. Focus on full sexual intercourse with adventurous elements and moderate BDSM/kink. Examples: "Sex in different locations/positions", "Light bondage", "Anal play with toys", "Recording consensual intimate moments". Explicit sexual activities & moderate adventure.', 4: 'INTIMACY LEVEL: BOLD. Focus on trust, taking the lead, following a partner, and switching relationship dynamics. Examples: "Let your partner lead the whole date", "Set a playful rule for an evening", "Switch who makes every decision". No named fetishes, gear, sexual acts, anatomy, nudity, or arousal instructions.',
5: 'INTIMACY LEVEL: INTENSE. Focus on edge play, power dynamics, and intense physical experiences. Examples: "Heavy impact play", "Advanced bondage", "Consensual non-consent scenes", "Multi-partner activities". Advanced/BDSM/Extreme exploration.', 5: 'INTIMACY LEVEL: DARING. Focus on comfort zones, control, vulnerability, and user-led curiosity. Examples: "Share something you are curious to try", "Let your partner plan a bold surprise", "Try a new dynamic together". Emotionally daring but never sexually explicit or instructional.',
}; };
// ============================================================================= // =============================================================================
@@ -142,8 +138,10 @@ export const CORE_LANGUAGE_RULES = `
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?" 2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..." 3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless 4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
5. Maintain anatomical consistency - NEVER combine male-specific and female-specific acts as alternatives 5. Keep allowed catalogue proposals anatomy-neutral and suitable for every couple by default
6. Keep concise - ideal length is 5-12 words per question 6. Keep concise - ideal length is 5-12 words per question
7. UNIVERSAL SAFETY CEILING - never name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
8. Higher tone levels increase trust, anticipation, control, novelty, and vulnerability; they never override rule 7
</language_rules> </language_rules>
`; `;
@@ -164,32 +162,32 @@ export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals. 2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate warm, romantic activities with affectionate touch.</role> <role>Generate warm, romantic activities with affectionate touch.</role>
<constraint>Sensual but NOT explicit sex. Building anticipation without crossing into explicit territory.</constraint> <constraint>Romantic and suggestive but not sexual or instructional. Build anticipation without naming sexual content.</constraint>
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style> <style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format> <format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`, <audience>Adults aged 25-45 in committed relationships.</audience>`,
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals. 3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
<role>Generate playful, sexually exploratory activities.</role> <role>Generate playful, flirty activities about anticipation and novelty.</role>
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint> <constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style> <style>Playful, suggestive, and adventurous without supplying the sexual act.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format> <format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`, <audience>Adults aged 25-45 in committed relationships.</audience>`,
4: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals. 4: `You are an expert content writer for Sauci, a couples connection app where partners swipe on activity proposals.
<role>Generate steamy, explicit sexual activities with moderate adventure.</role> <role>Generate bold activities about trust, leadership, following, and switching dynamics.</role>
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint> <constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
<style>Passionate, explicit, adventurous. Bold but not extreme.</style> <style>Confident, suggestive, and adventurous without explicit content.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format> <format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships.</audience>`, <audience>Adults aged 25-45 in committed relationships.</audience>`,
5: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals. 5: `You are an expert content writer for Sauci, a couples connection app where partners swipe on activity proposals.
<role>Generate intense, advanced sexual activities including BDSM and kinks.</role> <role>Generate emotionally daring activities about comfort zones, control, vulnerability, and user-led curiosity.</role>
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint> <constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions. Never turn curiosity into a developer-supplied sexual example.</constraint>
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style> <style>Bold and intriguing while preserving the universal safety ceiling.</style>
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format> <format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`, <audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
}; };
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const create = vi.fn();
vi.mock('@/hooks/useAiConfig', () => ({
getCachedAiConfig: () => null,
preloadAiConfig: vi.fn(),
}));
vi.mock('./client', () => ({
getOpenAI: () => ({ chat: { completions: { create } } }),
getModel: () => 'test-model',
getTemperature: (_purpose?: string, fallback?: number) => fallback ?? 0,
}));
import { SYSTEM_MESSAGES, TONE_INSTRUCTIONS } from './config';
import { analyzeQuestionDeletions } from './analyzers/deletions';
import { describeImage } from './analyzers/media';
import { analyzeQuestionText } from './analyzers/text';
import { generateQuestions } from './generators/questions';
import { polishContent } from './tools/polish';
const prohibitedLegacyPhrases = [
'adult content is allowed',
'adult content is expected and acceptable',
'do not over-sanitize',
'keep explicit terms',
];
function serializedMessages(): string {
const request = create.mock.calls[create.mock.calls.length - 1]?.[0];
return JSON.stringify(request?.messages ?? []).toLowerCase();
}
describe('universal catalogue AI safety ceiling', () => {
beforeEach(() => {
create.mockReset();
create.mockResolvedValue({
choices: [{ message: { content: '{"polished":"safe","suggestions":[],"deletions":[]}' } }],
});
});
it('keeps every generation level non-explicit', () => {
for (const level of [1, 2, 3, 4, 5] as const) {
const instructions = `${TONE_INSTRUCTIONS[level]} ${SYSTEM_MESSAGES[level]}`.toLowerCase();
expect(instructions).toMatch(/no sexual|not sexual|never sexually explicit/);
}
});
it('does not let the legacy explicit flag weaken polish safety', async () => {
await polishContent('legacy explicit copy', 'question', true);
const messages = serializedMessages();
expect(messages).toContain('legacy explicit flag');
expect(messages).toContain('universal catalogue safety ceiling');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
it('keeps daring generation and the legacy crude option below the same ceiling', async () => {
await generateQuestions('Daring connection', 2, 5, undefined, undefined, true);
const messages = serializedMessages();
expect(messages).toContain('critical universal safety ceiling');
expect(messages).toContain('legacy crude-language option');
expect(messages).toContain('never more explicit content');
});
it('does not let the legacy explicit flag weaken text repair safety', async () => {
await analyzeQuestionText([{ id: 'question-1', text: 'legacy explicit copy' }], true);
const messages = serializedMessages();
expect(messages).toContain('legacy explicit flag');
expect(messages).toContain('universal catalogue safety ceiling');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
it('routes legacy explicit content toward deletion', async () => {
await analyzeQuestionDeletions([{ id: 'question-1', text: 'legacy explicit copy' }], true);
const messages = serializedMessages();
expect(messages).toContain('legacy explicit flag');
expect(messages).toContain('delete developer-authored sexual acts');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
it('classifies adult imagery as ineligible rather than acceptable', async () => {
await describeImage('data:image/jpeg;base64,test');
const messages = serializedMessages();
expect(messages).toContain('adult_content');
expect(messages).toContain('not appropriate for publication');
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
});
});
@@ -71,11 +71,11 @@ export async function suggestCategories(
: 'None'; : 'None';
const explicitInstruction = explicit const explicitInstruction = explicit
? 'Include bold, spicy, and explicitly intimate categories (NSFW is allowed).' ? 'This legacy explicit flag does not override the universal safety ceiling. Keep categories suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.'; : 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const crudeLangInstruction = crudeLang const crudeLangInstruction = crudeLang
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.' ? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
: ''; : '';
// Sanitize inspiration to prevent prompt injection // Sanitize inspiration to prevent prompt injection
@@ -50,7 +50,7 @@ Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQues
<pack_context> <pack_context>
- Pack Name: "${packContext.name}" - Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''} ${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'} - Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
- Tone Level: ${packContext.tone} (${toneDescription}) - Tone Level: ${packContext.tone} (${toneDescription})
- Requested question count: ${requestedCount} - Requested question count: ${requestedCount}
</pack_context> </pack_context>
@@ -77,7 +77,7 @@ Calibrate your scores knowing these weights. A low compliance score has the most
<duplicate_detection> <duplicate_detection>
Two questions are duplicates if they: Two questions are duplicates if they:
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside") - Describe the same core activity/action (e.g., "Plan a surprise date" vs "Arrange a mystery date")
- Differ only in minor phrasing variations - Differ only in minor phrasing variations
- Would feel repetitive if both appeared in the same pack - Would feel repetitive if both appeared in the same pack
+4 -4
View File
@@ -19,11 +19,11 @@ export async function generatePack(
const openai = getOpenAI(); const openai = getOpenAI();
const explicitInstruction = explicit const explicitInstruction = explicit
? 'Include bold, spicy, and uncensored themes (NSFW is allowed).' ? 'This legacy explicit flag does not override the universal safety ceiling. Keep the theme suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.'; : 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const crudeLangInstruction = crudeLang const crudeLangInstruction = crudeLang
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.' ? 'LEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
: ''; : '';
// Sanitize inspiration to prevent prompt injection // Sanitize inspiration to prevent prompt injection
@@ -105,11 +105,11 @@ export async function suggestPacks(
: 'None'; : 'None';
const explicitInstruction = explicit const explicitInstruction = explicit
? 'Include bold, spicy, and uncensored pack ideas (NSFW is allowed).' ? 'This legacy explicit flag does not override the universal safety ceiling. Keep ideas suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.'; : 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
const crudeLangInstruction = crudeLang const crudeLangInstruction = crudeLang
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.' ? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
: ''; : '';
// Sanitize inspiration to prevent prompt injection // Sanitize inspiration to prevent prompt injection
+15 -28
View File
@@ -19,10 +19,8 @@ function buildQuestionPrompt(
crudeLang: boolean = false, crudeLang: boolean = false,
inspiration?: string inspiration?: string
): string { ): string {
const isExplicit = tone >= 4;
const crudeLangInstruction = crudeLang const crudeLangInstruction = crudeLang
? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.' ? '\n\nLEGACY CRUDE-LANGUAGE OPTION: Do not follow it. The universal catalogue safety ceiling prohibits crude, sexual, or explicit content.'
: ''; : '';
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases) // Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
@@ -41,31 +39,20 @@ function buildQuestionPrompt(
// Examples based on tone level // Examples based on tone level
const symmetricExamples = tone <= 2 const symmetricExamples = tone <= 2
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".' ? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Share a longer kiss".'
: isExplicit : 'GOOD: "Create a private signal together", "Try a new dynamic together", "Switch who leads the date", "Share something you are curious to try".';
? 'GOOD: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
const asymmetricExamples = tone <= 2 const asymmetricExamples = tone <= 2
? `Examples: ? `Examples:
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal" - text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner" - text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"` - text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
: isExplicit : `Examples:
? `Examples: - text: "Plan a bold surprise for your partner" → partner_text: "Let your partner plan a bold surprise"
- text: "Tie your partner up" → partner_text: "Get tied up by your partner" - text: "Lead your partner through a mystery date" → partner_text: "Follow your partner through a mystery date"
- text: "Spank your partner" → partner_text: "Get spanked by your partner" - text: "Choose a playful rule for your partner" → partner_text: "Follow a playful rule chosen by your partner"`;
- text: "Use a toy on your partner" → partner_text: "Have a toy used on you"`
: `Examples:
- text: "Perform oral on your partner" → partner_text: "Receive oral from your partner"
- text: "Tease your partner" → partner_text: "Be teased by your partner"
- text: "Undress your partner" → partner_text: "Be undressed by your partner"`;
const explicitWarning = tone === 1 const explicitWarning = '\n\nCRITICAL UNIVERSAL SAFETY CEILING: Reject any request to generate named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Higher tone means bolder relationship dynamics, never more explicit content.';
? '\n\nCRITICAL: This is a GENTLE pack. Focus on emotional connection and non-sexual bonding. NO explicit sexual acts.'
: isExplicit
? ''
: '\n\nCRITICAL: Avoid extreme kinks or hardcore content unless specifically requested.';
const descriptionContext = packDescription const descriptionContext = packDescription
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.` ? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
@@ -90,8 +77,8 @@ ${toneInstruction}${explicitWarning}${crudeLangInstruction}
${CORE_LANGUAGE_RULES} ${CORE_LANGUAGE_RULES}
<examples> <examples>
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..." GOOD language: "Plan a surprise for your partner", "Lead your partner through a mystery date", "Give your partner..."
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..." BAD language: "Plan a surprise for me" (who is "me"?), "Let me lead you", "I want you to..."
The card reader is the DOER. Their partner is "your partner". The card reader is the DOER. Their partner is "your partner".
</examples> </examples>
@@ -114,13 +101,13 @@ Both questions in a pair should have the SAME inverse_pair_id (a unique string p
Example pair (same inverse_pair_id "pair_1"): Example pair (same inverse_pair_id "pair_1"):
Question 1 (primary): Question 1 (primary):
text: "Spank your partner" text: "Plan a bold surprise for your partner"
partner_text: "Be spanked by your partner" partner_text: "Let your partner plan a bold surprise"
inverse_pair_id: "pair_1" inverse_pair_id: "pair_1"
Question 2 (inverse): Question 2 (inverse):
text: "Be spanked by your partner" text: "Let your partner plan a bold surprise"
partner_text: "Spank your partner" partner_text: "Plan a bold surprise for your partner"
inverse_pair_id: "pair_1" inverse_pair_id: "pair_1"
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse. The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
@@ -135,7 +122,7 @@ Return a JSON object with this exact structure:
{ {
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner" "text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric "partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"]) "requires_props": string[]|null, // Optional: ordinary items needed (e.g., ["playing cards", "picnic blanket"])
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric "inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional "location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional "effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
+5 -5
View File
@@ -52,7 +52,7 @@ Review each generated question against our quality guidelines.
<pack_context> <pack_context>
- Pack Name: "${packContext.name}" - Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''} ${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'} - Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
- Tone Level: ${packContext.tone} (${toneDescription}) - Tone Level: ${packContext.tone} (${toneDescription})
</pack_context> </pack_context>
@@ -63,7 +63,7 @@ ${REVIEW_GUIDELINES}
<verdict_rules> <verdict_rules>
- PASS: All scores >= 7, no major issues - PASS: All scores >= 7, no major issues
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use) - FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
- REJECT: Any score < 5, or major violations (mixed anatomy, severe guideline violation) - REJECT: Any score < 5, any sexual/explicit content, or another major guideline violation
</verdict_rules> </verdict_rules>
<questions_to_review> <questions_to_review>
@@ -104,7 +104,7 @@ Review ALL questions against ALL 7 criteria. Be thorough but fair - only flag/re
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: 'You are a quality assurance reviewer for a couples intimacy app. You evaluate generated questions for guideline compliance, creativity, clarity, and accuracy. Be thorough but fair. Always respond with valid JSON only.', content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Always respond with valid JSON only.',
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
@@ -213,7 +213,7 @@ Compare ${candidates.length} different sets of generated questions from differen
<pack_context> <pack_context>
- Pack Name: "${packContext.name}" - Pack Name: "${packContext.name}"
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''} ${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'} - Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
- Tone Level: ${packContext.tone} (${toneDescription}) - Tone Level: ${packContext.tone} (${toneDescription})
</pack_context> </pack_context>
@@ -270,7 +270,7 @@ Select the best set and review ALL questions in that set against ALL 7 criteria.
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: 'You are a quality assurance reviewer for a couples intimacy app. You compare multiple AI-generated question sets and select the best one, then provide detailed reviews. Be thorough but fair. Always respond with valid JSON only.', content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Compare candidate sets and always respond with valid JSON only.',
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
+16 -20
View File
@@ -18,8 +18,8 @@ export async function polishContent(
const openai = getOpenAI(); const openai = getOpenAI();
const explicitInstruction = explicit const explicitInstruction = explicit
? 'Use nuanced language: tasteful phrasing for general acts ("have sex", "perform oral") but crude/specific terms when relevant to the activity ("cum on", "cock ring", "edge"). Do not over-sanitize - keep explicit terms that are essential to the activity. NEVER change "cum" to "come" - they have different meanings.' ? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Remove named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. If preserving the original action would violate this rule, return a safe high-level relationship proposal instead.'
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.'; : 'Keep it romantic, playful, and suggestive without naming or describing sexual content. If the original text is explicit, return a safe high-level relationship proposal instead.';
const contextMap: Record<PolishType, string> = { const contextMap: Record<PolishType, string> = {
question: 'a question for a couples app', question: 'a question for a couples app',
@@ -37,28 +37,24 @@ export async function polishContent(
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions. 1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage". - GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
- BAD: "Do you want to have...", "Have you ever thought about...". - BAD: "Do you want to have...", "Have you ever thought about...".
2. ACCURACY: Do NOT rewrite the text effectively changing the action. 2. ACCURACY: Preserve the original relationship intent when it is catalogue-safe.
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner" - "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
- DO NOT change it to something unrelated like "Go to the movies". - If the core action is explicit, replace it with a high-level proposal about curiosity, trust, anticipation, or relationship dynamics.
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL. 3. TONE: Engaging, romantic, flirty, or daring, but never sexual or instructional.
3. TONE: Engaging, romantic, or spicy (depending on context), but direct. 4. UNIVERSAL SAFETY CEILING: Do not name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity.
4. ANATOMICAL CONSISTENCY: NEVER combine male-specific and female-specific acts as alternatives.
- BAD: "Finger or give your partner a handjob" (requires different anatomy)
- BAD: "Suck their cock or eat them out" (male vs female anatomy)
- If text has mixed anatomy alternatives, keep ONE activity and remove the incompatible one.
`; `;
fewShotExamples = ` fewShotExamples = `
=== FEW-SHOT EXAMPLES === === FEW-SHOT EXAMPLES ===
${type === 'question' ? ` ${type === 'question' ? `
"Would you want to give me a massage?" -> "Give your partner a sensual massage" "Would you want to give me a massage?" -> "Give your partner a relaxing massage"
"Have you ever thought about sex in public?" -> "Have sex somewhere you might get caught" "Have you ever thought about trying something daring?" -> "Try something daring with your partner"
"maybe we could try using a blindfold sometime" -> "Blindfold your partner and tease their senses" "maybe we could explore a new dynamic sometime" -> "Explore a new dynamic with your partner"
"cum on partner" -> "Cum on your partner" (keep "cum", don't change to "come") "describe a private fantasy" -> "Share a private desire in your own words"
` : ` ` : `
"Receive a massage from your partner" -> "Let your partner's hands work the tension from your body" "Receive a massage from your partner" -> "Let your partner's hands work the tension from your body"
"Get oral from your partner" -> "Let your partner pleasure you with their mouth" "Let your partner plan the evening" -> "Let your partner take the lead for an evening"
"Be tied up" -> "Let your partner tie you up and surrender control" "Try something new together" -> "Explore a new dynamic with your partner"
"Moan for your partner" -> "Let your partner make you moan" "Tell your partner what you want" -> "Share a private desire in your own words"
`} `}
=== END EXAMPLES === === END EXAMPLES ===
`; `;
@@ -79,8 +75,8 @@ ${explicitInstruction}
<rules> <rules>
${additionalRules} ${additionalRules}
- Make it concise, engaging, and grammatically correct - Make it concise, engaging, and grammatically correct
- Maintain the original intent and meaning - Preserve the original intent only when it stays inside the universal safety ceiling
- Do NOT change the core action - only improve phrasing - Replace explicit core actions with safe, high-level relationship proposals
</rules> </rules>
${fewShotExamples} ${fewShotExamples}
@@ -96,7 +92,7 @@ ${fewShotExamples}
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: 'You are a professional editor for a couples app. Improve the copy while maintaining the tone. Always respond with valid JSON only.', content: 'You are a professional editor for a couples connection app. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.',
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
@@ -0,0 +1,7 @@
import type { ContentReviewStatus } from '@sauci/shared';
export const CONTENT_STATUS_LABELS: Record<ContentReviewStatus, string> = {
unreviewed: 'Needs review',
allowed: 'Allowed',
archived: 'Archived',
};
+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();
}
+8 -3
View File
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
Daily Response Limit (Free Users) Daily Response Limit (Free Users)
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59) Limit the number of questions non-premium users can answer per day, on their own local calendar day
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
className="max-w-[200px]" className="max-w-[200px]"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Maximum number of questions a non-premium user can answer per UTC day. Maximum number of questions a non-premium user can answer per day.
Premium users (via personal or partner subscription) bypass this limit. Premium users (via personal or partner subscription) bypass this limit.
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely. Set to <strong>0</strong> to disable daily limits entirely.
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit. Resets at midnight in the user's own timezone, as reported by their device.
Users who have not reported a timezone yet fall back to 00:00 UTC.
</p>
<p className="text-xs text-muted-foreground">
<strong>Does not count toward the limit:</strong> editing an existing answer, or
answering a question the partner had already answered (catching up is always free).
</p> </p>
</div> </div>
</CardContent> </CardContent>
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination'; import { PaginationControls } from '@/components/ui/pagination';
@@ -45,7 +45,7 @@ export function AuditLogsPage() {
const from = (page - 1) * pageSize; const from = (page - 1) * pageSize;
const to = from + pageSize - 1; const to = from + pageSize - 1;
const { data, error, count } = await supabase const { data, error, count } = await adminData
.from('audit_logs') .from('audit_logs')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
+25 -27
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { supabase } from '@/config'; import { adminData, adminRequest } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react'; import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -43,6 +43,7 @@ function formatFeatureName(featureName: string) {
export function DashboardPage() { export function DashboardPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS); const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
categories: 0, categories: 0,
packs: 0, packs: 0,
@@ -64,10 +65,12 @@ export function DashboardPage() {
{ count: questionsCount }, { count: questionsCount },
{ count: usersCount }, { count: usersCount },
] = await Promise.all([ ] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }), adminData.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }), adminData.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }), adminData.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }), canViewUsers
? adminData.from('profiles').select('*', { count: 'exact', head: true })
: Promise.resolve({ count: 0 }),
]); ]);
setStats({ setStats({
@@ -85,31 +88,24 @@ export function DashboardPage() {
{ data: featureInterests }, { data: featureInterests },
{ data: recentInterests }, { data: recentInterests },
] = await Promise.all([ ] = await Promise.all([
supabase canViewChats
.from('messages') ? adminData.from('messages').select('created_at').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
.select('created_at') : Promise.resolve({ data: [] }),
.gte('created_at', startDate.toISOString()) canViewUsers
.lte('created_at', endDate.toISOString()), ? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
supabase : Promise.resolve({ data: [] }),
.from('feature_interests') canViewUsers
.select('created_at, user_id') ? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
.gte('created_at', startDate.toISOString()) : Promise.resolve({ data: [] }),
.lte('created_at', endDate.toISOString()),
supabase
.from('feature_interests')
.select('id, user_id, feature_name, created_at')
.order('created_at', { ascending: false })
.limit(10),
]); ]);
if (canViewUsers) { if (canViewUsers) {
const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts'); try {
const { counts } = await adminRequest<{ counts: FeatureInterestCountsRow[] }>('/v1/admin/feature-interest-counts');
if (countsError) { setFeatureInterestCounts(counts);
} catch (countsError) {
console.error('Failed to fetch feature interest counts:', countsError); console.error('Failed to fetch feature interest counts:', countsError);
setFeatureInterestCounts([]); setFeatureInterestCounts([]);
} else {
setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]);
} }
} else { } else {
setFeatureInterestCounts([]); setFeatureInterestCounts([]);
@@ -172,7 +168,7 @@ export function DashboardPage() {
if (canViewUsers && recentActivity.length > 0) { if (canViewUsers && recentActivity.length > 0) {
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id))); const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
const { data: profiles } = await supabase const { data: profiles } = await adminData
.from('profiles') .from('profiles')
.select('id, name, email, avatar_url') .select('id, name, email, avatar_url')
.in('id', userIds); .in('id', userIds);
@@ -193,11 +189,12 @@ export function DashboardPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canViewUsers]); }, [canViewChats, canViewUsers]);
// Real-time subscriptions for dashboard stats (with debouncing) // Real-time subscriptions for dashboard stats (with debouncing)
const { status: profilesStatus } = useRealtimeSubscription({ const { status: profilesStatus } = useRealtimeSubscription({
table: 'profiles', table: 'profiles',
enabled: canViewUsers,
onInsert: fetchStats, onInsert: fetchStats,
onDelete: fetchStats, onDelete: fetchStats,
debounceMs: 2000, debounceMs: 2000,
@@ -205,6 +202,7 @@ export function DashboardPage() {
const { status: messagesStatus } = useRealtimeSubscription({ const { status: messagesStatus } = useRealtimeSubscription({
table: 'messages', table: 'messages',
enabled: canViewChats,
onInsert: fetchStats, onInsert: fetchStats,
debounceMs: 2000, debounceMs: 2000,
}); });
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -102,7 +102,7 @@ export function FeedbackPage() {
const fetchFeedback = useCallback(async () => { const fetchFeedback = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase const { data, error } = await adminData
.from('feedback') .from('feedback')
.select(` .select(`
*, *,
@@ -161,7 +161,7 @@ export function FeedbackPage() {
setIsUpdating(true); setIsUpdating(true);
try { try {
const { error } = await supabase const { error } = await adminData
.from('feedback') .from('feedback')
.update({ .update({
status: editStatus, status: editStatus,
+16 -47
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminBinaryRequest, adminData, adminRequest } from '@/lib/adminApi';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -95,7 +95,7 @@ export function FlaggedMessagesPage() {
const from = (aiPage - 1) * aiPageSize; const from = (aiPage - 1) * aiPageSize;
const to = from + aiPageSize - 1; const to = from + aiPageSize - 1;
const { data, error, count } = await supabase const { data, error, count } = await adminData
.from('messages') .from('messages')
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' }) .select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
.eq('moderation_status', 'flagged') .eq('moderation_status', 'flagged')
@@ -120,7 +120,7 @@ export function FlaggedMessagesPage() {
const from = (reportsPage - 1) * reportsPageSize; const from = (reportsPage - 1) * reportsPageSize;
const to = from + reportsPageSize - 1; const to = from + reportsPageSize - 1;
const { data, error, count } = await supabase const { data, error, count } = await adminData
.from('message_reports') .from('message_reports')
.select(` .select(`
id, id,
@@ -166,24 +166,11 @@ export function FlaggedMessagesPage() {
} }
}, [reportsPage, reportsPageSize, reportsTotal]); }, [reportsPage, reportsPageSize, reportsTotal]);
// Custom media fetcher because supabase.functions.invoke is JSON-centric
const fetchDecryptedMedia = async (messageId: string) => { const fetchDecryptedMedia = async (messageId: string) => {
try { try {
const { data: { session } } = await supabase.auth.getSession(); const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
if (!session) return null; method: 'POST', body: { messageId },
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId })
}); });
if (!response.ok) throw new Error('Failed to fetch media');
const blob = await response.blob();
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -199,8 +186,8 @@ export function FlaggedMessagesPage() {
setImageDescription(null); setImageDescription(null);
try { try {
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', { const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
body: { messageId: message.id } method: 'POST', body: { messageId: message.id },
}); });
let mediaUrl = null; let mediaUrl = null;
@@ -209,7 +196,7 @@ export function FlaggedMessagesPage() {
} }
setDecryptedContent({ setDecryptedContent({
text: textData?.content || (textError ? 'Error decrypting text' : null), text: textData.content,
mediaUrl mediaUrl
}); });
@@ -233,8 +220,8 @@ export function FlaggedMessagesPage() {
setImageDescription(null); setImageDescription(null);
try { try {
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', { const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
body: { messageId: report.message.id } method: 'POST', body: { messageId: report.message.id },
}); });
let mediaUrl = null; let mediaUrl = null;
@@ -243,7 +230,7 @@ export function FlaggedMessagesPage() {
} }
setDecryptedContent({ setDecryptedContent({
text: textData?.content || (textError ? 'Error decrypting text' : null), text: textData.content,
mediaUrl mediaUrl
}); });
@@ -274,27 +261,9 @@ export function FlaggedMessagesPage() {
setDescribingInline(message.id); setDescribingInline(message.id);
try { try {
const { data: { session } } = await supabase.auth.getSession(); const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
if (!session) { method: 'POST', body: { messageId: message.id },
toast.error('Not authenticated');
return;
}
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ messageId: message.id })
}); });
if (!response.ok) {
toast.error('Failed to decrypt media');
return;
}
const blob = await response.blob();
const base64 = await new Promise<string>((resolve, reject) => { const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -319,7 +288,7 @@ export function FlaggedMessagesPage() {
const handleMarkSafe = async (messageId: string) => { const handleMarkSafe = async (messageId: string) => {
setProcessing(messageId); setProcessing(messageId);
try { try {
const { error } = await supabase const { error } = await adminData
.from('messages') .from('messages')
.update({ moderation_status: 'safe' }) .update({ moderation_status: 'safe' })
.eq('id', messageId); .eq('id', messageId);
@@ -339,7 +308,7 @@ export function FlaggedMessagesPage() {
const handleDismissReport = async (reportId: string) => { const handleDismissReport = async (reportId: string) => {
setProcessing(reportId); setProcessing(reportId);
try { try {
const { error } = await supabase const { error } = await adminData
.from('message_reports') .from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() }) .update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', reportId); .eq('id', reportId);
@@ -359,7 +328,7 @@ export function FlaggedMessagesPage() {
const handleReviewReport = async (reportId: string) => { const handleReviewReport = async (reportId: string) => {
setProcessing(reportId); setProcessing(reportId);
try { try {
const { error } = await supabase const { error } = await adminData
.from('message_reports') .from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() }) .update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', reportId); .eq('id', reportId);
+8 -8
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -100,7 +100,7 @@ export function RedemptionCodesPage() {
const to = from + pageSize - 1; const to = from + pageSize - 1;
const trimmedSearch = search.trim(); const trimmedSearch = search.trim();
let query = supabase let query = adminData
.from('redemption_codes') .from('redemption_codes')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@@ -129,7 +129,7 @@ export function RedemptionCodesPage() {
const from = (redemptionsPage - 1) * redemptionsPageSize; const from = (redemptionsPage - 1) * redemptionsPageSize;
const to = from + redemptionsPageSize - 1; const to = from + redemptionsPageSize - 1;
const { data: redemptionData, error: redemptionError, count } = await supabase const { data: redemptionData, error: redemptionError, count } = await adminData
.from('code_redemptions') .from('code_redemptions')
.select('id, user_id, redeemed_at', { count: 'exact' }) .select('id, user_id, redeemed_at', { count: 'exact' })
.eq('code_id', codeId) .eq('code_id', codeId)
@@ -142,7 +142,7 @@ export function RedemptionCodesPage() {
if (redemptionData && redemptionData.length > 0) { if (redemptionData && redemptionData.length > 0) {
const userIds = redemptionData.map(r => r.user_id); const userIds = redemptionData.map(r => r.user_id);
const { data: profileData } = await supabase const { data: profileData } = await adminData
.from('profiles') .from('profiles')
.select('id, name, email') .select('id, name, email')
.in('id', userIds); .in('id', userIds);
@@ -200,7 +200,7 @@ export function RedemptionCodesPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const { error } = await auditedSupabase.insert('redemption_codes', { const { error } = await auditedAdminData.insert('redemption_codes', {
code: formData.code.toUpperCase().trim(), code: formData.code.toUpperCase().trim(),
description: formData.description.trim() || null, description: formData.description.trim() || null,
max_uses: formData.max_uses, max_uses: formData.max_uses,
@@ -229,7 +229,7 @@ export function RedemptionCodesPage() {
const handleToggleActive = async (code: RedemptionCode) => { const handleToggleActive = async (code: RedemptionCode) => {
try { try {
const { error } = await auditedSupabase.update('redemption_codes', code.id, { const { error } = await auditedAdminData.update('redemption_codes', code.id, {
is_active: !code.is_active, is_active: !code.is_active,
}); });
@@ -248,7 +248,7 @@ export function RedemptionCodesPage() {
} }
try { try {
const { error } = await auditedSupabase.delete('redemption_codes', code.id); const { error } = await auditedAdminData.delete('redemption_codes', code.id);
if (error) throw error; if (error) throw error;
+5 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react'; import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
@@ -65,14 +65,14 @@ export function UsageInsightsPage() {
const fetchStats = async () => { const fetchStats = async () => {
try { try {
// Get total users count // Get total users count
const { count: total } = await supabase const { count: total } = await adminData
.from('profiles') .from('profiles')
.select('*', { count: 'exact', head: true }); .select('*', { count: 'exact', head: true });
setTotalUsers(total || 0); setTotalUsers(total || 0);
// Get onboarded users count // Get onboarded users count
const { count: onboarded } = await supabase const { count: onboarded } = await adminData
.from('profiles') .from('profiles')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.eq('onboarding_completed', true); .eq('onboarding_completed', true);
@@ -80,7 +80,7 @@ export function UsageInsightsPage() {
setOnboardedUsers(onboarded || 0); setOnboardedUsers(onboarded || 0);
// Get usage reason breakdown // Get usage reason breakdown
const { data: usageData } = await supabase const { data: usageData } = await adminData
.from('profiles') .from('profiles')
.select('usage_reason') .select('usage_reason')
.not('usage_reason', 'is', null); .not('usage_reason', 'is', null);
@@ -103,7 +103,7 @@ export function UsageInsightsPage() {
setUsageStats(usageStatsArray); setUsageStats(usageStatsArray);
// Get gender breakdown // Get gender breakdown
const { data: genderData } = await supabase const { data: genderData } = await adminData
.from('profiles') .from('profiles')
.select('gender') .select('gender')
.not('gender', 'is', null); .not('gender', 'is', null);
@@ -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>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { supabase } from '@/config'; import { adminData, adminRequest } from '@/lib/adminApi';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -146,7 +146,7 @@ export function UserActivityPage() {
const from = (responsesPage - 1) * responsesPageSize; const from = (responsesPage - 1) * responsesPageSize;
const to = from + responsesPageSize - 1; const to = from + responsesPageSize - 1;
const { data, error, count } = await supabase const { data, error, count } = await adminData
.from('responses') .from('responses')
.select(` .select(`
id, user_id, answer, response_data, created_at, id, user_id, answer, response_data, created_at,
@@ -163,7 +163,7 @@ export function UserActivityPage() {
setResponsesTotal(count || 0); setResponsesTotal(count || 0);
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations) // Transform the API's nested relationship objects to the view model.
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({ const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id, id: item.id,
user_id: item.user_id, user_id: item.user_id,
@@ -194,7 +194,7 @@ export function UserActivityPage() {
const from = (matchesPage - 1) * matchesPageSize; const from = (matchesPage - 1) * matchesPageSize;
const to = from + matchesPageSize - 1; const to = from + matchesPageSize - 1;
const { data: matchesData, error: matchesError, count } = await supabase const { data: matchesData, error: matchesError, count } = await adminData
.from('matches') .from('matches')
.select(` .select(`
id, couple_id, match_type, is_new, created_at, id, couple_id, match_type, is_new, created_at,
@@ -210,7 +210,7 @@ export function UserActivityPage() {
// Get unique couple IDs and fetch profiles // Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))]; const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) { if (coupleIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase const { data: profiles, error: profilesError } = await adminData
.from('profiles') .from('profiles')
.select('id, name, email, couple_id') .select('id, name, email, couple_id')
.in('couple_id', coupleIds); .in('couple_id', coupleIds);
@@ -256,7 +256,7 @@ export function UserActivityPage() {
const to = from + messagesPageSize - 1; const to = from + messagesPageSize - 1;
// Fetch messages with match data // Fetch messages with match data
const { data: messagesData, error: messagesError, count } = await supabase const { data: messagesData, error: messagesError, count } = await adminData
.from('messages') .from('messages')
.select(` .select(`
id, user_id, content, media_path, media_type, created_at, id, user_id, content, media_path, media_type, created_at,
@@ -277,7 +277,7 @@ export function UserActivityPage() {
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {}; let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
if (userIds.length > 0) { if (userIds.length > 0) {
const { data: profiles, error: profilesError } = await supabase const { data: profiles, error: profilesError } = await adminData
.from('profiles') .from('profiles')
.select('id, name, email') .select('id, name, email')
.in('id', userIds); .in('id', userIds);
@@ -318,10 +318,7 @@ export function UserActivityPage() {
setLoading(prev => ({ ...prev, signups: true })); setLoading(prev => ({ ...prev, signups: true }));
try { try {
// Use RPC to get profiles with auth info (includes email from auth.users) // Use RPC to get profiles with auth info (includes email from auth.users)
const { data: allData, error } = await supabase const { users: allData } = await adminRequest<{ users: SignupActivity[] }>('/v1/admin/users');
.rpc('get_profiles_with_auth_info');
if (error) throw error;
// Sort by created_at descending and apply pagination // Sort by created_at descending and apply pagination
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) => const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
+11 -11
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -82,7 +82,7 @@ export function AdminsPage() {
setLoading(true); setLoading(true);
try { try {
// First fetch admin users with permissions // First fetch admin users with permissions
const { data: adminData, error: adminError } = await supabase const { data: adminRows, error: adminError } = await adminData
.from('admin_users') .from('admin_users')
.select('id, user_id, role, permissions, created_at') .select('id, user_id, role, permissions, created_at')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@@ -91,9 +91,9 @@ export function AdminsPage() {
// Then fetch profiles for these users // Then fetch profiles for these users
// We do this manually to avoid relying on foreign key relationships if they aren't perfect // We do this manually to avoid relying on foreign key relationships if they aren't perfect
if (adminData && adminData.length > 0) { if (adminRows && adminRows.length > 0) {
const userIds = adminData.map(a => a.user_id); const userIds = adminRows.map(a => a.user_id);
const { data: profileData, error: profileError } = await supabase const { data: profileData, error: profileError } = await adminData
.from('profiles') .from('profiles')
.select('id, name, email, avatar_url') .select('id, name, email, avatar_url')
.in('id', userIds); .in('id', userIds);
@@ -101,7 +101,7 @@ export function AdminsPage() {
if (profileError) throw profileError; if (profileError) throw profileError;
// Merge data // Merge data
const joinedAdmins = adminData.map(admin => ({ const joinedAdmins = adminRows.map(admin => ({
...admin, ...admin,
permissions: (admin.permissions as string[]) || [], permissions: (admin.permissions as string[]) || [],
profile: profileData?.find(p => p.id === admin.user_id) profile: profileData?.find(p => p.id === admin.user_id)
@@ -149,7 +149,7 @@ export function AdminsPage() {
// Determine which users are already admins to exclude them // Determine which users are already admins to exclude them
const adminUserIds = new Set(admins.map(a => a.user_id)); const adminUserIds = new Set(admins.map(a => a.user_id));
const { data, error } = await supabase const { data, error } = await adminData
.from('profiles') .from('profiles')
.select('id, name, email, avatar_url') .select('id, name, email, avatar_url')
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`) .or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
@@ -179,7 +179,7 @@ export function AdminsPage() {
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator'; const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
const permissions = isSuperAdminChecked ? [] : selectedPermissions; const permissions = isSuperAdminChecked ? [] : selectedPermissions;
const { error } = await auditedSupabase.insert('admin_users', { const { error } = await auditedAdminData.insert('admin_users', {
user_id: selectedUser.id, user_id: selectedUser.id,
role, role,
permissions permissions
@@ -219,7 +219,7 @@ export function AdminsPage() {
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator'; const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
const permissions = editIsSuperAdmin ? [] : editPermissions; const permissions = editIsSuperAdmin ? [] : editPermissions;
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, { const { error } = await auditedAdminData.update('admin_users', editingAdmin.id, {
role, role,
permissions permissions
}); });
@@ -261,7 +261,7 @@ export function AdminsPage() {
} }
try { try {
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id); const { error } = await auditedAdminData.deleteBy('admin_users', 'user_id', admin.user_id);
if (error) throw error; if (error) throw error;
+27 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm'; import { useEntityForm } from '@/hooks/useEntityForm';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -43,6 +43,8 @@ import {
rectSortingStrategy, rectSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
// ============================================================================= // =============================================================================
// Types // Types
@@ -56,6 +58,8 @@ interface Category {
sort_order: number | null; sort_order: number | null;
created_at: string | null; created_at: string | null;
is_public: boolean; is_public: boolean;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
question_pack_count?: number; question_pack_count?: number;
dare_pack_count?: number; dare_pack_count?: number;
} }
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
onEdit: (category: Category) => void; onEdit: (category: Category) => void;
onDelete: (category: Category) => void; onDelete: (category: Category) => void;
onToggleVisibility: (category: Category) => void; onToggleVisibility: (category: Category) => void;
onReviewChanged: () => void | Promise<void>;
} }
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) { function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility, onReviewChanged }: SortableCategoryCardProps) {
const { const {
attributes, attributes,
listeners, listeners,
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
</div> </div>
</div> </div>
<ContentReviewControl
table="categories"
entityId={category.id}
entityLabel={`category “${category.name}”`}
status={category.content_status}
reason={category.content_review_reason}
onChanged={onReviewChanged}
compact
/>
<div className="grid gap-2"> <div className="grid gap-2">
<Link to={`/categories/${category.id}/packs`}> <Link to={`/categories/${category.id}/packs`}>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full">
@@ -215,7 +230,7 @@ export function CategoriesPage() {
const from = (page - 1) * pageSize; const from = (page - 1) * pageSize;
const to = from + pageSize - 1; const to = from + pageSize - 1;
const { data: cats, error, count } = await supabase const { data: cats, error, count } = await adminData
.from('categories') .from('categories')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }) .order('sort_order', { ascending: true })
@@ -231,11 +246,11 @@ export function CategoriesPage() {
if (categoryIds.length > 0) { if (categoryIds.length > 0) {
const [{ data: packs }, { data: darePacks }] = await Promise.all([ const [{ data: packs }, { data: darePacks }] = await Promise.all([
supabase adminData
.from('question_packs') .from('question_packs')
.select('category_id') .select('category_id')
.in('category_id', categoryIds), .in('category_id', categoryIds),
supabase adminData
.from('dare_packs') .from('dare_packs')
.select('category_id') .select('category_id')
.in('category_id', categoryIds), .in('category_id', categoryIds),
@@ -310,11 +325,11 @@ export function CategoriesPage() {
}; };
if (form.editingItem) { if (form.editingItem) {
const { error } = await auditedSupabase.update('categories', form.editingItem.id, data); const { error } = await auditedAdminData.update('categories', form.editingItem.id, data);
if (error) throw error; if (error) throw error;
toast.success('Category updated'); toast.success('Category updated');
} else { } else {
const { error } = await auditedSupabase.insert('categories', { const { error } = await auditedAdminData.insert('categories', {
...data, ...data,
sort_order: totalCount, sort_order: totalCount,
}); });
@@ -338,7 +353,7 @@ export function CategoriesPage() {
} }
try { try {
const { error } = await auditedSupabase.delete('categories', category.id); const { error } = await auditedAdminData.delete('categories', category.id);
if (error) throw error; if (error) throw error;
toast.success('Category deleted'); toast.success('Category deleted');
fetchCategories(); fetchCategories();
@@ -355,7 +370,7 @@ export function CategoriesPage() {
c.id === category.id ? { ...c, is_public: !c.is_public } : c c.id === category.id ? { ...c, is_public: !c.is_public } : c
)); ));
const { error } = await auditedSupabase.update('categories', category.id, { const { error } = await auditedAdminData.update('categories', category.id, {
is_public: !category.is_public is_public: !category.is_public
}); });
@@ -399,7 +414,7 @@ export function CategoriesPage() {
// Persist new order to database // Persist new order to database
try { try {
const updates = newCategories.map((category, index) => const updates = newCategories.map((category, index) =>
auditedSupabase.update('categories', category.id, { sort_order: index }) auditedAdminData.update('categories', category.id, { sort_order: index })
); );
const results = await Promise.all(updates); const results = await Promise.all(updates);
@@ -579,6 +594,7 @@ export function CategoriesPage() {
onEdit={form.openEdit} onEdit={form.openEdit}
onDelete={handleDelete} onDelete={handleDelete}
onToggleVisibility={handleToggleVisibility} onToggleVisibility={handleToggleVisibility}
onReviewChanged={fetchCategories}
/> />
))} ))}
</div> </div>
+90 -11
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm'; import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -31,6 +31,9 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react'; import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
import { parseIntensityRange } from '@/lib/dareIntensity';
// ============================================================================= // =============================================================================
// Types // Types
@@ -52,8 +55,13 @@ 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_review_reason?: string | null;
} }
interface DarePackFormData { interface DarePackFormData {
@@ -64,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;
} }
// ============================================================================= // =============================================================================
@@ -92,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,
@@ -101,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) : '',
}) })
); );
@@ -116,7 +130,7 @@ export function DarePacksPage() {
// Fetch category info // Fetch category info
if (categoryId) { if (categoryId) {
const { data: categoryData } = await supabase const { data: categoryData } = await adminData
.from('categories') .from('categories')
.select('id, name, icon') .select('id, name, icon')
.eq('id', categoryId) .eq('id', categoryId)
@@ -127,7 +141,7 @@ export function DarePacksPage() {
} }
// Fetch dare packs // Fetch dare packs
let query = supabase let query = adminData
.from('dare_packs') .from('dare_packs')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }) .order('sort_order', { ascending: true })
@@ -148,7 +162,7 @@ export function DarePacksPage() {
const dareCounts: Record<string, number> = {}; const dareCounts: Record<string, number> = {};
if (packIds.length > 0) { if (packIds.length > 0) {
const { data: dares } = await supabase const { data: dares } = await adminData
.from('dares') .from('dares')
.select('pack_id') .select('pack_id')
.in('pack_id', packIds); .in('pack_id', packIds);
@@ -159,7 +173,7 @@ export function DarePacksPage() {
} }
// Fetch categories // Fetch categories
const { data: categoryData } = await supabase const { data: categoryData } = await adminData
.from('categories') .from('categories')
.select('id, name') .select('id, name')
.order('name'); .order('name');
@@ -205,10 +219,18 @@ 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) {
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, { const { error } = await auditedAdminData.update('dare_packs', form.editingItem.id, {
name: form.formData.name, name: form.formData.name,
description: form.formData.description || null, description: form.formData.description || null,
icon: form.formData.icon || null, icon: form.formData.icon || null,
@@ -216,11 +238,13 @@ 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');
} else { } else {
const { error } = await auditedSupabase.insert('dare_packs', { const { error } = await auditedAdminData.insert('dare_packs', {
name: form.formData.name, name: form.formData.name,
description: form.formData.description || null, description: form.formData.description || null,
icon: form.formData.icon || null, icon: form.formData.icon || null,
@@ -228,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;
@@ -250,7 +276,7 @@ export function DarePacksPage() {
} }
try { try {
const { error } = await auditedSupabase.delete('dare_packs', pack.id); const { error } = await auditedAdminData.delete('dare_packs', pack.id);
if (error) throw error; if (error) throw error;
toast.success('Dare pack deleted'); toast.success('Dare pack deleted');
fetchData(); fetchData();
@@ -273,8 +299,8 @@ export function DarePacksPage() {
const targetOrder = targetPack.sort_order ?? targetIndex; const targetOrder = targetPack.sort_order ?? targetIndex;
const [currentUpdate, targetUpdate] = await Promise.all([ const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }), auditedAdminData.update('dare_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }), auditedAdminData.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
]); ]);
if (currentUpdate.error || targetUpdate.error) { if (currentUpdate.error || targetUpdate.error) {
@@ -406,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>
@@ -511,6 +567,15 @@ export function DarePacksPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ContentReviewControl
table="dare_packs"
entityId={pack.id}
entityLabel={`dare pack “${pack.name}”`}
status={pack.content_status}
reason={pack.content_review_reason}
onChanged={fetchData}
compact
/>
{pack.is_explicit && ( {pack.is_explicit && (
<Badge variant="destructive"> <Badge variant="destructive">
NSFW NSFW
@@ -559,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' : ''}
+63 -9
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -37,6 +37,9 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner'; 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 type { ContentReviewStatus } from '@sauci/shared';
import { clampDareIntensity, MAX_DARE_INTENSITY, MIN_DARE_INTENSITY } from '@/lib/dareIntensity';
// ============================================================================= // =============================================================================
// Types // Types
@@ -56,6 +59,8 @@ interface Dare {
intensity: number; intensity: number;
suggested_duration_hours: number | null; suggested_duration_hours: number | null;
created_at: string | null; created_at: string | null;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
} }
// Duration options for dares // Duration options for dares
@@ -98,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: '',
}); });
@@ -110,7 +117,7 @@ export function DaresPage() {
setLoading(true); setLoading(true);
try { try {
// Fetch pack info // Fetch pack info
const { data: packData } = await supabase const { data: packData } = await adminData
.from('dare_packs') .from('dare_packs')
.select('id, name, description, icon') .select('id, name, description, icon')
.eq('id', packId) .eq('id', packId)
@@ -122,7 +129,7 @@ export function DaresPage() {
const to = from + pageSize - 1; const to = from + pageSize - 1;
// Fetch dares // Fetch dares
const { data: dareData, error, count } = await supabase const { data: dareData, error, count } = await adminData
.from('dares') .from('dares')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -162,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);
}; };
@@ -170,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);
@@ -181,21 +189,24 @@ 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,
}; };
if (editingDare) { if (editingDare) {
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData); const { error } = await auditedAdminData.update('dares', editingDare.id, dareData);
if (error) throw error; if (error) throw error;
toast.success('Dare updated'); toast.success('Dare updated');
} else { } else {
const { error } = await auditedSupabase.insert('dares', { const { error } = await auditedAdminData.insert('dares', {
...dareData, ...dareData,
pack_id: packId, pack_id: packId,
}); });
@@ -219,7 +230,7 @@ export function DaresPage() {
} }
try { try {
const { error } = await auditedSupabase.delete('dares', dare.id); const { error } = await auditedAdminData.delete('dares', dare.id);
if (error) throw error; if (error) throw error;
toast.success('Dare deleted'); toast.success('Dare deleted');
fetchData(); fetchData();
@@ -259,7 +270,7 @@ export function DaresPage() {
setBulkDeleting(true); setBulkDeleting(true);
try { try {
await Promise.all( await Promise.all(
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id)) Array.from(selectedIds).map(id => auditedAdminData.delete('dares', id))
); );
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`); toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
setSelectedIds(new Set()); setSelectedIds(new Set());
@@ -334,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
@@ -439,6 +475,8 @@ export function DaresPage() {
</TableHead> </TableHead>
<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-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>
@@ -462,6 +500,22 @@ export function DaresPage() {
<TableCell> <TableCell>
<span className="line-clamp-2">{dare.text}</span> <span className="line-clamp-2">{dare.text}</span>
</TableCell> </TableCell>
<TableCell>
<ContentReviewControl
table="dares"
entityId={dare.id}
entityLabel={`dare ${((page - 1) * pageSize) + index + 1}`}
status={dare.content_status}
reason={dare.content_review_reason}
onChanged={fetchData}
compact
/>
</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">
+28 -15
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useEntityForm } from '@/hooks/useEntityForm'; import { useEntityForm } from '@/hooks/useEntityForm';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -14,6 +14,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog'; import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog'; import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
import { IconPreview } from '@/components/ui/icon-picker'; import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
// ============================================================================= // =============================================================================
// Types // Types
@@ -43,6 +45,8 @@ interface QuestionPack {
created_at: string | null; created_at: string | null;
scheduled_release_at?: string | null; // Optional for backwards compatibility scheduled_release_at?: string | null; // Optional for backwards compatibility
release_notified?: boolean; // True if notifications have been sent or should be skipped release_notified?: boolean; // True if notifications have been sent or should be skipped
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
question_count?: number; question_count?: number;
topics?: Topic[]; topics?: Topic[];
} }
@@ -103,7 +107,7 @@ export function PacksPage() {
try { try {
// Fetch category info // Fetch category info
if (categoryId) { if (categoryId) {
const { data: cat } = await supabase const { data: cat } = await adminData
.from('categories') .from('categories')
.select('id, name, icon') .select('id, name, icon')
.eq('id', categoryId) .eq('id', categoryId)
@@ -111,7 +115,7 @@ export function PacksPage() {
setCategory(cat); setCategory(cat);
} }
const { data: categoriesData } = await supabase const { data: categoriesData } = await adminData
.from('categories') .from('categories')
.select('id, name') .select('id, name')
.order('name'); .order('name');
@@ -121,7 +125,7 @@ export function PacksPage() {
const to = from + pageSize - 1; const to = from + pageSize - 1;
// Fetch packs // Fetch packs
let query = supabase let query = adminData
.from('question_packs') .from('question_packs')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }) .order('sort_order', { ascending: true })
@@ -141,7 +145,7 @@ export function PacksPage() {
const questionCounts: Record<string, number> = {}; const questionCounts: Record<string, number> = {};
if (packIds.length > 0) { if (packIds.length > 0) {
const { data: questions } = await supabase const { data: questions } = await adminData
.from('questions') .from('questions')
.select('pack_id') .select('pack_id')
.in('pack_id', packIds); .in('pack_id', packIds);
@@ -152,7 +156,7 @@ export function PacksPage() {
} }
// Fetch all topics // Fetch all topics
const { data: topicsData } = await supabase const { data: topicsData } = await adminData
.from('topics') .from('topics')
.select('id, name') .select('id, name')
.order('name'); .order('name');
@@ -161,7 +165,7 @@ export function PacksPage() {
// Fetch pack_topics relationships // Fetch pack_topics relationships
let packTopicsData: any[] = []; let packTopicsData: any[] = [];
if (packIds.length > 0) { if (packIds.length > 0) {
const { data } = await supabase const { data } = await adminData
.from('pack_topics') .from('pack_topics')
.select('pack_id, topic_id, topics(id, name)') .select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds); .in('pack_id', packIds);
@@ -236,7 +240,7 @@ export function PacksPage() {
? true ? true
: (form.formData.scheduled_release_at ? false : undefined); : (form.formData.scheduled_release_at ? false : undefined);
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, { const { error } = await auditedAdminData.update('question_packs', form.editingItem.id, {
name: form.formData.name, name: form.formData.name,
description: form.formData.description || null, description: form.formData.description || null,
icon: form.formData.icon || null, icon: form.formData.icon || null,
@@ -254,7 +258,7 @@ export function PacksPage() {
if (error) throw error; if (error) throw error;
packId = form.editingItem.id; packId = form.editingItem.id;
} else { } else {
const { data, error } = await supabase const { data, error } = await adminData
.from('question_packs') .from('question_packs')
.insert({ .insert({
name: form.formData.name, name: form.formData.name,
@@ -279,10 +283,10 @@ export function PacksPage() {
} }
// Update topics // Update topics
await supabase.from('pack_topics').delete().eq('pack_id', packId); await adminData.from('pack_topics').delete().eq('pack_id', packId);
if (selectedTopicIds.size > 0) { if (selectedTopicIds.size > 0) {
const { error: topicsError } = await supabase const { error: topicsError } = await adminData
.from('pack_topics') .from('pack_topics')
.insert( .insert(
Array.from(selectedTopicIds).map(topicId => ({ Array.from(selectedTopicIds).map(topicId => ({
@@ -310,7 +314,7 @@ export function PacksPage() {
} }
try { try {
const { error } = await auditedSupabase.delete('question_packs', pack.id); const { error } = await auditedAdminData.delete('question_packs', pack.id);
if (error) throw error; if (error) throw error;
toast.success('Pack deleted'); toast.success('Pack deleted');
fetchData(); fetchData();
@@ -351,8 +355,8 @@ export function PacksPage() {
const targetOrder = targetPack.sort_order ?? targetIndex; const targetOrder = targetPack.sort_order ?? targetIndex;
const [currentUpdate, targetUpdate] = await Promise.all([ const [currentUpdate, targetUpdate] = await Promise.all([
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }), auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }),
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }), auditedAdminData.update('question_packs', targetPack.id, { sort_order: currentOrder }),
]); ]);
if (currentUpdate.error || targetUpdate.error) { if (currentUpdate.error || targetUpdate.error) {
@@ -513,6 +517,15 @@ export function PacksPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ContentReviewControl
table="question_packs"
entityId={pack.id}
entityLabel={`pack “${pack.name}”`}
status={pack.content_status}
reason={pack.content_review_reason}
onChanged={fetchData}
compact
/>
{pack.is_explicit && ( {pack.is_explicit && (
<Badge variant="destructive"> <Badge variant="destructive">
NSFW NSFW
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -126,7 +126,7 @@ export function QuestionAnalyticsPage() {
while (true) { while (true) {
const from = page * pageSize; const from = page * pageSize;
const to = from + pageSize - 1; const to = from + pageSize - 1;
const { data, error } = await supabase const { data, error } = await adminData
.from('questions') .from('questions')
.select('id, pack_id, intensity, partner_text, inverse_of, question_type') .select('id, pack_id, intensity, partner_text, inverse_of, question_type')
.range(from, to); .range(from, to);
@@ -150,7 +150,7 @@ export function QuestionAnalyticsPage() {
setLoading(true); setLoading(true);
try { try {
// Build pack query with optional is_public filter // Build pack query with optional is_public filter
let packQuery = supabase let packQuery = adminData
.from('question_packs') .from('question_packs')
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)'); .select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
@@ -162,11 +162,11 @@ export function QuestionAnalyticsPage() {
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([ const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
packQuery, packQuery,
supabase adminData
.from('topics') .from('topics')
.select('id, name') .select('id, name')
.order('name'), .order('name'),
supabase adminData
.from('pack_topics') .from('pack_topics')
.select('pack_id, topic_id, topics(id, name)'), .select('pack_id, topic_id, topics(id, name)'),
fetchAllQuestions(), fetchAllQuestions(),
+32 -16
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton'; import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog'; import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
import { IconPreview } from '@/components/ui/icon-picker'; import { IconPreview } from '@/components/ui/icon-picker';
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
import type { ContentReviewStatus } from '@sauci/shared';
interface QuestionPack { interface QuestionPack {
id: string; id: string;
@@ -70,6 +72,8 @@ interface Question {
created_at: string | null; created_at: string | null;
question_type?: QuestionType | null; question_type?: QuestionType | null;
config?: QuestionConfig | null; config?: QuestionConfig | null;
content_status?: ContentReviewStatus | null;
content_review_reason?: string | null;
} }
const DEFAULT_AUDIO_DURATION = 60; const DEFAULT_AUDIO_DURATION = 60;
@@ -150,12 +154,12 @@ export function QuestionsPage() {
const from = (page - 1) * pageSize; const from = (page - 1) * pageSize;
const to = from + pageSize - 1; const to = from + pageSize - 1;
const packPromise = supabase const packPromise = adminData
.from('question_packs') .from('question_packs')
.select('id, name, description, icon') .select('id, name, description, icon')
.eq('id', packId) .eq('id', packId)
.single(); .single();
let questionsQuery = supabase let questionsQuery = adminData
.from('questions') .from('questions')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -167,7 +171,7 @@ export function QuestionsPage() {
} }
const questionsPromise = questionsQuery; const questionsPromise = questionsQuery;
const allPackPromise = supabase const allPackPromise = adminData
.from('questions') .from('questions')
.select('id, text, inverse_of') .select('id, text, inverse_of')
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -250,7 +254,7 @@ export function QuestionsPage() {
// Fetch all questions in pack for inverse_of dropdown // Fetch all questions in pack for inverse_of dropdown
const fetchAllPackQuestions = useCallback(async () => { const fetchAllPackQuestions = useCallback(async () => {
if (!packId) return; if (!packId) return;
const { data } = await supabase const { data } = await adminData
.from('questions') .from('questions')
.select('id, text, inverse_of') .select('id, text, inverse_of')
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -317,7 +321,7 @@ export function QuestionsPage() {
setSaving(true); setSaving(true);
try { try {
if (editingQuestion) { if (editingQuestion) {
const { error } = await auditedSupabase.update('questions', editingQuestion.id, { const { error } = await auditedAdminData.update('questions', editingQuestion.id, {
text: formData.text, text: formData.text,
partner_text: formData.partner_text || null, partner_text: formData.partner_text || null,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
@@ -331,7 +335,7 @@ export function QuestionsPage() {
if (error) throw error; if (error) throw error;
toast.success('Question updated'); toast.success('Question updated');
} else { } else {
const { error } = await auditedSupabase.insert('questions', { const { error } = await auditedAdminData.insert('questions', {
pack_id: packId, pack_id: packId,
text: formData.text, text: formData.text,
partner_text: formData.partner_text || null, partner_text: formData.partner_text || null,
@@ -363,7 +367,7 @@ export function QuestionsPage() {
} }
try { try {
const { error } = await auditedSupabase.delete('questions', question.id); const { error } = await auditedAdminData.delete('questions', question.id);
if (error) throw error; if (error) throw error;
toast.success('Question deleted'); toast.success('Question deleted');
@@ -396,7 +400,7 @@ export function QuestionsPage() {
// Insert standalone questions first (no inverse_of) // Insert standalone questions first (no inverse_of)
if (standaloneQuestions.length > 0) { if (standaloneQuestions.length > 0) {
const { error: standaloneError } = await auditedSupabase.insert( const { error: standaloneError } = await auditedAdminData.insert(
'questions', 'questions',
standaloneQuestions.map(q => ({ standaloneQuestions.map(q => ({
pack_id: packId, pack_id: packId,
@@ -415,7 +419,7 @@ export function QuestionsPage() {
if (pair.length >= 2) { if (pair.length >= 2) {
// First question is primary (inverse_of = NULL) // First question is primary (inverse_of = NULL)
const primary = pair[0]; const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert( const { data: primaryData, error: primaryError } = await auditedAdminData.insert(
'questions', 'questions',
[{ [{
pack_id: packId, pack_id: packId,
@@ -432,7 +436,7 @@ export function QuestionsPage() {
const primaryId = primaryData?.[0]?.id; const primaryId = primaryData?.[0]?.id;
if (primaryId) { if (primaryId) {
const inverse = pair[1]; const inverse = pair[1];
const { error: inverseError } = await auditedSupabase.insert( const { error: inverseError } = await auditedAdminData.insert(
'questions', 'questions',
[{ [{
pack_id: packId, pack_id: packId,
@@ -449,7 +453,7 @@ export function QuestionsPage() {
// Handle any additional questions in the pair (shouldn't happen, but just in case) // Handle any additional questions in the pair (shouldn't happen, but just in case)
for (let i = 2; i < pair.length; i++) { for (let i = 2; i < pair.length; i++) {
const extra = pair[i]; const extra = pair[i];
const { error: extraError } = await auditedSupabase.insert( const { error: extraError } = await auditedAdminData.insert(
'questions', 'questions',
[{ [{
pack_id: packId, pack_id: packId,
@@ -464,7 +468,7 @@ export function QuestionsPage() {
} }
} else if (pair.length === 1) { } else if (pair.length === 1) {
// Single question with pair_id but no pair - treat as standalone // Single question with pair_id but no pair - treat as standalone
const { error } = await auditedSupabase.insert( const { error } = await auditedAdminData.insert(
'questions', 'questions',
[{ [{
pack_id: packId, pack_id: packId,
@@ -497,7 +501,7 @@ export function QuestionsPage() {
setReviewLoading(true); setReviewLoading(true);
try { try {
const { data, error } = await supabase const { data, error } = await adminData
.from('questions') .from('questions')
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at') .select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -544,7 +548,7 @@ export function QuestionsPage() {
setBulkDeleting(true); setBulkDeleting(true);
try { try {
await Promise.all( await Promise.all(
Array.from(selectedIds).map(id => auditedSupabase.delete('questions', id)) Array.from(selectedIds).map(id => auditedAdminData.delete('questions', id))
); );
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`); toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
setSelectedIds(new Set()); setSelectedIds(new Set());
@@ -982,6 +986,7 @@ export function QuestionsPage() {
</TableHead> </TableHead>
<TableHead className="w-12">#</TableHead> <TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead> <TableHead>Question</TableHead>
<TableHead className="w-40">Catalogue</TableHead>
<TableHead className="w-28">Type</TableHead> <TableHead className="w-28">Type</TableHead>
<TableHead className="w-20">Inverse</TableHead> <TableHead className="w-20">Inverse</TableHead>
<TableHead className="w-32">Partner Text</TableHead> <TableHead className="w-32">Partner Text</TableHead>
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
<TableCell> <TableCell>
<span className="line-clamp-2">{question.text}</span> <span className="line-clamp-2">{question.text}</span>
</TableCell> </TableCell>
<TableCell>
<ContentReviewControl
table="questions"
entityId={question.id}
entityLabel={`question ${((page - 1) * pageSize) + index + 1}`}
status={question.content_status}
reason={question.content_review_reason}
onChanged={fetchData}
compact
/>
</TableCell>
<TableCell> <TableCell>
<div className="space-y-1"> <div className="space-y-1">
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide"> <Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
@@ -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>
);
}
+10 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -64,9 +64,9 @@ export function TagsPage() {
setLoading(true); setLoading(true);
try { try {
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([ const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
supabase.from('topics').select('id, name').order('name'), adminData.from('topics').select('id, name').order('name'),
supabase.from('pack_topics').select('pack_id, topic_id'), adminData.from('pack_topics').select('pack_id, topic_id'),
supabase adminData
.from('question_packs') .from('question_packs')
.select('id, name, description, is_public, category:categories(name)') .select('id, name, description, is_public, category:categories(name)')
.order('name'), .order('name'),
@@ -106,7 +106,7 @@ export function TagsPage() {
const questionCounts: Record<string, number> = {}; const questionCounts: Record<string, number> = {};
if (untaggedPackIds.length > 0) { if (untaggedPackIds.length > 0) {
const { data: questions, error: questionsError } = await supabase const { data: questions, error: questionsError } = await adminData
.from('questions') .from('questions')
.select('pack_id') .select('pack_id')
.in('pack_id', untaggedPackIds); .in('pack_id', untaggedPackIds);
@@ -167,7 +167,7 @@ export function TagsPage() {
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' }); setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
try { try {
const { data: topicsData, error: topicsError } = await supabase const { data: topicsData, error: topicsError } = await adminData
.from('topics') .from('topics')
.select('id, name') .select('id, name')
.order('name'); .order('name');
@@ -188,7 +188,7 @@ export function TagsPage() {
}); });
try { try {
const { data: questions, error: questionsError } = await supabase const { data: questions, error: questionsError } = await adminData
.from('questions') .from('questions')
.select('text') .select('text')
.eq('pack_id', pack.id); .eq('pack_id', pack.id);
@@ -237,7 +237,7 @@ export function TagsPage() {
}); });
if (newTopics.length > 0) { if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase const { data: createdTopics, error: createError } = await adminData
.from('topics') .from('topics')
.insert(newTopics) .insert(newTopics)
.select('id, name'); .select('id, name');
@@ -250,14 +250,14 @@ export function TagsPage() {
}); });
} }
await supabase.from('pack_topics').delete().eq('pack_id', pack.id); await adminData.from('pack_topics').delete().eq('pack_id', pack.id);
if (topicIds.length === 0) { if (topicIds.length === 0) {
skippedCount += 1; skippedCount += 1;
continue; continue;
} }
const { error: linkError } = await supabase const { error: linkError } = await adminData
.from('pack_topics') .from('pack_topics')
.insert(topicIds.map((topicId) => ({ .insert(topicIds.map((topicId) => ({
pack_id: pack.id, pack_id: pack.id,
+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>
);
}
+12 -57
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase, supabaseConfig } from '@/config'; import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -89,29 +89,9 @@ function ChatImage({ messageId }: { messageId: string }) {
setError(null); setError(null);
setUrl(null); setUrl(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, { const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', method: 'POST', body: { messageId },
headers: {
'Content-Type': 'application/json',
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ messageId }),
}); });
if (!res.ok) {
const raw = await res.text().catch(() => '');
let message = raw;
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed?.error === 'string') message = parsed.error;
} catch {
// ignore
}
throw new Error(message || `Failed to load media (${res.status})`);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
if (cancelled) return; if (cancelled) return;
@@ -180,29 +160,9 @@ function ChatVideo({ messageId }: { messageId: string }) {
setError(null); setError(null);
setUrl(null); setUrl(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, { const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', method: 'POST', body: { messageId },
headers: {
'Content-Type': 'application/json',
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({ messageId }),
}); });
if (!res.ok) {
const raw = await res.text().catch(() => '');
let message = raw;
try {
const parsed = JSON.parse(raw) as { error?: unknown };
if (typeof parsed?.error === 'string') message = parsed.error;
} catch {
// ignore
}
throw new Error(message || `Failed to load media (${res.status})`);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
if (cancelled) return; if (cancelled) return;
@@ -289,18 +249,13 @@ function DecryptedMessageText({ message }: { message: Message }) {
const decrypt = async () => { const decrypt = async () => {
setIsDecrypting(true); setIsDecrypting(true);
try { try {
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', { const data = await adminRequest<{ content?: string | null }>('/v1/admin/actions/decrypt-message', {
body: { messageId: message.id }, method: 'POST', body: { messageId: message.id },
}); });
if (cancelled) return; if (cancelled) return;
if (error) { setText(data.content ?? null);
setText('[Unable to decrypt]');
return;
}
setText((data as { content?: string | null } | null)?.content ?? null);
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
setText('[Unable to decrypt]'); setText('[Unable to decrypt]');
@@ -344,7 +299,7 @@ export function MatchChatPage() {
setLoading(true); setLoading(true);
try { try {
// Fetch user profile // Fetch user profile
const { data: userData } = await supabase const { data: userData } = await adminData
.from('profiles') .from('profiles')
.select('id, name, avatar_url, couple_id') .select('id, name, avatar_url, couple_id')
.eq('id', userId) .eq('id', userId)
@@ -354,7 +309,7 @@ export function MatchChatPage() {
// Fetch partner // Fetch partner
if (userData?.couple_id) { if (userData?.couple_id) {
const { data: partnerData } = await supabase const { data: partnerData } = await adminData
.from('profiles') .from('profiles')
.select('id, name, avatar_url') .select('id, name, avatar_url')
.eq('couple_id', userData.couple_id) .eq('couple_id', userData.couple_id)
@@ -367,7 +322,7 @@ export function MatchChatPage() {
} }
// Fetch match with question // Fetch match with question
const { data: matchData } = await supabase const { data: matchData } = await adminData
.from('matches') .from('matches')
.select(` .select(`
id, match_type, id, match_type,
@@ -382,7 +337,7 @@ export function MatchChatPage() {
const to = from + pageSize - 1; const to = from + pageSize - 1;
// Fetch messages // Fetch messages
const { data: messageData, error: messagesError, count } = await supabase const { data: messageData, error: messagesError, count } = await adminData
.from('messages') .from('messages')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.eq('match_id', matchId) .eq('match_id', matchId)
+305 -136
View File
@@ -1,6 +1,6 @@
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 { supabase, supabaseConfig } from '@/config'; import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
@@ -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',
@@ -193,16 +257,9 @@ function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: strin
const load = async () => { const load = async () => {
try { try {
setError(null); setError(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, { const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', method: 'POST', body: { messageId },
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ messageId }),
}); });
if (!res.ok) {
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
throw new Error(message);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
if (!cancelled) setUrl(objectUrl); if (!cancelled) setUrl(objectUrl);
} catch (err) { } catch (err) {
@@ -249,16 +306,9 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
const load = async () => { const load = async () => {
try { try {
setError(null); setError(null);
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, { const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
method: 'POST', method: 'POST', body: { messageId },
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
body: JSON.stringify({ messageId }),
}); });
if (!res.ok) {
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
throw new Error(message);
}
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
if (!cancelled) setUrl(objectUrl); if (!cancelled) setUrl(objectUrl);
} catch (err) { } catch (err) {
@@ -283,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);
@@ -311,7 +343,7 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
const fetchStrokes = async () => { const fetchStrokes = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
const { data, error: err } = await supabase const { data, error: err } = await adminData
.from('live_draw_sessions') .from('live_draw_sessions')
.select('strokes') .select('strokes')
.eq('couple_id', coupleId) .eq('couple_id', coupleId)
@@ -325,79 +357,17 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
setStrokes((data?.strokes as StrokeSegment[]) || []); setStrokes((data?.strokes as StrokeSegment[]) || []);
setLoading(false); setLoading(false);
}; };
fetchStrokes(); void fetchStrokes();
const interval = window.setInterval(() => {
// Subscribe to real-time updates if (document.visibilityState === 'visible') void fetchStrokes();
const channel = supabase }, 5_000);
.channel(`admin-livedraw-${coupleId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'live_draw_sessions',
filter: `couple_id=eq.${coupleId}`,
}, (payload: any) => {
if (payload.new?.strokes) {
setStrokes(payload.new.strokes as StrokeSegment[]);
}
})
.subscribe();
return () => { return () => {
cancelled = true; cancelled = true;
supabase.removeChannel(channel); window.clearInterval(interval);
}; };
}, [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>;
@@ -410,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>
@@ -450,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);
@@ -485,13 +454,12 @@ export function UserDetailPage() {
} }
expiresAt = date.toISOString(); expiresAt = date.toISOString();
} }
// Use RPC function which handles permissions internally const days = expiresAt === null ? null : Math.max(1, Math.ceil((new Date(expiresAt).getTime() - now.getTime()) / 86_400_000));
const { error } = await supabase.rpc('admin_gift_premium', { await adminRequest(`/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, {
target_user_id: userId, method: 'POST', body: { days, reason: 'Granted from admin user detail' },
expires_at_param: expiresAt,
}); });
if (error) throw error; const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>(); const updatedProfile = users[0] ?? null;
if (updatedProfile) setProfile(updatedProfile); if (updatedProfile) setProfile(updatedProfile);
setUpgradeOpen(false); setUpgradeOpen(false);
alert('User upgraded to premium successfully!'); alert('User upgraded to premium successfully!');
@@ -507,37 +475,82 @@ export function UserDetailPage() {
if (!userId) return; if (!userId) return;
setLoading(true); setLoading(true);
try { try {
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>(); const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
const profileData = users[0] ?? null;
setProfile(profileData); setProfile(profileData);
// Fetch feature interests for the user // Fetch feature interests for the user
const { data: featureInterestsData } = await supabase const { data: featureInterestsData } = await adminData
.from('feature_interests') .from('feature_interests')
.select('id, feature_name, created_at') .select('id, feature_name, created_at')
.eq('user_id', userId) .eq('user_id', userId)
.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 supabase.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);
// Fetch streak data // Fetch streak data
const { data: streakData } = await supabase const { data: streakData } = await adminData
.from('couple_streaks') .from('couple_streaks')
.select('*') .select('*')
.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;
const responsesTo = responsesFrom + responsesPageSize - 1; const responsesTo = responsesFrom + responsesPageSize - 1;
const { data: responseData, count: responsesCount, error: responsesError } = await supabase const { data: responseData, count: responsesCount, error: responsesError } = await adminData
.from('responses') .from('responses')
.select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' }) .select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' })
.eq('user_id', userId) .eq('user_id', userId)
@@ -553,7 +566,7 @@ export function UserDetailPage() {
const matchesFrom = (matchesPage - 1) * matchesPageSize; const matchesFrom = (matchesPage - 1) * matchesPageSize;
const matchesTo = matchesFrom + matchesPageSize - 1; const matchesTo = matchesFrom + matchesPageSize - 1;
const { data: matchData, count: matchesCount, error: matchesError } = await supabase const { data: matchData, count: matchesCount, error: matchesError } = await adminData
.from('matches') .from('matches')
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' }) .select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
.eq('couple_id', profileData.couple_id) .eq('couple_id', profileData.couple_id)
@@ -566,7 +579,7 @@ export function UserDetailPage() {
const matchIds = (matchData || []).map(m => m.id); const matchIds = (matchData || []).map(m => m.id);
if (matchIds.length > 0) { if (matchIds.length > 0) {
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds); const { data: messages } = await adminData.from('messages').select('match_id').in('match_id', matchIds);
const messageCounts: Record<string, number> = {}; const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; }); messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]); setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
@@ -574,7 +587,7 @@ export function UserDetailPage() {
setMatches([]); setMatches([]);
} }
const { data: allMatchIdsData } = await supabase const { data: allMatchIdsData } = await adminData
.from('matches') .from('matches')
.select('id') .select('id')
.eq('couple_id', profileData.couple_id); .eq('couple_id', profileData.couple_id);
@@ -584,7 +597,7 @@ export function UserDetailPage() {
const mediaFrom = (mediaPage - 1) * mediaPageSize; const mediaFrom = (mediaPage - 1) * mediaPageSize;
const mediaTo = mediaFrom + mediaPageSize - 1; const mediaTo = mediaFrom + mediaPageSize - 1;
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await adminData
.from('messages') .from('messages')
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' }) .select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
.in('match_id', allMatchIds) .in('match_id', allMatchIds)
@@ -605,7 +618,7 @@ export function UserDetailPage() {
setMediaTotal(0); setMediaTotal(0);
} }
// Fetch enabled packs for the couple // Fetch enabled packs for the couple
const { data: enabledPacksData, error: packsError } = await supabase const { data: enabledPacksData, error: packsError } = await adminData
.from('couple_packs') .from('couple_packs')
.select(` .select(`
pack_id, pack_id,
@@ -634,17 +647,17 @@ export function UserDetailPage() {
// Fetch outstanding questions (partner answered, user hasn't) // Fetch outstanding questions (partner answered, user hasn't)
// Use partner from state (set earlier in the first couple_id block) // Use partner from state (set earlier in the first couple_id block)
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data; const partnerProfile = partner || (await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
if (partnerProfile) { if (partnerProfile) {
// Get question IDs user has already answered // Get question IDs user has already answered
const { data: userResponseIds } = await supabase const { data: userResponseIds } = await adminData
.from('responses') .from('responses')
.select('question_id') .select('question_id')
.eq('user_id', userId); .eq('user_id', userId);
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id)); const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
// Get all partner responses in this couple // Get all partner responses in this couple
const { data: partnerResponses } = await supabase const { data: partnerResponses } = await adminData
.from('responses') .from('responses')
.select(` .select(`
id, id,
@@ -663,7 +676,7 @@ export function UserDetailPage() {
if (unansweredQuestionIds.length > 0) { if (unansweredQuestionIds.length > 0) {
// Fetch question details (no pack filter — admin sees all) // Fetch question details (no pack filter — admin sees all)
const { data: questionDetails } = await supabase const { data: questionDetails } = await adminData
.from('questions') .from('questions')
.select('id, text, intensity, question_type, pack:question_packs(id, name)') .select('id, text, intensity, question_type, pack:question_packs(id, name)')
.in('id', unansweredQuestionIds); .in('id', unansweredQuestionIds);
@@ -701,7 +714,7 @@ export function UserDetailPage() {
} }
// Calculate gap status // Calculate gap status
const { data: appConfig } = await supabase const { data: appConfig } = await adminData
.from('app_config') .from('app_config')
.select('answer_gap_threshold') .select('answer_gap_threshold')
.limit(1) .limit(1)
@@ -713,7 +726,7 @@ export function UserDetailPage() {
if (threshold > 0) { if (threshold > 0) {
// Count questions user answered that partner hasn't // Count questions user answered that partner hasn't
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id)); const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
const { data: userResponses } = await supabase const { data: userResponses } = await adminData
.from('responses') .from('responses')
.select('question_id') .select('question_id')
.eq('user_id', userId) .eq('user_id', userId)
@@ -815,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 && (
@@ -861,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" />
@@ -874,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>
@@ -898,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>
+3 -20
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { supabase } from '@/config'; import { adminRequest } from '@/lib/adminApi';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -55,24 +55,7 @@ export function UsersPage() {
setLoading(true); setLoading(true);
try { try {
// Fetch profiles with auth info (includes last_sign_in_at) // Fetch profiles with auth info (includes last_sign_in_at)
const { data: profilesDataRaw, error } = await supabase const { users: profilesData } = await adminRequest<{ users: Profile[] }>('/v1/admin/users');
.rpc('get_profiles_with_auth_info');
if (error) throw error;
const profilesData = (profilesDataRaw || []) as Profile[];
// Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase
.rpc('get_user_storage_usage');
// Create a map of user_id -> storage_bytes
const storageMap: Record<string, number> = {};
if (storageData) {
storageData.forEach((row: { owner: string; total_bytes: number }) => {
storageMap[row.owner] = row.total_bytes;
});
}
// Build a map of couple_id -> users with premium in that couple // Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {}; const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
@@ -100,7 +83,7 @@ export function UsersPage() {
} }
return { return {
...profile, ...profile,
storage_bytes: storageMap[profile.id] || 0, storage_bytes: Number(profile.storage_bytes || 0),
partner_is_premium: partnerIsPremium, partner_is_premium: partnerIsPremium,
partner_count: partnerCount, partner_count: partnerCount,
}; };
+10 -10
View File
@@ -1,5 +1,5 @@
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
// ============================================================================= // =============================================================================
// Types // Types
@@ -32,7 +32,7 @@ export interface CategoryFormData {
*/ */
export async function fetchCategories(): Promise<Category[]> { export async function fetchCategories(): Promise<Category[]> {
// Fetch categories // Fetch categories
const { data: categories, error: catError } = await supabase const { data: categories, error: catError } = await adminData
.from('categories') .from('categories')
.select('*') .select('*')
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true });
@@ -40,7 +40,7 @@ export async function fetchCategories(): Promise<Category[]> {
if (catError) throw catError; if (catError) throw catError;
// Fetch pack counts per category // Fetch pack counts per category
const { data: packs } = await supabase const { data: packs } = await adminData
.from('question_packs') .from('question_packs')
.select('category_id'); .select('category_id');
@@ -61,7 +61,7 @@ export async function fetchCategories(): Promise<Category[]> {
* Fetch a single category by ID * Fetch a single category by ID
*/ */
export async function fetchCategoryById(id: string): Promise<Category | null> { export async function fetchCategoryById(id: string): Promise<Category | null> {
const { data, error } = await supabase const { data, error } = await adminData
.from('categories') .from('categories')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
@@ -82,7 +82,7 @@ export async function createCategory(
data: CategoryFormData, data: CategoryFormData,
sortOrder: number sortOrder: number
): Promise<Category> { ): Promise<Category> {
const { data: created, error } = await auditedSupabase.insert('categories', { const { data: created, error } = await auditedAdminData.insert('categories', {
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
icon: data.icon || null, icon: data.icon || null,
@@ -102,7 +102,7 @@ export async function updateCategory(
id: string, id: string,
data: Partial<CategoryFormData> data: Partial<CategoryFormData>
): Promise<void> { ): Promise<void> {
const { error } = await auditedSupabase.update('categories', id, { const { error } = await auditedAdminData.update('categories', id, {
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
icon: data.icon || null, icon: data.icon || null,
@@ -116,7 +116,7 @@ export async function updateCategory(
* Delete a category * Delete a category
*/ */
export async function deleteCategory(id: string): Promise<void> { export async function deleteCategory(id: string): Promise<void> {
const { error } = await auditedSupabase.delete('categories', id); const { error } = await auditedAdminData.delete('categories', id);
if (error) throw error; if (error) throw error;
} }
@@ -130,7 +130,7 @@ export async function swapCategorySortOrder(
category2Order: number category2Order: number
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
auditedSupabase.update('categories', category1Id, { sort_order: category2Order }), auditedAdminData.update('categories', category1Id, { sort_order: category2Order }),
auditedSupabase.update('categories', category2Id, { sort_order: category1Order }), auditedAdminData.update('categories', category2Id, { sort_order: category1Order }),
]); ]);
} }
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
vi.mock('@/hooks/useAuditedAdminData', () => ({
auditedAdminData: { update },
}));
import { updateContentReviewStatus } from './contentReviews';
describe('catalogue review labels', () => {
it('uses archive language for reversible removals', () => {
expect(CONTENT_STATUS_LABELS).toEqual({
unreviewed: 'Needs review',
allowed: 'Allowed',
archived: 'Archived',
});
});
beforeEach(() => {
update.mockReset();
update.mockResolvedValue({ data: null, error: null });
});
it('writes a trimmed, audited review decision', async () => {
await updateContentReviewStatus(
'question_packs',
'pack-1',
'archived',
' Store compliance review ',
);
expect(update).toHaveBeenCalledWith('question_packs', 'pack-1', {
content_status: 'archived',
content_review_reason: 'Store compliance review',
});
});
it('rejects an empty reason before writing', async () => {
await expect(
updateContentReviewStatus('questions', 'question-1', 'allowed', ' '),
).rejects.toThrow('A review reason is required');
expect(update).not.toHaveBeenCalled();
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { ContentReviewStatus } from '@sauci/shared';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
export type ReviewableContentTable =
| 'categories'
| 'question_packs'
| 'questions'
| 'dare_packs'
| 'dares';
export async function updateContentReviewStatus(
table: ReviewableContentTable,
id: string,
status: ContentReviewStatus,
reason: string,
): Promise<void> {
const normalizedReason = reason.trim();
if (!normalizedReason) {
throw new Error('A review reason is required');
}
const { error } = await auditedAdminData.update(table, id, {
content_status: status,
content_review_reason: normalizedReason,
});
if (error) throw error;
}
+15 -15
View File
@@ -1,5 +1,5 @@
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
// ============================================================================= // =============================================================================
// Types // Types
@@ -42,7 +42,7 @@ export interface Topic {
* Fetch all packs for a category with question counts * Fetch all packs for a category with question counts
*/ */
export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> { export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> {
const { data: packs, error: packError } = await supabase const { data: packs, error: packError } = await adminData
.from('question_packs') .from('question_packs')
.select('*, category:categories(name)') .select('*, category:categories(name)')
.eq('category_id', categoryId) .eq('category_id', categoryId)
@@ -51,7 +51,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
if (packError) throw packError; if (packError) throw packError;
// Get question counts per pack // Get question counts per pack
const { data: questions } = await supabase const { data: questions } = await adminData
.from('questions') .from('questions')
.select('pack_id'); .select('pack_id');
@@ -72,7 +72,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
* Fetch a single pack by ID * Fetch a single pack by ID
*/ */
export async function fetchPackById(id: string): Promise<Pack | null> { export async function fetchPackById(id: string): Promise<Pack | null> {
const { data, error } = await supabase const { data, error } = await adminData
.from('question_packs') .from('question_packs')
.select('*, category:categories(name)') .select('*, category:categories(name)')
.eq('id', id) .eq('id', id)
@@ -86,7 +86,7 @@ export async function fetchPackById(id: string): Promise<Pack | null> {
* Fetch all topics * Fetch all topics
*/ */
export async function fetchAllTopics(): Promise<Topic[]> { export async function fetchAllTopics(): Promise<Topic[]> {
const { data, error } = await supabase const { data, error } = await adminData
.from('topics') .from('topics')
.select('*') .select('*')
.order('name'); .order('name');
@@ -99,7 +99,7 @@ export async function fetchAllTopics(): Promise<Topic[]> {
* Fetch topic IDs for a specific pack * Fetch topic IDs for a specific pack
*/ */
export async function fetchPackTopicIds(packId: string): Promise<Set<string>> { export async function fetchPackTopicIds(packId: string): Promise<Set<string>> {
const { data, error } = await supabase const { data, error } = await adminData
.from('pack_topics') .from('pack_topics')
.select('topic_id') .select('topic_id')
.eq('pack_id', packId); .eq('pack_id', packId);
@@ -120,7 +120,7 @@ export async function createPack(
data: PackFormData, data: PackFormData,
sortOrder: number sortOrder: number
): Promise<Pack> { ): Promise<Pack> {
const { data: created, error } = await auditedSupabase.insert('question_packs', { const { data: created, error } = await auditedAdminData.insert('question_packs', {
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
icon: data.icon || null, icon: data.icon || null,
@@ -143,7 +143,7 @@ export async function updatePack(
id: string, id: string,
data: Partial<PackFormData> data: Partial<PackFormData>
): Promise<void> { ): Promise<void> {
const { error } = await auditedSupabase.update('question_packs', id, { const { error } = await auditedAdminData.update('question_packs', id, {
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
icon: data.icon || null, icon: data.icon || null,
@@ -159,7 +159,7 @@ export async function updatePack(
* Delete a pack * Delete a pack
*/ */
export async function deletePack(id: string): Promise<void> { export async function deletePack(id: string): Promise<void> {
const { error } = await auditedSupabase.delete('question_packs', id); const { error } = await auditedAdminData.delete('question_packs', id);
if (error) throw error; if (error) throw error;
} }
@@ -173,8 +173,8 @@ export async function swapPackSortOrder(
pack2Order: number pack2Order: number
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
auditedSupabase.update('question_packs', pack1Id, { sort_order: pack2Order }), auditedAdminData.update('question_packs', pack1Id, { sort_order: pack2Order }),
auditedSupabase.update('question_packs', pack2Id, { sort_order: pack1Order }), auditedAdminData.update('question_packs', pack2Id, { sort_order: pack1Order }),
]); ]);
} }
@@ -190,11 +190,11 @@ export async function updatePackTopics(
topicIds: string[] topicIds: string[]
): Promise<void> { ): Promise<void> {
// Delete existing topics // Delete existing topics
await supabase.from('pack_topics').delete().eq('pack_id', packId); await adminData.from('pack_topics').delete().eq('pack_id', packId);
// Insert new topics // Insert new topics
if (topicIds.length > 0) { if (topicIds.length > 0) {
const { error } = await supabase.from('pack_topics').insert( const { error } = await adminData.from('pack_topics').insert(
topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId })) topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId }))
); );
if (error) throw error; if (error) throw error;
@@ -205,7 +205,7 @@ export async function updatePackTopics(
* Create a new topic * Create a new topic
*/ */
export async function createTopic(name: string): Promise<Topic> { export async function createTopic(name: string): Promise<Topic> {
const { data, error } = await supabase const { data, error } = await adminData
.from('topics') .from('topics')
.insert({ name }) .insert({ name })
.select() .select()
+15 -15
View File
@@ -1,5 +1,5 @@
import { supabase } from '@/config'; import { adminData } from '@/lib/adminApi';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedAdminData } from '@/hooks/useAuditedAdminData';
// ============================================================================= // =============================================================================
// Types // Types
@@ -29,7 +29,7 @@ export interface QuestionFormData {
* Fetch all questions for a pack (excludes soft-deleted by default) * Fetch all questions for a pack (excludes soft-deleted by default)
*/ */
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> { export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
let query = supabase let query = adminData
.from('questions') .from('questions')
.select('*') .select('*')
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -49,7 +49,7 @@ export async function fetchQuestionsForPack(packId: string, includeDeleted = fal
* Fetch a single question by ID * Fetch a single question by ID
*/ */
export async function fetchQuestionById(id: string): Promise<Question | null> { export async function fetchQuestionById(id: string): Promise<Question | null> {
const { data, error } = await supabase const { data, error } = await adminData
.from('questions') .from('questions')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
@@ -63,7 +63,7 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
* Fetch question count for a pack (excludes soft-deleted) * Fetch question count for a pack (excludes soft-deleted)
*/ */
export async function fetchQuestionCount(packId: string): Promise<number> { export async function fetchQuestionCount(packId: string): Promise<number> {
const { count, error } = await supabase const { count, error } = await adminData
.from('questions') .from('questions')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.eq('pack_id', packId) .eq('pack_id', packId)
@@ -84,7 +84,7 @@ export async function createQuestion(
packId: string, packId: string,
data: QuestionFormData data: QuestionFormData
): Promise<Question> { ): Promise<Question> {
const { data: created, error } = await auditedSupabase.insert('questions', { const { data: created, error } = await auditedAdminData.insert('questions', {
text: data.text, text: data.text,
partner_text: data.partner_text || null, partner_text: data.partner_text || null,
intensity: data.intensity, intensity: data.intensity,
@@ -103,7 +103,7 @@ export async function updateQuestion(
id: string, id: string,
data: Partial<QuestionFormData> data: Partial<QuestionFormData>
): Promise<void> { ): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, { const { error } = await auditedAdminData.update('questions', id, {
text: data.text, text: data.text,
partner_text: data.partner_text || null, partner_text: data.partner_text || null,
intensity: data.intensity, intensity: data.intensity,
@@ -116,7 +116,7 @@ export async function updateQuestion(
* Soft delete a question (sets deleted_at timestamp) * Soft delete a question (sets deleted_at timestamp)
*/ */
export async function deleteQuestion(id: string): Promise<void> { export async function deleteQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, { const { error } = await auditedAdminData.update('questions', id, {
deleted_at: new Date().toISOString(), deleted_at: new Date().toISOString(),
}); });
if (error) throw error; if (error) throw error;
@@ -127,7 +127,7 @@ export async function deleteQuestion(id: string): Promise<void> {
*/ */
export async function deleteQuestions(ids: string[]): Promise<void> { export async function deleteQuestions(ids: string[]): Promise<void> {
await Promise.all( await Promise.all(
ids.map(id => auditedSupabase.update('questions', id, { ids.map(id => auditedAdminData.update('questions', id, {
deleted_at: new Date().toISOString(), deleted_at: new Date().toISOString(),
})) }))
); );
@@ -137,7 +137,7 @@ export async function deleteQuestions(ids: string[]): Promise<void> {
* Restore a soft-deleted question * Restore a soft-deleted question
*/ */
export async function restoreQuestion(id: string): Promise<void> { export async function restoreQuestion(id: string): Promise<void> {
const { error } = await auditedSupabase.update('questions', id, { const { error } = await auditedAdminData.update('questions', id, {
deleted_at: null, deleted_at: null,
}); });
if (error) throw error; if (error) throw error;
@@ -169,7 +169,7 @@ export async function bulkCreateQuestions(
// Insert standalone questions first // Insert standalone questions first
for (const q of standaloneQuestions) { for (const q of standaloneQuestions) {
const { data, error } = await auditedSupabase.insert('questions', { const { data, error } = await auditedAdminData.insert('questions', {
pack_id: packId, pack_id: packId,
text: q.text, text: q.text,
partner_text: q.partner_text || null, partner_text: q.partner_text || null,
@@ -186,7 +186,7 @@ export async function bulkCreateQuestions(
if (pair.length >= 2) { if (pair.length >= 2) {
// First question is primary // First question is primary
const primary = pair[0]; const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', { const { data: primaryData, error: primaryError } = await auditedAdminData.insert('questions', {
pack_id: packId, pack_id: packId,
text: primary.text, text: primary.text,
partner_text: primary.partner_text || null, partner_text: primary.partner_text || null,
@@ -201,7 +201,7 @@ export async function bulkCreateQuestions(
// Second question is inverse // Second question is inverse
const inverse = pair[1]; const inverse = pair[1];
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', { const { data: inverseData, error: inverseError } = await auditedAdminData.insert('questions', {
pack_id: packId, pack_id: packId,
text: inverse.text, text: inverse.text,
partner_text: inverse.partner_text || null, partner_text: inverse.partner_text || null,
@@ -216,7 +216,7 @@ export async function bulkCreateQuestions(
// Handle any additional questions in the pair // Handle any additional questions in the pair
for (let i = 2; i < pair.length; i++) { for (let i = 2; i < pair.length; i++) {
const extra = pair[i]; const extra = pair[i];
const { data, error } = await auditedSupabase.insert('questions', { const { data, error } = await auditedAdminData.insert('questions', {
pack_id: packId, pack_id: packId,
text: extra.text, text: extra.text,
partner_text: extra.partner_text || null, partner_text: extra.partner_text || null,
@@ -228,7 +228,7 @@ export async function bulkCreateQuestions(
} }
} else if (pair.length === 1) { } else if (pair.length === 1) {
// Single question with pair_id but no pair // Single question with pair_id but no pair
const { data, error } = await auditedSupabase.insert('questions', { const { data, error } = await auditedAdminData.insert('questions', {
pack_id: packId, pack_id: packId,
text: pair[0].text, text: pair[0].text,
partner_text: pair[0].partner_text || null, partner_text: pair[0].partner_text || null,
Loaded 100 of 584 files, more files were not shown because too many files have changed in this diff. Show more