mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Compare commits
158
Commits
b43804d8fd
..
v1.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3659cc8c2f | ||
|
|
4b227ad62c | ||
|
|
7c8d91c89c | ||
|
|
6883befff3 | ||
|
|
636811e66a | ||
|
|
d825e3d80c | ||
|
|
13094267a6 | ||
|
|
ecc31a13a2 | ||
|
|
7a76936829 | ||
|
|
9609a9084b | ||
|
|
90cde4aca4 | ||
|
|
ecff9e2ec8 | ||
|
|
ffc655f576 | ||
|
|
d8bedead0c | ||
|
|
6c3a181613 | ||
|
|
d8ff39a2f3 | ||
|
|
b8cb2addff | ||
|
|
5e28117662 | ||
|
|
196428f796 | ||
|
|
6bd04a6c06 | ||
|
|
7aca39aa8f | ||
|
|
79233f58aa | ||
|
|
c9e8835810 | ||
|
|
ff8f99f418 | ||
|
|
604eec3322 | ||
|
|
dbf548c7bd | ||
|
|
4973f2c631 | ||
|
|
2b05e50312 | ||
|
|
26312e5a58 | ||
|
|
66c25849e9 | ||
|
|
5c4a4608b1 | ||
|
|
659cee76b6 | ||
|
|
6bc4e31719 | ||
|
|
aa062c9fd0 | ||
|
|
5947220fc6 | ||
|
|
8bf93f372b | ||
|
|
2f05e9ae87 | ||
|
|
3fc6f8aa1a | ||
|
|
02a7349899 | ||
|
|
8f70dbcc00 | ||
|
|
ac80d6b614 | ||
|
|
056bb62d04 | ||
|
|
80ec1663f9 | ||
|
|
8ff05c050b | ||
|
|
549419892d | ||
|
|
a46b8b95e6 | ||
|
|
e54f6d31ed | ||
|
|
267f3c3cf9 | ||
|
|
02aa6e4a75 | ||
|
|
cf19d23d8a | ||
|
|
db5980c9bd | ||
|
|
2664791233 | ||
|
|
f37673cad7 | ||
|
|
0ada812294 | ||
|
|
f4db567267 | ||
|
|
d298a33656 | ||
|
|
af4e1d0cf6 | ||
|
|
9e6e4612f5 | ||
|
|
7d392e756d | ||
|
|
3c08963a70 | ||
|
|
d86d57d5c2 | ||
|
|
57501230cd | ||
|
|
986d0b21cb | ||
|
|
504eddaf68 | ||
|
|
f52b8536be | ||
|
|
09255da7db | ||
|
|
8e83cc0d85 | ||
|
|
8955e49aae | ||
|
|
04822043ae | ||
|
|
eab73e6314 | ||
|
|
fd8981fa28 | ||
|
|
25b7c6238e | ||
|
|
77f2718fdd | ||
|
|
f22d4a3649 | ||
|
|
45d24911fb | ||
|
|
f38b6f14fa | ||
|
|
74c1acfe2d | ||
|
|
23db4ed951 | ||
|
|
ccd3f03437 | ||
|
|
6523ddeb1c | ||
|
|
4f9950f21b | ||
|
|
d48b44baa8 | ||
|
|
5564f9bbab | ||
|
|
c972b1c236 | ||
|
|
30aa7bc386 | ||
|
|
8bbb0e8cea | ||
|
|
fbdb2a8fff | ||
|
|
bdfe8b7171 | ||
|
|
2cbe9e9c55 | ||
|
|
8d9a3f4f1c | ||
|
|
476e4c845f | ||
|
|
3d53d001b7 | ||
|
|
8658fc38fe | ||
|
|
24de9af4cd | ||
|
|
5e581efe51 | ||
|
|
12c6b8bcc9 | ||
|
|
599be22eb1 | ||
|
|
9514835bf2 | ||
|
|
65bd1bbd6b | ||
|
|
e0813ba5a3 | ||
|
|
f7c686d5cf | ||
|
|
314664ecd6 | ||
|
|
11f89b1345 | ||
|
|
2481956c11 | ||
|
|
8804e121b6 | ||
|
|
7b3de48dbd | ||
|
|
5ebf57bdbf | ||
|
|
089e629021 | ||
|
|
58b70710db | ||
|
|
d645d83c47 | ||
|
|
179f912170 | ||
|
|
a604f82110 | ||
|
|
ae8e72ddb5 | ||
|
|
2e4cddd1c8 | ||
|
|
440f224095 | ||
|
|
22871d037c | ||
|
|
fd38b303f0 | ||
|
|
266233a473 | ||
|
|
387b242aea | ||
|
|
2bc69c2790 | ||
|
|
9da44217f7 | ||
|
|
5f77b06044 | ||
|
|
5c973c4ac5 | ||
|
|
a13bbc3f65 | ||
|
|
10c4ae8d84 | ||
|
|
ac4a05d5ed | ||
|
|
c7ee8a4851 | ||
|
|
9957fa43f2 | ||
|
|
502452c9e4 | ||
|
|
e6bb368bc8 | ||
|
|
aedba2a480 | ||
|
|
78900a24fb | ||
|
|
03443fe898 | ||
|
|
b60c788362 | ||
|
|
61ec3692b7 | ||
|
|
1482305810 | ||
|
|
8b37569818 | ||
|
|
2178750d07 | ||
|
|
028e21e54c | ||
|
|
b24a402b4c | ||
|
|
ce0b188776 | ||
|
|
f35114dc77 | ||
|
|
aacb4f8731 | ||
|
|
1be54175e4 | ||
|
|
a7470310ae | ||
|
|
3acadeff7c | ||
|
|
91544629fa | ||
|
|
32f5214751 | ||
|
|
96882642c7 | ||
|
|
a455325147 | ||
|
|
2dad7aa352 | ||
|
|
be20391788 | ||
|
|
006761d3b0 | ||
|
|
7a0de45482 | ||
|
|
72e031eb84 | ||
|
|
2ab976ec42 | ||
|
|
c0650ed5b7 | ||
|
|
94c76535a4 |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: 2026-08-28-maestro-stable-login-readiness
|
||||
date: 2026-08-28
|
||||
status: promoted
|
||||
scope: native Auth-shell acceptance flows
|
||||
evidence: iOS simulator login-shell acceptance on current main
|
||||
destination: e2e/maestro
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
The credential-free native smoke failed even though the current app reached the
|
||||
login screen and displayed its required controls.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run `e2e/maestro/login-screen-smoke.yaml` against current main. The flow waited
|
||||
for the retired tagline `Explore intimacy together`; the app displayed updated
|
||||
marketing copy while `Sauci` and `Password` were present.
|
||||
|
||||
## Cause
|
||||
|
||||
Login readiness was coupled to mutable marketing copy rather than a stable
|
||||
required control.
|
||||
|
||||
## Remediation
|
||||
|
||||
Wait for the password control that defines login readiness, while retaining the
|
||||
brand and password assertions. Apply the same readiness condition to the
|
||||
authenticated and credential-free flows.
|
||||
|
||||
## Evidence
|
||||
|
||||
Simulator read-back confirmed the current login shell exposes `Sauci` and
|
||||
`Password`. The maintained Maestro flows now wait for that stable control rather
|
||||
than the tagline.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: 2026-08-28-mobile-release-source-integrity
|
||||
date: 2026-08-28
|
||||
status: promoted
|
||||
scope: mobile release build and upload workflow
|
||||
evidence: clean-source local EAS builds for iOS 43 and Android 46
|
||||
destination: scripts/lint-harness.mjs
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
A build-only request initially regenerated tracked native projects. The generated
|
||||
Android widget provider replaced the checked-in implementation, and the Android
|
||||
release script would also have uploaded after building.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run the former mobile release scripts or run `expo prebuild` before a local EAS
|
||||
build, then inspect `git diff`. The reproduced diff included the Android widget
|
||||
provider, widget XML, and Xcode project rather than version metadata alone.
|
||||
|
||||
## Cause
|
||||
|
||||
The release scripts combined three independently reviewed operations: native
|
||||
regeneration, artifact creation, and store upload. Sauci already tracks native
|
||||
projects, and EAS skips prebuild when those directories exist.
|
||||
|
||||
## Remediation
|
||||
|
||||
Keep native regeneration explicit, make release scripts artifact-only, expose
|
||||
upload separately, and lint the scripts so those responsibilities cannot be
|
||||
silently recombined.
|
||||
|
||||
## Evidence
|
||||
|
||||
Corrected local EAS logs reported `PREBUILD` skipped. Both production artifacts
|
||||
built successfully from the clean tracked sources; the post-build worktree
|
||||
contained only the four expected build-number files. `npm run lint:harness`
|
||||
enforces the release-script boundary.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 2026-08-30-ios-extension-version-verification
|
||||
date: 2026-08-30
|
||||
status: promoted
|
||||
scope: iOS release artifact verification
|
||||
evidence: v1.0.6 exported IPA inspection and reproduced v1.0.7 Xcode build-setting mismatch
|
||||
destination: apps/mobile/scripts/release-preflight.mjs and .codex/skills/release-mobile/SKILL.md
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
The signed iOS build logged that the widget extension build number 44 did not
|
||||
match the containing app build number 45, even though export completed.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Build iOS version 1.0.6 with EAS auto-increment from build 44 to 45 and retain the
|
||||
archive log. Extract the resulting IPA with `ditto`, then read the containing app
|
||||
and every packaged extension `Info.plist` with `PlistBuddy`.
|
||||
|
||||
## Cause
|
||||
|
||||
The v1.0.6 archive warning described an intermediate state and was not
|
||||
authoritative for that exported package. The later v1.0.7 reproduction found a
|
||||
separate real hazard: `CURRENT_PROJECT_VERSION` in both LiveDrawWidget
|
||||
configurations can override the widget plist and package an older build number.
|
||||
|
||||
## Remediation
|
||||
|
||||
Before every iOS release, require public-version parity across app config, both
|
||||
plists, and Xcode marketing settings. Require the widget Debug and Release
|
||||
`CURRENT_PROJECT_VERSION` values to equal both plists. Then inspect the exported
|
||||
app and all packaged extensions. Reject or rebuild only when the final IPA values
|
||||
differ; do not accept or reject a release from archive warnings alone.
|
||||
|
||||
## Evidence
|
||||
|
||||
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
|
||||
both v1.0.6 packaged plists. The v1.0.7 build later proved a stale widget Xcode
|
||||
setting can survive plist-only review. `release-preflight.mjs` and its node test
|
||||
now reject that mismatch before EAS allocation; exported IPA inspection remains
|
||||
the final authority.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: 2026-08-31-expo-prebuild-native-regression
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: apps/mobile native projects (ios/, android/) and expo prebuild
|
||||
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
|
||||
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
|
||||
review_after: 2026-11-30
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
|
||||
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
|
||||
native invariants. Each regression was caught only by a release gate or by
|
||||
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
|
||||
prebuild itself.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
|
||||
the regenerated ios/ and android/ projects against the previously committed
|
||||
projects.
|
||||
|
||||
## Cause
|
||||
|
||||
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
|
||||
the target folder name, changing com.sauci.app.LiveDrawWidget to
|
||||
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
|
||||
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
|
||||
2. Android manifest lost the tools:node="remove" attributes on
|
||||
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
|
||||
Play-forbidden permissions (verified via bundletool dump on the rejected
|
||||
build 50 AAB).
|
||||
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
|
||||
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
|
||||
4. android/gradle.properties reset to stock -Xmx2048m
|
||||
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
|
||||
under RN 0.85.
|
||||
|
||||
Separately, a package absent from the new SDK's bundledNativeModules
|
||||
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
|
||||
imports removed ExpoModulesCore headers).
|
||||
|
||||
## Remediation
|
||||
|
||||
Fixed by restoring widget identity and tracking
|
||||
targets/widget/generated.entitlements with a pinned bundleIdentifier
|
||||
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
|
||||
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
|
||||
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
|
||||
expo-video/expo-audio (b8cb2add).
|
||||
|
||||
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
|
||||
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
|
||||
preflight check; item 4 hard-fails the build by itself). Add an "After
|
||||
native regeneration" checklist to docs/releasing.md.
|
||||
|
||||
## Evidence
|
||||
|
||||
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
|
||||
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
|
||||
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
|
||||
NSLocalNetworkUsageDescription, or RCTMetroPort.
|
||||
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
|
||||
checks.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 2026-08-31-local-eas-build-preconditions
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: apps/mobile local EAS release builds
|
||||
evidence: v1.0.8 local EAS build session, commit d8bedea
|
||||
destination: docs/releasing.md
|
||||
review_after: 2026-11-30
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
Local EAS release builds failed before native compilation for environment
|
||||
reasons unrelated to the app source, and a successful iOS build left the
|
||||
tracked source in a state that fails release-preflight afterward.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run `eas build --local` from a non-interactive shell without first exporting
|
||||
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
|
||||
found" because non-interactive shells do not load the user profile. Run it
|
||||
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
|
||||
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
|
||||
build log through `tail`: the full log needed for diagnosis is lost. After a
|
||||
successful iOS build, EAS auto-increment updates both Info.plists but not
|
||||
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
|
||||
release-preflight fails until it is synced.
|
||||
|
||||
## Cause
|
||||
|
||||
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
|
||||
23.0.0 verified). Non-interactive shells used for automation do not source
|
||||
the profile that normally exports the Android/Java environment variables.
|
||||
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
|
||||
auto-increment writes plist versions but does not touch the widget target's
|
||||
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
|
||||
one increment after every successful build.
|
||||
|
||||
## Remediation
|
||||
|
||||
Before a local EAS build: verify `eas` is on PATH, explicitly export
|
||||
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
|
||||
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
|
||||
apps/mobile. Capture the full build log to a file rather than piping through
|
||||
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
|
||||
to the new build number (commit d8bedea, sed 46 to 47) and sync
|
||||
ios/Podfile.lock from the successful build's working directory when
|
||||
dependencies changed, since the archive's pod install resolution is
|
||||
authoritative and local CocoaPods here is broken.
|
||||
|
||||
## Evidence
|
||||
|
||||
docs/releasing.md's local-build section documents the environment
|
||||
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
|
||||
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: 2026-08-31-mobile-release-preflight-and-attempts
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: mobile production release preparation and local EAS builds
|
||||
evidence: v1.0.7 Android and iOS local EAS release investigation and verified artifacts
|
||||
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, docs/releasing.md, and release skills
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
The v1.0.7 release spent repeated attempts on archive, plugin startup, package
|
||||
installation, disk-space, signing, and iOS extension-version failures before
|
||||
valid Android and iOS artifacts were accepted.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run a local production EAS build with an ignored public OTA certificate, a
|
||||
package-directory plugin path, insufficient disk space, or divergent widget
|
||||
`CURRENT_PROJECT_VERSION`. Each condition fails or produces an invalid input
|
||||
before a valid final artifact can be accepted.
|
||||
|
||||
## Cause
|
||||
|
||||
Pre-compilation setup failures were treated as rebuild failures. Source parity,
|
||||
archive inclusion, local EAS plugin shape, disk capacity, and effective iOS widget
|
||||
build settings were not checked together before EAS allocation.
|
||||
|
||||
## Remediation
|
||||
|
||||
Run the deterministic mobile release preflight before every production build.
|
||||
Classify attempts before retrying: archive, plugin, npm, and ENOSPC failures do
|
||||
not consume a native build; signing, profile, and capability failures require
|
||||
provider read-back before one replacement build. Retain work directories and logs
|
||||
at stable ignored paths. Accept only independently inspected artifacts and keep
|
||||
rejected artifacts out of the upload path.
|
||||
|
||||
The Android native input failure was a Gradle task ordering issue: Prefab inputs
|
||||
must be available before dependent native tasks. Preserve the explicit ordering
|
||||
fix rather than removing Firebase or changing dependency versions without proof.
|
||||
|
||||
## Evidence
|
||||
|
||||
The preflight node tests reject an ignored OTA certificate and an effective widget
|
||||
build-setting mismatch. The checked-in source invariants pass the preflight. The
|
||||
maintained release guide and skills record local EAS path requirements, provider
|
||||
read-back, phase-specific status language, artifact inspection, store read-back,
|
||||
and scoped upload authorization boundaries.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 2026-08-31-npm-lockfile-divergence
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: root package.json, package-lock.json, apps/web dependencies, CI
|
||||
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
|
||||
destination: docs/development/dependencies.md
|
||||
review_after: 2026-11-30
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
|
||||
invisibly while a lockfile regenerated on this dev machine (node 26/npm
|
||||
11.19) passed local `npm ci`. Pushes to staging bypass required status
|
||||
checks, so the red CI ran unnoticed for hours.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
|
||||
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
|
||||
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
|
||||
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
|
||||
"Invalid: lock file's ... does not satisfy". The reverse direction also
|
||||
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
|
||||
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
|
||||
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
|
||||
variants breaking local npm ci), matching the npm/cli#4828-style
|
||||
optional-dependency resolution gap.
|
||||
|
||||
## Cause
|
||||
|
||||
Two different npm major versions in active use (CI/Docker on npm 10, this
|
||||
dev machine on npm 11) resolve wildcard/range dependencies to different
|
||||
versions at the same tree position, and each npm's own platform-optional
|
||||
binary selection is not portable to the other npm's lockfile.
|
||||
|
||||
## Remediation
|
||||
|
||||
Pin the wildcard-range dependency via a root package.json override
|
||||
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
|
||||
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
|
||||
binaries as direct optionalDependencies (must be bumped together with any
|
||||
`next` version bump). Regenerate the lock and validate it both ways: local
|
||||
`npm ci` plus `npm run verify:fast`, and
|
||||
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
|
||||
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
|
||||
and the platform-binary check is meaningless). After any push that bypassed
|
||||
required status checks, check `gh run list --branch staging`.
|
||||
|
||||
## Evidence
|
||||
|
||||
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
|
||||
and record a lockfile that passes both the amd64-emulated `npm ci` under
|
||||
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-mobile
|
||||
description: Prepare and verify Sauci iOS or Android releases and store uploads.
|
||||
description: Prepare, verify, and ship Sauci iOS or Android releases end to end, including store uploads, review submission, and rollout.
|
||||
---
|
||||
|
||||
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "architecture_reviewer"
|
||||
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Evaluate the delegated decision against current system boundaries, data flow,
|
||||
migration path, operability, reversibility, and verification. Prefer the smallest
|
||||
coherent change. Return explicit trade-offs, risks, and a recommendation with
|
||||
file-backed evidence. Do not edit files or review unrelated local refactors.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "database_reviewer"
|
||||
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md, database migration skill, and migration safety
|
||||
documentation. Review only the delegated diff for migration tracking,
|
||||
idempotency, authorization and tenant isolation, portability, rollback impact,
|
||||
types, and integration coverage. Never mutate a database or edit files. Return
|
||||
prioritized line-specific findings with concrete evidence and counterevidence.
|
||||
"""
|
||||
@@ -0,0 +1,14 @@
|
||||
name = "implementation"
|
||||
description = "Terra executor for one continuous bounded Sauci product or harness change after scope and acceptance criteria are known."
|
||||
model = "gpt-5.6-terra"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||
skill. Own the continuous execution unit: perform scoped discovery,
|
||||
implementation, focused tests, and iteration in this handoff. Implement only the
|
||||
delegated objective and preserve unrelated work. Update maintained contract
|
||||
documentation and add focused tests when the contract changes. Run the required
|
||||
focused checks and return changed files, exact results, assumptions, and blockers.
|
||||
Stop for missing authority or a materially ambiguous product choice. Do not expand
|
||||
scope or perform external, destructive, production, release, or provider writes
|
||||
unless the task explicitly authorizes that action.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "repo_scout"
|
||||
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
|
||||
model = "gpt-5.6-luna"
|
||||
model_reasoning_effort = "low"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
|
||||
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
|
||||
and uncertainties. Do not edit files, make design decisions, or propose broad
|
||||
refactors. Do not claim runtime behavior you did not observe.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "security_reviewer"
|
||||
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Perform a scoped threat-boundary review of the delegated change. Trace callers
|
||||
through authentication and authorization to the data sink. Prioritize exploitable
|
||||
correctness and security risks, include counterevidence, and cite exact files and
|
||||
lines. Do not edit files or expand into a repository-wide audit unless explicitly
|
||||
requested.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "test_writer"
|
||||
description = "Focused Sauci regression and contract-test author for already-defined behavior."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md and testing documentation. Test the intended
|
||||
observable contract at the lowest useful layer while preserving real auth,
|
||||
authorization, tenant, and couple boundaries. Do not weaken assertions to
|
||||
accommodate a failure. Run the focused test and return files changed, exact
|
||||
commands, results, and any product defect exposed by the test.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "verifier"
|
||||
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
|
||||
exercise the delegated acceptance criteria against the running non-production
|
||||
product. You may write generated proof only under evidence/. Do not edit source,
|
||||
tests, configuration, or acceptance criteria. Return expected behavior, observed
|
||||
behavior, evidence paths, environment limitations, and a works or broken verdict.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
[agents]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 3
|
||||
default_subagent_model = "gpt-5.6-terra"
|
||||
default_subagent_reasoning_effort = "medium"
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: delegate-to-claude
|
||||
description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review.
|
||||
---
|
||||
|
||||
# Delegate to Claude Code
|
||||
|
||||
Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance.
|
||||
|
||||
## Preconditions
|
||||
|
||||
1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory.
|
||||
2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order.
|
||||
3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has.
|
||||
|
||||
## Work order
|
||||
|
||||
Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include:
|
||||
|
||||
- role: executor or read-only reviewer;
|
||||
- exact objective and boundaries;
|
||||
- working directory and relevant files;
|
||||
- repository instructions Claude must read;
|
||||
- allowed side effects and forbidden actions;
|
||||
- required checks and expected evidence;
|
||||
- requested return format: findings or changed files, commands run, results, and blockers.
|
||||
|
||||
The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient.
|
||||
|
||||
## Run
|
||||
|
||||
From the target repository directory:
|
||||
|
||||
```bash
|
||||
/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json
|
||||
```
|
||||
|
||||
The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode:
|
||||
|
||||
```bash
|
||||
CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`.
|
||||
- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`.
|
||||
- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode.
|
||||
- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary.
|
||||
|
||||
The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access.
|
||||
|
||||
Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox.
|
||||
|
||||
For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run:
|
||||
|
||||
```bash
|
||||
scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json
|
||||
```
|
||||
|
||||
If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate.
|
||||
|
||||
## Accept or iterate
|
||||
|
||||
Claude's summary is evidence to inspect, not proof. Codex must:
|
||||
|
||||
1. inspect the actual diff or artifacts;
|
||||
2. run proportionate independent checks;
|
||||
3. compare the result with the original scope and repository rules;
|
||||
4. send a focused follow-up with concrete failures when iteration is needed;
|
||||
5. report Claude's contribution separately from Codex's verification.
|
||||
|
||||
Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Delegate to Claude Code"
|
||||
short_description: "Delegate scoped work to Claude Code"
|
||||
default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result."
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
printf 'claude_delegate: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage:
|
||||
claude_delegate.sh check
|
||||
claude_delegate.sh run <prompt-file> [result-json]
|
||||
claude_delegate.sh resume <session-id> <prompt-file> [result-json]
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH"
|
||||
|
||||
if [[ "${1:-}" == "check" ]]; then
|
||||
claude --version
|
||||
auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status"
|
||||
[[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated"
|
||||
printf 'authentication: logged in\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mode="${1:-}"
|
||||
case "$mode" in
|
||||
run)
|
||||
[[ $# -ge 2 && $# -le 3 ]] || usage
|
||||
prompt_file="$2"
|
||||
result_file="${3:-}"
|
||||
resume_id=""
|
||||
;;
|
||||
resume)
|
||||
[[ $# -ge 3 && $# -le 4 ]] || usage
|
||||
resume_id="$2"
|
||||
prompt_file="$3"
|
||||
result_file="${4:-}"
|
||||
[[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file"
|
||||
[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file"
|
||||
|
||||
prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")"
|
||||
|
||||
effort="${CLAUDE_DELEGATE_EFFORT:-medium}"
|
||||
permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}"
|
||||
case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac
|
||||
case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;;
|
||||
*) die "invalid permission mode: $permission_mode" ;;
|
||||
esac
|
||||
|
||||
model="${CLAUDE_DELEGATE_MODEL:-sonnet}"
|
||||
args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome)
|
||||
[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD")
|
||||
[[ -n "$resume_id" ]] && args+=(--resume "$resume_id")
|
||||
|
||||
if [[ -n "$result_file" ]]; then
|
||||
result_dir="$(dirname "$result_file")"
|
||||
[[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir"
|
||||
claude "${args[@]}" "$prompt" | tee "$result_file"
|
||||
else
|
||||
claude "${args[@]}" "$prompt"
|
||||
fi
|
||||
@@ -9,20 +9,26 @@ Use `scripts/dev-local.sh`; do not manually mix remote and local services.
|
||||
|
||||
| Service | Port |
|
||||
|---|---:|
|
||||
| web | 3000 |
|
||||
| admin | 3001 |
|
||||
| MCP | 3002 |
|
||||
| PostgreSQL | 54320 |
|
||||
| API | 3003 |
|
||||
| web | 3000 (overridable with `SAUCI_WEB_PORT`) |
|
||||
| admin | 3001 (overridable with `SAUCI_ADMIN_PORT`) |
|
||||
| MCP | 3002 (overridable with `SAUCI_MCP_PORT`) |
|
||||
| Expo Metro | 8081 |
|
||||
| Supabase API / DB / Studio | 54321 / 54322 / 54323 |
|
||||
|
||||
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime, and
|
||||
the repository Supabase CLI dependency.
|
||||
Prerequisites: Node from `.nvmrc`, `npm ci`, tmux, Docker-compatible runtime,
|
||||
and the configured hosted Auth credentials where an authenticated client needs them.
|
||||
|
||||
- `scripts/dev-local.sh up` — idempotently start infra and services.
|
||||
- `status`, `logs <service>`, `restart <service>`, `attach` — inspect.
|
||||
- `reset` — local-only database reset plus deterministic fixtures.
|
||||
- `ios` / `android` — launch the native app against local Supabase.
|
||||
- `down`; `down --all` — stop services, optionally infrastructure.
|
||||
- `scripts/dev-local.sh up`: idempotently start PostgreSQL and services.
|
||||
- `status`, `logs <service>`, `restart <service>`, `attach`: inspect.
|
||||
- `reset`: local-only database reset plus deterministic fixtures.
|
||||
- `e2e`: start or safely reuse the local stack, validate local endpoints, and
|
||||
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
|
||||
anything. If a tmux window exited, read its logs before restarting it.
|
||||
Hosted Supabase provides Auth only. Local E2E never starts or writes to a
|
||||
Supabase data plane. If a port is occupied, use `status` and inspect its owning
|
||||
process before stopping anything. If a tmux window exited, read its logs before
|
||||
restarting it.
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: generate-release
|
||||
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
|
||||
---
|
||||
|
||||
# Generate release
|
||||
|
||||
Create a reproducible release whose tag identifies the reviewed release source
|
||||
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
|
||||
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
|
||||
[references/release-notes-voice.md](references/release-notes-voice.md) before
|
||||
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
|
||||
before accepting a build.
|
||||
|
||||
A request to generate or create a release authorizes the scoped version edits,
|
||||
release notes, local builds, release commits, and one local annotated tag. When
|
||||
the request also asks to ship, upload, submit, or release to the stores, it
|
||||
authorizes the complete store pipeline for the stated platforms after the exact
|
||||
artifact has been verified: upload, App Review / Google Play review submission,
|
||||
and rollout, including full production rollout (see the `release-mobile` skill).
|
||||
It does not authorize pushing commits or tags, changing signing credentials, or
|
||||
regenerating native projects unless the request explicitly includes that action.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Build both iOS and Android production binaries unless the user narrows the
|
||||
platforms.
|
||||
- Increment the patch version unless the user specifies `minor`, `major`, or an
|
||||
exact SemVer version.
|
||||
- Use `v<version>` for the annotated Git tag.
|
||||
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
|
||||
binaries, credentials, logs, or build directories.
|
||||
|
||||
## Preflight
|
||||
|
||||
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
|
||||
toolchains, EAS authentication, and signing availability without changing them.
|
||||
Require all of the following:
|
||||
|
||||
- a named branch, normally `main` for a production release;
|
||||
- a clean starting worktree with `HEAD` equal to its fetched upstream;
|
||||
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
|
||||
- consistent public and OTA runtime versions across `app.json`, explicit
|
||||
`app.config.js` runtime, Android, and every iOS target
|
||||
(`scripts/set_version.mjs --check`);
|
||||
- `npm run release:preflight -w @sauci/mobile`, which also checks the iOS widget
|
||||
effective build settings and OTA certificate archive inclusion;
|
||||
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||
- the configured production API/Auth/RevenueCat environment, without printing
|
||||
secret values.
|
||||
|
||||
For an OTA release, run `npm run ota:preflight:production -w @sauci/mobile`
|
||||
before any publish. This exercises the EOAS-compatible Expo export environment
|
||||
without contacting the update server or uploading an update.
|
||||
|
||||
Do not switch branches, discard work, repair credentials, push, or weaken branch
|
||||
protection merely to pass preflight. Stop with the exact blocker. Never run
|
||||
`expo prebuild` as part of this workflow.
|
||||
|
||||
## Prepare the release
|
||||
|
||||
1. Derive the next version with `scripts/set_version.mjs --bump <kind>` or set an
|
||||
explicit version with `--version <x.y.z>`. The script updates the checked-in
|
||||
public-version sources but deliberately leaves platform build numbers to EAS.
|
||||
2. Review the complete first-parent and ordinary commit diff from the previous
|
||||
reachable release tag to the starting commit. Inspect the actual product diff
|
||||
for every user-facing claim; commit subjects alone are not evidence.
|
||||
3. Add the new entry immediately below the title in `docs/release-notes.md`.
|
||||
Describe user-visible value, meaningful fixes, privacy or reliability changes,
|
||||
and only claims supported by the diff. Omit internal migrations, refactors,
|
||||
CI, infrastructure, dependency churn, and implementation jargon unless the
|
||||
user experiences a direct result. Use a temporary `Build numbers pending
|
||||
artifact verification` line until the binaries have been inspected; never
|
||||
guess or predict build numbers.
|
||||
4. Run `npm run verify:full` and the native acceptance required by the
|
||||
`release-mobile` skill. A missing prerequisite is a failure/limitation, not a
|
||||
pass.
|
||||
5. Review the scoped diff and create `chore(release): prepare v<version>`. Do not
|
||||
stage unrelated paths. This clean release-preparation commit must descend
|
||||
directly from the fetched upstream commit.
|
||||
|
||||
## Build and capture platform metadata
|
||||
|
||||
Build from the clean prepared source using the package scripts in
|
||||
`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the
|
||||
versioned artifact directory. When building both platforms, build Android first,
|
||||
then iOS.
|
||||
|
||||
For local EAS, retain the working directory and logs under a stable ignored path,
|
||||
set `EAS_LOCAL_BUILD_ARTIFACTS_DIR`, and preflight available disk space before
|
||||
starting. Set `EAS_LOCAL_BUILD_PLUGIN_PATH` only to the matching plugin
|
||||
executable `bin/run`, never a package directory or a hardcoded cache location.
|
||||
Classify plugin, archive, npm, and ENOSPC failures as pre-compilation failures;
|
||||
repair and preflight them without spending another build. Make at most one
|
||||
meaningful build for each newly proven input state. After a signing, profile, or
|
||||
capability failure, read back and prove the provider correction before rebuilding.
|
||||
|
||||
After each build:
|
||||
|
||||
1. Inspect the worktree before doing anything else. Accept only the documented
|
||||
EAS build-number changes in `app.json`, that platform's native version source,
|
||||
and the iOS widget plist when applicable. Treat any implementation, resource,
|
||||
dependency, or native-project regeneration as a failed build.
|
||||
2. Independently inspect the binary for package/bundle identifier, public
|
||||
version, platform build number, production endpoint configuration where
|
||||
inspectable, signing identity/fingerprint, OTA runtime/channel/certificate,
|
||||
and SHA-256. Require the embedded public version to equal the requested
|
||||
version. For Android, also inspect all required ABIs, Worklets/Reanimated/Skia
|
||||
libraries, and the release permissions. For iOS, inspect every packaged
|
||||
extension after export.
|
||||
3. Record the verified platform build number in the new release-note entry.
|
||||
4. Commit only the expected metadata and release-note update as
|
||||
`chore(<platform>): record v<version> build <number>`. The worktree must be
|
||||
clean before starting the next platform build.
|
||||
|
||||
Do not accept a successful EAS exit code as proof of a valid artifact. Quarantine
|
||||
or remove rejected artifacts so only accepted paths can be offered for upload. If
|
||||
either requested platform fails, do not tag a partial release.
|
||||
|
||||
## Tag and prove the result
|
||||
|
||||
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
|
||||
artifact exists, has a SHA-256 digest, passed independent version/signature
|
||||
inspection, and was produced from the release commit chain. Require a clean
|
||||
worktree, then create the annotated local tag `v<version>` at `HEAD`.
|
||||
|
||||
Read back the tag object and prove:
|
||||
|
||||
- the tag resolves to `HEAD`;
|
||||
- the tag version equals all public-version sources and embedded artifacts;
|
||||
- recorded build numbers equal the embedded platform build numbers;
|
||||
- no binary or ignored build output entered the commit;
|
||||
- the release commits descend from the fetched starting branch commit;
|
||||
- for a two-platform release, the Android artifact's source commit differs from
|
||||
the final tag only by the later verified iOS build metadata.
|
||||
|
||||
Report the branch, tag, commit, release-note summary, artifact paths, versions,
|
||||
build numbers, signatures, SHA-256 digests, checks run, and any environmental
|
||||
limitations. State explicitly that the tag and commits remain local unless a
|
||||
separate authorized push was completed.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Generate Release"
|
||||
short_description: "Bump, build, note, and tag Sauci releases"
|
||||
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
|
||||
@@ -0,0 +1,39 @@
|
||||
# Release artifact verification
|
||||
|
||||
Use the strongest locally available platform tools and record the commands and
|
||||
results. Never print provisioning profiles, keys, tokens, or environment values.
|
||||
|
||||
## Android AAB
|
||||
|
||||
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
|
||||
or Android SDK `apkanalyzer`, and verify:
|
||||
|
||||
- package `com.sauci.app`;
|
||||
- `versionName` equals the release version;
|
||||
- `versionCode` equals the recorded Android build number.
|
||||
|
||||
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
|
||||
signing certificate SHA-1 to the production fingerprint documented in
|
||||
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
|
||||
the artifact SHA-256 with `shasum -a 256`.
|
||||
|
||||
## iOS IPA
|
||||
|
||||
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
|
||||
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
|
||||
|
||||
- `CFBundleIdentifier` is `com.sauci.app`;
|
||||
- `CFBundleShortVersionString` equals the release version;
|
||||
- `CFBundleVersion` equals the recorded iOS build number.
|
||||
|
||||
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
|
||||
to inspect and verify signing without exposing the embedded profile. Calculate the
|
||||
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
|
||||
extraction directory after inspection.
|
||||
|
||||
## Source correspondence
|
||||
|
||||
For both platforms, retain the build command, artifact path, build completion
|
||||
time, and digest in the final report. Compare the post-build tracked diff with the
|
||||
documented allowed version files. A binary with the right version but unexpected
|
||||
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Sauci release-note voice
|
||||
|
||||
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
|
||||
couples app about private connection and shared curiosity: supportive without
|
||||
being clinical, polished without sounding corporate, and intimate without being
|
||||
graphic or presumptuous.
|
||||
|
||||
## Shape
|
||||
|
||||
Use a short opening sentence followed by three to six compact bullets. Lead with
|
||||
the change that most improves a couple's experience. Combine related work into
|
||||
one benefit rather than mirroring the commit list. Include a brief closing line
|
||||
only when it adds warmth or useful context.
|
||||
|
||||
In `docs/release-notes.md`, use:
|
||||
|
||||
```markdown
|
||||
## v1.2.3 — 29 August 2026
|
||||
_iOS build 44 · Android build 48_
|
||||
|
||||
A little more ease, clarity, and connection in every check-in.
|
||||
|
||||
- **Closer conversations** — Find the prompts that feel right for both of you.
|
||||
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
|
||||
- **More control** — Clearer choices help keep your shared space comfortable.
|
||||
```
|
||||
|
||||
Adapt the words to the evidence; do not reuse this copy when those claims are not
|
||||
present in the release. If only one platform was requested, record only its build.
|
||||
|
||||
## Language
|
||||
|
||||
- Address the couple's experience with `you`, `your partner`, `together`, or
|
||||
`your shared space` where natural.
|
||||
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
|
||||
`keep conversations flowing`, `stay in control`.
|
||||
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
|
||||
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
|
||||
fix a relationship.
|
||||
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
|
||||
`revolutionary`, `game-changing`, `massive`, or `best ever`.
|
||||
- Explain a fix as the experience restored, not the defect mechanics. Name a
|
||||
security or privacy change plainly when users need to understand it.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
Release notes describe shipped behavior only. Do not claim a feature from a plan,
|
||||
flagged-off path, migration, test, or commit title without confirming the runtime
|
||||
path in the diff and release configuration. Do not expose internal service names,
|
||||
database details, moderation logic, security-sensitive implementation, or content
|
||||
that the product intentionally keeps private between partners.
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "../../../..");
|
||||
const files = {
|
||||
app: path.join(repoRoot, "apps/mobile/app.json"),
|
||||
appConfig: path.join(repoRoot, "apps/mobile/app.config.js"),
|
||||
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
|
||||
androidExpo: path.join(repoRoot, "apps/mobile/android/app/src/main/res/values/strings.xml"),
|
||||
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
|
||||
iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"),
|
||||
iosExpo: path.join(repoRoot, "apps/mobile/ios/Sauci/Supporting/Expo.plist"),
|
||||
widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"),
|
||||
};
|
||||
|
||||
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`set_version: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseVersion(value, label) {
|
||||
const match = semverPattern.exec(value);
|
||||
if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`);
|
||||
return match.slice(1).map(Number);
|
||||
}
|
||||
|
||||
function writePreservingFinalNewline(file, contents, previous) {
|
||||
fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, ""));
|
||||
}
|
||||
|
||||
function readState() {
|
||||
const appRaw = fs.readFileSync(files.app, "utf8");
|
||||
const appConfigRaw = fs.readFileSync(files.appConfig, "utf8");
|
||||
const androidRaw = fs.readFileSync(files.android, "utf8");
|
||||
const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8");
|
||||
const iosRaw = fs.readFileSync(files.ios, "utf8");
|
||||
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
|
||||
const iosExpoRaw = fs.readFileSync(files.iosExpo, "utf8");
|
||||
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8");
|
||||
const appVersion = JSON.parse(appRaw).expo?.version;
|
||||
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
|
||||
(match) => match[1].trim(),
|
||||
);
|
||||
const plistVersion = (raw, label) => {
|
||||
const versions = [...raw.matchAll(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
if (versions.length !== 1) fail(`expected exactly one ${label} CFBundleShortVersionString`);
|
||||
return versions[0];
|
||||
};
|
||||
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
|
||||
const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget Info.plist");
|
||||
const expoRuntimeVersion = (raw, label) => {
|
||||
const versions = [...raw.matchAll(/<key>EXUpdatesRuntimeVersion<\/key>\s*<string>([^<]+)<\/string>/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
if (versions.length !== 1) fail(`expected exactly one ${label} Expo runtime version`);
|
||||
return versions[0];
|
||||
};
|
||||
const appConfigRuntimeVersions = [...appConfigRaw.matchAll(/runtimeVersion:\s*['"]([^'"]+)['"]/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
const androidRuntimeVersions = [...androidExpoRaw.matchAll(/<string name="expo_runtime_version">([^<]+)<\/string>/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
if (appConfigRuntimeVersions.length !== 1) fail("expected exactly one explicit app.config.js runtimeVersion");
|
||||
if (androidRuntimeVersions.length !== 1) fail("expected exactly one Android Expo runtime version");
|
||||
const iosExpoVersion = expoRuntimeVersion(iosExpoRaw, "iOS");
|
||||
|
||||
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
|
||||
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
||||
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
|
||||
|
||||
const versions = [
|
||||
appVersion,
|
||||
...androidVersions,
|
||||
...iosVersions,
|
||||
iosInfoVersion,
|
||||
widgetInfoVersion,
|
||||
appConfigRuntimeVersions[0],
|
||||
androidRuntimeVersions[0],
|
||||
iosExpoVersion,
|
||||
];
|
||||
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
|
||||
if (new Set(versions).size !== 1) {
|
||||
fail(`public versions disagree: ${versions.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
appRaw,
|
||||
appConfigRaw,
|
||||
androidRaw,
|
||||
androidExpoRaw,
|
||||
iosRaw,
|
||||
iosInfoRaw,
|
||||
iosExpoRaw,
|
||||
widgetInfoRaw,
|
||||
current: appVersion,
|
||||
iosCount: iosVersions.length,
|
||||
};
|
||||
}
|
||||
|
||||
function nextVersion(current, bump) {
|
||||
const [major, minor, patch] = parseVersion(current, "current version");
|
||||
if (bump === "major") return `${major + 1}.0.0`;
|
||||
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||
if (bump === "patch") return `${major}.${minor}.${patch + 1}`;
|
||||
fail("--bump must be patch, minor, or major");
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const check = args.length === 1 && args[0] === "--check";
|
||||
const bumpIndex = args.indexOf("--bump");
|
||||
const versionIndex = args.indexOf("--version");
|
||||
|
||||
if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
|
||||
fail("use --check, --bump patch|minor|major, or --version x.y.z");
|
||||
}
|
||||
|
||||
const state = readState();
|
||||
if (check) {
|
||||
console.log(`public and OTA runtime version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const desired =
|
||||
bumpIndex !== -1
|
||||
? nextVersion(state.current, args[bumpIndex + 1])
|
||||
: (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]);
|
||||
|
||||
if (desired === state.current) fail(`requested version is already ${state.current}`);
|
||||
const currentParts = parseVersion(state.current, "current version");
|
||||
const desiredParts = parseVersion(desired, "requested version");
|
||||
const increases = desiredParts.some(
|
||||
(part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]),
|
||||
);
|
||||
if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`);
|
||||
|
||||
const appRaw = state.appRaw.replace(
|
||||
/(\"version\"\s*:\s*\")([^\"]+)(\")/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const androidRaw = state.androidRaw.replace(
|
||||
/(\bversionName\s+["'])([^"']+)(["'])/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const appConfigRaw = state.appConfigRaw.replace(
|
||||
/(runtimeVersion:\s*['"])([^'"]+)(['"])/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const androidExpoRaw = state.androidExpoRaw.replace(
|
||||
/(<string name="expo_runtime_version">)([^<]+)(<\/string>)/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const iosRaw = state.iosRaw.replace(
|
||||
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const iosInfoRaw = state.iosInfoRaw.replace(
|
||||
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const iosExpoRaw = state.iosExpoRaw.replace(
|
||||
/(<key>EXUpdatesRuntimeVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const widgetInfoRaw = state.widgetInfoRaw.replace(
|
||||
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
|
||||
writePreservingFinalNewline(files.app, appRaw, state.appRaw);
|
||||
writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw);
|
||||
writePreservingFinalNewline(files.android, androidRaw, state.androidRaw);
|
||||
writePreservingFinalNewline(files.androidExpo, androidExpoRaw, state.androidExpoRaw);
|
||||
writePreservingFinalNewline(files.ios, iosRaw, state.iosRaw);
|
||||
writePreservingFinalNewline(files.iosInfo, iosInfoRaw, state.iosInfoRaw);
|
||||
writePreservingFinalNewline(files.iosExpo, iosExpoRaw, state.iosExpoRaw);
|
||||
writePreservingFinalNewline(files.widgetInfo, widgetInfoRaw, state.widgetInfoRaw);
|
||||
console.log(`updated public version ${state.current} -> ${desired}`);
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-mobile
|
||||
description: Prepare and verify Sauci iOS or Android releases. Use for versioning, EAS/native builds, Fastlane upload, release notes, or store submission preparation.
|
||||
description: Prepare, verify, and ship Sauci iOS or Android releases end to end. Use for versioning, EAS/native builds, store uploads, App Review / Play review submission, and rollout.
|
||||
---
|
||||
|
||||
# Mobile release
|
||||
@@ -9,7 +9,139 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
|
||||
scoped instructions. Confirm target platform and release profile. Run repository
|
||||
verification plus a native acceptance pass before starting a release build.
|
||||
|
||||
Use the package scripts in `apps/mobile/package.json`; preserve platform signing
|
||||
and versioning. Building or uploading to a store is an external mutation: obtain
|
||||
explicit authorization for the requested target, then report the build artifact,
|
||||
version/build number, upload result, and read-back state.
|
||||
## OTA update vs full store release
|
||||
|
||||
Choose the delivery mechanism before doing anything else; they are different
|
||||
pipelines with different blast radii. `docs/mobile-ota.md` is the authority.
|
||||
|
||||
- **OTA update** (`npm run ota:publish:<env>` in `apps/mobile`): ships only the
|
||||
JavaScript bundle and assets to builds already installed on devices. No store
|
||||
review, live within minutes, and rolled back by repointing the XPREM channel
|
||||
at a previous update. Valid only when the change touches no native code, no
|
||||
native dependency, no `ios/`/`android/` project files, and no config that
|
||||
alters the native binary. The OTA runtime version equals the public app
|
||||
version, so an OTA update can only reach installs of the current
|
||||
`expo.version`.
|
||||
- **Full store release** (this skill): a new signed binary through App Store
|
||||
Connect and Google Play, including review. Required whenever native code,
|
||||
native dependencies, the checked-in native projects, Expo SDK, permissions,
|
||||
entitlements, or `expo.version` change. Rollback is coarser: Play staged
|
||||
rollouts can be halted, iOS phased release can be paused, and a bad release is
|
||||
otherwise superseded by shipping the next version.
|
||||
|
||||
When a change qualifies for OTA, prefer OTA unless the user asks for a store
|
||||
release. Never present an OTA publish as a store release or vice versa; report
|
||||
which mechanism was used.
|
||||
|
||||
## Authorization
|
||||
|
||||
An explicit request for a mobile release authorizes the complete pipeline for
|
||||
the stated platforms: version bump, build, verification, store upload, review
|
||||
submission (App Review / Google Play review), and rollout — including full
|
||||
production rollout. Rollout is recoverable by design (staged-rollout halt,
|
||||
phased-release pause, OTA supersession, or the next store version), so do not
|
||||
pause to re-confirm these steps; execute them and report. A request that
|
||||
explicitly narrows scope (for example "upload only", "TestFlight only", or a
|
||||
named track) still bounds what you do.
|
||||
|
||||
Before a build from the current upstream branch, fetch, require a clean worktree,
|
||||
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||
build a clean, scoped release-preparation commit that descends directly from that
|
||||
fetched upstream; between its multi-platform builds it commits only independently
|
||||
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
|
||||
so routine release builds must package those checked-in sources. Do not run
|
||||
`expo prebuild` first; native regeneration is a separate reviewed change.
|
||||
|
||||
Run `npm run release:preflight -w @sauci/mobile` before allocating an EAS build.
|
||||
It rejects inconsistent public/runtime versions, an iOS widget build-setting
|
||||
override, and a public OTA certificate that would be omitted from the archive.
|
||||
For a local EAS build, also run `npm run release:preflight:local-eas -w
|
||||
@sauci/mobile` after setting `EAS_LOCAL_BUILD_PLUGIN_PATH` to the matching
|
||||
plugin's executable `bin/run`. Do not point it at the package directory or
|
||||
hardcode a machine-local cache path.
|
||||
|
||||
Classify a failure before rebuilding. Archive, plugin-spawn, package-install, and
|
||||
disk-space failures happen before native compilation and must be repaired and
|
||||
preflighted without consuming another build. After a signing, profile, or
|
||||
capability failure, read back the provider state and prove only the affected
|
||||
input changed before rebuilding. Retain the EAS work directory, command log, and
|
||||
stable artifact path for every meaningful attempt. Never retry an ambiguous
|
||||
upload.
|
||||
|
||||
Report provider progress by phase: packaging, native compilation, submission
|
||||
queue, or store processing. Do not describe every failure as a rebuild.
|
||||
|
||||
Use the package scripts in `apps/mobile/package.json` to build artifacts and the
|
||||
Fastlane lanes in `apps/mobile/fastlane/Fastfile` to upload, submit, and roll
|
||||
out.
|
||||
|
||||
## Upload and submission execution
|
||||
|
||||
Verification before upload is unchanged and mandatory. Independently verify and
|
||||
report:
|
||||
|
||||
- the platform, exact artifact path, bundle/package identifier, public version,
|
||||
build number, signature identity, and SHA-256 digest;
|
||||
- the exact destination and resulting state: which Play track and rollout
|
||||
status, or App Store Connect version state and whether App Review submission
|
||||
and automatic release are included.
|
||||
|
||||
Once these details match the request, proceed through upload, review
|
||||
submission, and rollout without requesting further confirmation.
|
||||
|
||||
- **Android**: `bundle exec fastlane android release aab:<absolute path>`
|
||||
commits the release on the production track (pass `track:` to target another
|
||||
track, `rollout:0.1` for a staged rollout). Play review runs automatically;
|
||||
rollout proceeds on approval. Use the `upload` lane only when the request is
|
||||
explicitly upload-only.
|
||||
- **iOS**: `bundle exec fastlane ios release ipa:<absolute path>` uploads,
|
||||
creates the App Store version, submits for App Review, and releases
|
||||
automatically on approval (pass `phased_release:true` for a 7-day phased
|
||||
release, `release_notes:"..."` for What's New). Use the `upload` lane for
|
||||
TestFlight-only requests. `eas submit` remains valid for upload-only flows;
|
||||
when used, pass explicit `--platform`, `--profile production`, `--path`, and
|
||||
`--wait` — never `--latest`.
|
||||
|
||||
App Store Connect API group association does not establish the automatic-
|
||||
distribution switch. Verify that switch in an authenticated App Store Connect UI
|
||||
session before upload; if that UI verification is unavailable, stop rather
|
||||
than inferring the distribution effect from API data.
|
||||
|
||||
If an upload or submission command returns an ambiguous result, inspect EAS and
|
||||
the store before doing anything else; do not retry blind. After submission, read
|
||||
back the store state (Play Console track status / App Store Connect version
|
||||
state) and report it.
|
||||
|
||||
Preserve platform signing and local EAS versioning. After a build, verify that
|
||||
tracked changes are limited to the expected version metadata, then independently
|
||||
read the artifact version and signature. For an iOS IPA, inspect every packaged
|
||||
app extension as well as the containing app and require matching
|
||||
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
|
||||
warnings as diagnostic evidence, not the final artifact state: decide whether to
|
||||
reject or rebuild only after reading the exported IPA itself. Report the
|
||||
artifact, version/build number, upload and submission results, rollout state,
|
||||
and store read-back state.
|
||||
|
||||
For Android, verify the accepted AAB has all four required ABIs, the Worklets,
|
||||
Reanimated, and Skia native libraries, no broad `READ_MEDIA_IMAGES` or
|
||||
`READ_MEDIA_VIDEO` permissions, and the production OTA runtime, channel, and
|
||||
public certificate. For iOS, verify the exported IPA, not archive warnings, and
|
||||
check both the containing app and every extension.
|
||||
|
||||
For the Android native dependency ordering already tracked in Gradle, preserve
|
||||
the explicit task dependency that makes Prefab inputs available before dependent
|
||||
native tasks. Fix task validation with ordering, not Firebase removal or
|
||||
dependency-version roulette.
|
||||
|
||||
After Android upload, EAS Submit `FINISHED`, the exact internal/DRAFT/no-rollout
|
||||
submit configuration, and the retained artifact digest are the available
|
||||
authoritative read-back when a direct Play track read would require creating an
|
||||
edit. Never create an edit merely to claim read-only verification.
|
||||
|
||||
For a checked-in iOS entitlement, inspect the real Apple App ID capability state
|
||||
before a signing rebuild. The capability sync bug tracked in
|
||||
https://github.com/expo/eas-cli/issues/3986 can report success while Apple
|
||||
rejects a malformed update. Read back Associated Domains, regenerate only the
|
||||
affected profile, and decode that profile to prove the entitlement before
|
||||
rebuilding. Noninteractive managed App Store Connect key auth may require
|
||||
`EXPO_APPLE_TEAM_ID`; preserve certificates and unrelated target profiles.
|
||||
@@ -5,25 +5,34 @@ description: Prove a completed Sauci engineering task works before a PR. Use whe
|
||||
|
||||
# Verify Sauci work
|
||||
|
||||
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
|
||||
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. Give a fresh read-only verifier the acceptance criteria. It drives browser
|
||||
1. Package-local deterministic checks do not need a running stack. For runtime
|
||||
verification, reuse a compatible running local stack when available; otherwise
|
||||
start the documented full stack with `scripts/dev-local.sh up`. Reset fixtures
|
||||
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
|
||||
or the iOS simulator skill, and APIs with `curl`. Authentication fixtures are
|
||||
described by `e2e/.auth/fixtures.json` after `dev-local.sh reset`.
|
||||
3. The verifier writes screenshots, traces, video, or response evidence under
|
||||
`evidence/` and returns only expected, observed, evidence paths, and
|
||||
`works|broken`. It never edits code.
|
||||
4. If broken, fix and use a fresh verifier. Stop after three failed rounds and
|
||||
report the unresolved evidence.
|
||||
5. After the task works, run `npm run verify:fast`, affected E2E, and
|
||||
`npm run verify:full` when local Supabase/Deno are available. Never weaken an
|
||||
assertion just to pass.
|
||||
4. If independent verification is broken, fix and use a fresh verifier. Stop
|
||||
after three failed rounds and report the unresolved evidence.
|
||||
5. Run focused checks for the affected change. Run affected E2E when the runtime
|
||||
path requires it. Run `npm run verify:full` when its prerequisites are
|
||||
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
|
||||
evidence to the established CI artifact or `pr-evidence` prerelease and embed
|
||||
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.
|
||||
+37
-2
@@ -1,14 +1,49 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
**/.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
dist
|
||||
**/dist
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
**/.expo
|
||||
**/android
|
||||
**/ios
|
||||
*.log
|
||||
.turbo
|
||||
**/.turbo
|
||||
coverage
|
||||
.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
|
||||
|
||||
# The admin app builds from this same monorepo context (apps/admin/Dockerfile).
|
||||
!apps/admin
|
||||
!apps/admin/**
|
||||
|
||||
# Host build artefacts must never leak into any image context.
|
||||
**/node_modules
|
||||
**/dist
|
||||
@@ -1,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
|
||||
@@ -1,15 +1,46 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [staging, main]
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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" ]; then
|
||||
case "$HEAD_BRANCH" in
|
||||
staging|hotfix/*|release/*) ;;
|
||||
*)
|
||||
echo "main takes staging promotions, or a hotfix/* or release/* branch for direct work" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -21,58 +52,43 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run learn:check
|
||||
- run: npm run verify:fast
|
||||
- name: Enforce API and mobile coverage floors
|
||||
run: npm run test:coverage
|
||||
- run: npm run build
|
||||
|
||||
full-stack:
|
||||
backend-integration:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Start local Supabase
|
||||
- name: Run standalone backend integration tests
|
||||
env:
|
||||
GOOGLE_CLIENT_ID: local
|
||||
GOOGLE_CLIENT_SECRET: local
|
||||
APPLE_CLIENT_ID: local
|
||||
APPLE_CLIENT_SECRET: local
|
||||
run: npx supabase start --workdir apps
|
||||
- name: Reset and seed
|
||||
run: |
|
||||
npx supabase db reset --workdir apps
|
||||
scripts/with-local-supabase-env.sh tests node scripts/seed-e2e.mjs
|
||||
- name: Start application services
|
||||
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
|
||||
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
||||
run: npm run test:integration:coverage -w @sauci/api
|
||||
|
||||
container-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify production Compose contract
|
||||
run: infra/backend/verify-production.sh
|
||||
+5
-2
@@ -55,10 +55,12 @@ evidence/
|
||||
e2e/.auth/
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
.playwright-mcp/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.cache/
|
||||
*.pem
|
||||
!apps/mobile/certs/certificate.pem
|
||||
.cache/
|
||||
.temp/
|
||||
.tmp/
|
||||
*.tmp
|
||||
@@ -70,3 +72,4 @@ nul
|
||||
# Agent runtime evidence (maintained lessons remain tracked)
|
||||
.ralph/activity.log
|
||||
.ralph/runs/
|
||||
.migration-state/
|
||||
@@ -1,23 +1,45 @@
|
||||
# Sauci agent map
|
||||
|
||||
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
|
||||
workspace.
|
||||
web app, standalone Node/PostgreSQL backend, hosted Supabase Auth, and an internal
|
||||
MCP service in one npm/Turborepo workspace.
|
||||
|
||||
## Golden rules
|
||||
|
||||
1. Read the nearest `AGENTS.md` before changing a scoped area.
|
||||
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
|
||||
`npm run db:migration:new -- <name>`. Never use remote `apply_migration`, never
|
||||
execute DDL through MCP SQL tools, and use those SQL tools for `SELECT` only.
|
||||
4. Never target production from local development or E2E. Local tests must fail
|
||||
closed when a configured Supabase URL is not localhost.
|
||||
3. Product-data schema changes are committed SQL files in `apps/api/drizzle/`.
|
||||
Hosted Supabase owns Auth only; do not deploy product-data migrations or Edge
|
||||
Functions there. Never execute remote DDL through MCP or provider tools.
|
||||
4. Never target production from local development or E2E. Database integration
|
||||
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.
|
||||
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.
|
||||
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`.
|
||||
12. Agents must not use em dash characters in text they add to this repository.
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -26,7 +48,8 @@ workspace.
|
||||
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
|
||||
| `apps/admin` | Vite administration UI | `apps/admin/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` |
|
||||
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
|
||||
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
|
||||
@@ -34,6 +57,8 @@ workspace.
|
||||
|
||||
## 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`
|
||||
- Full local stack: `scripts/dev-local.sh up`
|
||||
- Fast checks: `npm run verify:fast`
|
||||
@@ -51,6 +76,7 @@ environment limitation; do not call it a passing product check.
|
||||
|---|---|
|
||||
| System boundaries and data flow | `docs/architecture/system.md` |
|
||||
| Local services, ports, fixtures | `docs/development/local-stack.md` |
|
||||
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
|
||||
| Test layers and required gates | `docs/testing/strategy.md` |
|
||||
| Database migration safety | `docs/operations/database-migrations.md` |
|
||||
| Agent roles and effort routing | `docs/agents/routing.md` |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
# Hosted Supabase Auth public settings only. Admin product data and mutations go
|
||||
# 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_OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ RUN npm run build -w @sauci/shared
|
||||
|
||||
# Build admin app with env vars
|
||||
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_OPENROUTER_API_KEY
|
||||
ARG VITE_AI_MODEL
|
||||
@@ -23,7 +24,8 @@ ARG VITE_COUNCIL_GENERATOR_MODEL
|
||||
ARG VITE_COUNCIL_REVIEWER_MODEL
|
||||
|
||||
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_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY
|
||||
ENV VITE_AI_MODEL=$VITE_AI_MODEL
|
||||
|
||||
@@ -5,7 +5,8 @@ services:
|
||||
dockerfile: apps/admin/Dockerfile
|
||||
args:
|
||||
- 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_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
environment:
|
||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||
- 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_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-dom": "~19.1.7",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
|
||||
+37
-1
@@ -11,8 +11,10 @@ import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage';
|
||||
import { TagsPage } from '@/pages/content/TagsPage';
|
||||
import { DarePacksPage } from '@/pages/content/DarePacksPage';
|
||||
import { DaresPage } from '@/pages/content/DaresPage';
|
||||
import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage';
|
||||
import { UsersPage } from '@/pages/users/UsersPage';
|
||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||
import { CouplesPage } from '@/pages/users/CouplesPage';
|
||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
@@ -20,6 +22,8 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||
import { SentDaresPage } from '@/pages/activity/SentDaresPage';
|
||||
import { LiveDrawSessionsPage } from '@/pages/activity/LiveDrawSessionsPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
||||
@@ -108,6 +112,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/quiz-questions"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuizQuestionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User management */}
|
||||
<Route
|
||||
@@ -142,6 +154,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/couples"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<CouplesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
@@ -150,6 +170,22 @@ function App() {
|
||||
</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 */}
|
||||
<Route
|
||||
@@ -195,7 +231,7 @@ function App() {
|
||||
<Route
|
||||
path="/flagged-messages"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<FlaggedMessagesPage />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
|
||||
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
|
||||
import { supabase } from '@/config';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ExtractTopicsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
|
||||
const [reasoning, setReasoning] = useState('');
|
||||
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
// Fetch questions for this pack
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
if (questionsError) throw questionsError;
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
toast.error('This pack has no questions to analyze');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch existing topics
|
||||
const { data: existingTopics, error: topicsError } = await supabase
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
|
||||
if (topicsError) throw topicsError;
|
||||
|
||||
const result = await extractTopicsFromPack(
|
||||
pack.name,
|
||||
pack.description,
|
||||
questions.map(q => q.text),
|
||||
existingTopics || []
|
||||
);
|
||||
|
||||
setTopics(result.topics);
|
||||
setReasoning(result.reasoning);
|
||||
// Default select all
|
||||
setSelectedTopics(new Set(result.topics.map(t => t.name)));
|
||||
setStep('review');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to analyze pack');
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
const selectedList = topics.filter(t => selectedTopics.has(t.name));
|
||||
if (selectedList.length === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// First, create any new topics
|
||||
const newTopics = selectedList.filter(t => t.isNew);
|
||||
const existingTopicIds: string[] = selectedList
|
||||
.filter(t => !t.isNew && t.existingTopicId)
|
||||
.map(t => t.existingTopicId!);
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
.from('topics')
|
||||
.insert(newTopics.map(t => ({ name: t.name })))
|
||||
.select('id');
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
if (createdTopics) {
|
||||
existingTopicIds.push(...createdTopics.map(t => t.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing pack_topics for this pack
|
||||
await supabase
|
||||
.from('pack_topics')
|
||||
.delete()
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
// Insert new pack_topics relationships
|
||||
if (existingTopicIds.length > 0) {
|
||||
const { error: linkError } = await supabase
|
||||
.from('pack_topics')
|
||||
.insert(existingTopicIds.map(topicId => ({
|
||||
pack_id: pack.id,
|
||||
topic_id: topicId,
|
||||
})));
|
||||
|
||||
if (linkError) throw linkError;
|
||||
}
|
||||
|
||||
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
|
||||
onUpdated();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to apply topics');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset state after transition
|
||||
setTimeout(() => {
|
||||
setStep('initial');
|
||||
setTopics([]);
|
||||
setReasoning('');
|
||||
setSelectedTopics(new Set());
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
const next = new Set(selectedTopics);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
setSelectedTopics(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Tags className="h-5 w-5 text-blue-500" />
|
||||
Extract Topics
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use AI to identify topics and kinks for "{pack.name}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{step === 'initial' ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
|
||||
<div className="bg-blue-500/20 p-4 rounded-full">
|
||||
<Tags className="h-12 w-12 text-blue-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
The AI will analyze the questions in this pack and suggest relevant topics
|
||||
like bondage, exhibitionism, romance, etc. It will prefer matching existing
|
||||
topics when possible.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
|
||||
{analyzing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
'Start Analysis'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{topics.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||
No topics could be identified for this pack.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Suggested Topics</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topics.map((topic) => (
|
||||
<Badge
|
||||
key={topic.name}
|
||||
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer text-sm py-1.5 px-3 ${
|
||||
selectedTopics.has(topic.name)
|
||||
? topic.isNew
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => toggleSelection(topic.name)}
|
||||
>
|
||||
{selectedTopics.has(topic.name) ? (
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
) : null}
|
||||
{topic.name}
|
||||
{topic.isNew && (
|
||||
<Plus className="h-3 w-3 ml-1" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-600" />
|
||||
Existing topic
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-green-600" />
|
||||
<Plus className="h-3 w-3" />
|
||||
New topic
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reasoning && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">AI Reasoning</h4>
|
||||
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{reasoning}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{step === 'review' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep('initial')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
|
||||
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ExtractTopicsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
|
||||
const [reasoning, setReasoning] = useState('');
|
||||
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
// Fetch questions for this pack
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
if (questionsError) throw questionsError;
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
toast.error('This pack has no questions to analyze');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch existing topics
|
||||
const { data: existingTopics, error: topicsError } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
|
||||
if (topicsError) throw topicsError;
|
||||
|
||||
const result = await extractTopicsFromPack(
|
||||
pack.name,
|
||||
pack.description,
|
||||
questions.map(q => q.text),
|
||||
existingTopics || []
|
||||
);
|
||||
|
||||
setTopics(result.topics);
|
||||
setReasoning(result.reasoning);
|
||||
// Default select all
|
||||
setSelectedTopics(new Set(result.topics.map(t => t.name)));
|
||||
setStep('review');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to analyze pack');
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true);
|
||||
try {
|
||||
const selectedList = topics.filter(t => selectedTopics.has(t.name));
|
||||
if (selectedList.length === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// First, create any new topics
|
||||
const newTopics = selectedList.filter(t => t.isNew);
|
||||
const existingTopicIds: string[] = selectedList
|
||||
.filter(t => !t.isNew && t.existingTopicId)
|
||||
.map(t => t.existingTopicId!);
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await adminData
|
||||
.from('topics')
|
||||
.insert(newTopics.map(t => ({ name: t.name })))
|
||||
.select('id');
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
if (createdTopics) {
|
||||
existingTopicIds.push(...createdTopics.map(t => t.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove existing pack_topics for this pack
|
||||
await adminData
|
||||
.from('pack_topics')
|
||||
.delete()
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
// Insert new pack_topics relationships
|
||||
if (existingTopicIds.length > 0) {
|
||||
const { error: linkError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(existingTopicIds.map(topicId => ({
|
||||
pack_id: pack.id,
|
||||
topic_id: topicId,
|
||||
})));
|
||||
|
||||
if (linkError) throw linkError;
|
||||
}
|
||||
|
||||
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
|
||||
onUpdated();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to apply topics');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
// Reset state after transition
|
||||
setTimeout(() => {
|
||||
setStep('initial');
|
||||
setTopics([]);
|
||||
setReasoning('');
|
||||
setSelectedTopics(new Set());
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
const next = new Set(selectedTopics);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
setSelectedTopics(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Tags className="h-5 w-5 text-blue-500" />
|
||||
Extract Topics
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use AI to identify topics and kinks for "{pack.name}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{step === 'initial' ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
|
||||
<div className="bg-blue-500/20 p-4 rounded-full">
|
||||
<Tags className="h-12 w-12 text-blue-400" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
The AI will analyze the questions in this pack and suggest relevant topics
|
||||
like bondage, exhibitionism, romance, etc. It will prefer matching existing
|
||||
topics when possible.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
|
||||
{analyzing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
'Start Analysis'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{topics.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||
No topics could be identified for this pack.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Suggested Topics</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topics.map((topic) => (
|
||||
<Badge
|
||||
key={topic.name}
|
||||
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer text-sm py-1.5 px-3 ${
|
||||
selectedTopics.has(topic.name)
|
||||
? topic.isNew
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => toggleSelection(topic.name)}
|
||||
>
|
||||
{selectedTopics.has(topic.name) ? (
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
) : null}
|
||||
{topic.name}
|
||||
{topic.isNew && (
|
||||
<Plus className="h-3 w-3 ml-1" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-blue-600" />
|
||||
Existing topic
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded bg-green-600" />
|
||||
<Plus className="h-3 w-3" />
|
||||
New topic
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reasoning && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">AI Reasoning</h4>
|
||||
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{reasoning}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{step === 'review' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep('initial')}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Applying...
|
||||
</>
|
||||
) : (
|
||||
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
|
||||
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Define local compatible interface
|
||||
@@ -111,7 +111,7 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
|
||||
// Apply updates in parallel with audit logging
|
||||
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,
|
||||
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 { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
|
||||
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Question {
|
||||
@@ -62,7 +62,7 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
|
||||
|
||||
// Apply updates in parallel with audit logging
|
||||
await Promise.all(updates.map(update =>
|
||||
auditedSupabase.update('questions', update.id, {
|
||||
auditedAdminData.update('questions', update.id, {
|
||||
text: update.suggested_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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Image as ImageIcon, Music2 } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabaseConfig } from '@/config';
|
||||
import { getAdminResponseMedia } from '@/lib/adminApi';
|
||||
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
||||
|
||||
interface ResponseAnswerProps {
|
||||
@@ -62,38 +61,17 @@ function ProtectedResponseMedia({
|
||||
mediaType: 'photo' | 'audio';
|
||||
label: string;
|
||||
}) {
|
||||
const { session } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.access_token) {
|
||||
setError('Sign in to load media');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, {
|
||||
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());
|
||||
objectUrl = URL.createObjectURL(await getAdminResponseMedia(responseId));
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
@@ -108,7 +86,7 @@ function ProtectedResponseMedia({
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [responseId, session?.access_token]);
|
||||
}, [responseId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
DeletionAnalysis,
|
||||
PropsAnalysis,
|
||||
} from '@/lib/openai';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('required_props')
|
||||
.not('required_props', 'is', null)
|
||||
@@ -252,12 +252,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
|
||||
|
||||
// Apply updates/deletions in parallel with audit logging
|
||||
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) {
|
||||
operations.push(
|
||||
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
|
||||
auditedAdminData.deleteMany('questions', deleteIds).then(result => {
|
||||
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 { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -32,7 +32,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
if (params.packId && !params.categoryId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('question_packs')
|
||||
.select('category_id, categories(name)')
|
||||
.eq('id', params.packId)
|
||||
@@ -63,7 +63,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
if (params.userId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('id', params.userId)
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
PieChart,
|
||||
Tags,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
Heart,
|
||||
Swords,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -68,6 +72,12 @@ const contentNav: NavItem[] = [
|
||||
icon: <Tags className="h-5 w-5" />,
|
||||
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[] = [
|
||||
@@ -83,6 +93,24 @@ const usersNav: NavItem[] = [
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
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',
|
||||
href: '/usage-insights',
|
||||
@@ -108,7 +136,7 @@ const systemNav: NavItem[] = [
|
||||
label: 'Flagged Messages',
|
||||
href: '/flagged-messages',
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_CHATS,
|
||||
},
|
||||
{
|
||||
label: 'AI Settings',
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
export const supabaseConfig = {
|
||||
export const supabaseConfig = {
|
||||
url: import.meta.env.VITE_SUPABASE_URL,
|
||||
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||
};
|
||||
|
||||
export const supabase = createClient(
|
||||
supabaseConfig.url,
|
||||
supabaseConfig.anonKey
|
||||
);
|
||||
};
|
||||
|
||||
export const authClient = createClient(
|
||||
supabaseConfig.url,
|
||||
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(/\/$/, '');
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
|
||||
|
||||
@@ -80,23 +81,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
authClient.auth.getSession().then(async ({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
// Restore permissions from localStorage
|
||||
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
|
||||
if (storedPermissions) {
|
||||
setPermissions(JSON.parse(storedPermissions));
|
||||
// Preload AI config if user has an existing session (non-blocking)
|
||||
preloadAiConfig().catch(console.error);
|
||||
if (session) {
|
||||
try {
|
||||
const currentPermissions = await adminRequest<AdminPermissions>('/v1/admin/me');
|
||||
setPermissions(currentPermissions);
|
||||
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(currentPermissions));
|
||||
preloadAiConfig().catch(console.error);
|
||||
} catch {
|
||||
setPermissions(null);
|
||||
localStorage.removeItem(PERMISSIONS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
const { data: { subscription } } = authClient.auth.onAuthStateChange(
|
||||
(_event, session) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
@@ -112,7 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
const { error } = await authClient.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
@@ -122,20 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
// Fetch admin role and permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role, permissions')
|
||||
.eq('user_id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (adminError || !adminData) {
|
||||
await supabase.auth.signOut();
|
||||
let adminData: AdminPermissions;
|
||||
try {
|
||||
adminData = await adminRequest<AdminPermissions>('/v1/admin/me');
|
||||
} catch {
|
||||
await authClient.auth.signOut();
|
||||
throw new Error('Access denied. You are not an administrator.');
|
||||
}
|
||||
|
||||
// Store permissions from database
|
||||
const perms: AdminPermissions = {
|
||||
role: adminData.role as AdminRole,
|
||||
role: adminData.role,
|
||||
permissions: (adminData.permissions as string[]) || [],
|
||||
};
|
||||
|
||||
@@ -150,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.removeItem(PERMISSIONS_KEY);
|
||||
setPermissions(null);
|
||||
clearAiConfigCache();
|
||||
await supabase.auth.signOut();
|
||||
await authClient.auth.signOut();
|
||||
};
|
||||
|
||||
const isSuperAdmin = permissions?.role === 'super_admin';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
export interface CouncilGenerator {
|
||||
model: string;
|
||||
@@ -74,7 +74,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
@@ -82,7 +82,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
|
||||
if (fetchError) {
|
||||
// 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.');
|
||||
} else {
|
||||
setError(fetchError.message);
|
||||
@@ -110,7 +110,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
const { error: updateError } = await adminData
|
||||
.from('ai_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
@@ -160,7 +160,7 @@ export async function preloadAiConfig(): Promise<AiConfig | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
export interface AppConfig {
|
||||
id: string;
|
||||
@@ -40,7 +40,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from('app_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
@@ -71,7 +71,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
const { error: updateError } = await adminData
|
||||
.from('app_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
|
||||
+21
-31
@@ -1,10 +1,13 @@
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
// Tables to audit
|
||||
const AUDITED_TABLES = [
|
||||
'question_packs',
|
||||
'questions',
|
||||
'categories',
|
||||
'dare_packs',
|
||||
'dares',
|
||||
'quiz_questions',
|
||||
'profiles',
|
||||
'couples',
|
||||
'responses',
|
||||
@@ -26,23 +29,10 @@ async function logAction(
|
||||
oldValues?: Record<string, unknown> | null,
|
||||
newValues?: Record<string, unknown> | null
|
||||
): Promise<void> {
|
||||
if (!AUDITED_TABLES.includes(tableName)) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('log_admin_action', {
|
||||
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
|
||||
}
|
||||
// The standalone admin API writes the mutation and audit record in one
|
||||
// transaction. Keeping this no-op preserves the existing call sites while
|
||||
// avoiding duplicate or client-forgeable audit entries.
|
||||
void tableName; void action; void recordId; void oldValues; void newValues;
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -68,7 +58,7 @@ export const auditedSupabase = {
|
||||
): Promise<InsertResult<T>> {
|
||||
const recordArray = Array.isArray(records) ? records : [records];
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.insert(recordArray)
|
||||
.select();
|
||||
@@ -94,7 +84,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before update
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -102,7 +92,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
@@ -127,14 +117,14 @@ export const auditedSupabase = {
|
||||
// Fetch old values before update
|
||||
let oldRecords: Record<string, unknown>[] = [];
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq(filter.column, filter.value);
|
||||
oldRecords = oldData || [];
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.eq(filter.column, filter.value)
|
||||
@@ -161,7 +151,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -169,7 +159,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
@@ -191,14 +181,14 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldRecords: Record<string, unknown>[] = [];
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.in('id', ids);
|
||||
oldRecords = oldData || [];
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.in('id', ids);
|
||||
@@ -224,7 +214,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq(field, value)
|
||||
@@ -232,7 +222,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq(field, value);
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SupabaseFilterFn = (query: any) => any;
|
||||
type AdminFilterFn = (query: any) => any;
|
||||
|
||||
interface UseCrudOperationsOptions<T> {
|
||||
/**
|
||||
* The Supabase table name
|
||||
* The standalone admin API resource name
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @example
|
||||
@@ -90,13 +90,13 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
* @param filter Optional filter function to modify the query
|
||||
*/
|
||||
const fetchAll = useCallback(async (
|
||||
filter?: SupabaseFilterFn
|
||||
filter?: AdminFilterFn
|
||||
): Promise<T[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 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) {
|
||||
query = filter(query);
|
||||
@@ -131,7 +131,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
*/
|
||||
const fetchById = useCallback(async (id: string): Promise<T | null> => {
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from(table)
|
||||
.select(select)
|
||||
.eq('id', id)
|
||||
@@ -158,7 +158,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
// 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;
|
||||
|
||||
@@ -170,7 +170,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
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;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
@@ -194,7 +194,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
): Promise<boolean> => {
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
const { error: updateError } = await auditedSupabase.update(table, id, data);
|
||||
const { error: updateError } = await auditedAdminData.update(table, id, data);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
@@ -233,7 +233,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: deleteError } = await auditedSupabase.delete(table, id);
|
||||
const { error: deleteError } = await auditedAdminData.delete(table, id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
|
||||
@@ -1,243 +1,92 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { toast } from 'sonner';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
||||
|
||||
export interface UseRealtimeSubscriptionOptions<T> {
|
||||
table: string;
|
||||
schema?: string;
|
||||
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
||||
filter?: string;
|
||||
onInsert?: (payload: T) => void;
|
||||
onUpdate?: (payload: { old: T; new: T }) => void;
|
||||
onDelete?: (payload: T) => void;
|
||||
insertToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: T) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
updateToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: { old: T; new: T }) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
deleteToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: T) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseRealtimeSubscriptionReturn {
|
||||
status: SubscriptionStatus;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export function useRealtimeSubscription<T>(
|
||||
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 [error, setError] = useState<Error | null>(null);
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 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(() => {
|
||||
onInsertRef.current = onInsert;
|
||||
onUpdateRef.current = onUpdate;
|
||||
onDeleteRef.current = onDelete;
|
||||
insertToastRef.current = insertToast;
|
||||
updateToastRef.current = updateToast;
|
||||
deleteToastRef.current = deleteToast;
|
||||
});
|
||||
|
||||
// Debounced callback wrapper
|
||||
const debouncedCallback = useCallback(
|
||||
(callback: () => void) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (debounceMs > 0) {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
callback();
|
||||
}
|
||||
}, debounceMs);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
};
|
||||
|
||||
subscribe();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (channelRef.current) {
|
||||
supabase.removeChannel(channelRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, table, schema, event, filter, handleChange]);
|
||||
|
||||
return { status, error };
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { queryAdminRows, type AdminFilter } from '@/lib/adminApi';
|
||||
|
||||
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
||||
export interface UseRealtimeSubscriptionOptions<T> {
|
||||
table: string; schema?: string; event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; filter?: string;
|
||||
onInsert?: (payload: T) => void; onUpdate?: (payload: { old: T; new: T }) => void; onDelete?: (payload: T) => void;
|
||||
insertToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||
updateToast?: { enabled: boolean; message: string | ((payload: { old: T; new: T }) => string); type?: 'success' | 'info' | 'warning' };
|
||||
deleteToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||
debounceMs?: number; enabled?: boolean; pollIntervalMs?: number;
|
||||
}
|
||||
export interface UseRealtimeSubscriptionReturn { status: SubscriptionStatus; error: Error | null }
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
||||
const MIN_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
function parseFilter(filter?: string): AdminFilter[] | undefined {
|
||||
if (!filter) return undefined;
|
||||
const match = /^([a-z_][a-z0-9_]*)=(eq|neq|gte|lte)\.(.+)$/.exec(filter);
|
||||
return match ? [{ column: match[1], op: match[2] as AdminFilter['op'], value: match[3] }] : undefined;
|
||||
}
|
||||
|
||||
function rowKey(row: unknown, index: number): string {
|
||||
if (row && typeof row === 'object') {
|
||||
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);
|
||||
}
|
||||
|
||||
export function useRealtimeSubscription<T>(options: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionReturn {
|
||||
const { table, event = '*', filter, onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast,
|
||||
debounceMs = 0, enabled = true, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = options;
|
||||
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const snapshotRef = useRef<Map<string, { value: T; json: string }> | null>(null);
|
||||
const callbacksRef = useRef({ onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast });
|
||||
callbacksRef.current = { onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast };
|
||||
const emit = useCallback((callback: () => void) => {
|
||||
if (debounceMs <= 0) callback(); else window.setTimeout(callback, debounceMs);
|
||||
}, [debounceMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) { setStatus('CLOSED'); return; }
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const interval = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
|
||||
const poll = async () => {
|
||||
if (document.visibilityState === 'hidden') { timer = window.setTimeout(poll, interval); return; }
|
||||
try {
|
||||
setStatus((current) => current === 'SUBSCRIBED' ? current : 'SUBSCRIBING');
|
||||
const { rows } = await queryAdminRows<T>(table, { filters: parseFilter(filter), limit: 500 });
|
||||
if (cancelled) return;
|
||||
const next = new Map(rows.map((row, index) => [rowKey(row, index), { value: row, json: JSON.stringify(row) }]));
|
||||
const previous = snapshotRef.current;
|
||||
if (previous) {
|
||||
if (event === '*' || event === 'INSERT') for (const [key, entry] of next) if (!previous.has(key)) {
|
||||
const config = callbacksRef.current;
|
||||
if (config.insertToast?.enabled) toast[config.insertToast.type || 'info'](typeof config.insertToast.message === 'function' ? config.insertToast.message(entry.value) : config.insertToast.message);
|
||||
if (config.onInsert) emit(() => config.onInsert!(entry.value));
|
||||
}
|
||||
if (event === '*' || event === 'UPDATE') for (const [key, entry] of next) {
|
||||
const old = previous.get(key);
|
||||
if (old && old.json !== entry.json) {
|
||||
const payload = { old: old.value, new: entry.value };
|
||||
const config = callbacksRef.current;
|
||||
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));
|
||||
}
|
||||
}
|
||||
if (event === '*' || event === 'DELETE') for (const [key, entry] of previous) if (!next.has(key)) {
|
||||
const config = callbacksRef.current;
|
||||
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));
|
||||
}
|
||||
}
|
||||
snapshotRef.current = next;
|
||||
setStatus('SUBSCRIBED'); setError(null);
|
||||
} catch (cause) {
|
||||
if (!cancelled) { setStatus('CHANNEL_ERROR'); setError(cause instanceof Error ? cause : new Error(String(cause))); }
|
||||
} finally {
|
||||
if (!cancelled) timer = window.setTimeout(poll, interval);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); snapshotRef.current = null; };
|
||||
}, [emit, enabled, event, filter, pollIntervalMs, table]);
|
||||
return { status, error };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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); },
|
||||
};
|
||||
@@ -23,8 +23,8 @@ export async function analyzeQuestionDeletions(
|
||||
}));
|
||||
|
||||
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.'
|
||||
: '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.';
|
||||
? '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.'
|
||||
: '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>
|
||||
Identify questions that should be DELETED instead of edited.
|
||||
@@ -38,7 +38,7 @@ ${toneInstruction}
|
||||
<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.
|
||||
- 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
|
||||
- too-vague: not actionable or not a clear proposal
|
||||
- broken: grammar/structure too broken to repair without inventing content
|
||||
@@ -73,8 +73,8 @@ Only return questions that should be deleted.
|
||||
</output_format>`;
|
||||
|
||||
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 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. 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 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({
|
||||
model: getModel('fix'),
|
||||
|
||||
@@ -9,16 +9,16 @@ export interface ImageSafetyAnalysis {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<categories>
|
||||
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults)
|
||||
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review
|
||||
- SAFE: Non-explicit relationship, romance, lifestyle, or abstract imagery appropriate for the universal catalogue
|
||||
- ADULT_CONTENT: Sexual, sexually gratifying, fetish, explicit-anatomy, or stimulation-focused imagery; not appropriate for publication
|
||||
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
|
||||
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
|
||||
- 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>
|
||||
- none: No safety concerns at all
|
||||
- low: Minor flag, likely fine but worth noting
|
||||
- medium: Should be reviewed by admin
|
||||
- high: Likely inappropriate, requires immediate review
|
||||
- low: Minor flag, needs human review before publication
|
||||
- medium: Not eligible without a documented human safety decision
|
||||
- high: Inappropriate for publication and should be archived
|
||||
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
|
||||
</severity_levels>
|
||||
|
||||
@@ -40,7 +40,7 @@ Return a JSON object:
|
||||
"severity": "none"|"low"|"medium"|"high"|"critical",
|
||||
"description": "Brief factual description of what's shown (max 50 words)",
|
||||
"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>`;
|
||||
|
||||
|
||||
@@ -22,18 +22,17 @@ export async function analyzeQuestionText(
|
||||
}));
|
||||
|
||||
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.'
|
||||
: '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.';
|
||||
? '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.'
|
||||
: '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
|
||||
? '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"';
|
||||
|
||||
const asymmetricExamples = isExplicit
|
||||
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
||||
- GOOD: "Spank your partner" / "Be spanked by your partner"`
|
||||
? `text (The Doer): Active safe proposal (e.g., "Let your partner take the lead").
|
||||
- partner_text (The Receiver): Reciprocal safe proposal (e.g., "Take the lead with 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").
|
||||
- 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"`;
|
||||
@@ -66,17 +65,15 @@ Cards should be "Proposals" for specific actions, NOT interview questions.
|
||||
|
||||
Partner text rules:
|
||||
- Describe RECEIVER's experience clearly
|
||||
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING:
|
||||
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
|
||||
* NOT: "Moan for your partner" (sounds forced, not natural)
|
||||
- Keep both perspectives inside the same universal safety ceiling
|
||||
</question_types>
|
||||
|
||||
<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
|
||||
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
|
||||
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
|
||||
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
|
||||
8. Skip questions that are ALREADY well-phrased
|
||||
@@ -102,8 +99,8 @@ Only include questions that need text improvement.
|
||||
</output_format>`;
|
||||
|
||||
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 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 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 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({
|
||||
model: getModel('fix'),
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
@@ -62,9 +62,9 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
export const TONE_LEVELS = [
|
||||
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
|
||||
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
|
||||
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' },
|
||||
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' },
|
||||
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
|
||||
{ level: 3, label: 'Playful', description: 'Flirty anticipation, novelty & playful connection' },
|
||||
{ level: 4, label: 'Bold', description: 'Trust, leadership & suggestive relationship dynamics' },
|
||||
{ level: 5, label: 'Daring', description: 'Comfort zones, control & user-led curiosity' },
|
||||
] as const;
|
||||
|
||||
// =============================================================================
|
||||
@@ -92,30 +92,26 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
- Partner text (if present) clearly describes receiver's experience
|
||||
- No confusing or ambiguous phrasing
|
||||
|
||||
4. ANATOMICAL CONSISTENCY:
|
||||
- No mixed male/female anatomy in alternatives
|
||||
- BAD: "Finger or give your partner a handjob"
|
||||
- GOOD: Pick one activity
|
||||
4. CATALOGUE SAFETY:
|
||||
- REJECT named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
|
||||
- REJECT sexual instructions disguised with euphemisms
|
||||
- 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):
|
||||
- Engaging and enticing, not clinical
|
||||
- BAD: "Receive oral from your partner"
|
||||
- GOOD: "Let your partner pleasure you with their mouth"
|
||||
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
||||
- Describes the reciprocal perspective without adding sexual detail
|
||||
- Preserves the same safe proposal and emotional intensity
|
||||
|
||||
6. COUPLE TARGETING (flag if incorrect):
|
||||
- Default: ALL couples (null) unless explicit anatomical requirement
|
||||
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
|
||||
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
|
||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
||||
- Flag if targeting seems too restrictive or missing needed restrictions
|
||||
- Default: ALL couples (null)
|
||||
- Safe catalogue proposals should not require anatomical targeting
|
||||
- Flag unnecessary or exclusionary targeting
|
||||
|
||||
7. INITIATOR TARGETING (for asymmetric questions only):
|
||||
- Default: null (anyone can initiate) unless explicit anatomical requirement
|
||||
- Set initiator based on WHO DOES the action in "text" field
|
||||
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
|
||||
- "Give your partner a massage" -> initiator: null (anyone can give)
|
||||
- Flag if initiator seems incorrectly assigned
|
||||
- Default: null (anyone can initiate)
|
||||
- Set initiator only when a legitimate non-anatomical product rule requires it
|
||||
- Flag unnecessary targeting
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
@@ -125,10 +121,10 @@ REVIEW CRITERIA - Score each 1-10:
|
||||
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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 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: 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: 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?"
|
||||
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
|
||||
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
|
||||
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>
|
||||
`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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>
|
||||
<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>`,
|
||||
|
||||
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>
|
||||
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint>
|
||||
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style>
|
||||
<role>Generate playful, flirty activities about anticipation and novelty.</role>
|
||||
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
|
||||
<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>
|
||||
<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>
|
||||
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint>
|
||||
<style>Passionate, explicit, adventurous. Bold but not extreme.</style>
|
||||
<role>Generate bold activities about trust, leadership, following, and switching dynamics.</role>
|
||||
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
|
||||
<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>
|
||||
<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>
|
||||
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint>
|
||||
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style>
|
||||
<role>Generate emotionally daring activities about comfort zones, control, vulnerability, and user-led curiosity.</role>
|
||||
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions. Never turn curiosity into a developer-supplied sexual example.</constraint>
|
||||
<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>
|
||||
<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';
|
||||
|
||||
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.';
|
||||
|
||||
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
|
||||
|
||||
@@ -50,7 +50,7 @@ Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQues
|
||||
<pack_context>
|
||||
- Pack Name: "${packContext.name}"
|
||||
${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})
|
||||
- Requested question count: ${requestedCount}
|
||||
</pack_context>
|
||||
@@ -77,7 +77,7 @@ Calibrate your scores knowing these weights. A low compliance score has the most
|
||||
|
||||
<duplicate_detection>
|
||||
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
|
||||
- Would feel repetitive if both appeared in the same pack
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ export async function generatePack(
|
||||
const openai = getOpenAI();
|
||||
|
||||
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.';
|
||||
|
||||
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
|
||||
@@ -105,11 +105,11 @@ export async function suggestPacks(
|
||||
: 'None';
|
||||
|
||||
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.';
|
||||
|
||||
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
|
||||
|
||||
@@ -19,10 +19,8 @@ function buildQuestionPrompt(
|
||||
crudeLang: boolean = false,
|
||||
inspiration?: string
|
||||
): string {
|
||||
const isExplicit = tone >= 4;
|
||||
|
||||
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)
|
||||
@@ -41,31 +39,20 @@ function buildQuestionPrompt(
|
||||
|
||||
// Examples based on tone level
|
||||
const symmetricExamples = tone <= 2
|
||||
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".'
|
||||
: isExplicit
|
||||
? '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".';
|
||||
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Share a longer kiss".'
|
||||
: 'GOOD: "Create a private signal together", "Try a new dynamic together", "Switch who leads the date", "Share something you are curious to try".';
|
||||
|
||||
const asymmetricExamples = tone <= 2
|
||||
? `Examples:
|
||||
- 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: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
|
||||
: isExplicit
|
||||
? `Examples:
|
||||
- text: "Tie your partner up" → partner_text: "Get tied up by your partner"
|
||||
- text: "Spank your partner" → partner_text: "Get spanked 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"`;
|
||||
: `Examples:
|
||||
- text: "Plan a bold surprise for your partner" → partner_text: "Let your partner plan a bold surprise"
|
||||
- text: "Lead your partner through a mystery date" → partner_text: "Follow your partner through a mystery date"
|
||||
- text: "Choose a playful rule for your partner" → partner_text: "Follow a playful rule chosen by your partner"`;
|
||||
|
||||
const explicitWarning = tone === 1
|
||||
? '\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 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.';
|
||||
|
||||
const descriptionContext = packDescription
|
||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||
@@ -90,8 +77,8 @@ ${toneInstruction}${explicitWarning}${crudeLangInstruction}
|
||||
${CORE_LANGUAGE_RULES}
|
||||
|
||||
<examples>
|
||||
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..."
|
||||
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
|
||||
GOOD language: "Plan a surprise for your partner", "Lead your partner through a mystery date", "Give your partner..."
|
||||
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".
|
||||
</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"):
|
||||
Question 1 (primary):
|
||||
text: "Spank your partner"
|
||||
partner_text: "Be spanked by your partner"
|
||||
text: "Plan a bold surprise for your partner"
|
||||
partner_text: "Let your partner plan a bold surprise"
|
||||
inverse_pair_id: "pair_1"
|
||||
|
||||
Question 2 (inverse):
|
||||
text: "Be spanked by your partner"
|
||||
partner_text: "Spank your partner"
|
||||
text: "Let your partner plan a bold surprise"
|
||||
partner_text: "Plan a bold surprise for your partner"
|
||||
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.
|
||||
@@ -135,7 +122,7 @@ Return a JSON object with this exact structure:
|
||||
{
|
||||
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
||||
"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
|
||||
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
||||
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
|
||||
|
||||
@@ -52,7 +52,7 @@ Review each generated question against our quality guidelines.
|
||||
<pack_context>
|
||||
- Pack Name: "${packContext.name}"
|
||||
${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})
|
||||
</pack_context>
|
||||
|
||||
@@ -63,7 +63,7 @@ ${REVIEW_GUIDELINES}
|
||||
<verdict_rules>
|
||||
- PASS: All scores >= 7, no major issues
|
||||
- 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>
|
||||
|
||||
<questions_to_review>
|
||||
@@ -104,7 +104,7 @@ Review ALL questions against ALL 7 criteria. Be thorough but fair - only flag/re
|
||||
messages: [
|
||||
{
|
||||
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 },
|
||||
],
|
||||
@@ -213,7 +213,7 @@ Compare ${candidates.length} different sets of generated questions from differen
|
||||
<pack_context>
|
||||
- Pack Name: "${packContext.name}"
|
||||
${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})
|
||||
</pack_context>
|
||||
|
||||
@@ -270,7 +270,7 @@ Select the best set and review ALL questions in that set against ALL 7 criteria.
|
||||
messages: [
|
||||
{
|
||||
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 },
|
||||
],
|
||||
|
||||
@@ -18,8 +18,8 @@ export async function polishContent(
|
||||
const openai = getOpenAI();
|
||||
|
||||
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.'
|
||||
: '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.';
|
||||
? '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.'
|
||||
: '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> = {
|
||||
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.
|
||||
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
|
||||
- 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"
|
||||
- DO NOT change it to something unrelated like "Go to the movies".
|
||||
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
|
||||
3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
|
||||
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.
|
||||
- If the core action is explicit, replace it with a high-level proposal about curiosity, trust, anticipation, or relationship dynamics.
|
||||
3. TONE: Engaging, romantic, flirty, or daring, but never sexual or instructional.
|
||||
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.
|
||||
`;
|
||||
fewShotExamples = `
|
||||
=== FEW-SHOT EXAMPLES ===
|
||||
${type === 'question' ? `
|
||||
"Would you want to give me a massage?" -> "Give your partner a sensual massage"
|
||||
"Have you ever thought about sex in public?" -> "Have sex somewhere you might get caught"
|
||||
"maybe we could try using a blindfold sometime" -> "Blindfold your partner and tease their senses"
|
||||
"cum on partner" -> "Cum on your partner" (keep "cum", don't change to "come")
|
||||
"Would you want to give me a massage?" -> "Give your partner a relaxing massage"
|
||||
"Have you ever thought about trying something daring?" -> "Try something daring with your partner"
|
||||
"maybe we could explore a new dynamic sometime" -> "Explore a new dynamic with your partner"
|
||||
"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"
|
||||
"Get oral from your partner" -> "Let your partner pleasure you with their mouth"
|
||||
"Be tied up" -> "Let your partner tie you up and surrender control"
|
||||
"Moan for your partner" -> "Let your partner make you moan"
|
||||
"Let your partner plan the evening" -> "Let your partner take the lead for an evening"
|
||||
"Try something new together" -> "Explore a new dynamic with your partner"
|
||||
"Tell your partner what you want" -> "Share a private desire in your own words"
|
||||
`}
|
||||
=== END EXAMPLES ===
|
||||
`;
|
||||
@@ -79,8 +75,8 @@ ${explicitInstruction}
|
||||
<rules>
|
||||
${additionalRules}
|
||||
- Make it concise, engaging, and grammatically correct
|
||||
- Maintain the original intent and meaning
|
||||
- Do NOT change the core action - only improve phrasing
|
||||
- Preserve the original intent only when it stays inside the universal safety ceiling
|
||||
- Replace explicit core actions with safe, high-level relationship proposals
|
||||
</rules>
|
||||
|
||||
${fewShotExamples}
|
||||
@@ -96,7 +92,7 @@ ${fewShotExamples}
|
||||
messages: [
|
||||
{
|
||||
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 },
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from './couples';
|
||||
|
||||
describe('coupleStatusFromMemberCount', () => {
|
||||
it('treats zero or one member as pending', () => {
|
||||
expect(coupleStatusFromMemberCount(0)).toBe('pending');
|
||||
expect(coupleStatusFromMemberCount(1)).toBe('pending');
|
||||
});
|
||||
|
||||
it('treats two or more members as paired', () => {
|
||||
expect(coupleStatusFromMemberCount(2)).toBe('paired');
|
||||
expect(coupleStatusFromMemberCount(3)).toBe('paired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCoupleSearchFilter', () => {
|
||||
it('returns null for an empty or blank search', () => {
|
||||
expect(buildCoupleSearchFilter('')).toBeNull();
|
||||
expect(buildCoupleSearchFilter(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('matches a uuid-shaped search as an exact id filter', () => {
|
||||
expect(buildCoupleSearchFilter('a1b2c3d4-1111-1111-1111-111111111111')).toEqual({
|
||||
column: 'id', op: 'eq', value: 'a1b2c3d4-1111-1111-1111-111111111111',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats any other search as a partial invite code match', () => {
|
||||
expect(buildCoupleSearchFilter('SUNSET42')).toEqual({
|
||||
column: 'invite_code', op: 'ilike', value: '%SUNSET42%',
|
||||
});
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before deciding the filter shape', () => {
|
||||
expect(buildCoupleSearchFilter(' sunset42 ')).toEqual({
|
||||
column: 'invite_code', op: 'ilike', value: '%sunset42%',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Couples do not carry an explicit "paired" flag (see apps/api/src/domains/couples/schema.ts).
|
||||
// Pairing is inferred from how many profiles currently reference the couple id, the same
|
||||
// approach UsersPage already uses inline for the couple badge on a profile row.
|
||||
|
||||
export type CoupleStatus = 'paired' | 'pending';
|
||||
|
||||
/**
|
||||
* Derive whether a couple is fully paired from the number of profiles that
|
||||
* reference it. A couple starts with one member (the creator) and becomes
|
||||
* paired once a partner joins via invite code.
|
||||
*/
|
||||
export function coupleStatusFromMemberCount(memberCount: number): CoupleStatus {
|
||||
return memberCount >= 2 ? 'paired' : 'pending';
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export type CoupleSearchFilter =
|
||||
| { column: 'id'; op: 'eq'; value: string }
|
||||
| { column: 'invite_code'; op: 'ilike'; value: string }
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Build the admin query filter for the couples search box. A UUID-shaped
|
||||
* search term is treated as an exact id lookup; anything else is matched
|
||||
* against the invite code with a case-insensitive partial match.
|
||||
*/
|
||||
export function buildCoupleSearchFilter(rawSearch: string): CoupleSearchFilter {
|
||||
const search = rawSearch.trim();
|
||||
if (!search) return null;
|
||||
if (UUID_PATTERN.test(search)) {
|
||||
return { column: 'id', op: 'eq', value: search };
|
||||
}
|
||||
return { column: 'invite_code', op: 'ilike', value: `%${search}%` };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clampDareIntensity, parseIntensityRange } from './dareIntensity';
|
||||
|
||||
describe('clampDareIntensity', () => {
|
||||
it('accepts values within the 1-5 range', () => {
|
||||
expect(clampDareIntensity('1')).toBe(1);
|
||||
expect(clampDareIntensity('3')).toBe(3);
|
||||
expect(clampDareIntensity('5')).toBe(5);
|
||||
});
|
||||
|
||||
it('falls back to 1 for missing, non-numeric, or out-of-range values', () => {
|
||||
expect(clampDareIntensity('')).toBe(1);
|
||||
expect(clampDareIntensity('abc')).toBe(1);
|
||||
expect(clampDareIntensity('0')).toBe(1);
|
||||
expect(clampDareIntensity('6')).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to a custom default when provided', () => {
|
||||
expect(clampDareIntensity('', 3)).toBe(3);
|
||||
expect(clampDareIntensity('9', 3)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntensityRange', () => {
|
||||
it('allows both bounds blank', () => {
|
||||
expect(parseIntensityRange('', '')).toEqual({ range: { min: null, max: null }, error: null });
|
||||
});
|
||||
|
||||
it('parses valid bounds', () => {
|
||||
expect(parseIntensityRange('2', '4')).toEqual({ range: { min: 2, max: 4 }, error: null });
|
||||
});
|
||||
|
||||
it('rejects a minimum outside 1-5', () => {
|
||||
const { error } = parseIntensityRange('0', '');
|
||||
expect(error).toMatch(/Minimum intensity/);
|
||||
});
|
||||
|
||||
it('rejects a maximum outside 1-5', () => {
|
||||
const { error } = parseIntensityRange('', '6');
|
||||
expect(error).toMatch(/Maximum intensity/);
|
||||
});
|
||||
|
||||
it('rejects a minimum greater than the maximum', () => {
|
||||
const { error } = parseIntensityRange('4', '2');
|
||||
expect(error).toMatch(/cannot be greater than/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Dares and dare packs both gate on a 1-5 intensity scale (see
|
||||
// apps/api/drizzle/0010_admin.sql and 0013_dares_loop.sql). These helpers keep
|
||||
// the admin form validation consistent with that range.
|
||||
|
||||
export const MIN_DARE_INTENSITY = 1;
|
||||
export const MAX_DARE_INTENSITY = 5;
|
||||
|
||||
/**
|
||||
* Clamp a raw form value to a valid dare intensity (1-5), falling back to 1
|
||||
* when the value is missing or not a finite number in range.
|
||||
*/
|
||||
export function clampDareIntensity(rawValue: string, fallback: number = MIN_DARE_INTENSITY): number {
|
||||
const parsed = parseInt(rawValue, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < MIN_DARE_INTENSITY || parsed > MAX_DARE_INTENSITY) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface IntensityRange {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate an optional min/max intensity pair for a dare pack.
|
||||
* Returns the parsed range, or an error message when the values are invalid.
|
||||
*/
|
||||
export function parseIntensityRange(rawMin: string, rawMax: string): { range: IntensityRange; error: string | null } {
|
||||
const min = rawMin.trim() ? parseInt(rawMin, 10) : null;
|
||||
const max = rawMax.trim() ? parseInt(rawMax, 10) : null;
|
||||
|
||||
if (min !== null && (!Number.isFinite(min) || min < MIN_DARE_INTENSITY || min > MAX_DARE_INTENSITY)) {
|
||||
return { range: { min: null, max: null }, error: 'Minimum intensity must be between 1 and 5' };
|
||||
}
|
||||
if (max !== null && (!Number.isFinite(max) || max < MIN_DARE_INTENSITY || max > MAX_DARE_INTENSITY)) {
|
||||
return { range: { min: null, max: null }, error: 'Maximum intensity must be between 1 and 5' };
|
||||
}
|
||||
if (min !== null && max !== null && min > max) {
|
||||
return { range: { min: null, max: null }, error: 'Minimum intensity cannot be greater than maximum intensity' };
|
||||
}
|
||||
|
||||
return { range: { min, max }, error: null };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeQuizOptions, validateQuizQuestionForm } from './quizQuestions';
|
||||
|
||||
describe('sanitizeQuizOptions', () => {
|
||||
it('trims and drops empty options', () => {
|
||||
expect(sanitizeQuizOptions([' A ', '', ' ', 'B'])).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateQuizQuestionForm', () => {
|
||||
const base = {
|
||||
prompt_self: 'What is your favourite season?',
|
||||
prompt_guess: "What is your partner's favourite season?",
|
||||
options: ['Spring', 'Summer'],
|
||||
};
|
||||
|
||||
it('accepts a valid form', () => {
|
||||
expect(validateQuizQuestionForm(base)).toBeNull();
|
||||
});
|
||||
|
||||
it('requires the self prompt', () => {
|
||||
expect(validateQuizQuestionForm({ ...base, prompt_self: ' ' })).toMatch(/about yourself/);
|
||||
});
|
||||
|
||||
it('requires the guess prompt', () => {
|
||||
expect(validateQuizQuestionForm({ ...base, prompt_guess: '' })).toMatch(/about your partner/);
|
||||
});
|
||||
|
||||
it('requires at least two options', () => {
|
||||
expect(validateQuizQuestionForm({ ...base, options: ['Only one'] })).toMatch(/At least 2 options/);
|
||||
});
|
||||
|
||||
it('allows at most four options', () => {
|
||||
expect(validateQuizQuestionForm({ ...base, options: ['A', 'B', 'C', 'D', 'E'] })).toMatch(/At most 4 options/);
|
||||
});
|
||||
|
||||
it('ignores blank options when counting', () => {
|
||||
expect(validateQuizQuestionForm({ ...base, options: ['Spring', 'Summer', '', ''] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Quiz question options are constrained by the database check
|
||||
// `array_length(options, 1) between 2 and 4` (see
|
||||
// apps/api/drizzle/0019_quiz.sql). These helpers keep the admin form
|
||||
// validation consistent with that constraint.
|
||||
|
||||
export const MIN_QUIZ_OPTIONS = 2;
|
||||
export const MAX_QUIZ_OPTIONS = 4;
|
||||
|
||||
export interface QuizQuestionFormValues {
|
||||
prompt_self: string;
|
||||
prompt_guess: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
/** Trim options and drop any that are empty. */
|
||||
export function sanitizeQuizOptions(options: string[]): string[] {
|
||||
return options.map((option) => option.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a quiz question form. Returns an error message, or null when the
|
||||
* form is valid.
|
||||
*/
|
||||
export function validateQuizQuestionForm(form: QuizQuestionFormValues): string | null {
|
||||
if (!form.prompt_self.trim()) return 'The "about yourself" prompt is required';
|
||||
if (!form.prompt_guess.trim()) return 'The "about your partner" prompt is required';
|
||||
const cleanedOptions = sanitizeQuizOptions(form.options);
|
||||
if (cleanedOptions.length < MIN_QUIZ_OPTIONS) return `At least ${MIN_QUIZ_OPTIONS} options are required`;
|
||||
if (cleanedOptions.length > MAX_QUIZ_OPTIONS) return `At most ${MAX_QUIZ_OPTIONS} options are allowed`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isSentDareOverdue, SENT_DARE_STATUS_LABELS } from './sentDares';
|
||||
|
||||
describe('SENT_DARE_STATUS_LABELS', () => {
|
||||
it('has a label for every documented status', () => {
|
||||
for (const status of ['pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled'] as const) {
|
||||
expect(SENT_DARE_STATUS_LABELS[status].label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSentDareOverdue', () => {
|
||||
const now = new Date('2026-08-30T12:00:00Z');
|
||||
|
||||
it('is false when there is no expiry', () => {
|
||||
expect(isSentDareOverdue('pending', null, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a terminal status even if expiry has passed', () => {
|
||||
expect(isSentDareOverdue('completed', '2026-08-01T00:00:00Z', now)).toBe(false);
|
||||
expect(isSentDareOverdue('expired', '2026-08-01T00:00:00Z', now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for an open status whose expiry has not passed', () => {
|
||||
expect(isSentDareOverdue('active', '2026-09-01T00:00:00Z', now)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for an open status whose expiry has already passed', () => {
|
||||
expect(isSentDareOverdue('pending', '2026-08-01T00:00:00Z', now)).toBe(true);
|
||||
expect(isSentDareOverdue('active', '2026-08-01T00:00:00Z', now)).toBe(true);
|
||||
expect(isSentDareOverdue('submitted', '2026-08-01T00:00:00Z', now)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// sent_dares status lifecycle mirrors the check constraint added in
|
||||
// apps/api/drizzle/0013_dares_loop.sql: pending -> active -> submitted -> completed,
|
||||
// with expired/declined/cancelled as terminal side branches.
|
||||
|
||||
export const SENT_DARE_STATUSES = [
|
||||
'pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled',
|
||||
] as const;
|
||||
|
||||
export type SentDareStatus = typeof SENT_DARE_STATUSES[number];
|
||||
|
||||
export type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
|
||||
export const SENT_DARE_STATUS_LABELS: Record<SentDareStatus, { label: string; variant: BadgeVariant }> = {
|
||||
pending: { label: 'Pending', variant: 'secondary' },
|
||||
active: { label: 'Active', variant: 'default' },
|
||||
submitted: { label: 'Submitted', variant: 'default' },
|
||||
completed: { label: 'Completed', variant: 'outline' },
|
||||
expired: { label: 'Expired', variant: 'destructive' },
|
||||
declined: { label: 'Declined', variant: 'destructive' },
|
||||
cancelled: { label: 'Cancelled', variant: 'outline' },
|
||||
};
|
||||
|
||||
const OPEN_STATUSES: ReadonlySet<SentDareStatus> = new Set(['pending', 'active', 'submitted']);
|
||||
|
||||
/**
|
||||
* A dare is overdue when it is still in an open state but its expiry has
|
||||
* already passed. The worker sweeps these to `expired` asynchronously, so the
|
||||
* admin view can lag the true status briefly; this flags that gap for support.
|
||||
*/
|
||||
export function isSentDareOverdue(
|
||||
status: string,
|
||||
expiresAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!expiresAt) return false;
|
||||
if (!OPEN_STATUSES.has(status as SentDareStatus)) return false;
|
||||
return new Date(expiresAt).getTime() < now.getTime();
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
|
||||
Daily Response Limit (Free Users)
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<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.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set to <strong>0</strong> to disable daily limits entirely.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
@@ -45,7 +45,7 @@ export function AuditLogsPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('audit_logs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -43,6 +43,7 @@ function formatFeatureName(featureName: string) {
|
||||
export function DashboardPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
|
||||
const [stats, setStats] = useState({
|
||||
categories: 0,
|
||||
packs: 0,
|
||||
@@ -64,10 +65,12 @@ export function DashboardPage() {
|
||||
{ count: questionsCount },
|
||||
{ count: usersCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('categories').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('questions').select('*', { count: 'exact', head: true }),
|
||||
canViewUsers
|
||||
? adminData.from('profiles').select('*', { count: 'exact', head: true })
|
||||
: Promise.resolve({ count: 0 }),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
@@ -85,31 +88,24 @@ export function DashboardPage() {
|
||||
{ data: featureInterests },
|
||||
{ data: recentInterests },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('messages')
|
||||
.select('created_at')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString()),
|
||||
supabase
|
||||
.from('feature_interests')
|
||||
.select('created_at, user_id')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString()),
|
||||
supabase
|
||||
.from('feature_interests')
|
||||
.select('id, user_id, feature_name, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
canViewChats
|
||||
? adminData.from('messages').select('created_at').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
if (canViewUsers) {
|
||||
const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts');
|
||||
|
||||
if (countsError) {
|
||||
try {
|
||||
const { counts } = await adminRequest<{ counts: FeatureInterestCountsRow[] }>('/v1/admin/feature-interest-counts');
|
||||
setFeatureInterestCounts(counts);
|
||||
} catch (countsError) {
|
||||
console.error('Failed to fetch feature interest counts:', countsError);
|
||||
setFeatureInterestCounts([]);
|
||||
} else {
|
||||
setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]);
|
||||
}
|
||||
} else {
|
||||
setFeatureInterestCounts([]);
|
||||
@@ -172,7 +168,7 @@ export function DashboardPage() {
|
||||
if (canViewUsers && recentActivity.length > 0) {
|
||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||
|
||||
const { data: profiles } = await supabase
|
||||
const { data: profiles } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
@@ -193,11 +189,12 @@ export function DashboardPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewUsers]);
|
||||
}, [canViewChats, canViewUsers]);
|
||||
|
||||
// Real-time subscriptions for dashboard stats (with debouncing)
|
||||
const { status: profilesStatus } = useRealtimeSubscription({
|
||||
table: 'profiles',
|
||||
enabled: canViewUsers,
|
||||
onInsert: fetchStats,
|
||||
onDelete: fetchStats,
|
||||
debounceMs: 2000,
|
||||
@@ -205,6 +202,7 @@ export function DashboardPage() {
|
||||
|
||||
const { status: messagesStatus } = useRealtimeSubscription({
|
||||
table: 'messages',
|
||||
enabled: canViewChats,
|
||||
onInsert: fetchStats,
|
||||
debounceMs: 2000,
|
||||
});
|
||||
|
||||
@@ -1,146 +1,146 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
||||
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
screenshot_url: string | null;
|
||||
device_info: Record<string, unknown>;
|
||||
status: FeedbackStatus;
|
||||
admin_notes: string | null;
|
||||
question_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
question?: {
|
||||
id: string;
|
||||
text: string;
|
||||
pack?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
bug: { label: 'Bug', icon: <Bug className="h-3 w-3" />, variant: 'destructive' },
|
||||
feature_request: { label: 'Feature', icon: <Lightbulb className="h-3 w-3" />, variant: 'default' },
|
||||
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
|
||||
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
|
||||
};
|
||||
|
||||
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
new: { label: 'New', variant: 'destructive' },
|
||||
reviewed: { label: 'Reviewed', variant: 'secondary' },
|
||||
in_progress: { label: 'In Progress', variant: 'default' },
|
||||
resolved: { label: 'Resolved', variant: 'outline' },
|
||||
closed: { label: 'Closed', variant: 'outline' },
|
||||
};
|
||||
|
||||
export function FeedbackPage() {
|
||||
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
||||
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
screenshot_url: string | null;
|
||||
device_info: Record<string, unknown>;
|
||||
status: FeedbackStatus;
|
||||
admin_notes: string | null;
|
||||
question_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
question?: {
|
||||
id: string;
|
||||
text: string;
|
||||
pack?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
bug: { label: 'Bug', icon: <Bug className="h-3 w-3" />, variant: 'destructive' },
|
||||
feature_request: { label: 'Feature', icon: <Lightbulb className="h-3 w-3" />, variant: 'default' },
|
||||
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
|
||||
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
|
||||
};
|
||||
|
||||
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
new: { label: 'New', variant: 'destructive' },
|
||||
reviewed: { label: 'Reviewed', variant: 'secondary' },
|
||||
in_progress: { label: 'In Progress', variant: 'default' },
|
||||
resolved: { label: 'Resolved', variant: 'outline' },
|
||||
closed: { label: 'Closed', variant: 'outline' },
|
||||
};
|
||||
|
||||
export function FeedbackPage() {
|
||||
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
|
||||
// Detail Dialog State
|
||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
profile:profiles!feedback_user_id_fkey(id, name, email),
|
||||
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setFeedback(data || []);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load feedback:', error);
|
||||
toast.error("Failed to load feedback");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Real-time subscription for feedback table
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
|
||||
table: 'feedback',
|
||||
onInsert: useCallback(() => {
|
||||
// Refetch to get the full data with profile/question relations
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]),
|
||||
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
|
||||
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
|
||||
}, []),
|
||||
onDelete: useCallback((deleted: Feedback) => {
|
||||
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
|
||||
}, []),
|
||||
insertToast: {
|
||||
enabled: true,
|
||||
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
|
||||
type: 'info',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Detail Dialog State
|
||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await adminData
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
profile:profiles!feedback_user_id_fkey(id, name, email),
|
||||
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setFeedback(data || []);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load feedback:', error);
|
||||
toast.error("Failed to load feedback");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Real-time subscription for feedback table
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
|
||||
table: 'feedback',
|
||||
onInsert: useCallback(() => {
|
||||
// Refetch to get the full data with profile/question relations
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]),
|
||||
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
|
||||
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
|
||||
}, []),
|
||||
onDelete: useCallback((deleted: Feedback) => {
|
||||
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
|
||||
}, []),
|
||||
insertToast: {
|
||||
enabled: true,
|
||||
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
|
||||
type: 'info',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]);
|
||||
@@ -149,40 +149,40 @@ export function FeedbackPage() {
|
||||
setPage(1);
|
||||
}, [search, typeFilter, statusFilter]);
|
||||
|
||||
|
||||
const handleOpenDetail = (item: Feedback) => {
|
||||
setSelectedFeedback(item);
|
||||
setEditStatus(item.status);
|
||||
setEditNotes(item.admin_notes || '');
|
||||
};
|
||||
|
||||
const handleUpdateFeedback = async () => {
|
||||
if (!selectedFeedback) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('feedback')
|
||||
.update({
|
||||
status: editStatus,
|
||||
admin_notes: editNotes.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', selectedFeedback.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Feedback updated");
|
||||
setSelectedFeedback(null);
|
||||
fetchFeedback();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update feedback:', error);
|
||||
toast.error("Failed to update feedback");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleOpenDetail = (item: Feedback) => {
|
||||
setSelectedFeedback(item);
|
||||
setEditStatus(item.status);
|
||||
setEditNotes(item.admin_notes || '');
|
||||
};
|
||||
|
||||
const handleUpdateFeedback = async () => {
|
||||
if (!selectedFeedback) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const { error } = await adminData
|
||||
.from('feedback')
|
||||
.update({
|
||||
status: editStatus,
|
||||
admin_notes: editNotes.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', selectedFeedback.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Feedback updated");
|
||||
setSelectedFeedback(null);
|
||||
fetchFeedback();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update feedback:', error);
|
||||
toast.error("Failed to update feedback");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFeedback = feedback.filter(item => {
|
||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
@@ -210,35 +210,35 @@ export function FeedbackPage() {
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
|
||||
const getTypeBadge = (type: FeedbackType) => {
|
||||
const config = feedbackTypeConfig[type];
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: FeedbackStatus) => {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const formatDeviceInfo = (info: Record<string, unknown>) => {
|
||||
if (!info || Object.keys(info).length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
if (info.platform) lines.push(`Platform: ${info.platform}`);
|
||||
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
|
||||
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
|
||||
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
const getTypeBadge = (type: FeedbackType) => {
|
||||
const config = feedbackTypeConfig[type];
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: FeedbackStatus) => {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const formatDeviceInfo = (info: Record<string, unknown>) => {
|
||||
if (!info || Object.keys(info).length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
if (info.platform) lines.push(`Platform: ${info.platform}`);
|
||||
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
|
||||
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
|
||||
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -246,13 +246,13 @@ export function FeedbackPage() {
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<p className="text-muted-foreground">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:flex-1 md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -267,30 +267,30 @@ export function FeedbackPage() {
|
||||
<SelectTrigger className="w-full md:w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="feature_request">Feature Request</SelectItem>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
<SelectItem value="question">Question</SelectItem>
|
||||
</SelectContent>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="feature_request">Feature Request</SelectItem>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
<SelectItem value="question">Question</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full md:w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -373,131 +373,131 @@ export function FeedbackPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
|
||||
<span className="truncate">{selectedFeedback?.title}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedFeedback && (
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
|
||||
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
|
||||
{selectedFeedback.profile?.id && (
|
||||
<Link
|
||||
to={`/users/${selectedFeedback.profile.id}`}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View user profile <ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Related Question */}
|
||||
{selectedFeedback.question && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<Label className="text-sm font-medium">Related Question</Label>
|
||||
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
|
||||
{selectedFeedback.question.pack && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
From pack: {selectedFeedback.question.pack.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
{selectedFeedback.screenshot_url && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Screenshot</Label>
|
||||
<a
|
||||
href={selectedFeedback.screenshot_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 block"
|
||||
>
|
||||
<img
|
||||
src={selectedFeedback.screenshot_url}
|
||||
alt="Screenshot"
|
||||
className="max-w-full max-h-[200px] rounded border object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Info */}
|
||||
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm font-medium">Device Info</Label>
|
||||
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||
{formatDeviceInfo(selectedFeedback.device_info)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Admin Notes</Label>
|
||||
<Textarea
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
placeholder="Add internal notes about this feedback..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
|
||||
<span className="truncate">{selectedFeedback?.title}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedFeedback && (
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
|
||||
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
|
||||
{selectedFeedback.profile?.id && (
|
||||
<Link
|
||||
to={`/users/${selectedFeedback.profile.id}`}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View user profile <ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Related Question */}
|
||||
{selectedFeedback.question && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<Label className="text-sm font-medium">Related Question</Label>
|
||||
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
|
||||
{selectedFeedback.question.pack && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
From pack: {selectedFeedback.question.pack.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
{selectedFeedback.screenshot_url && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Screenshot</Label>
|
||||
<a
|
||||
href={selectedFeedback.screenshot_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 block"
|
||||
>
|
||||
<img
|
||||
src={selectedFeedback.screenshot_url}
|
||||
alt="Screenshot"
|
||||
className="max-w-full max-h-[200px] rounded border object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Info */}
|
||||
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm font-medium">Device Info</Label>
|
||||
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||
{formatDeviceInfo(selectedFeedback.device_info)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Admin Notes</Label>
|
||||
<Textarea
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
placeholder="Add internal notes about this feedback..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -95,7 +95,7 @@ export function FlaggedMessagesPage() {
|
||||
const from = (aiPage - 1) * aiPageSize;
|
||||
const to = from + aiPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('messages')
|
||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||
.eq('moderation_status', 'flagged')
|
||||
@@ -120,7 +120,7 @@ export function FlaggedMessagesPage() {
|
||||
const from = (reportsPage - 1) * reportsPageSize;
|
||||
const to = from + reportsPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('message_reports')
|
||||
.select(`
|
||||
id,
|
||||
@@ -166,24 +166,11 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
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 })
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch media');
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -199,8 +186,8 @@ export function FlaggedMessagesPage() {
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
@@ -209,7 +196,7 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
text: textData.content,
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
@@ -233,8 +220,8 @@ export function FlaggedMessagesPage() {
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: report.message.id }
|
||||
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: report.message.id },
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
@@ -243,7 +230,7 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
text: textData.content,
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
@@ -274,27 +261,9 @@ export function FlaggedMessagesPage() {
|
||||
|
||||
setDescribingInline(message.id);
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
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 })
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { 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 reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -319,7 +288,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleMarkSafe = async (messageId: string) => {
|
||||
setProcessing(messageId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('messages')
|
||||
.update({ moderation_status: 'safe' })
|
||||
.eq('id', messageId);
|
||||
@@ -339,7 +308,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleDismissReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('message_reports')
|
||||
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
@@ -359,7 +328,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleReviewReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('message_reports')
|
||||
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RedemptionCode {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
max_uses: number;
|
||||
current_uses: number;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CodeRedemption {
|
||||
id: string;
|
||||
user_id: string;
|
||||
redeemed_at: string;
|
||||
profile?: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function generateCode(length: number = 8): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function RedemptionCodesPage() {
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RedemptionCode {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
max_uses: number;
|
||||
current_uses: number;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CodeRedemption {
|
||||
id: string;
|
||||
user_id: string;
|
||||
redeemed_at: string;
|
||||
profile?: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function generateCode(length: number = 8): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function RedemptionCodesPage() {
|
||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -71,18 +71,18 @@ export function RedemptionCodesPage() {
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
|
||||
// Add Code State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
|
||||
// View Redemptions State
|
||||
|
||||
// Add Code State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
|
||||
// View Redemptions State
|
||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||
@@ -90,9 +90,9 @@ export function RedemptionCodesPage() {
|
||||
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
||||
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const fetchCodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -100,7 +100,7 @@ export function RedemptionCodesPage() {
|
||||
const to = from + pageSize - 1;
|
||||
const trimmedSearch = search.trim();
|
||||
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('redemption_codes')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -122,14 +122,14 @@ export function RedemptionCodesPage() {
|
||||
}
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
|
||||
|
||||
const fetchRedemptions = useCallback(async (codeId: string) => {
|
||||
setLoadingRedemptions(true);
|
||||
try {
|
||||
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||
const to = from + redemptionsPageSize - 1;
|
||||
|
||||
const { data: redemptionData, error: redemptionError, count } = await supabase
|
||||
const { data: redemptionData, error: redemptionError, count } = await adminData
|
||||
.from('code_redemptions')
|
||||
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||
.eq('code_id', codeId)
|
||||
@@ -142,7 +142,7 @@ export function RedemptionCodesPage() {
|
||||
|
||||
if (redemptionData && redemptionData.length > 0) {
|
||||
const userIds = redemptionData.map(r => r.user_id);
|
||||
const { data: profileData } = await supabase
|
||||
const { data: profileData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
@@ -191,117 +191,117 @@ export function RedemptionCodesPage() {
|
||||
}
|
||||
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
||||
|
||||
|
||||
const handleAddCode = async () => {
|
||||
if (!formData.code.trim()) {
|
||||
toast.error("Please enter a code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await auditedSupabase.insert('redemption_codes', {
|
||||
code: formData.code.toUpperCase().trim(),
|
||||
description: formData.description.trim() || null,
|
||||
max_uses: formData.max_uses,
|
||||
current_uses: 0,
|
||||
expires_at: formData.expires_at || null,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Redemption code created");
|
||||
setIsAddDialogOpen(false);
|
||||
resetForm();
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create code:', error);
|
||||
if (error.message?.includes('duplicate') || error.code === '23505') {
|
||||
toast.error("This code already exists");
|
||||
} else {
|
||||
toast.error(error.message || "Failed to create code");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (code: RedemptionCode) => {
|
||||
try {
|
||||
const { error } = await auditedSupabase.update('redemption_codes', code.id, {
|
||||
is_active: !code.is_active,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(code.is_active ? "Code deactivated" : "Code activated");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to update code");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCode = async (code: RedemptionCode) => {
|
||||
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('redemption_codes', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Code deleted");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to delete code");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
resetForm();
|
||||
setFormData(prev => ({ ...prev, code: generateCode() }));
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleAddCode = async () => {
|
||||
if (!formData.code.trim()) {
|
||||
toast.error("Please enter a code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await auditedAdminData.insert('redemption_codes', {
|
||||
code: formData.code.toUpperCase().trim(),
|
||||
description: formData.description.trim() || null,
|
||||
max_uses: formData.max_uses,
|
||||
current_uses: 0,
|
||||
expires_at: formData.expires_at || null,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Redemption code created");
|
||||
setIsAddDialogOpen(false);
|
||||
resetForm();
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create code:', error);
|
||||
if (error.message?.includes('duplicate') || error.code === '23505') {
|
||||
toast.error("This code already exists");
|
||||
} else {
|
||||
toast.error(error.message || "Failed to create code");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (code: RedemptionCode) => {
|
||||
try {
|
||||
const { error } = await auditedAdminData.update('redemption_codes', code.id, {
|
||||
is_active: !code.is_active,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(code.is_active ? "Code deactivated" : "Code activated");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to update code");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCode = async (code: RedemptionCode) => {
|
||||
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedAdminData.delete('redemption_codes', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Code deleted");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to delete code");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
resetForm();
|
||||
setFormData(prev => ({ ...prev, code: generateCode() }));
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||
setViewingCode(code);
|
||||
setRedemptionsPage(1);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const filteredCodes = codes;
|
||||
|
||||
|
||||
const getStatusBadge = (code: RedemptionCode) => {
|
||||
if (!code.is_active) {
|
||||
return <Badge variant="secondary">Inactive</Badge>;
|
||||
}
|
||||
if (code.expires_at && new Date(code.expires_at) < new Date()) {
|
||||
return <Badge variant="destructive">Expired</Badge>;
|
||||
}
|
||||
if (code.current_uses >= code.max_uses) {
|
||||
return <Badge variant="outline">Exhausted</Badge>;
|
||||
}
|
||||
return <Badge variant="default">Active</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
const getStatusBadge = (code: RedemptionCode) => {
|
||||
if (!code.is_active) {
|
||||
return <Badge variant="secondary">Inactive</Badge>;
|
||||
}
|
||||
if (code.expires_at && new Date(code.expires_at) < new Date()) {
|
||||
return <Badge variant="destructive">Expired</Badge>;
|
||||
}
|
||||
if (code.current_uses >= code.max_uses) {
|
||||
return <Badge variant="outline">Exhausted</Badge>;
|
||||
}
|
||||
return <Badge variant="default">Active</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
@@ -317,9 +317,9 @@ export function RedemptionCodesPage() {
|
||||
placeholder="Search codes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
|
||||
@@ -327,14 +327,14 @@ export function RedemptionCodesPage() {
|
||||
Generate Code
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new code that users can redeem for premium access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new code that users can redeem for premium access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Code</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
@@ -343,57 +343,57 @@ export function RedemptionCodesPage() {
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||
placeholder="e.g., PREMIUM2024"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
||||
title="Generate random code"
|
||||
>
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="e.g., Early adopter reward"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Max Uses</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.max_uses}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How many times this code can be redeemed
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expires At (optional)</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Code'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
||||
title="Generate random code"
|
||||
>
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="e.g., Early adopter reward"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Max Uses</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.max_uses}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How many times this code can be redeemed
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expires At (optional)</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Code'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -545,18 +545,18 @@ export function RedemptionCodesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* View Redemptions Dialog */}
|
||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* View Redemptions Dialog */}
|
||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{loadingRedemptions ? (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -607,10 +607,10 @@ export function RedemptionCodesPage() {
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +1,386 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
|
||||
interface UsageReasonStat {
|
||||
reason: UsageReason | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface GenderStat {
|
||||
gender: string | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
improve_communication: {
|
||||
label: 'Improve communication',
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
spice_up_intimacy: {
|
||||
label: 'Spice up intimacy',
|
||||
icon: <Flame className="h-5 w-5" />,
|
||||
color: '#ef4444',
|
||||
},
|
||||
deeper_connection: {
|
||||
label: 'Build deeper connection',
|
||||
icon: <Heart className="h-5 w-5" />,
|
||||
color: '#ec4899',
|
||||
},
|
||||
have_fun: {
|
||||
label: 'Have fun together',
|
||||
icon: <Smile className="h-5 w-5" />,
|
||||
color: '#f59e0b',
|
||||
},
|
||||
strengthen_relationship: {
|
||||
label: 'Strengthen relationship',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
color: '#10b981',
|
||||
},
|
||||
};
|
||||
|
||||
const genderConfig: Record<string, { label: string; color: string }> = {
|
||||
male: { label: 'Male', color: '#3b82f6' },
|
||||
female: { label: 'Female', color: '#ec4899' },
|
||||
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
|
||||
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
|
||||
};
|
||||
|
||||
export function UsageInsightsPage() {
|
||||
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
|
||||
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [onboardedUsers, setOnboardedUsers] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Get total users count
|
||||
const { count: total } = await supabase
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
setTotalUsers(total || 0);
|
||||
|
||||
// Get onboarded users count
|
||||
const { count: onboarded } = await supabase
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('onboarding_completed', true);
|
||||
|
||||
setOnboardedUsers(onboarded || 0);
|
||||
|
||||
// Get usage reason breakdown
|
||||
const { data: usageData } = await supabase
|
||||
.from('profiles')
|
||||
.select('usage_reason')
|
||||
.not('usage_reason', 'is', null);
|
||||
|
||||
const usageCounts: Record<string, number> = {};
|
||||
usageData?.forEach(profile => {
|
||||
const reason = profile.usage_reason as string;
|
||||
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
|
||||
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
|
||||
.map(([reason, count]) => ({
|
||||
reason: reason as UsageReason,
|
||||
count,
|
||||
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setUsageStats(usageStatsArray);
|
||||
|
||||
// Get gender breakdown
|
||||
const { data: genderData } = await supabase
|
||||
.from('profiles')
|
||||
.select('gender')
|
||||
.not('gender', 'is', null);
|
||||
|
||||
const genderCounts: Record<string, number> = {};
|
||||
genderData?.forEach(profile => {
|
||||
const gender = profile.gender as string;
|
||||
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
||||
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
|
||||
.map(([gender, count]) => ({
|
||||
gender,
|
||||
count,
|
||||
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setGenderStats(genderStatsArray);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch usage stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const chartData = usageStats.map(stat => ({
|
||||
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
const pieData = genderStats.map(stat => ({
|
||||
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Target className="h-8 w-8 text-primary" />
|
||||
Usage Insights
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Understand why users are using Sauci based on onboarding responses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
|
||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
users provided usage reason
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Usage Reasons Chart */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
Why Users Join Sauci
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of user motivations from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={150}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gender Distribution */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
Gender Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
User demographics from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : genderStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No gender data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Individual statistics for each usage reason
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{usageStats.map(stat => {
|
||||
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
|
||||
return (
|
||||
<div
|
||||
key={stat.reason}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border"
|
||||
>
|
||||
<div
|
||||
className="p-3 rounded-lg"
|
||||
style={{ backgroundColor: `${config?.color}20` }}
|
||||
>
|
||||
<div style={{ color: config?.color }}>
|
||||
{config?.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{config?.label || stat.reason}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${stat.percentage}%`,
|
||||
backgroundColor: config?.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{stat.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">{stat.count}</p>
|
||||
<p className="text-xs text-muted-foreground">users</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useEffect } from 'react';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
|
||||
interface UsageReasonStat {
|
||||
reason: UsageReason | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface GenderStat {
|
||||
gender: string | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
improve_communication: {
|
||||
label: 'Improve communication',
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
spice_up_intimacy: {
|
||||
label: 'Spice up intimacy',
|
||||
icon: <Flame className="h-5 w-5" />,
|
||||
color: '#ef4444',
|
||||
},
|
||||
deeper_connection: {
|
||||
label: 'Build deeper connection',
|
||||
icon: <Heart className="h-5 w-5" />,
|
||||
color: '#ec4899',
|
||||
},
|
||||
have_fun: {
|
||||
label: 'Have fun together',
|
||||
icon: <Smile className="h-5 w-5" />,
|
||||
color: '#f59e0b',
|
||||
},
|
||||
strengthen_relationship: {
|
||||
label: 'Strengthen relationship',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
color: '#10b981',
|
||||
},
|
||||
};
|
||||
|
||||
const genderConfig: Record<string, { label: string; color: string }> = {
|
||||
male: { label: 'Male', color: '#3b82f6' },
|
||||
female: { label: 'Female', color: '#ec4899' },
|
||||
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
|
||||
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
|
||||
};
|
||||
|
||||
export function UsageInsightsPage() {
|
||||
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
|
||||
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [onboardedUsers, setOnboardedUsers] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Get total users count
|
||||
const { count: total } = await adminData
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
setTotalUsers(total || 0);
|
||||
|
||||
// Get onboarded users count
|
||||
const { count: onboarded } = await adminData
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('onboarding_completed', true);
|
||||
|
||||
setOnboardedUsers(onboarded || 0);
|
||||
|
||||
// Get usage reason breakdown
|
||||
const { data: usageData } = await adminData
|
||||
.from('profiles')
|
||||
.select('usage_reason')
|
||||
.not('usage_reason', 'is', null);
|
||||
|
||||
const usageCounts: Record<string, number> = {};
|
||||
usageData?.forEach(profile => {
|
||||
const reason = profile.usage_reason as string;
|
||||
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
|
||||
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
|
||||
.map(([reason, count]) => ({
|
||||
reason: reason as UsageReason,
|
||||
count,
|
||||
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setUsageStats(usageStatsArray);
|
||||
|
||||
// Get gender breakdown
|
||||
const { data: genderData } = await adminData
|
||||
.from('profiles')
|
||||
.select('gender')
|
||||
.not('gender', 'is', null);
|
||||
|
||||
const genderCounts: Record<string, number> = {};
|
||||
genderData?.forEach(profile => {
|
||||
const gender = profile.gender as string;
|
||||
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
||||
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
|
||||
.map(([gender, count]) => ({
|
||||
gender,
|
||||
count,
|
||||
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setGenderStats(genderStatsArray);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch usage stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const chartData = usageStats.map(stat => ({
|
||||
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
const pieData = genderStats.map(stat => ({
|
||||
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Target className="h-8 w-8 text-primary" />
|
||||
Usage Insights
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Understand why users are using Sauci based on onboarding responses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
|
||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
users provided usage reason
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Usage Reasons Chart */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
Why Users Join Sauci
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of user motivations from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={150}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gender Distribution */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
Gender Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
User demographics from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : genderStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No gender data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Individual statistics for each usage reason
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{usageStats.map(stat => {
|
||||
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
|
||||
return (
|
||||
<div
|
||||
key={stat.reason}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border"
|
||||
>
|
||||
<div
|
||||
className="p-3 rounded-lg"
|
||||
style={{ backgroundColor: `${config?.color}20` }}
|
||||
>
|
||||
<div style={{ color: config?.color }}>
|
||||
{config?.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{config?.label || stat.reason}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${stat.percentage}%`,
|
||||
backgroundColor: config?.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{stat.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">{stat.count}</p>
|
||||
<p className="text-xs text-muted-foreground">users</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -146,7 +146,7 @@ export function UserActivityPage() {
|
||||
const from = (responsesPage - 1) * responsesPageSize;
|
||||
const to = from + responsesPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, user_id, answer, response_data, created_at,
|
||||
@@ -163,7 +163,7 @@ export function UserActivityPage() {
|
||||
|
||||
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) => ({
|
||||
id: item.id,
|
||||
user_id: item.user_id,
|
||||
@@ -194,7 +194,7 @@ export function UserActivityPage() {
|
||||
const from = (matchesPage - 1) * matchesPageSize;
|
||||
const to = from + matchesPageSize - 1;
|
||||
|
||||
const { data: matchesData, error: matchesError, count } = await supabase
|
||||
const { data: matchesData, error: matchesError, count } = await adminData
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, couple_id, match_type, is_new, created_at,
|
||||
@@ -210,7 +210,7 @@ export function UserActivityPage() {
|
||||
// Get unique couple IDs and fetch profiles
|
||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||
if (coupleIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
const { data: profiles, error: profilesError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, couple_id')
|
||||
.in('couple_id', coupleIds);
|
||||
@@ -256,7 +256,7 @@ export function UserActivityPage() {
|
||||
const to = from + messagesPageSize - 1;
|
||||
|
||||
// Fetch messages with match data
|
||||
const { data: messagesData, error: messagesError, count } = await supabase
|
||||
const { data: messagesData, error: messagesError, count } = await adminData
|
||||
.from('messages')
|
||||
.select(`
|
||||
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 }> = {};
|
||||
|
||||
if (userIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
const { data: profiles, error: profilesError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
@@ -318,10 +318,7 @@ export function UserActivityPage() {
|
||||
setLoading(prev => ({ ...prev, signups: true }));
|
||||
try {
|
||||
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||
const { data: allData, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
const { users: allData } = await adminRequest<{ users: SignupActivity[] }>('/v1/admin/users');
|
||||
|
||||
// Sort by created_at descending and apply pagination
|
||||
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -82,7 +82,7 @@ export function AdminsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First fetch admin users with permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
const { data: adminRows, error: adminError } = await adminData
|
||||
.from('admin_users')
|
||||
.select('id, user_id, role, permissions, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -91,9 +91,9 @@ export function AdminsPage() {
|
||||
|
||||
// Then fetch profiles for these users
|
||||
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
|
||||
if (adminData && adminData.length > 0) {
|
||||
const userIds = adminData.map(a => a.user_id);
|
||||
const { data: profileData, error: profileError } = await supabase
|
||||
if (adminRows && adminRows.length > 0) {
|
||||
const userIds = adminRows.map(a => a.user_id);
|
||||
const { data: profileData, error: profileError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
@@ -101,7 +101,7 @@ export function AdminsPage() {
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Merge data
|
||||
const joinedAdmins = adminData.map(admin => ({
|
||||
const joinedAdmins = adminRows.map(admin => ({
|
||||
...admin,
|
||||
permissions: (admin.permissions as string[]) || [],
|
||||
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
|
||||
const adminUserIds = new Set(admins.map(a => a.user_id));
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
|
||||
@@ -179,7 +179,7 @@ export function AdminsPage() {
|
||||
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
|
||||
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.insert('admin_users', {
|
||||
const { error } = await auditedAdminData.insert('admin_users', {
|
||||
user_id: selectedUser.id,
|
||||
role,
|
||||
permissions
|
||||
@@ -219,7 +219,7 @@ export function AdminsPage() {
|
||||
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
|
||||
const permissions = editIsSuperAdmin ? [] : editPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
|
||||
const { error } = await auditedAdminData.update('admin_users', editingAdmin.id, {
|
||||
role,
|
||||
permissions
|
||||
});
|
||||
@@ -261,7 +261,7 @@ export function AdminsPage() {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Category {
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_pack_count?: number;
|
||||
dare_pack_count?: number;
|
||||
}
|
||||
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (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 {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
||||
</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">
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
@@ -215,7 +230,7 @@ export function CategoriesPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data: cats, error, count } = await supabase
|
||||
const { data: cats, error, count } = await adminData
|
||||
.from('categories')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -231,11 +246,11 @@ export function CategoriesPage() {
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
||||
supabase
|
||||
adminData
|
||||
.from('question_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
supabase
|
||||
adminData
|
||||
.from('dare_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
@@ -310,11 +325,11 @@ export function CategoriesPage() {
|
||||
};
|
||||
|
||||
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;
|
||||
toast.success('Category updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
const { error } = await auditedAdminData.insert('categories', {
|
||||
...data,
|
||||
sort_order: totalCount,
|
||||
});
|
||||
@@ -338,7 +353,7 @@ export function CategoriesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
||||
const { error } = await auditedAdminData.delete('categories', category.id);
|
||||
if (error) throw error;
|
||||
toast.success('Category deleted');
|
||||
fetchCategories();
|
||||
@@ -355,7 +370,7 @@ export function CategoriesPage() {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -399,7 +414,7 @@ export function CategoriesPage() {
|
||||
// Persist new order to database
|
||||
try {
|
||||
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);
|
||||
@@ -579,6 +594,7 @@ export function CategoriesPage() {
|
||||
onEdit={form.openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
onReviewChanged={fetchCategories}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
import { parseIntensityRange } from '@/lib/dareIntensity';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -52,8 +55,13 @@ interface DarePack {
|
||||
is_explicit: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
created_at: string | null;
|
||||
dare_count?: number;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
interface DarePackFormData {
|
||||
@@ -64,6 +72,8 @@ interface DarePackFormData {
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
category_id: string;
|
||||
min_intensity: string;
|
||||
max_intensity: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -92,6 +102,8 @@ export function DarePacksPage() {
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
min_intensity: '',
|
||||
max_intensity: '',
|
||||
},
|
||||
(pack) => ({
|
||||
name: pack.name,
|
||||
@@ -101,6 +113,8 @@ export function DarePacksPage() {
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
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
|
||||
if (categoryId) {
|
||||
const { data: categoryData } = await supabase
|
||||
const { data: categoryData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
@@ -127,7 +141,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
// Fetch dare packs
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('dare_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -148,7 +162,7 @@ export function DarePacksPage() {
|
||||
const dareCounts: Record<string, number> = {};
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: dares } = await supabase
|
||||
const { data: dares } = await adminData
|
||||
.from('dares')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
@@ -159,7 +173,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const { data: categoryData } = await supabase
|
||||
const { data: categoryData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -205,10 +219,18 @@ export function DarePacksPage() {
|
||||
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);
|
||||
try {
|
||||
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,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -216,11 +238,13 @@ export function DarePacksPage() {
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: form.formData.category_id || null,
|
||||
min_intensity: parsedMin,
|
||||
max_intensity: parsedMax,
|
||||
});
|
||||
if (error) throw error;
|
||||
toast.success('Dare pack updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('dare_packs', {
|
||||
const { error } = await auditedAdminData.insert('dare_packs', {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -228,6 +252,8 @@ export function DarePacksPage() {
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: form.formData.category_id || null,
|
||||
min_intensity: parsedMin,
|
||||
max_intensity: parsedMax,
|
||||
sort_order: totalCount,
|
||||
});
|
||||
if (error) throw error;
|
||||
@@ -250,7 +276,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
|
||||
const { error } = await auditedAdminData.delete('dare_packs', pack.id);
|
||||
if (error) throw error;
|
||||
toast.success('Dare pack deleted');
|
||||
fetchData();
|
||||
@@ -273,8 +299,8 @@ export function DarePacksPage() {
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
auditedAdminData.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedAdminData.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
@@ -406,6 +432,36 @@ export function DarePacksPage() {
|
||||
</Select>
|
||||
</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="space-y-0.5">
|
||||
<Label>Premium</Label>
|
||||
@@ -511,6 +567,15 @@ export function DarePacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
@@ -559,6 +624,20 @@ export function DarePacksPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 { toast } from 'sonner';
|
||||
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
|
||||
@@ -56,6 +59,8 @@ interface Dare {
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
created_at: string | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
// Duration options for dares
|
||||
@@ -98,9 +103,11 @@ export function DaresPage() {
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<{
|
||||
text: string;
|
||||
intensity: string;
|
||||
suggested_duration_hours: string;
|
||||
}>({
|
||||
text: '',
|
||||
intensity: '1',
|
||||
suggested_duration_hours: '',
|
||||
});
|
||||
|
||||
@@ -110,7 +117,7 @@ export function DaresPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch pack info
|
||||
const { data: packData } = await supabase
|
||||
const { data: packData } = await adminData
|
||||
.from('dare_packs')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
@@ -122,7 +129,7 @@ export function DaresPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch dares
|
||||
const { data: dareData, error, count } = await supabase
|
||||
const { data: dareData, error, count } = await adminData
|
||||
.from('dares')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
@@ -162,7 +169,7 @@ export function DaresPage() {
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingDare(null);
|
||||
setFormData({ text: '', suggested_duration_hours: '' });
|
||||
setFormData({ text: '', intensity: '1', suggested_duration_hours: '' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -170,6 +177,7 @@ export function DaresPage() {
|
||||
setEditingDare(dare);
|
||||
setFormData({
|
||||
text: dare.text,
|
||||
intensity: String(dare.intensity ?? 1),
|
||||
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -181,21 +189,24 @@ export function DaresPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const intensity = clampDareIntensity(formData.intensity);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const dareData = {
|
||||
text: formData.text,
|
||||
intensity,
|
||||
suggested_duration_hours: formData.suggested_duration_hours
|
||||
? parseInt(formData.suggested_duration_hours, 10)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (editingDare) {
|
||||
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
|
||||
const { error } = await auditedAdminData.update('dares', editingDare.id, dareData);
|
||||
if (error) throw error;
|
||||
toast.success('Dare updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('dares', {
|
||||
const { error } = await auditedAdminData.insert('dares', {
|
||||
...dareData,
|
||||
pack_id: packId,
|
||||
});
|
||||
@@ -219,7 +230,7 @@ export function DaresPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('dares', dare.id);
|
||||
const { error } = await auditedAdminData.delete('dares', dare.id);
|
||||
if (error) throw error;
|
||||
toast.success('Dare deleted');
|
||||
fetchData();
|
||||
@@ -259,7 +270,7 @@ export function DaresPage() {
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
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`);
|
||||
setSelectedIds(new Set());
|
||||
@@ -334,6 +345,31 @@ export function DaresPage() {
|
||||
/>
|
||||
</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">
|
||||
<Label>Suggested Duration</Label>
|
||||
<Select
|
||||
@@ -439,6 +475,8 @@ export function DaresPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</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">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -462,6 +500,22 @@ export function DaresPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{dare.text}</span>
|
||||
</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>
|
||||
{dare.suggested_duration_hours ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -43,6 +45,8 @@ interface QuestionPack {
|
||||
created_at: string | null;
|
||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||
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;
|
||||
topics?: Topic[];
|
||||
}
|
||||
@@ -103,7 +107,7 @@ export function PacksPage() {
|
||||
try {
|
||||
// Fetch category info
|
||||
if (categoryId) {
|
||||
const { data: cat } = await supabase
|
||||
const { data: cat } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
@@ -111,7 +115,7 @@ export function PacksPage() {
|
||||
setCategory(cat);
|
||||
}
|
||||
|
||||
const { data: categoriesData } = await supabase
|
||||
const { data: categoriesData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -121,7 +125,7 @@ export function PacksPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch packs
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('question_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -141,7 +145,7 @@ export function PacksPage() {
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: questions } = await supabase
|
||||
const { data: questions } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
@@ -152,7 +156,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
// Fetch all topics
|
||||
const { data: topicsData } = await supabase
|
||||
const { data: topicsData } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -161,7 +165,7 @@ export function PacksPage() {
|
||||
// Fetch pack_topics relationships
|
||||
let packTopicsData: any[] = [];
|
||||
if (packIds.length > 0) {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)')
|
||||
.in('pack_id', packIds);
|
||||
@@ -236,7 +240,7 @@ export function PacksPage() {
|
||||
? true
|
||||
: (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,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -254,7 +258,7 @@ export function PacksPage() {
|
||||
if (error) throw error;
|
||||
packId = form.editingItem.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('question_packs')
|
||||
.insert({
|
||||
name: form.formData.name,
|
||||
@@ -279,10 +283,10 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const { error: topicsError } = await supabase
|
||||
const { error: topicsError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(
|
||||
Array.from(selectedTopicIds).map(topicId => ({
|
||||
@@ -310,7 +314,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
||||
const { error } = await auditedAdminData.delete('question_packs', pack.id);
|
||||
if (error) throw error;
|
||||
toast.success('Pack deleted');
|
||||
fetchData();
|
||||
@@ -351,8 +355,8 @@ export function PacksPage() {
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedAdminData.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
@@ -513,6 +517,15 @@ export function PacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -126,7 +126,7 @@ export function QuestionAnalyticsPage() {
|
||||
while (true) {
|
||||
const from = page * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
|
||||
.range(from, to);
|
||||
@@ -150,7 +150,7 @@ export function QuestionAnalyticsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Build pack query with optional is_public filter
|
||||
let packQuery = supabase
|
||||
let packQuery = adminData
|
||||
.from('question_packs')
|
||||
.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([
|
||||
packQuery,
|
||||
supabase
|
||||
adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name'),
|
||||
supabase
|
||||
adminData
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)'),
|
||||
fetchAllQuestions(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
interface QuestionPack {
|
||||
id: string;
|
||||
@@ -70,6 +72,8 @@ interface Question {
|
||||
created_at: string | null;
|
||||
question_type?: QuestionType | null;
|
||||
config?: QuestionConfig | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_DURATION = 60;
|
||||
@@ -150,12 +154,12 @@ export function QuestionsPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const packPromise = supabase
|
||||
const packPromise = adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
let questionsQuery = supabase
|
||||
let questionsQuery = adminData
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
@@ -167,7 +171,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
const questionsPromise = questionsQuery;
|
||||
const allPackPromise = supabase
|
||||
const allPackPromise = adminData
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
.eq('pack_id', packId)
|
||||
@@ -250,7 +254,7 @@ export function QuestionsPage() {
|
||||
// Fetch all questions in pack for inverse_of dropdown
|
||||
const fetchAllPackQuestions = useCallback(async () => {
|
||||
if (!packId) return;
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
.eq('pack_id', packId)
|
||||
@@ -317,7 +321,7 @@ export function QuestionsPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingQuestion) {
|
||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
||||
const { error } = await auditedAdminData.update('questions', editingQuestion.id, {
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || 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;
|
||||
toast.success('Question updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('questions', {
|
||||
const { error } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
@@ -363,7 +367,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('questions', question.id);
|
||||
const { error } = await auditedAdminData.delete('questions', question.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Question deleted');
|
||||
@@ -396,7 +400,7 @@ export function QuestionsPage() {
|
||||
|
||||
// Insert standalone questions first (no inverse_of)
|
||||
if (standaloneQuestions.length > 0) {
|
||||
const { error: standaloneError } = await auditedSupabase.insert(
|
||||
const { error: standaloneError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
standaloneQuestions.map(q => ({
|
||||
pack_id: packId,
|
||||
@@ -415,7 +419,7 @@ export function QuestionsPage() {
|
||||
if (pair.length >= 2) {
|
||||
// First question is primary (inverse_of = NULL)
|
||||
const primary = pair[0];
|
||||
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
|
||||
const { data: primaryData, error: primaryError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -432,7 +436,7 @@ export function QuestionsPage() {
|
||||
const primaryId = primaryData?.[0]?.id;
|
||||
if (primaryId) {
|
||||
const inverse = pair[1];
|
||||
const { error: inverseError } = await auditedSupabase.insert(
|
||||
const { error: inverseError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -449,7 +453,7 @@ export function QuestionsPage() {
|
||||
// Handle any additional questions in the pair (shouldn't happen, but just in case)
|
||||
for (let i = 2; i < pair.length; i++) {
|
||||
const extra = pair[i];
|
||||
const { error: extraError } = await auditedSupabase.insert(
|
||||
const { error: extraError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -464,7 +468,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
} else if (pair.length === 1) {
|
||||
// Single question with pair_id but no pair - treat as standalone
|
||||
const { error } = await auditedSupabase.insert(
|
||||
const { error } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -497,7 +501,7 @@ export function QuestionsPage() {
|
||||
|
||||
setReviewLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
|
||||
.eq('pack_id', packId)
|
||||
@@ -544,7 +548,7 @@ export function QuestionsPage() {
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
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`);
|
||||
setSelectedIds(new Set());
|
||||
@@ -982,6 +986,7 @@ export function QuestionsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-20">Inverse</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{question.text}</span>
|
||||
</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>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -64,9 +64,9 @@ export function TagsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
|
||||
supabase.from('topics').select('id, name').order('name'),
|
||||
supabase.from('pack_topics').select('pack_id, topic_id'),
|
||||
supabase
|
||||
adminData.from('topics').select('id, name').order('name'),
|
||||
adminData.from('pack_topics').select('pack_id, topic_id'),
|
||||
adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, description, is_public, category:categories(name)')
|
||||
.order('name'),
|
||||
@@ -106,7 +106,7 @@ export function TagsPage() {
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (untaggedPackIds.length > 0) {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', untaggedPackIds);
|
||||
@@ -167,7 +167,7 @@ export function TagsPage() {
|
||||
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
|
||||
|
||||
try {
|
||||
const { data: topicsData, error: topicsError } = await supabase
|
||||
const { data: topicsData, error: topicsError } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -188,7 +188,7 @@ export function TagsPage() {
|
||||
});
|
||||
|
||||
try {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
@@ -237,7 +237,7 @@ export function TagsPage() {
|
||||
});
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
const { data: createdTopics, error: createError } = await adminData
|
||||
.from('topics')
|
||||
.insert(newTopics)
|
||||
.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) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
const { error: linkError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(topicIds.map((topicId) => ({
|
||||
pack_id: pack.id,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Heart, Search, Users, Hourglass } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { buildCoupleSearchFilter, coupleStatusFromMemberCount } from '@/lib/couples';
|
||||
|
||||
interface Couple {
|
||||
id: string;
|
||||
invite_code: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
couple_id: string | null;
|
||||
}
|
||||
|
||||
export function CouplesPage() {
|
||||
const [couples, setCouples] = useState<Couple[]>([]);
|
||||
const [membersByCouple, setMembersByCouple] = useState<Record<string, Profile[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const fetchCouples = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
let query = adminData
|
||||
.from<Couple>('couples')
|
||||
.select('id, invite_code, created_at', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
const filter = buildCoupleSearchFilter(search);
|
||||
if (filter) {
|
||||
query = filter.op === 'eq' ? query.eq(filter.column, filter.value) : query.ilike(filter.column, filter.value);
|
||||
}
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const rows = data || [];
|
||||
setCouples(rows);
|
||||
setTotal(count || 0);
|
||||
|
||||
const coupleIds = rows.map((row) => row.id);
|
||||
if (coupleIds.length > 0) {
|
||||
const { data: profiles } = await adminData
|
||||
.from<Profile>('profiles')
|
||||
.select('id, name, email, couple_id')
|
||||
.in('couple_id', coupleIds);
|
||||
const grouped: Record<string, Profile[]> = {};
|
||||
(profiles || []).forEach((profile) => {
|
||||
if (!profile.couple_id) return;
|
||||
(grouped[profile.couple_id] ??= []).push(profile);
|
||||
});
|
||||
setMembersByCouple(grouped);
|
||||
} else {
|
||||
setMembersByCouple({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load couples:', error);
|
||||
toast.error('Failed to load couples');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
useEffect(() => { fetchCouples(); }, [fetchCouples]);
|
||||
useEffect(() => { setPage(1); }, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, pageSize, total]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Heart className="h-8 w-8 text-primary" />
|
||||
Couples
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Look up an invite funnel by invite code or couple id
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search invite code or couple id..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invite Code</TableHead>
|
||||
<TableHead>Members</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : couples.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
{search ? 'No couples found matching your search.' : 'No couples yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
couples.map((couple) => {
|
||||
const members = membersByCouple[couple.id] || [];
|
||||
const status = coupleStatusFromMemberCount(members.length);
|
||||
return (
|
||||
<TableRow key={couple.id}>
|
||||
<TableCell className="font-mono">{couple.invite_code}</TableCell>
|
||||
<TableCell>
|
||||
{members.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">No members</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{members.map((member) => (
|
||||
<Link key={member.id} to={`/users/${member.id}`} className="text-primary hover:underline text-sm">
|
||||
{member.name || member.email || member.id}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{status === 'paired' ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Paired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||
<Hourglass className="h-3 w-3 mr-1" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(couple.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{total > 0 && (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalCount={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -89,29 +89,9 @@ function ChatImage({ messageId }: { messageId: string }) {
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { 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);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -180,29 +160,9 @@ function ChatVideo({ messageId }: { messageId: string }) {
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { 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);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -289,18 +249,13 @@ function DecryptedMessageText({ message }: { message: Message }) {
|
||||
const decrypt = async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id },
|
||||
const data = await adminRequest<{ content?: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (error) {
|
||||
setText('[Unable to decrypt]');
|
||||
return;
|
||||
}
|
||||
|
||||
setText((data as { content?: string | null } | null)?.content ?? null);
|
||||
setText(data.content ?? null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setText('[Unable to decrypt]');
|
||||
@@ -344,7 +299,7 @@ export function MatchChatPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch user profile
|
||||
const { data: userData } = await supabase
|
||||
const { data: userData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, couple_id')
|
||||
.eq('id', userId)
|
||||
@@ -354,7 +309,7 @@ export function MatchChatPage() {
|
||||
|
||||
// Fetch partner
|
||||
if (userData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
const { data: partnerData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('couple_id', userData.couple_id)
|
||||
@@ -367,7 +322,7 @@ export function MatchChatPage() {
|
||||
}
|
||||
|
||||
// Fetch match with question
|
||||
const { data: matchData } = await supabase
|
||||
const { data: matchData } = await adminData
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type,
|
||||
@@ -382,7 +337,7 @@ export function MatchChatPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch messages
|
||||
const { data: messageData, error: messagesError, count } = await supabase
|
||||
const { data: messageData, error: messagesError, count } = await adminData
|
||||
.from('messages')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('match_id', matchId)
|
||||
|
||||
@@ -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 { supabase, supabaseConfig } from '@/config';
|
||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
@@ -37,12 +37,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { IconPreview } from '@/components/ui/icon-picker';
|
||||
import type { AnswerType, QuestionType } from '@sauci/shared';
|
||||
import { ResponseAnswer } from '@/components/content/ResponseAnswer';
|
||||
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 Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
@@ -61,6 +63,7 @@ interface Profile {
|
||||
usage_reason: UsageReason | null;
|
||||
hide_nsfw: boolean | null;
|
||||
onboarding_completed: boolean | null;
|
||||
timezone: string | null;
|
||||
}
|
||||
|
||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||
@@ -158,11 +161,72 @@ interface CoupleStreak {
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_active_date: string | null;
|
||||
last_completed_date: string | null;
|
||||
last_milestone_celebrated: number | null;
|
||||
created_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 = {
|
||||
yes_yes: 'Both Yes!',
|
||||
yes_maybe: 'Yes + Maybe',
|
||||
@@ -193,16 +257,9 @@ function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: strin
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { 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);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
@@ -249,16 +306,9 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { 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);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} 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" />;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -311,7 +343,7 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
const fetchStrokes = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await supabase
|
||||
const { data, error: err } = await adminData
|
||||
.from('live_draw_sessions')
|
||||
.select('strokes')
|
||||
.eq('couple_id', coupleId)
|
||||
@@ -325,79 +357,17 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
setStrokes((data?.strokes as StrokeSegment[]) || []);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchStrokes();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const channel = supabase
|
||||
.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();
|
||||
void fetchStrokes();
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void fetchStrokes();
|
||||
}, 5_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [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 (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>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl overflow-hidden border" style={{ background: CANVAS_BACKGROUND }}>
|
||||
<canvas
|
||||
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>
|
||||
<StrokeCanvas strokes={strokes} />
|
||||
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} - updates in real-time</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -450,6 +415,10 @@ export function UserDetailPage() {
|
||||
const [mediaPage, setMediaPage] = useState(1);
|
||||
const [mediaPageSize, setMediaPageSize] = useState(12);
|
||||
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 canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||
@@ -485,13 +454,12 @@ export function UserDetailPage() {
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
// Use RPC function which handles permissions internally
|
||||
const { error } = await supabase.rpc('admin_gift_premium', {
|
||||
target_user_id: userId,
|
||||
expires_at_param: expiresAt,
|
||||
const days = expiresAt === null ? null : Math.max(1, Math.ceil((new Date(expiresAt).getTime() - now.getTime()) / 86_400_000));
|
||||
await adminRequest(`/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, {
|
||||
method: 'POST', body: { days, reason: 'Granted from admin user detail' },
|
||||
});
|
||||
if (error) throw error;
|
||||
const { data: updatedProfile } = 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 updatedProfile = users[0] ?? null;
|
||||
if (updatedProfile) setProfile(updatedProfile);
|
||||
setUpgradeOpen(false);
|
||||
alert('User upgraded to premium successfully!');
|
||||
@@ -507,37 +475,82 @@ export function UserDetailPage() {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
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);
|
||||
|
||||
// Fetch feature interests for the user
|
||||
const { data: featureInterestsData } = await supabase
|
||||
const { data: featureInterestsData } = await adminData
|
||||
.from('feature_interests')
|
||||
.select('id, feature_name, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
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) {
|
||||
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);
|
||||
|
||||
// Fetch streak data
|
||||
const { data: streakData } = await supabase
|
||||
const { data: streakData } = await adminData
|
||||
.from('couple_streaks')
|
||||
.select('*')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.maybeSingle();
|
||||
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 {
|
||||
setPartner(null);
|
||||
setStreak(null);
|
||||
setQuizSessions([]);
|
||||
}
|
||||
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
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')
|
||||
.select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
@@ -553,7 +566,7 @@ export function UserDetailPage() {
|
||||
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||
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')
|
||||
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
@@ -566,7 +579,7 @@ export function UserDetailPage() {
|
||||
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
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> = {};
|
||||
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[]);
|
||||
@@ -574,7 +587,7 @@ export function UserDetailPage() {
|
||||
setMatches([]);
|
||||
}
|
||||
|
||||
const { data: allMatchIdsData } = await supabase
|
||||
const { data: allMatchIdsData } = await adminData
|
||||
.from('matches')
|
||||
.select('id')
|
||||
.eq('couple_id', profileData.couple_id);
|
||||
@@ -584,7 +597,7 @@ export function UserDetailPage() {
|
||||
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||
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')
|
||||
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||
.in('match_id', allMatchIds)
|
||||
@@ -605,7 +618,7 @@ export function UserDetailPage() {
|
||||
setMediaTotal(0);
|
||||
}
|
||||
// Fetch enabled packs for the couple
|
||||
const { data: enabledPacksData, error: packsError } = await supabase
|
||||
const { data: enabledPacksData, error: packsError } = await adminData
|
||||
.from('couple_packs')
|
||||
.select(`
|
||||
pack_id,
|
||||
@@ -634,17 +647,17 @@ export function UserDetailPage() {
|
||||
|
||||
// Fetch outstanding questions (partner answered, user hasn't)
|
||||
// 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) {
|
||||
// Get question IDs user has already answered
|
||||
const { data: userResponseIds } = await supabase
|
||||
const { data: userResponseIds } = await adminData
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId);
|
||||
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
|
||||
|
||||
// Get all partner responses in this couple
|
||||
const { data: partnerResponses } = await supabase
|
||||
const { data: partnerResponses } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
id,
|
||||
@@ -663,7 +676,7 @@ export function UserDetailPage() {
|
||||
|
||||
if (unansweredQuestionIds.length > 0) {
|
||||
// Fetch question details (no pack filter — admin sees all)
|
||||
const { data: questionDetails } = await supabase
|
||||
const { data: questionDetails } = await adminData
|
||||
.from('questions')
|
||||
.select('id, text, intensity, question_type, pack:question_packs(id, name)')
|
||||
.in('id', unansweredQuestionIds);
|
||||
@@ -701,7 +714,7 @@ export function UserDetailPage() {
|
||||
}
|
||||
|
||||
// Calculate gap status
|
||||
const { data: appConfig } = await supabase
|
||||
const { data: appConfig } = await adminData
|
||||
.from('app_config')
|
||||
.select('answer_gap_threshold')
|
||||
.limit(1)
|
||||
@@ -713,7 +726,7 @@ export function UserDetailPage() {
|
||||
if (threshold > 0) {
|
||||
// Count questions user answered that partner hasn't
|
||||
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
|
||||
const { data: userResponses } = await supabase
|
||||
const { data: userResponses } = await adminData
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId)
|
||||
@@ -815,12 +828,59 @@ export function UserDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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 */}
|
||||
<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-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.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>
|
||||
{/* Streak Info */}
|
||||
{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>
|
||||
</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="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
@@ -874,6 +944,28 @@ export function UserDetailPage() {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
{featureInterests.length > 0 && (
|
||||
<Card>
|
||||
@@ -898,6 +990,83 @@ export function UserDetailPage() {
|
||||
</CardContent>
|
||||
</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 defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { adminRequest } from '@/lib/adminApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -55,24 +55,7 @@ export function UsersPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||
const { data: profilesDataRaw, error } = await supabase
|
||||
.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;
|
||||
});
|
||||
}
|
||||
const { users: profilesData } = await adminRequest<{ users: Profile[] }>('/v1/admin/users');
|
||||
|
||||
// Build a map of couple_id -> users with premium in that couple
|
||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||
@@ -100,7 +83,7 @@ export function UsersPage() {
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
storage_bytes: Number(profile.storage_bytes || 0),
|
||||
partner_is_premium: partnerIsPremium,
|
||||
partner_count: partnerCount,
|
||||
};
|
||||
|
||||
Loaded 100 of 657 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user