mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Compare commits
12
Commits
b43804d8fd
..
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91544629fa | ||
|
|
32f5214751 | ||
|
|
96882642c7 | ||
|
|
a455325147 | ||
|
|
2dad7aa352 | ||
|
|
be20391788 | ||
|
|
006761d3b0 | ||
|
|
7a0de45482 | ||
|
|
72e031eb84 | ||
|
|
2ab976ec42 | ||
|
|
c0650ed5b7 | ||
|
|
94c76535a4 |
No files matched your search
+23
-2
@@ -1,14 +1,35 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
.env*
|
.env*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
|
**/dist
|
||||||
.expo
|
.expo
|
||||||
android
|
**/.expo
|
||||||
ios
|
**/android
|
||||||
|
**/ios
|
||||||
*.log
|
*.log
|
||||||
.turbo
|
.turbo
|
||||||
|
**/.turbo
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
apps/admin
|
||||||
|
apps/mcp
|
||||||
|
!apps/mcp
|
||||||
|
!apps/mcp/**
|
||||||
|
apps/mobile
|
||||||
|
apps/supabase
|
||||||
|
apps/web
|
||||||
|
e2e
|
||||||
|
evidence
|
||||||
|
etc
|
||||||
|
infra
|
||||||
|
scripts
|
||||||
|
terraform
|
||||||
|
.agents
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
.ralph
|
||||||
@@ -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
|
|
||||||
@@ -3,13 +3,35 @@ name: Verify
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [staging, main]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: verify-${{ github.ref }}
|
group: verify-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
branch-policy:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Enforce promotion flow
|
||||||
|
env:
|
||||||
|
BASE_BRANCH: ${{ github.base_ref }}
|
||||||
|
HEAD_BRANCH: ${{ github.head_ref }}
|
||||||
|
run: |
|
||||||
|
if [ "$BASE_BRANCH" = "master" ]; then
|
||||||
|
echo "master is frozen; target staging or main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then
|
||||||
|
echo "Only staging may be promoted to main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
|
||||||
|
echo "Feature pull requests target staging; promotions target main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
static:
|
static:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -23,56 +45,39 @@ jobs:
|
|||||||
- run: npm run verify:fast
|
- run: npm run verify:fast
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
full-stack:
|
backend-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 15
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: sauci_test
|
||||||
|
POSTGRES_USER: sauci
|
||||||
|
POSTGRES_PASSWORD: sauci_ci_only
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U sauci -d sauci_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: npm
|
cache: npm
|
||||||
- uses: denoland/setup-deno@v2
|
|
||||||
with:
|
|
||||||
deno-version: v2.x
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps chromium
|
- name: Run standalone backend integration tests
|
||||||
- name: Start local Supabase
|
|
||||||
env:
|
env:
|
||||||
GOOGLE_CLIENT_ID: local
|
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
||||||
GOOGLE_CLIENT_SECRET: local
|
run: npm run test:api:integration
|
||||||
APPLE_CLIENT_ID: local
|
|
||||||
APPLE_CLIENT_SECRET: local
|
container-contract:
|
||||||
run: npx supabase start --workdir apps
|
runs-on: ubuntu-latest
|
||||||
- name: Reset and seed
|
timeout-minutes: 20
|
||||||
run: |
|
steps:
|
||||||
npx supabase db reset --workdir apps
|
- uses: actions/checkout@v4
|
||||||
scripts/with-local-supabase-env.sh tests node scripts/seed-e2e.mjs
|
- name: Verify production Compose contract
|
||||||
- name: Start application services
|
run: infra/backend/verify-production.sh
|
||||||
run: |
|
|
||||||
npx supabase functions serve --workdir apps > /tmp/sauci-functions.log 2>&1 &
|
|
||||||
scripts/with-local-supabase-env.sh web npm run dev -w @sauci/web -- --port 3000 > /tmp/sauci-web.log 2>&1 &
|
|
||||||
scripts/with-local-supabase-env.sh admin npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001 > /tmp/sauci-admin.log 2>&1 &
|
|
||||||
scripts/with-local-supabase-env.sh mcp npm run dev -w sauci-mcp-server > /tmp/sauci-mcp.log 2>&1 &
|
|
||||||
- name: Wait for services
|
|
||||||
run: |
|
|
||||||
for url in http://127.0.0.1:3000 http://127.0.0.1:3001/login http://127.0.0.1:3002/health; do
|
|
||||||
for attempt in $(seq 1 60); do
|
|
||||||
curl --fail --silent "$url" >/dev/null && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
curl --fail --silent "$url" >/dev/null
|
|
||||||
done
|
|
||||||
- run: npm run test:supabase
|
|
||||||
- run: npm run verify:e2e
|
|
||||||
- name: Upload E2E proof
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-proof
|
|
||||||
path: |
|
|
||||||
e2e/test-results
|
|
||||||
e2e/playwright-report
|
|
||||||
/tmp/sauci-*.log
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 14
|
|
||||||
@@ -55,6 +55,7 @@ evidence/
|
|||||||
e2e/.auth/
|
e2e/.auth/
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
e2e/playwright-report/
|
e2e/playwright-report/
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
@@ -70,3 +71,4 @@ nul
|
|||||||
# Agent runtime evidence (maintained lessons remain tracked)
|
# Agent runtime evidence (maintained lessons remain tracked)
|
||||||
.ralph/activity.log
|
.ralph/activity.log
|
||||||
.ralph/runs/
|
.ralph/runs/
|
||||||
|
.migration-state/
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
# Sauci agent map
|
# Sauci agent map
|
||||||
|
|
||||||
Sauci is a couples connection product: an Expo mobile app, React admin, Next.js
|
Sauci is a couples connection product: an Expo mobile app, React admin, Next.js
|
||||||
web app, Supabase backend, and an internal MCP service in one npm/Turborepo
|
web app, standalone Node/PostgreSQL backend, hosted Supabase Auth, and an internal
|
||||||
workspace.
|
MCP service in one npm/Turborepo workspace.
|
||||||
|
|
||||||
## Golden rules
|
## Golden rules
|
||||||
|
|
||||||
1. Read the nearest `AGENTS.md` before changing a scoped area.
|
1. Read the nearest `AGENTS.md` before changing a scoped area.
|
||||||
2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth.
|
2. Preserve tenant and couple isolation. Never bypass RLS, role checks, or auth.
|
||||||
3. All schema changes are local files in `apps/supabase/migrations/` created with
|
3. Product-data schema changes are committed SQL files in `apps/api/drizzle/`.
|
||||||
`npm run db:migration:new -- <name>`. Never use remote `apply_migration`, never
|
Hosted Supabase owns Auth only; do not deploy product-data migrations or Edge
|
||||||
execute DDL through MCP SQL tools, and use those SQL tools for `SELECT` only.
|
Functions there. Never execute remote DDL through MCP or provider tools.
|
||||||
4. Never target production from local development or E2E. Local tests must fail
|
4. Never target production from local development or E2E. Database integration
|
||||||
closed when a configured Supabase URL is not localhost.
|
tests must fail closed unless `DATABASE_URL` resolves to loopback. Authenticated
|
||||||
|
native acceptance may use only the designated non-production Auth project.
|
||||||
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
||||||
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
||||||
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
||||||
@@ -26,7 +27,8 @@ workspace.
|
|||||||
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
|
| `apps/mobile` | Expo React Native product | `apps/mobile/AGENTS.md` |
|
||||||
| `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` |
|
| `apps/admin` | Vite administration UI | `apps/admin/AGENTS.md` |
|
||||||
| `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` |
|
| `apps/web` | Next.js marketing/redemption UI | `apps/web/AGENTS.md` |
|
||||||
| `apps/supabase` | migrations, functions, local backend | `apps/supabase/AGENTS.md` |
|
| `apps/api` | standalone API, worker, PostgreSQL migrations | repository rules |
|
||||||
|
| `apps/supabase` | legacy data-plane history; hosted Auth configuration only | `apps/supabase/AGENTS.md` |
|
||||||
| `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` |
|
| `apps/mcp` | internal administrative MCP service | `apps/mcp/AGENTS.md` |
|
||||||
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
|
| `packages/shared` | shared TypeScript contracts | `packages/shared/AGENTS.md` |
|
||||||
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
|
| `e2e` | cross-application Playwright flows | `e2e/README.md` |
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
VITE_SUPABASE_URL=your_supabase_url
|
# Hosted Supabase Auth public settings only. Admin product data and mutations go
|
||||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
# through VITE_API_URL; never expose a service-role or admin service token here.
|
||||||
|
VITE_SUPABASE_URL=your_nonproduction_supabase_auth_url
|
||||||
|
VITE_SUPABASE_ANON_KEY=your_nonproduction_supabase_anon_key
|
||||||
|
VITE_API_URL=http://127.0.0.1:3003
|
||||||
VITE_OPENAI_API_KEY=your_openai_api_key
|
VITE_OPENAI_API_KEY=your_openai_api_key
|
||||||
VITE_OPENROUTER_API_KEY=your_openrouter_api_key
|
VITE_OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ RUN npm run build -w @sauci/shared
|
|||||||
|
|
||||||
# Build admin app with env vars
|
# Build admin app with env vars
|
||||||
ARG VITE_SUPABASE_URL
|
ARG VITE_SUPABASE_URL
|
||||||
ARG VITE_SUPABASE_ANON_KEY
|
ARG VITE_SUPABASE_ANON_KEY
|
||||||
|
ARG VITE_API_URL
|
||||||
ARG VITE_OPENAI_API_KEY
|
ARG VITE_OPENAI_API_KEY
|
||||||
ARG VITE_OPENROUTER_API_KEY
|
ARG VITE_OPENROUTER_API_KEY
|
||||||
ARG VITE_AI_MODEL
|
ARG VITE_AI_MODEL
|
||||||
@@ -23,7 +24,8 @@ ARG VITE_COUNCIL_GENERATOR_MODEL
|
|||||||
ARG VITE_COUNCIL_REVIEWER_MODEL
|
ARG VITE_COUNCIL_REVIEWER_MODEL
|
||||||
|
|
||||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY
|
ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY
|
||||||
ENV VITE_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY
|
ENV VITE_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY
|
||||||
ENV VITE_AI_MODEL=$VITE_AI_MODEL
|
ENV VITE_AI_MODEL=$VITE_AI_MODEL
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ services:
|
|||||||
dockerfile: apps/admin/Dockerfile
|
dockerfile: apps/admin/Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||||
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||||
|
- VITE_API_URL=${VITE_API_URL}
|
||||||
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
||||||
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||||
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||||
|
- VITE_API_URL=${VITE_API_URL:-http://127.0.0.1:3003}
|
||||||
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
||||||
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/flagged-messages"
|
path="/flagged-messages"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||||
<FlaggedMessagesPage />
|
<FlaggedMessagesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
import { Archive, CheckCircle2, ClipboardCheck, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
CONTENT_STATUS_LABELS,
|
||||||
|
type ReviewableContentTable,
|
||||||
|
updateContentReviewStatus,
|
||||||
|
} from '@/services/contentReviews';
|
||||||
|
|
||||||
|
interface ContentReviewControlProps {
|
||||||
|
table: ReviewableContentTable;
|
||||||
|
entityId: string;
|
||||||
|
entityLabel: string;
|
||||||
|
status?: ContentReviewStatus | null;
|
||||||
|
reason?: string | null;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeClasses: Record<ContentReviewStatus, string> = {
|
||||||
|
unreviewed: 'border-amber-500 text-amber-500',
|
||||||
|
allowed: 'border-green-500 text-green-500',
|
||||||
|
archived: 'border-slate-500 text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons: Record<ContentReviewStatus, typeof ClipboardCheck> = {
|
||||||
|
unreviewed: ClipboardCheck,
|
||||||
|
allowed: CheckCircle2,
|
||||||
|
archived: Archive,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentReviewControl({
|
||||||
|
table,
|
||||||
|
entityId,
|
||||||
|
entityLabel,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
onChanged,
|
||||||
|
compact = false,
|
||||||
|
}: ContentReviewControlProps) {
|
||||||
|
const { isSuperAdmin } = useAuth();
|
||||||
|
const resolvedStatus = status ?? 'unreviewed';
|
||||||
|
const StatusIcon = statusIcons[resolvedStatus];
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [nextStatus, setNextStatus] = useState<ContentReviewStatus>(resolvedStatus);
|
||||||
|
const [nextReason, setNextReason] = useState(reason ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setNextStatus(resolvedStatus);
|
||||||
|
setNextReason(reason ?? '');
|
||||||
|
}
|
||||||
|
}, [open, reason, resolvedStatus]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!nextReason.trim()) {
|
||||||
|
toast.error('A review reason is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateContentReviewStatus(table, entityId, nextStatus, nextReason);
|
||||||
|
toast.success(`${entityLabel} marked ${CONTENT_STATUS_LABELS[nextStatus].toLowerCase()}`);
|
||||||
|
setOpen(false);
|
||||||
|
await onChanged();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update catalogue status');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${badgeClasses[resolvedStatus]} ${compact ? 'text-[10px] px-1.5 py-0' : ''}`}
|
||||||
|
title={reason || 'No review reason recorded'}
|
||||||
|
>
|
||||||
|
<StatusIcon className="mr-1 h-3 w-3" />
|
||||||
|
{CONTENT_STATUS_LABELS[resolvedStatus]}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size={compact ? 'sm' : 'default'}
|
||||||
|
className={compact ? 'h-7 px-2 text-xs' : undefined}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label={`Review catalogue status for ${entityLabel}`}
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Review catalogue status</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This records a reversible editorial decision for {entityLabel}.
|
||||||
|
Current customer apps do not filter this status yet.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`content-status-${entityId}`}>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={nextStatus}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const selectedStatus = value as ContentReviewStatus;
|
||||||
|
setNextStatus(selectedStatus);
|
||||||
|
if (selectedStatus !== resolvedStatus) setNextReason('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`content-status-${entityId}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="unreviewed">Needs review</SelectItem>
|
||||||
|
<SelectItem value="allowed">Allowed</SelectItem>
|
||||||
|
<SelectItem value="archived">Archived</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`content-reason-${entityId}`}>Reason</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`content-reason-${entityId}`}
|
||||||
|
value={nextReason}
|
||||||
|
onChange={(event) => setNextReason(event.target.value)}
|
||||||
|
placeholder="Explain why this content is allowed, archived, or needs another review"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={save} disabled={saving || !nextReason.trim()}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save decision
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,281 +1,281 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
|
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
|
||||||
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
|
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface ExtractTopicsDialogProps {
|
interface ExtractTopicsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
pack: {
|
pack: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
};
|
};
|
||||||
onUpdated: () => void;
|
onUpdated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
|
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
|
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
|
||||||
const [reasoning, setReasoning] = useState('');
|
const [reasoning, setReasoning] = useState('');
|
||||||
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
|
||||||
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
const [step, setStep] = useState<'initial' | 'review'>('initial');
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
try {
|
try {
|
||||||
// Fetch questions for this pack
|
// Fetch questions for this pack
|
||||||
const { data: questions, error: questionsError } = await supabase
|
const { data: questions, error: questionsError } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('text')
|
.select('text')
|
||||||
.eq('pack_id', pack.id);
|
.eq('pack_id', pack.id);
|
||||||
|
|
||||||
if (questionsError) throw questionsError;
|
if (questionsError) throw questionsError;
|
||||||
|
|
||||||
if (!questions || questions.length === 0) {
|
if (!questions || questions.length === 0) {
|
||||||
toast.error('This pack has no questions to analyze');
|
toast.error('This pack has no questions to analyze');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch existing topics
|
// Fetch existing topics
|
||||||
const { data: existingTopics, error: topicsError } = await supabase
|
const { data: existingTopics, error: topicsError } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.order('name');
|
.order('name');
|
||||||
|
|
||||||
if (topicsError) throw topicsError;
|
if (topicsError) throw topicsError;
|
||||||
|
|
||||||
const result = await extractTopicsFromPack(
|
const result = await extractTopicsFromPack(
|
||||||
pack.name,
|
pack.name,
|
||||||
pack.description,
|
pack.description,
|
||||||
questions.map(q => q.text),
|
questions.map(q => q.text),
|
||||||
existingTopics || []
|
existingTopics || []
|
||||||
);
|
);
|
||||||
|
|
||||||
setTopics(result.topics);
|
setTopics(result.topics);
|
||||||
setReasoning(result.reasoning);
|
setReasoning(result.reasoning);
|
||||||
// Default select all
|
// Default select all
|
||||||
setSelectedTopics(new Set(result.topics.map(t => t.name)));
|
setSelectedTopics(new Set(result.topics.map(t => t.name)));
|
||||||
setStep('review');
|
setStep('review');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Failed to analyze pack');
|
toast.error('Failed to analyze pack');
|
||||||
} finally {
|
} finally {
|
||||||
setAnalyzing(false);
|
setAnalyzing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
const selectedList = topics.filter(t => selectedTopics.has(t.name));
|
const selectedList = topics.filter(t => selectedTopics.has(t.name));
|
||||||
if (selectedList.length === 0) {
|
if (selectedList.length === 0) {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, create any new topics
|
// First, create any new topics
|
||||||
const newTopics = selectedList.filter(t => t.isNew);
|
const newTopics = selectedList.filter(t => t.isNew);
|
||||||
const existingTopicIds: string[] = selectedList
|
const existingTopicIds: string[] = selectedList
|
||||||
.filter(t => !t.isNew && t.existingTopicId)
|
.filter(t => !t.isNew && t.existingTopicId)
|
||||||
.map(t => t.existingTopicId!);
|
.map(t => t.existingTopicId!);
|
||||||
|
|
||||||
if (newTopics.length > 0) {
|
if (newTopics.length > 0) {
|
||||||
const { data: createdTopics, error: createError } = await supabase
|
const { data: createdTopics, error: createError } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.insert(newTopics.map(t => ({ name: t.name })))
|
.insert(newTopics.map(t => ({ name: t.name })))
|
||||||
.select('id');
|
.select('id');
|
||||||
|
|
||||||
if (createError) throw createError;
|
if (createError) throw createError;
|
||||||
|
|
||||||
if (createdTopics) {
|
if (createdTopics) {
|
||||||
existingTopicIds.push(...createdTopics.map(t => t.id));
|
existingTopicIds.push(...createdTopics.map(t => t.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove existing pack_topics for this pack
|
// Remove existing pack_topics for this pack
|
||||||
await supabase
|
await adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('pack_id', pack.id);
|
.eq('pack_id', pack.id);
|
||||||
|
|
||||||
// Insert new pack_topics relationships
|
// Insert new pack_topics relationships
|
||||||
if (existingTopicIds.length > 0) {
|
if (existingTopicIds.length > 0) {
|
||||||
const { error: linkError } = await supabase
|
const { error: linkError } = await adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.insert(existingTopicIds.map(topicId => ({
|
.insert(existingTopicIds.map(topicId => ({
|
||||||
pack_id: pack.id,
|
pack_id: pack.id,
|
||||||
topic_id: topicId,
|
topic_id: topicId,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
if (linkError) throw linkError;
|
if (linkError) throw linkError;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
|
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
|
||||||
onUpdated();
|
onUpdated();
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Failed to apply topics');
|
toast.error('Failed to apply topics');
|
||||||
} finally {
|
} finally {
|
||||||
setApplying(false);
|
setApplying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// Reset state after transition
|
// Reset state after transition
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setStep('initial');
|
setStep('initial');
|
||||||
setTopics([]);
|
setTopics([]);
|
||||||
setReasoning('');
|
setReasoning('');
|
||||||
setSelectedTopics(new Set());
|
setSelectedTopics(new Set());
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelection = (name: string) => {
|
const toggleSelection = (name: string) => {
|
||||||
const next = new Set(selectedTopics);
|
const next = new Set(selectedTopics);
|
||||||
if (next.has(name)) next.delete(name);
|
if (next.has(name)) next.delete(name);
|
||||||
else next.add(name);
|
else next.add(name);
|
||||||
setSelectedTopics(next);
|
setSelectedTopics(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Tags className="h-5 w-5 text-blue-500" />
|
<Tags className="h-5 w-5 text-blue-500" />
|
||||||
Extract Topics
|
Extract Topics
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Use AI to identify topics and kinks for "{pack.name}"
|
Use AI to identify topics and kinks for "{pack.name}"
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto py-4">
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
{step === 'initial' ? (
|
{step === 'initial' ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
|
<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">
|
<div className="bg-blue-500/20 p-4 rounded-full">
|
||||||
<Tags className="h-12 w-12 text-blue-400" />
|
<Tags className="h-12 w-12 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
|
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
The AI will analyze the questions in this pack and suggest relevant topics
|
The AI will analyze the questions in this pack and suggest relevant topics
|
||||||
like bondage, exhibitionism, romance, etc. It will prefer matching existing
|
like bondage, exhibitionism, romance, etc. It will prefer matching existing
|
||||||
topics when possible.
|
topics when possible.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
|
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
|
||||||
{analyzing ? (
|
{analyzing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Analyzing...
|
Analyzing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Start Analysis'
|
'Start Analysis'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{topics.length === 0 ? (
|
{topics.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||||
No topics could be identified for this pack.
|
No topics could be identified for this pack.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">Suggested Topics</h4>
|
<h4 className="text-sm font-medium">Suggested Topics</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{topics.map((topic) => (
|
{topics.map((topic) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={topic.name}
|
key={topic.name}
|
||||||
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
|
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
|
||||||
className={`cursor-pointer text-sm py-1.5 px-3 ${
|
className={`cursor-pointer text-sm py-1.5 px-3 ${
|
||||||
selectedTopics.has(topic.name)
|
selectedTopics.has(topic.name)
|
||||||
? topic.isNew
|
? topic.isNew
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
: 'bg-blue-600 hover:bg-blue-700'
|
: 'bg-blue-600 hover:bg-blue-700'
|
||||||
: 'hover:bg-muted'
|
: 'hover:bg-muted'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => toggleSelection(topic.name)}
|
onClick={() => toggleSelection(topic.name)}
|
||||||
>
|
>
|
||||||
{selectedTopics.has(topic.name) ? (
|
{selectedTopics.has(topic.name) ? (
|
||||||
<Check className="h-3 w-3 mr-1" />
|
<Check className="h-3 w-3 mr-1" />
|
||||||
) : null}
|
) : null}
|
||||||
{topic.name}
|
{topic.name}
|
||||||
{topic.isNew && (
|
{topic.isNew && (
|
||||||
<Plus className="h-3 w-3 ml-1" />
|
<Plus className="h-3 w-3 ml-1" />
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<div className="w-3 h-3 rounded bg-blue-600" />
|
<div className="w-3 h-3 rounded bg-blue-600" />
|
||||||
Existing topic
|
Existing topic
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<div className="w-3 h-3 rounded bg-green-600" />
|
<div className="w-3 h-3 rounded bg-green-600" />
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
New topic
|
New topic
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reasoning && (
|
{reasoning && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium">AI Reasoning</h4>
|
<h4 className="text-sm font-medium">AI Reasoning</h4>
|
||||||
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||||
{reasoning}
|
{reasoning}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{step === 'review' && (
|
{step === 'review' && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => setStep('initial')}>
|
<Button variant="outline" onClick={() => setStep('initial')}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
|
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
{applying ? (
|
{applying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Applying...
|
Applying...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
|
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
|
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
|
||||||
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
|
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// Define local compatible interface
|
// Define local compatible interface
|
||||||
@@ -111,7 +111,7 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
|||||||
|
|
||||||
// Apply updates in parallel with audit logging
|
// Apply updates in parallel with audit logging
|
||||||
await Promise.all(updates.map(update =>
|
await Promise.all(updates.map(update =>
|
||||||
auditedSupabase.update('questions', update.id, {
|
auditedAdminData.update('questions', update.id, {
|
||||||
allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
|
allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
|
||||||
target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
|
target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
|
import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
|
||||||
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
|
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface Question {
|
interface Question {
|
||||||
@@ -62,7 +62,7 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
|
|||||||
|
|
||||||
// Apply updates in parallel with audit logging
|
// Apply updates in parallel with audit logging
|
||||||
await Promise.all(updates.map(update =>
|
await Promise.all(updates.map(update =>
|
||||||
auditedSupabase.update('questions', update.id, {
|
auditedAdminData.update('questions', update.id, {
|
||||||
text: update.suggested_text,
|
text: update.suggested_text,
|
||||||
partner_text: update.suggested_partner_text,
|
partner_text: update.suggested_partner_text,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import type { AnswerType, QuestionType } from '@sauci/shared';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Image as ImageIcon, Music2 } from 'lucide-react';
|
import { Image as ImageIcon, Music2 } from 'lucide-react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { getAdminResponseMedia } from '@/lib/adminApi';
|
||||||
import { supabaseConfig } from '@/config';
|
|
||||||
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
|
||||||
|
|
||||||
interface ResponseAnswerProps {
|
interface ResponseAnswerProps {
|
||||||
@@ -62,38 +61,17 @@ function ProtectedResponseMedia({
|
|||||||
mediaType: 'photo' | 'audio';
|
mediaType: 'photo' | 'audio';
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
const { session } = useAuth();
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.access_token) {
|
|
||||||
setError('Sign in to load media');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let objectUrl: string | null = null;
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, {
|
objectUrl = URL.createObjectURL(await getAdminResponseMedia(responseId));
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
apikey: supabaseConfig.anonKey,
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ responseId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(body?.error || `Failed to load media (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
objectUrl = URL.createObjectURL(await response.blob());
|
|
||||||
if (!cancelled) setUrl(objectUrl);
|
if (!cancelled) setUrl(objectUrl);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -108,7 +86,7 @@ function ProtectedResponseMedia({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
};
|
};
|
||||||
}, [responseId, session?.access_token]);
|
}, [responseId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
DeletionAnalysis,
|
DeletionAnalysis,
|
||||||
PropsAnalysis,
|
PropsAnalysis,
|
||||||
} from '@/lib/openai';
|
} from '@/lib/openai';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||||
@@ -101,7 +101,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
|
|||||||
if (existingProps.length > 0) return existingProps;
|
if (existingProps.length > 0) return existingProps;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('required_props')
|
.select('required_props')
|
||||||
.not('required_props', 'is', null)
|
.not('required_props', 'is', null)
|
||||||
@@ -252,12 +252,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
|
|||||||
|
|
||||||
// Apply updates/deletions in parallel with audit logging
|
// Apply updates/deletions in parallel with audit logging
|
||||||
const operations: Promise<unknown>[] = updates.map(update =>
|
const operations: Promise<unknown>[] = updates.map(update =>
|
||||||
auditedSupabase.update('questions', update.id, update.data)
|
auditedAdminData.update('questions', update.id, update.data)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deleteIds.length > 0) {
|
if (deleteIds.length > 0) {
|
||||||
operations.push(
|
operations.push(
|
||||||
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
|
auditedAdminData.deleteMany('questions', deleteIds).then(result => {
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||||
import { ChevronRight, Home } from 'lucide-react';
|
import { ChevronRight, Home } from 'lucide-react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,7 +32,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
|||||||
if (params.packId && !params.categoryId) {
|
if (params.packId && !params.categoryId) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await supabase
|
const { data } = await adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('category_id, categories(name)')
|
.select('category_id, categories(name)')
|
||||||
.eq('id', params.packId)
|
.eq('id', params.packId)
|
||||||
@@ -63,7 +63,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
|||||||
if (params.userId) {
|
if (params.userId) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await supabase
|
const { data } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.eq('id', params.userId)
|
.eq('id', params.userId)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const systemNav: NavItem[] = [
|
|||||||
label: 'Flagged Messages',
|
label: 'Flagged Messages',
|
||||||
href: '/flagged-messages',
|
href: '/flagged-messages',
|
||||||
icon: <AlertTriangle className="h-5 w-5" />,
|
icon: <AlertTriangle className="h-5 w-5" />,
|
||||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
requiredPermission: PERMISSION_KEYS.VIEW_CHATS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'AI Settings',
|
label: 'AI Settings',
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export const supabaseConfig = {
|
export const supabaseConfig = {
|
||||||
url: import.meta.env.VITE_SUPABASE_URL,
|
url: import.meta.env.VITE_SUPABASE_URL,
|
||||||
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const supabase = createClient(
|
export const authClient = createClient(
|
||||||
supabaseConfig.url,
|
supabaseConfig.url,
|
||||||
supabaseConfig.anonKey
|
supabaseConfig.anonKey
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const configuredApiUrl = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
if (!configuredApiUrl) {
|
||||||
|
throw new Error('Missing required environment variable: VITE_API_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiUrl = configuredApiUrl.replace(/\/$/, '');
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { supabase } from '../config';
|
import { authClient } from '../config';
|
||||||
|
import { adminRequest } from '@/lib/adminApi';
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
import type { User, Session } from '@supabase/supabase-js';
|
||||||
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
|
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
|
||||||
|
|
||||||
@@ -80,23 +81,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get initial session
|
// Get initial session
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
authClient.auth.getSession().then(async ({ data: { session } }) => {
|
||||||
setSession(session);
|
setSession(session);
|
||||||
setUser(session?.user ?? null);
|
setUser(session?.user ?? null);
|
||||||
|
|
||||||
// Restore permissions from localStorage
|
if (session) {
|
||||||
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
|
try {
|
||||||
if (storedPermissions) {
|
const currentPermissions = await adminRequest<AdminPermissions>('/v1/admin/me');
|
||||||
setPermissions(JSON.parse(storedPermissions));
|
setPermissions(currentPermissions);
|
||||||
// Preload AI config if user has an existing session (non-blocking)
|
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(currentPermissions));
|
||||||
preloadAiConfig().catch(console.error);
|
preloadAiConfig().catch(console.error);
|
||||||
|
} catch {
|
||||||
|
setPermissions(null);
|
||||||
|
localStorage.removeItem(PERMISSIONS_KEY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for auth changes
|
// Listen for auth changes
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = authClient.auth.onAuthStateChange(
|
||||||
(_event, session) => {
|
(_event, session) => {
|
||||||
setSession(session);
|
setSession(session);
|
||||||
setUser(session?.user ?? null);
|
setUser(session?.user ?? null);
|
||||||
@@ -112,7 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
const { error } = await authClient.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
@@ -122,20 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch admin role and permissions
|
// Fetch admin role and permissions
|
||||||
const { data: adminData, error: adminError } = await supabase
|
let adminData: AdminPermissions;
|
||||||
.from('admin_users')
|
try {
|
||||||
.select('role, permissions')
|
adminData = await adminRequest<AdminPermissions>('/v1/admin/me');
|
||||||
.eq('user_id', data.user.id)
|
} catch {
|
||||||
.single();
|
await authClient.auth.signOut();
|
||||||
|
|
||||||
if (adminError || !adminData) {
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
throw new Error('Access denied. You are not an administrator.');
|
throw new Error('Access denied. You are not an administrator.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store permissions from database
|
// Store permissions from database
|
||||||
const perms: AdminPermissions = {
|
const perms: AdminPermissions = {
|
||||||
role: adminData.role as AdminRole,
|
role: adminData.role,
|
||||||
permissions: (adminData.permissions as string[]) || [],
|
permissions: (adminData.permissions as string[]) || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.removeItem(PERMISSIONS_KEY);
|
localStorage.removeItem(PERMISSIONS_KEY);
|
||||||
setPermissions(null);
|
setPermissions(null);
|
||||||
clearAiConfigCache();
|
clearAiConfigCache();
|
||||||
await supabase.auth.signOut();
|
await authClient.auth.signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSuperAdmin = permissions?.role === 'super_admin';
|
const isSuperAdmin = permissions?.role === 'super_admin';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
|
||||||
export interface CouncilGenerator {
|
export interface CouncilGenerator {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -74,7 +74,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error: fetchError } = await supabase
|
const { data, error: fetchError } = await adminData
|
||||||
.from('ai_config')
|
.from('ai_config')
|
||||||
.select('*')
|
.select('*')
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -82,7 +82,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
|||||||
|
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
// If user doesn't have access (not super admin), that's expected
|
// If user doesn't have access (not super admin), that's expected
|
||||||
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) {
|
if (fetchError.message.includes('permission')) {
|
||||||
setError('Access denied. Super admin privileges required.');
|
setError('Access denied. Super admin privileges required.');
|
||||||
} else {
|
} else {
|
||||||
setError(fetchError.message);
|
setError(fetchError.message);
|
||||||
@@ -110,7 +110,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await adminData
|
||||||
.from('ai_config')
|
.from('ai_config')
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq('id', config.id);
|
.eq('id', config.id);
|
||||||
@@ -160,7 +160,7 @@ export async function preloadAiConfig(): Promise<AiConfig | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await supabase
|
const { data } = await adminData
|
||||||
.from('ai_config')
|
.from('ai_config')
|
||||||
.select('*')
|
.select('*')
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,7 +40,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error: fetchError } = await supabase
|
const { data, error: fetchError } = await adminData
|
||||||
.from('app_config')
|
.from('app_config')
|
||||||
.select('*')
|
.select('*')
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -71,7 +71,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await adminData
|
||||||
.from('app_config')
|
.from('app_config')
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq('id', config.id);
|
.eq('id', config.id);
|
||||||
|
|||||||
+20
-31
@@ -1,10 +1,12 @@
|
|||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
|
||||||
// Tables to audit
|
// Tables to audit
|
||||||
const AUDITED_TABLES = [
|
const AUDITED_TABLES = [
|
||||||
'question_packs',
|
'question_packs',
|
||||||
'questions',
|
'questions',
|
||||||
'categories',
|
'categories',
|
||||||
|
'dare_packs',
|
||||||
|
'dares',
|
||||||
'profiles',
|
'profiles',
|
||||||
'couples',
|
'couples',
|
||||||
'responses',
|
'responses',
|
||||||
@@ -26,23 +28,10 @@ async function logAction(
|
|||||||
oldValues?: Record<string, unknown> | null,
|
oldValues?: Record<string, unknown> | null,
|
||||||
newValues?: Record<string, unknown> | null
|
newValues?: Record<string, unknown> | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!AUDITED_TABLES.includes(tableName)) return;
|
// The standalone admin API writes the mutation and audit record in one
|
||||||
|
// transaction. Keeping this no-op preserves the existing call sites while
|
||||||
try {
|
// avoiding duplicate or client-forgeable audit entries.
|
||||||
const { error } = await supabase.rpc('log_admin_action', {
|
void tableName; void action; void recordId; void oldValues; void newValues;
|
||||||
p_table_name: tableName,
|
|
||||||
p_action: action,
|
|
||||||
p_record_id: recordId,
|
|
||||||
p_old_values: oldValues || null,
|
|
||||||
p_new_values: newValues || null,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
console.error('Failed to log audit action:', error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to log audit action:', error);
|
|
||||||
// Don't throw - logging failure shouldn't block the operation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InsertResult<T> {
|
interface InsertResult<T> {
|
||||||
@@ -56,9 +45,9 @@ interface SingleResult<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs
|
* Admin API mutations. The server writes each mutation and audit record atomically.
|
||||||
*/
|
*/
|
||||||
export const auditedSupabase = {
|
export const auditedAdminData = {
|
||||||
/**
|
/**
|
||||||
* Insert records with audit logging
|
* Insert records with audit logging
|
||||||
*/
|
*/
|
||||||
@@ -68,7 +57,7 @@ export const auditedSupabase = {
|
|||||||
): Promise<InsertResult<T>> {
|
): Promise<InsertResult<T>> {
|
||||||
const recordArray = Array.isArray(records) ? records : [records];
|
const recordArray = Array.isArray(records) ? records : [records];
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.insert(recordArray)
|
.insert(recordArray)
|
||||||
.select();
|
.select();
|
||||||
@@ -94,7 +83,7 @@ export const auditedSupabase = {
|
|||||||
// Fetch old values before update
|
// Fetch old values before update
|
||||||
let oldValues: Record<string, unknown> | null = null;
|
let oldValues: Record<string, unknown> | null = null;
|
||||||
if (AUDITED_TABLES.includes(table)) {
|
if (AUDITED_TABLES.includes(table)) {
|
||||||
const { data: oldData } = await supabase
|
const { data: oldData } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -102,7 +91,7 @@ export const auditedSupabase = {
|
|||||||
oldValues = oldData;
|
oldValues = oldData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -127,14 +116,14 @@ export const auditedSupabase = {
|
|||||||
// Fetch old values before update
|
// Fetch old values before update
|
||||||
let oldRecords: Record<string, unknown>[] = [];
|
let oldRecords: Record<string, unknown>[] = [];
|
||||||
if (AUDITED_TABLES.includes(table)) {
|
if (AUDITED_TABLES.includes(table)) {
|
||||||
const { data: oldData } = await supabase
|
const { data: oldData } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq(filter.column, filter.value);
|
.eq(filter.column, filter.value);
|
||||||
oldRecords = oldData || [];
|
oldRecords = oldData || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq(filter.column, filter.value)
|
.eq(filter.column, filter.value)
|
||||||
@@ -161,7 +150,7 @@ export const auditedSupabase = {
|
|||||||
// Fetch old values before delete
|
// Fetch old values before delete
|
||||||
let oldValues: Record<string, unknown> | null = null;
|
let oldValues: Record<string, unknown> | null = null;
|
||||||
if (AUDITED_TABLES.includes(table)) {
|
if (AUDITED_TABLES.includes(table)) {
|
||||||
const { data: oldData } = await supabase
|
const { data: oldData } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -169,7 +158,7 @@ export const auditedSupabase = {
|
|||||||
oldValues = oldData;
|
oldValues = oldData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id);
|
.eq('id', id);
|
||||||
@@ -191,14 +180,14 @@ export const auditedSupabase = {
|
|||||||
// Fetch old values before delete
|
// Fetch old values before delete
|
||||||
let oldRecords: Record<string, unknown>[] = [];
|
let oldRecords: Record<string, unknown>[] = [];
|
||||||
if (AUDITED_TABLES.includes(table)) {
|
if (AUDITED_TABLES.includes(table)) {
|
||||||
const { data: oldData } = await supabase
|
const { data: oldData } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.select('*')
|
.select('*')
|
||||||
.in('id', ids);
|
.in('id', ids);
|
||||||
oldRecords = oldData || [];
|
oldRecords = oldData || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.delete()
|
.delete()
|
||||||
.in('id', ids);
|
.in('id', ids);
|
||||||
@@ -224,7 +213,7 @@ export const auditedSupabase = {
|
|||||||
// Fetch old values before delete
|
// Fetch old values before delete
|
||||||
let oldValues: Record<string, unknown> | null = null;
|
let oldValues: Record<string, unknown> | null = null;
|
||||||
if (AUDITED_TABLES.includes(table)) {
|
if (AUDITED_TABLES.includes(table)) {
|
||||||
const { data: oldData } = await supabase
|
const { data: oldData } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq(field, value)
|
.eq(field, value)
|
||||||
@@ -232,7 +221,7 @@ export const auditedSupabase = {
|
|||||||
oldValues = oldData;
|
oldValues = oldData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.delete()
|
.delete()
|
||||||
.eq(field, value);
|
.eq(field, value);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type SupabaseFilterFn = (query: any) => any;
|
type AdminFilterFn = (query: any) => any;
|
||||||
|
|
||||||
interface UseCrudOperationsOptions<T> {
|
interface UseCrudOperationsOptions<T> {
|
||||||
/**
|
/**
|
||||||
* The Supabase table name
|
* The standalone admin API resource name
|
||||||
*/
|
*/
|
||||||
table: string;
|
table: string;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ interface UseCrudOperationsOptions<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic CRUD operations hook for Supabase tables
|
* Generic CRUD operations hook for standalone admin API resources
|
||||||
* Reduces boilerplate for data fetching and mutations across pages.
|
* Reduces boilerplate for data fetching and mutations across pages.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -90,13 +90,13 @@ export function useCrudOperations<T extends { id: string }>({
|
|||||||
* @param filter Optional filter function to modify the query
|
* @param filter Optional filter function to modify the query
|
||||||
*/
|
*/
|
||||||
const fetchAll = useCallback(async (
|
const fetchAll = useCallback(async (
|
||||||
filter?: SupabaseFilterFn
|
filter?: AdminFilterFn
|
||||||
): Promise<T[]> => {
|
): Promise<T[]> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let query: any = supabase.from(table).select(select);
|
let query: any = adminData.from(table).select(select);
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
query = filter(query);
|
query = filter(query);
|
||||||
@@ -131,7 +131,7 @@ export function useCrudOperations<T extends { id: string }>({
|
|||||||
*/
|
*/
|
||||||
const fetchById = useCallback(async (id: string): Promise<T | null> => {
|
const fetchById = useCallback(async (id: string): Promise<T | null> => {
|
||||||
try {
|
try {
|
||||||
const { data, error: fetchError } = await supabase
|
const { data, error: fetchError } = await adminData
|
||||||
.from(table)
|
.from(table)
|
||||||
.select(select)
|
.select(select)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -158,7 +158,7 @@ export function useCrudOperations<T extends { id: string }>({
|
|||||||
const { refetch = true, silent = false } = options;
|
const { refetch = true, silent = false } = options;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
|
const { data: created, error: createError } = await auditedAdminData.insert(table, data as any);
|
||||||
|
|
||||||
if (createError) throw createError;
|
if (createError) throw createError;
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ export function useCrudOperations<T extends { id: string }>({
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditedSupabase.insert returns T[] | null, get first element
|
// auditedAdminData.insert returns T[] | null, get first element
|
||||||
return created && created.length > 0 ? (created[0] as T) : null;
|
return created && created.length > 0 ? (created[0] as T) : null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
@@ -194,7 +194,7 @@ export function useCrudOperations<T extends { id: string }>({
|
|||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const { refetch = true, silent = false } = options;
|
const { refetch = true, silent = false } = options;
|
||||||
try {
|
try {
|
||||||
const { error: updateError } = await auditedSupabase.update(table, id, data);
|
const { error: updateError } = await auditedAdminData.update(table, id, data);
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ export function useCrudOperations<T extends { id: string }>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error: deleteError } = await auditedSupabase.delete(table, id);
|
const { error: deleteError } = await auditedAdminData.delete(table, id);
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
if (deleteError) throw deleteError;
|
||||||
|
|
||||||
|
|||||||
@@ -1,243 +1,92 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { toast } from 'sonner';
|
||||||
import { toast } from 'sonner';
|
import { queryAdminRows, type AdminFilter } from '@/lib/adminApi';
|
||||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
|
||||||
|
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
||||||
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
export interface UseRealtimeSubscriptionOptions<T> {
|
||||||
|
table: string; schema?: string; event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; filter?: string;
|
||||||
export interface UseRealtimeSubscriptionOptions<T> {
|
onInsert?: (payload: T) => void; onUpdate?: (payload: { old: T; new: T }) => void; onDelete?: (payload: T) => void;
|
||||||
table: string;
|
insertToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||||
schema?: string;
|
updateToast?: { enabled: boolean; message: string | ((payload: { old: T; new: T }) => string); type?: 'success' | 'info' | 'warning' };
|
||||||
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
deleteToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||||
filter?: string;
|
debounceMs?: number; enabled?: boolean; pollIntervalMs?: number;
|
||||||
onInsert?: (payload: T) => void;
|
}
|
||||||
onUpdate?: (payload: { old: T; new: T }) => void;
|
export interface UseRealtimeSubscriptionReturn { status: SubscriptionStatus; error: Error | null }
|
||||||
onDelete?: (payload: T) => void;
|
|
||||||
insertToast?: {
|
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
||||||
enabled: boolean;
|
const MIN_POLL_INTERVAL_MS = 5_000;
|
||||||
message: string | ((payload: T) => string);
|
|
||||||
type?: 'success' | 'info' | 'warning';
|
function parseFilter(filter?: string): AdminFilter[] | undefined {
|
||||||
};
|
if (!filter) return undefined;
|
||||||
updateToast?: {
|
const match = /^([a-z_][a-z0-9_]*)=(eq|neq|gte|lte)\.(.+)$/.exec(filter);
|
||||||
enabled: boolean;
|
return match ? [{ column: match[1], op: match[2] as AdminFilter['op'], value: match[3] }] : undefined;
|
||||||
message: string | ((payload: { old: T; new: T }) => string);
|
}
|
||||||
type?: 'success' | 'info' | 'warning';
|
|
||||||
};
|
function rowKey(row: unknown, index: number): string {
|
||||||
deleteToast?: {
|
if (row && typeof row === 'object') {
|
||||||
enabled: boolean;
|
const record = row as Record<string, unknown>;
|
||||||
message: string | ((payload: T) => string);
|
const key = record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}`;
|
||||||
type?: 'success' | 'info' | 'warning';
|
if (key) return String(key);
|
||||||
};
|
}
|
||||||
debounceMs?: number;
|
return String(index);
|
||||||
enabled?: boolean;
|
}
|
||||||
}
|
|
||||||
|
export function useRealtimeSubscription<T>(options: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionReturn {
|
||||||
export interface UseRealtimeSubscriptionReturn {
|
const { table, event = '*', filter, onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast,
|
||||||
status: SubscriptionStatus;
|
debounceMs = 0, enabled = true, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = options;
|
||||||
error: Error | null;
|
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 MAX_RETRIES = 3;
|
const callbacksRef = useRef({ onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast });
|
||||||
|
callbacksRef.current = { onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast };
|
||||||
export function useRealtimeSubscription<T>(
|
const emit = useCallback((callback: () => void) => {
|
||||||
options: UseRealtimeSubscriptionOptions<T>
|
if (debounceMs <= 0) callback(); else window.setTimeout(callback, debounceMs);
|
||||||
): UseRealtimeSubscriptionReturn {
|
}, [debounceMs]);
|
||||||
const {
|
|
||||||
table,
|
useEffect(() => {
|
||||||
schema = 'public',
|
if (!enabled) { setStatus('CLOSED'); return; }
|
||||||
event = '*',
|
let cancelled = false;
|
||||||
filter,
|
let timer: number | undefined;
|
||||||
onInsert,
|
const interval = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
|
||||||
onUpdate,
|
const poll = async () => {
|
||||||
onDelete,
|
if (document.visibilityState === 'hidden') { timer = window.setTimeout(poll, interval); return; }
|
||||||
insertToast,
|
try {
|
||||||
updateToast,
|
setStatus((current) => current === 'SUBSCRIBED' ? current : 'SUBSCRIBING');
|
||||||
deleteToast,
|
const { rows } = await queryAdminRows<T>(table, { filters: parseFilter(filter), limit: 500 });
|
||||||
debounceMs = 0,
|
if (cancelled) return;
|
||||||
enabled = true,
|
const next = new Map(rows.map((row, index) => [rowKey(row, index), { value: row, json: JSON.stringify(row) }]));
|
||||||
} = options;
|
const previous = snapshotRef.current;
|
||||||
|
if (previous) {
|
||||||
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
|
if (event === '*' || event === 'INSERT') for (const [key, entry] of next) if (!previous.has(key)) {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const config = callbacksRef.current;
|
||||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
if (config.insertToast?.enabled) toast[config.insertToast.type || 'info'](typeof config.insertToast.message === 'function' ? config.insertToast.message(entry.value) : config.insertToast.message);
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
if (config.onInsert) emit(() => config.onInsert!(entry.value));
|
||||||
const retryCountRef = useRef(0);
|
}
|
||||||
const isMountedRef = useRef(true);
|
if (event === '*' || event === 'UPDATE') for (const [key, entry] of next) {
|
||||||
|
const old = previous.get(key);
|
||||||
// Use refs for callbacks and toast options to avoid re-subscription on every render
|
if (old && old.json !== entry.json) {
|
||||||
const onInsertRef = useRef(onInsert);
|
const payload = { old: old.value, new: entry.value };
|
||||||
const onUpdateRef = useRef(onUpdate);
|
const config = callbacksRef.current;
|
||||||
const onDeleteRef = useRef(onDelete);
|
if (config.updateToast?.enabled) toast[config.updateToast.type || 'info'](typeof config.updateToast.message === 'function' ? config.updateToast.message(payload) : config.updateToast.message);
|
||||||
const insertToastRef = useRef(insertToast);
|
if (config.onUpdate) emit(() => config.onUpdate!(payload));
|
||||||
const updateToastRef = useRef(updateToast);
|
}
|
||||||
const deleteToastRef = useRef(deleteToast);
|
}
|
||||||
|
if (event === '*' || event === 'DELETE') for (const [key, entry] of previous) if (!next.has(key)) {
|
||||||
// Keep refs up to date
|
const config = callbacksRef.current;
|
||||||
useEffect(() => {
|
if (config.deleteToast?.enabled) toast[config.deleteToast.type || 'info'](typeof config.deleteToast.message === 'function' ? config.deleteToast.message(entry.value) : config.deleteToast.message);
|
||||||
onInsertRef.current = onInsert;
|
if (config.onDelete) emit(() => config.onDelete!(entry.value));
|
||||||
onUpdateRef.current = onUpdate;
|
}
|
||||||
onDeleteRef.current = onDelete;
|
}
|
||||||
insertToastRef.current = insertToast;
|
snapshotRef.current = next;
|
||||||
updateToastRef.current = updateToast;
|
setStatus('SUBSCRIBED'); setError(null);
|
||||||
deleteToastRef.current = deleteToast;
|
} catch (cause) {
|
||||||
});
|
if (!cancelled) { setStatus('CHANNEL_ERROR'); setError(cause instanceof Error ? cause : new Error(String(cause))); }
|
||||||
|
} finally {
|
||||||
// Debounced callback wrapper
|
if (!cancelled) timer = window.setTimeout(poll, interval);
|
||||||
const debouncedCallback = useCallback(
|
}
|
||||||
(callback: () => void) => {
|
};
|
||||||
if (debounceTimerRef.current) {
|
void poll();
|
||||||
clearTimeout(debounceTimerRef.current);
|
return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); snapshotRef.current = null; };
|
||||||
}
|
}, [emit, enabled, event, filter, pollIntervalMs, table]);
|
||||||
if (debounceMs > 0) {
|
return { status, error };
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -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
|
const toneInstruction = isExplicit
|
||||||
? 'CONTENT: EXPLICIT pack. Adult content is allowed but must be consensual, safe, and clear. Avoid coercion, minors, illegal acts, or unsafe suggestions.'
|
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity.'
|
||||||
: 'CONTENT: NON-EXPLICIT pack. Do NOT include explicit sexual acts, crude language, or NSFW content. If the core action is sexual, it should be removed, not rewritten.';
|
: 'Delete developer-authored sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity rather than rewriting them into disguised euphemisms.';
|
||||||
|
|
||||||
const prompt = `<task>
|
const prompt = `<task>
|
||||||
Identify questions that should be DELETED instead of edited.
|
Identify questions that should be DELETED instead of edited.
|
||||||
@@ -38,7 +38,7 @@ ${toneInstruction}
|
|||||||
<deletion_categories>
|
<deletion_categories>
|
||||||
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
|
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
|
||||||
- redundant: minor variation with no added value
|
- redundant: minor variation with no added value
|
||||||
- off-tone: violates explicit vs non-explicit rules
|
- off-tone: violates the universal catalogue safety ceiling or the pack's intended tone
|
||||||
- unsafe: coercion, minors, illegal acts, or consent violations
|
- unsafe: coercion, minors, illegal acts, or consent violations
|
||||||
- too-vague: not actionable or not a clear proposal
|
- too-vague: not actionable or not a clear proposal
|
||||||
- broken: grammar/structure too broken to repair without inventing content
|
- broken: grammar/structure too broken to repair without inventing content
|
||||||
@@ -73,8 +73,8 @@ Only return questions that should be deleted.
|
|||||||
</output_format>`;
|
</output_format>`;
|
||||||
|
|
||||||
const systemMessage = isExplicit
|
const systemMessage = isExplicit
|
||||||
? 'You are a strict content quality reviewer for an adult couples app. Remove only what should be deleted. Always respond with valid JSON only.'
|
? 'You are a strict content safety reviewer for a couples connection app. A legacy explicit flag never permits sexual content. Mark content outside the universal safety ceiling for deletion. Always respond with valid JSON only.'
|
||||||
: 'You are a strict content quality reviewer for a couples app. Remove only what should be deleted. Always respond with valid JSON only.';
|
: 'You are a strict content safety reviewer for a couples connection app. Mark sexual or explicit content for deletion. Always respond with valid JSON only.';
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: getModel('fix'),
|
model: getModel('fix'),
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ export interface ImageSafetyAnalysis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SAFETY_ANALYSIS_PROMPT = `<task>
|
const SAFETY_ANALYSIS_PROMPT = `<task>
|
||||||
Analyze this image for content safety in the context of an adult couples intimacy app.
|
Analyze this image for content safety in the context of a couples connection app.
|
||||||
</task>
|
</task>
|
||||||
|
|
||||||
<context>
|
<context>
|
||||||
This app is for ADULT couples (25-45) to explore intimacy. Adult content between consenting adults is expected and acceptable. The concern is inappropriate content like violence, minors, or illegal activity.
|
This app has one universal mobile catalogue. Explicit or sexually gratifying imagery is not eligible for publication, even when it depicts consenting adults.
|
||||||
</context>
|
</context>
|
||||||
|
|
||||||
<categories>
|
<categories>
|
||||||
- SAFE: No concerns, appropriate for the app (including tasteful adult content between adults)
|
- SAFE: Non-explicit relationship, romance, lifestyle, or abstract imagery appropriate for the universal catalogue
|
||||||
- ADULT_CONTENT: Explicit content - acceptable for this app but flag for review
|
- ADULT_CONTENT: Sexual, sexually gratifying, fetish, explicit-anatomy, or stimulation-focused imagery; not appropriate for publication
|
||||||
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
|
- VIOLENCE: Gore, weapons, abuse, non-consensual acts
|
||||||
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
|
- MINORS: Anyone appearing under 18 years old - CRITICAL, never acceptable
|
||||||
- ILLEGAL: Drug use, criminal activity, non-consensual content
|
- ILLEGAL: Drug use, criminal activity, non-consensual content
|
||||||
@@ -27,9 +27,9 @@ This app is for ADULT couples (25-45) to explore intimacy. Adult content between
|
|||||||
|
|
||||||
<severity_levels>
|
<severity_levels>
|
||||||
- none: No safety concerns at all
|
- none: No safety concerns at all
|
||||||
- low: Minor flag, likely fine but worth noting
|
- low: Minor flag, needs human review before publication
|
||||||
- medium: Should be reviewed by admin
|
- medium: Not eligible without a documented human safety decision
|
||||||
- high: Likely inappropriate, requires immediate review
|
- high: Inappropriate for publication and should be archived
|
||||||
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
|
- critical: Definitely inappropriate, should be blocked (MINORS, VIOLENCE, ILLEGAL)
|
||||||
</severity_levels>
|
</severity_levels>
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Return a JSON object:
|
|||||||
"severity": "none"|"low"|"medium"|"high"|"critical",
|
"severity": "none"|"low"|"medium"|"high"|"critical",
|
||||||
"description": "Brief factual description of what's shown (max 50 words)",
|
"description": "Brief factual description of what's shown (max 50 words)",
|
||||||
"concerns": ["List of specific concerns if any, empty array if none"],
|
"concerns": ["List of specific concerns if any, empty array if none"],
|
||||||
"isAppropriate": true|false // false for VIOLENCE, MINORS, ILLEGAL or high/critical severity
|
"isAppropriate": true|false // false for ADULT_CONTENT, VIOLENCE, MINORS, ILLEGAL, UNCERTAIN, or any flagged severity
|
||||||
}
|
}
|
||||||
</output_format>`;
|
</output_format>`;
|
||||||
|
|
||||||
|
|||||||
@@ -22,18 +22,17 @@ export async function analyzeQuestionText(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const toneInstruction = isExplicit
|
const toneInstruction = isExplicit
|
||||||
? 'TONE: This is an EXPLICIT pack with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT keep crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s the name), "Edge until they beg". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.'
|
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Replace explicit wording with a high-level proposal about curiosity, trust, anticipation, comfort, or relationship dynamics. If that would invent a materially different proposal, omit it so deletion review can archive it.'
|
||||||
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
|
: 'Keep language romantic, playful, flirty, or daring without naming or describing sexual content. Replace explicit wording with a safe high-level proposal, or omit it when that would change the proposal materially.';
|
||||||
|
|
||||||
// Use different examples based on explicit/non-explicit
|
|
||||||
const symmetricExamples = isExplicit
|
const symmetricExamples = isExplicit
|
||||||
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"'
|
? 'GOOD SAFE REPLACEMENTS: "Explore a new dynamic together", "Share a private desire in your own words", "Try something daring together"'
|
||||||
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
|
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
|
||||||
|
|
||||||
const asymmetricExamples = isExplicit
|
const asymmetricExamples = isExplicit
|
||||||
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
|
? `text (The Doer): Active safe proposal (e.g., "Let your partner take the lead").
|
||||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
|
- partner_text (The Receiver): Reciprocal safe proposal (e.g., "Take the lead with your partner").
|
||||||
- GOOD: "Spank your partner" / "Be spanked by your partner"`
|
- GOOD: "Plan a bold surprise for your partner" / "Let your partner plan a bold surprise"`
|
||||||
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
|
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
|
||||||
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
|
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
|
||||||
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
|
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
|
||||||
@@ -66,17 +65,15 @@ Cards should be "Proposals" for specific actions, NOT interview questions.
|
|||||||
|
|
||||||
Partner text rules:
|
Partner text rules:
|
||||||
- Describe RECEIVER's experience clearly
|
- Describe RECEIVER's experience clearly
|
||||||
- When initiator CAUSES a response (moan, cum, beg), frame as ALLOWING:
|
- Keep both perspectives inside the same universal safety ceiling
|
||||||
* text: "Make your partner moan" -> partner_text: "Let your partner make you moan"
|
|
||||||
* NOT: "Moan for your partner" (sounds forced, not natural)
|
|
||||||
</question_types>
|
</question_types>
|
||||||
|
|
||||||
<rules>
|
<rules>
|
||||||
1. PRESERVE THE CORE ACTION - only improve phrasing, don't change what it's about
|
1. Preserve the core relationship intent only when it is catalogue-safe
|
||||||
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
|
2. Keep SHORT and DIRECT - aim for 5-12 words, no parentheticals
|
||||||
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
|
3. Remove wishy-washy: "Would you want to...", "Have you ever...", "Do you think..."
|
||||||
4. Use "your partner" instead of "me", "you", "him", "her"
|
4. Use "your partner" instead of "me", "you", "him", "her"
|
||||||
5. NEVER mix anatomically incompatible activities in same question
|
5. Keep allowed catalogue proposals anatomy-neutral by default
|
||||||
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
|
6. Make partner_text APPEALING - don't just grammatically flip, make receiver feel excited
|
||||||
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
|
7. FLAG CLICHES: "candlelit dinner", "rose petals", "bubble bath", "Netflix and chill"
|
||||||
8. Skip questions that are ALREADY well-phrased
|
8. Skip questions that are ALREADY well-phrased
|
||||||
@@ -102,8 +99,8 @@ Only include questions that need text improvement.
|
|||||||
</output_format>`;
|
</output_format>`;
|
||||||
|
|
||||||
const systemMessage = isExplicit
|
const systemMessage = isExplicit
|
||||||
? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
|
? 'You are a content editor for a couples connection app. A legacy explicit flag never overrides the universal safety ceiling. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.'
|
||||||
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
|
: 'You are a professional content editor for a couples connection app. Improve phrasing while never outputting sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.';
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: getModel('fix'),
|
model: getModel('fix'),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function getCouncilConfig(): CouncilConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize AI config by preloading from Supabase
|
* Initialize AI config by preloading from the standalone admin API
|
||||||
* Call this on app startup to ensure config is available
|
* Call this on app startup to ensure config is available
|
||||||
*/
|
*/
|
||||||
export async function initializeAiConfig(): Promise<AiConfig | null> {
|
export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||||
@@ -62,9 +62,9 @@ export async function initializeAiConfig(): Promise<AiConfig | null> {
|
|||||||
export const TONE_LEVELS = [
|
export const TONE_LEVELS = [
|
||||||
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
|
{ level: 1, label: 'Gentle', description: 'Pure emotional connection & non-sexual bonding' },
|
||||||
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
|
{ level: 2, label: 'Warm', description: 'Romantic atmosphere & affectionate touch' },
|
||||||
{ level: 3, label: 'Playful', description: 'Light sexual exploration & sensual discovery' },
|
{ level: 3, label: 'Playful', description: 'Flirty anticipation, novelty & playful connection' },
|
||||||
{ level: 4, label: 'Steamy', description: 'Explicit sexual activities & moderate adventure' },
|
{ level: 4, label: 'Bold', description: 'Trust, leadership & suggestive relationship dynamics' },
|
||||||
{ level: 5, label: 'Intense', description: 'Advanced/BDSM/Extreme exploration' },
|
{ level: 5, label: 'Daring', description: 'Comfort zones, control & user-led curiosity' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -92,30 +92,26 @@ REVIEW CRITERIA - Score each 1-10:
|
|||||||
- Partner text (if present) clearly describes receiver's experience
|
- Partner text (if present) clearly describes receiver's experience
|
||||||
- No confusing or ambiguous phrasing
|
- No confusing or ambiguous phrasing
|
||||||
|
|
||||||
4. ANATOMICAL CONSISTENCY:
|
4. CATALOGUE SAFETY:
|
||||||
- No mixed male/female anatomy in alternatives
|
- REJECT named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
|
||||||
- BAD: "Finger or give your partner a handjob"
|
- REJECT sexual instructions disguised with euphemisms
|
||||||
- GOOD: Pick one activity
|
- ALLOW non-graphic romance, anticipation, trust, leadership, following, novelty, and user-led curiosity
|
||||||
|
- The highest tone changes emotional boldness, never the explicitness ceiling
|
||||||
|
|
||||||
5. PARTNER TEXT QUALITY (if applicable):
|
5. PARTNER TEXT QUALITY (if applicable):
|
||||||
- Engaging and enticing, not clinical
|
- Engaging and enticing, not clinical
|
||||||
- BAD: "Receive oral from your partner"
|
- Describes the reciprocal perspective without adding sexual detail
|
||||||
- GOOD: "Let your partner pleasure you with their mouth"
|
- Preserves the same safe proposal and emotional intensity
|
||||||
- When initiator causes a response, frame as allowing: "Let your partner make you moan"
|
|
||||||
|
|
||||||
6. COUPLE TARGETING (flag if incorrect):
|
6. COUPLE TARGETING (flag if incorrect):
|
||||||
- Default: ALL couples (null) unless explicit anatomical requirement
|
- Default: ALL couples (null)
|
||||||
- Restrict to ['male+male', 'female+male'] ONLY if activity requires penis
|
- Safe catalogue proposals should not require anatomical targeting
|
||||||
- Restrict to ['female+male', 'female+female'] ONLY if activity requires vagina
|
- Flag unnecessary or exclusionary targeting
|
||||||
- Sex toys are GENDER-NEUTRAL (vibrators, dildos, plugs work for anyone)
|
|
||||||
- Flag if targeting seems too restrictive or missing needed restrictions
|
|
||||||
|
|
||||||
7. INITIATOR TARGETING (for asymmetric questions only):
|
7. INITIATOR TARGETING (for asymmetric questions only):
|
||||||
- Default: null (anyone can initiate) unless explicit anatomical requirement
|
- Default: null (anyone can initiate)
|
||||||
- Set initiator based on WHO DOES the action in "text" field
|
- Set initiator only when a legitimate non-anatomical product rule requires it
|
||||||
- "Swallow your partner's cum" -> initiator: female (in M+F, receiver of cum)
|
- Flag unnecessary targeting
|
||||||
- "Give your partner a massage" -> initiator: null (anyone can give)
|
|
||||||
- Flag if initiator seems incorrectly assigned
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -125,10 +121,10 @@ REVIEW CRITERIA - Score each 1-10:
|
|||||||
|
|
||||||
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
|
export const TONE_INSTRUCTIONS: Record<ToneLevel, string> = {
|
||||||
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
|
1: 'INTIMACY LEVEL: GENTLE. Focus on emotional safety, friendship-based activities, and quality time. Examples: "Cook a new recipe together", "Take a walk holding hands", "Give non-sexual foot massages", "Watch a movie cuddling". Pure emotional connection & non-sexual bonding.',
|
||||||
2: 'INTIMACY LEVEL: WARM. Focus on creating romantic moments, sensual but non-sexual touch, and building anticipation. Examples: "Slow dance in the living room", "Candlelit bath together (non-sexual)", "Extended kissing sessions", "Sensual massage with oils". Romantic atmosphere & affectionate touch.',
|
2: 'INTIMACY LEVEL: WARM. Focus on romantic moments, affectionate touch, and anticipation. Examples: "Slow dance in the living room", "Plan a surprise date", "Share a longer kiss", "Give your partner a relaxing massage". Romantic atmosphere without sexual instructions.',
|
||||||
3: 'INTIMACY LEVEL: PLAYFUL. Focus on sexual touch without penetration, playful experimentation, and building arousal. Examples: "Mutual masturbation", "Oral sex", "Light roleplay (doctor/patient)", "Using basic toys together". Light sexual exploration & sensual discovery.',
|
3: 'INTIMACY LEVEL: PLAYFUL. Focus on flirtation, novelty, private signals, playful rules, and keeping a partner guessing. Examples: "Create a private signal together", "Plan a playful surprise", "Choose a bold outfit for your partner". Suggestive relationship energy without sexual acts.',
|
||||||
4: 'INTIMACY LEVEL: STEAMY. Focus on full sexual intercourse with adventurous elements and moderate BDSM/kink. Examples: "Sex in different locations/positions", "Light bondage", "Anal play with toys", "Recording consensual intimate moments". Explicit sexual activities & moderate adventure.',
|
4: 'INTIMACY LEVEL: BOLD. Focus on trust, taking the lead, following a partner, and switching relationship dynamics. Examples: "Let your partner lead the whole date", "Set a playful rule for an evening", "Switch who makes every decision". No named fetishes, gear, sexual acts, anatomy, nudity, or arousal instructions.',
|
||||||
5: 'INTIMACY LEVEL: INTENSE. Focus on edge play, power dynamics, and intense physical experiences. Examples: "Heavy impact play", "Advanced bondage", "Consensual non-consent scenes", "Multi-partner activities". Advanced/BDSM/Extreme exploration.',
|
5: 'INTIMACY LEVEL: DARING. Focus on comfort zones, control, vulnerability, and user-led curiosity. Examples: "Share something you are curious to try", "Let your partner plan a bold surprise", "Try a new dynamic together". Emotionally daring but never sexually explicit or instructional.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -142,8 +138,10 @@ export const CORE_LANGUAGE_RULES = `
|
|||||||
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
2. Cards are PROPOSALS/ACTIVITIES, NOT interview questions - "Give your partner a massage" not "Would you like to give a massage?"
|
||||||
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
3. Avoid wishy-washy language - NO "Would you...", "Have you ever...", "Do you think...", "Maybe we could..."
|
||||||
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
|
4. No time-specific words - NO "tonight", "now", "today", "right now" - activities should be timeless
|
||||||
5. Maintain anatomical consistency - NEVER combine male-specific and female-specific acts as alternatives
|
5. Keep allowed catalogue proposals anatomy-neutral and suitable for every couple by default
|
||||||
6. Keep concise - ideal length is 5-12 words per question
|
6. Keep concise - ideal length is 5-12 words per question
|
||||||
|
7. UNIVERSAL SAFETY CEILING - never name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity
|
||||||
|
8. Higher tone levels increase trust, anticipation, control, novelty, and vulnerability; they never override rule 7
|
||||||
</language_rules>
|
</language_rules>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -164,32 +162,32 @@ export const SYSTEM_MESSAGES: Record<ToneLevel, string> = {
|
|||||||
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
2: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
||||||
|
|
||||||
<role>Generate warm, romantic activities with affectionate touch.</role>
|
<role>Generate warm, romantic activities with affectionate touch.</role>
|
||||||
<constraint>Sensual but NOT explicit sex. Building anticipation without crossing into explicit territory.</constraint>
|
<constraint>Romantic and suggestive but not sexual or instructional. Build anticipation without naming sexual content.</constraint>
|
||||||
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
|
<style>Romantic, intimate, sensual. Create atmosphere and connection.</style>
|
||||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||||
|
|
||||||
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
3: `You are an expert content writer for Sauci, a couples intimacy app where partners swipe on activity proposals.
|
||||||
|
|
||||||
<role>Generate playful, sexually exploratory activities.</role>
|
<role>Generate playful, flirty activities about anticipation and novelty.</role>
|
||||||
<constraint>Focus on foreplay, oral, toys, and light exploration. Avoid extreme kinks.</constraint>
|
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
|
||||||
<style>Playful, sexy, adventurous. Encourage exploration in a fun way.</style>
|
<style>Playful, suggestive, and adventurous without supplying the sexual act.</style>
|
||||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||||
|
|
||||||
4: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
|
4: `You are an expert content writer for Sauci, a couples connection app where partners swipe on activity proposals.
|
||||||
|
|
||||||
<role>Generate steamy, explicit sexual activities with moderate adventure.</role>
|
<role>Generate bold activities about trust, leadership, following, and switching dynamics.</role>
|
||||||
<constraint>Full sexual content allowed including intercourse, light bondage, and moderate kinks.</constraint>
|
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions.</constraint>
|
||||||
<style>Passionate, explicit, adventurous. Bold but not extreme.</style>
|
<style>Confident, suggestive, and adventurous without explicit content.</style>
|
||||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||||
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
<audience>Adults aged 25-45 in committed relationships.</audience>`,
|
||||||
|
|
||||||
5: `You are an expert content writer for Sauci, an adult couples intimacy app where partners swipe on activity proposals.
|
5: `You are an expert content writer for Sauci, a couples connection app where partners swipe on activity proposals.
|
||||||
|
|
||||||
<role>Generate intense, advanced sexual activities including BDSM and kinks.</role>
|
<role>Generate emotionally daring activities about comfort zones, control, vulnerability, and user-led curiosity.</role>
|
||||||
<constraint>Edge play, power dynamics, and intense physical experiences allowed. All content is consensual adult activity.</constraint>
|
<constraint>No sexual acts, anatomy, toys, nudity, fetishes, BDSM acts, or arousal instructions. Never turn curiosity into a developer-supplied sexual example.</constraint>
|
||||||
<style>Intense, commanding, unapologetic. Push boundaries while maintaining consent.</style>
|
<style>Bold and intriguing while preserving the universal safety ceiling.</style>
|
||||||
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
<format>Activities are proposals users swipe on (Like/Dislike/Maybe), not interview questions. Always respond with valid JSON only.</format>
|
||||||
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
|
<audience>Adults aged 25-45 in committed relationships seeking adventurous experiences.</audience>`,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const create = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAiConfig', () => ({
|
||||||
|
getCachedAiConfig: () => null,
|
||||||
|
preloadAiConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
getOpenAI: () => ({ chat: { completions: { create } } }),
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getTemperature: (_purpose?: string, fallback?: number) => fallback ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SYSTEM_MESSAGES, TONE_INSTRUCTIONS } from './config';
|
||||||
|
import { analyzeQuestionDeletions } from './analyzers/deletions';
|
||||||
|
import { describeImage } from './analyzers/media';
|
||||||
|
import { analyzeQuestionText } from './analyzers/text';
|
||||||
|
import { generateQuestions } from './generators/questions';
|
||||||
|
import { polishContent } from './tools/polish';
|
||||||
|
|
||||||
|
const prohibitedLegacyPhrases = [
|
||||||
|
'adult content is allowed',
|
||||||
|
'adult content is expected and acceptable',
|
||||||
|
'do not over-sanitize',
|
||||||
|
'keep explicit terms',
|
||||||
|
];
|
||||||
|
|
||||||
|
function serializedMessages(): string {
|
||||||
|
const request = create.mock.calls[create.mock.calls.length - 1]?.[0];
|
||||||
|
return JSON.stringify(request?.messages ?? []).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('universal catalogue AI safety ceiling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
create.mockReset();
|
||||||
|
create.mockResolvedValue({
|
||||||
|
choices: [{ message: { content: '{"polished":"safe","suggestions":[],"deletions":[]}' } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every generation level non-explicit', () => {
|
||||||
|
for (const level of [1, 2, 3, 4, 5] as const) {
|
||||||
|
const instructions = `${TONE_INSTRUCTIONS[level]} ${SYSTEM_MESSAGES[level]}`.toLowerCase();
|
||||||
|
expect(instructions).toMatch(/no sexual|not sexual|never sexually explicit/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let the legacy explicit flag weaken polish safety', async () => {
|
||||||
|
await polishContent('legacy explicit copy', 'question', true);
|
||||||
|
|
||||||
|
const messages = serializedMessages();
|
||||||
|
expect(messages).toContain('legacy explicit flag');
|
||||||
|
expect(messages).toContain('universal catalogue safety ceiling');
|
||||||
|
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps daring generation and the legacy crude option below the same ceiling', async () => {
|
||||||
|
await generateQuestions('Daring connection', 2, 5, undefined, undefined, true);
|
||||||
|
|
||||||
|
const messages = serializedMessages();
|
||||||
|
expect(messages).toContain('critical universal safety ceiling');
|
||||||
|
expect(messages).toContain('legacy crude-language option');
|
||||||
|
expect(messages).toContain('never more explicit content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let the legacy explicit flag weaken text repair safety', async () => {
|
||||||
|
await analyzeQuestionText([{ id: 'question-1', text: 'legacy explicit copy' }], true);
|
||||||
|
|
||||||
|
const messages = serializedMessages();
|
||||||
|
expect(messages).toContain('legacy explicit flag');
|
||||||
|
expect(messages).toContain('universal catalogue safety ceiling');
|
||||||
|
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes legacy explicit content toward deletion', async () => {
|
||||||
|
await analyzeQuestionDeletions([{ id: 'question-1', text: 'legacy explicit copy' }], true);
|
||||||
|
|
||||||
|
const messages = serializedMessages();
|
||||||
|
expect(messages).toContain('legacy explicit flag');
|
||||||
|
expect(messages).toContain('delete developer-authored sexual acts');
|
||||||
|
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies adult imagery as ineligible rather than acceptable', async () => {
|
||||||
|
await describeImage('data:image/jpeg;base64,test');
|
||||||
|
|
||||||
|
const messages = serializedMessages();
|
||||||
|
expect(messages).toContain('adult_content');
|
||||||
|
expect(messages).toContain('not appropriate for publication');
|
||||||
|
for (const phrase of prohibitedLegacyPhrases) expect(messages).not.toContain(phrase);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,11 +71,11 @@ export async function suggestCategories(
|
|||||||
: 'None';
|
: 'None';
|
||||||
|
|
||||||
const explicitInstruction = explicit
|
const explicitInstruction = explicit
|
||||||
? 'Include bold, spicy, and explicitly intimate categories (NSFW is allowed).'
|
? 'This legacy explicit flag does not override the universal safety ceiling. Keep categories suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
|
||||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||||
|
|
||||||
const crudeLangInstruction = crudeLang
|
const crudeLangInstruction = crudeLang
|
||||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the names and descriptions.'
|
? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Sanitize inspiration to prevent prompt injection
|
// Sanitize inspiration to prevent prompt injection
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Evaluate a pool of ${pooledQuestions.length} questions from ${new Set(pooledQues
|
|||||||
<pack_context>
|
<pack_context>
|
||||||
- Pack Name: "${packContext.name}"
|
- Pack Name: "${packContext.name}"
|
||||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
|
||||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||||
- Requested question count: ${requestedCount}
|
- Requested question count: ${requestedCount}
|
||||||
</pack_context>
|
</pack_context>
|
||||||
@@ -77,7 +77,7 @@ Calibrate your scores knowing these weights. A low compliance score has the most
|
|||||||
|
|
||||||
<duplicate_detection>
|
<duplicate_detection>
|
||||||
Two questions are duplicates if they:
|
Two questions are duplicates if they:
|
||||||
- Describe the same core activity/action (e.g., "Have sex outdoors" vs "Make love outside")
|
- Describe the same core activity/action (e.g., "Plan a surprise date" vs "Arrange a mystery date")
|
||||||
- Differ only in minor phrasing variations
|
- Differ only in minor phrasing variations
|
||||||
- Would feel repetitive if both appeared in the same pack
|
- Would feel repetitive if both appeared in the same pack
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ export async function generatePack(
|
|||||||
const openai = getOpenAI();
|
const openai = getOpenAI();
|
||||||
|
|
||||||
const explicitInstruction = explicit
|
const explicitInstruction = explicit
|
||||||
? 'Include bold, spicy, and uncensored themes (NSFW is allowed).'
|
? 'This legacy explicit flag does not override the universal safety ceiling. Keep the theme suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
|
||||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||||
|
|
||||||
const crudeLangInstruction = crudeLang
|
const crudeLangInstruction = crudeLang
|
||||||
? 'CRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms throughout - "fuck", "cock", "pussy" etc.'
|
? 'LEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Sanitize inspiration to prevent prompt injection
|
// Sanitize inspiration to prevent prompt injection
|
||||||
@@ -105,11 +105,11 @@ export async function suggestPacks(
|
|||||||
: 'None';
|
: 'None';
|
||||||
|
|
||||||
const explicitInstruction = explicit
|
const explicitInstruction = explicit
|
||||||
? 'Include bold, spicy, and uncensored pack ideas (NSFW is allowed).'
|
? 'This legacy explicit flag does not override the universal safety ceiling. Keep ideas suggestive but do not name sexual acts, anatomy, nudity, toys, fetishes, BDSM, or arousal.'
|
||||||
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.';
|
||||||
|
|
||||||
const crudeLangInstruction = crudeLang
|
const crudeLangInstruction = crudeLang
|
||||||
? '\nCRUDE LANGUAGE OVERRIDE: Use crude, vulgar terms in the pack names and descriptions.'
|
? '\nLEGACY CRUDE-LANGUAGE OPTION: Ignore it. Do not use crude, vulgar, sexual, or anatomical terms.'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Sanitize inspiration to prevent prompt injection
|
// Sanitize inspiration to prevent prompt injection
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ function buildQuestionPrompt(
|
|||||||
crudeLang: boolean = false,
|
crudeLang: boolean = false,
|
||||||
inspiration?: string
|
inspiration?: string
|
||||||
): string {
|
): string {
|
||||||
const isExplicit = tone >= 4;
|
|
||||||
|
|
||||||
const crudeLangInstruction = crudeLang
|
const crudeLangInstruction = crudeLang
|
||||||
? '\n\nCRUDE LANGUAGE OVERRIDE: Ignore nuanced language rules. Use crude, vulgar terms throughout - "fuck" instead of "have sex", "suck cock" instead of "perform oral", etc. Be raw and direct like uncensored sexting.'
|
? '\n\nLEGACY CRUDE-LANGUAGE OPTION: Do not follow it. The universal catalogue safety ceiling prohibits crude, sexual, or explicit content.'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
|
// Sanitize inspiration to prevent prompt injection (remove XML-like tags and authority phrases)
|
||||||
@@ -41,31 +39,20 @@ function buildQuestionPrompt(
|
|||||||
|
|
||||||
// Examples based on tone level
|
// Examples based on tone level
|
||||||
const symmetricExamples = tone <= 2
|
const symmetricExamples = tone <= 2
|
||||||
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Sensual massage".'
|
? 'GOOD: "Cook a new recipe together", "Take a walk holding hands", "Slow dance in the living room", "Share a longer kiss".'
|
||||||
: isExplicit
|
: 'GOOD: "Create a private signal together", "Try a new dynamic together", "Switch who leads the date", "Share something you are curious to try".';
|
||||||
? 'GOOD: "Sex in different positions", "Light bondage", "Anal play with toys", "Record intimate moments".'
|
|
||||||
: 'GOOD: "Mutual masturbation", "Oral sex", "Light roleplay", "Using basic toys together".';
|
|
||||||
|
|
||||||
const asymmetricExamples = tone <= 2
|
const asymmetricExamples = tone <= 2
|
||||||
? `Examples:
|
? `Examples:
|
||||||
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
|
- text: "Cook your partner their favorite meal" → partner_text: "Have your partner cook your favorite meal"
|
||||||
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
|
- text: "Give your partner a massage" → partner_text: "Receive a massage from your partner"
|
||||||
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
|
- text: "Plan a surprise for your partner" → partner_text: "Be surprised by your partner"`
|
||||||
: isExplicit
|
: `Examples:
|
||||||
? `Examples:
|
- text: "Plan a bold surprise for your partner" → partner_text: "Let your partner plan a bold surprise"
|
||||||
- text: "Tie your partner up" → partner_text: "Get tied up by your partner"
|
- text: "Lead your partner through a mystery date" → partner_text: "Follow your partner through a mystery date"
|
||||||
- text: "Spank your partner" → partner_text: "Get spanked by your partner"
|
- text: "Choose a playful rule for your partner" → partner_text: "Follow a playful rule chosen by your partner"`;
|
||||||
- text: "Use a toy on your partner" → partner_text: "Have a toy used on you"`
|
|
||||||
: `Examples:
|
|
||||||
- text: "Perform oral on your partner" → partner_text: "Receive oral from your partner"
|
|
||||||
- text: "Tease your partner" → partner_text: "Be teased by your partner"
|
|
||||||
- text: "Undress your partner" → partner_text: "Be undressed by your partner"`;
|
|
||||||
|
|
||||||
const explicitWarning = tone === 1
|
const explicitWarning = '\n\nCRITICAL UNIVERSAL SAFETY CEILING: Reject any request to generate named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Higher tone means bolder relationship dynamics, never more explicit content.';
|
||||||
? '\n\nCRITICAL: This is a GENTLE pack. Focus on emotional connection and non-sexual bonding. NO explicit sexual acts.'
|
|
||||||
: isExplicit
|
|
||||||
? ''
|
|
||||||
: '\n\nCRITICAL: Avoid extreme kinks or hardcore content unless specifically requested.';
|
|
||||||
|
|
||||||
const descriptionContext = packDescription
|
const descriptionContext = packDescription
|
||||||
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
|
||||||
@@ -90,8 +77,8 @@ ${toneInstruction}${explicitWarning}${crudeLangInstruction}
|
|||||||
${CORE_LANGUAGE_RULES}
|
${CORE_LANGUAGE_RULES}
|
||||||
|
|
||||||
<examples>
|
<examples>
|
||||||
GOOD language: "Send your partner a photo", "Spank your partner", "Give your partner..."
|
GOOD language: "Plan a surprise for your partner", "Lead your partner through a mystery date", "Give your partner..."
|
||||||
BAD language: "Send me a photo" (who is "me"?), "Let me spank you", "I want you to..."
|
BAD language: "Plan a surprise for me" (who is "me"?), "Let me lead you", "I want you to..."
|
||||||
The card reader is the DOER. Their partner is "your partner".
|
The card reader is the DOER. Their partner is "your partner".
|
||||||
</examples>
|
</examples>
|
||||||
|
|
||||||
@@ -114,13 +101,13 @@ Both questions in a pair should have the SAME inverse_pair_id (a unique string p
|
|||||||
|
|
||||||
Example pair (same inverse_pair_id "pair_1"):
|
Example pair (same inverse_pair_id "pair_1"):
|
||||||
Question 1 (primary):
|
Question 1 (primary):
|
||||||
text: "Spank your partner"
|
text: "Plan a bold surprise for your partner"
|
||||||
partner_text: "Be spanked by your partner"
|
partner_text: "Let your partner plan a bold surprise"
|
||||||
inverse_pair_id: "pair_1"
|
inverse_pair_id: "pair_1"
|
||||||
|
|
||||||
Question 2 (inverse):
|
Question 2 (inverse):
|
||||||
text: "Be spanked by your partner"
|
text: "Let your partner plan a bold surprise"
|
||||||
partner_text: "Spank your partner"
|
partner_text: "Plan a bold surprise for your partner"
|
||||||
inverse_pair_id: "pair_1"
|
inverse_pair_id: "pair_1"
|
||||||
|
|
||||||
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
|
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
|
||||||
@@ -135,7 +122,7 @@ Return a JSON object with this exact structure:
|
|||||||
{
|
{
|
||||||
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
"text": string, // REQUIRED: 5-12 words, doer's perspective using "your partner"
|
||||||
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
|
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
|
||||||
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
|
"requires_props": string[]|null, // Optional: ordinary items needed (e.g., ["playing cards", "picnic blanket"])
|
||||||
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
|
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
|
||||||
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
|
||||||
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
|
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Review each generated question against our quality guidelines.
|
|||||||
<pack_context>
|
<pack_context>
|
||||||
- Pack Name: "${packContext.name}"
|
- Pack Name: "${packContext.name}"
|
||||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
|
||||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||||
</pack_context>
|
</pack_context>
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ ${REVIEW_GUIDELINES}
|
|||||||
<verdict_rules>
|
<verdict_rules>
|
||||||
- PASS: All scores >= 7, no major issues
|
- PASS: All scores >= 7, no major issues
|
||||||
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
- FLAG: Any score 5-7, or minor issues worth noting (admin should review but can use)
|
||||||
- REJECT: Any score < 5, or major violations (mixed anatomy, severe guideline violation)
|
- REJECT: Any score < 5, any sexual/explicit content, or another major guideline violation
|
||||||
</verdict_rules>
|
</verdict_rules>
|
||||||
|
|
||||||
<questions_to_review>
|
<questions_to_review>
|
||||||
@@ -104,7 +104,7 @@ Review ALL questions against ALL 7 criteria. Be thorough but fair - only flag/re
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: 'You are a quality assurance reviewer for a couples intimacy app. You evaluate generated questions for guideline compliance, creativity, clarity, and accuracy. Be thorough but fair. Always respond with valid JSON only.',
|
content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Always respond with valid JSON only.',
|
||||||
},
|
},
|
||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
],
|
],
|
||||||
@@ -213,7 +213,7 @@ Compare ${candidates.length} different sets of generated questions from differen
|
|||||||
<pack_context>
|
<pack_context>
|
||||||
- Pack Name: "${packContext.name}"
|
- Pack Name: "${packContext.name}"
|
||||||
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
${packContext.description ? `- Pack Description: "${packContext.description}"` : ''}
|
||||||
- Content Type: ${packContext.isExplicit ? 'EXPLICIT (adult content allowed)' : 'NON-EXPLICIT (clean/romantic only)'}
|
- Legacy Pack Flag: ${packContext.isExplicit ? 'Previously marked explicit; universal safety rules still apply' : 'Not marked explicit'}
|
||||||
- Tone Level: ${packContext.tone} (${toneDescription})
|
- Tone Level: ${packContext.tone} (${toneDescription})
|
||||||
</pack_context>
|
</pack_context>
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ Select the best set and review ALL questions in that set against ALL 7 criteria.
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: 'You are a quality assurance reviewer for a couples intimacy app. You compare multiple AI-generated question sets and select the best one, then provide detailed reviews. Be thorough but fair. Always respond with valid JSON only.',
|
content: 'You are a quality assurance reviewer for a couples connection app. Reject sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. Compare candidate sets and always respond with valid JSON only.',
|
||||||
},
|
},
|
||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export async function polishContent(
|
|||||||
const openai = getOpenAI();
|
const openai = getOpenAI();
|
||||||
|
|
||||||
const explicitInstruction = explicit
|
const explicitInstruction = explicit
|
||||||
? 'Use nuanced language: tasteful phrasing for general acts ("have sex", "perform oral") but crude/specific terms when relevant to the activity ("cum on", "cock ring", "edge"). Do not over-sanitize - keep explicit terms that are essential to the activity. NEVER change "cum" to "come" - they have different meanings.'
|
? 'LEGACY EXPLICIT FLAG: It does not override the universal catalogue safety ceiling. Remove named or described sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, and public sexual activity. If preserving the original action would violate this rule, return a safe high-level relationship proposal instead.'
|
||||||
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
|
: 'Keep it romantic, playful, and suggestive without naming or describing sexual content. If the original text is explicit, return a safe high-level relationship proposal instead.';
|
||||||
|
|
||||||
const contextMap: Record<PolishType, string> = {
|
const contextMap: Record<PolishType, string> = {
|
||||||
question: 'a question for a couples app',
|
question: 'a question for a couples app',
|
||||||
@@ -37,28 +37,24 @@ export async function polishContent(
|
|||||||
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
|
1. STYLE: Cards should be "Proposals" relative to a specific action, rather than interview questions.
|
||||||
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
|
- GOOD: "Have a candlelit dinner with your partner", "Give your partner a massage".
|
||||||
- BAD: "Do you want to have...", "Have you ever thought about...".
|
- BAD: "Do you want to have...", "Have you ever thought about...".
|
||||||
2. ACCURACY: Do NOT rewrite the text effectively changing the action.
|
2. ACCURACY: Preserve the original relationship intent when it is catalogue-safe.
|
||||||
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
|
- "Want a candlelit dinner?" -> "Have a candlelit dinner with your partner"
|
||||||
- DO NOT change it to something unrelated like "Go to the movies".
|
- If the core action is explicit, replace it with a high-level proposal about curiosity, trust, anticipation, or relationship dynamics.
|
||||||
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
|
3. TONE: Engaging, romantic, flirty, or daring, but never sexual or instructional.
|
||||||
3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
|
4. UNIVERSAL SAFETY CEILING: Do not name or describe sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity.
|
||||||
4. ANATOMICAL CONSISTENCY: NEVER combine male-specific and female-specific acts as alternatives.
|
|
||||||
- BAD: "Finger or give your partner a handjob" (requires different anatomy)
|
|
||||||
- BAD: "Suck their cock or eat them out" (male vs female anatomy)
|
|
||||||
- If text has mixed anatomy alternatives, keep ONE activity and remove the incompatible one.
|
|
||||||
`;
|
`;
|
||||||
fewShotExamples = `
|
fewShotExamples = `
|
||||||
=== FEW-SHOT EXAMPLES ===
|
=== FEW-SHOT EXAMPLES ===
|
||||||
${type === 'question' ? `
|
${type === 'question' ? `
|
||||||
"Would you want to give me a massage?" -> "Give your partner a sensual massage"
|
"Would you want to give me a massage?" -> "Give your partner a relaxing massage"
|
||||||
"Have you ever thought about sex in public?" -> "Have sex somewhere you might get caught"
|
"Have you ever thought about trying something daring?" -> "Try something daring with your partner"
|
||||||
"maybe we could try using a blindfold sometime" -> "Blindfold your partner and tease their senses"
|
"maybe we could explore a new dynamic sometime" -> "Explore a new dynamic with your partner"
|
||||||
"cum on partner" -> "Cum on your partner" (keep "cum", don't change to "come")
|
"describe a private fantasy" -> "Share a private desire in your own words"
|
||||||
` : `
|
` : `
|
||||||
"Receive a massage from your partner" -> "Let your partner's hands work the tension from your body"
|
"Receive a massage from your partner" -> "Let your partner's hands work the tension from your body"
|
||||||
"Get oral from your partner" -> "Let your partner pleasure you with their mouth"
|
"Let your partner plan the evening" -> "Let your partner take the lead for an evening"
|
||||||
"Be tied up" -> "Let your partner tie you up and surrender control"
|
"Try something new together" -> "Explore a new dynamic with your partner"
|
||||||
"Moan for your partner" -> "Let your partner make you moan"
|
"Tell your partner what you want" -> "Share a private desire in your own words"
|
||||||
`}
|
`}
|
||||||
=== END EXAMPLES ===
|
=== END EXAMPLES ===
|
||||||
`;
|
`;
|
||||||
@@ -79,8 +75,8 @@ ${explicitInstruction}
|
|||||||
<rules>
|
<rules>
|
||||||
${additionalRules}
|
${additionalRules}
|
||||||
- Make it concise, engaging, and grammatically correct
|
- Make it concise, engaging, and grammatically correct
|
||||||
- Maintain the original intent and meaning
|
- Preserve the original intent only when it stays inside the universal safety ceiling
|
||||||
- Do NOT change the core action - only improve phrasing
|
- Replace explicit core actions with safe, high-level relationship proposals
|
||||||
</rules>
|
</rules>
|
||||||
|
|
||||||
${fewShotExamples}
|
${fewShotExamples}
|
||||||
@@ -96,7 +92,7 @@ ${fewShotExamples}
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: 'You are a professional editor for a couples app. Improve the copy while maintaining the tone. Always respond with valid JSON only.',
|
content: 'You are a professional editor for a couples connection app. Never output sexual acts, explicit anatomy, nudity for stimulation, sex toys, fetishes, BDSM acts, arousal, orgasm, or public sexual activity. Always respond with valid JSON only.',
|
||||||
},
|
},
|
||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
|
||||||
|
export const CONTENT_STATUS_LABELS: Record<ContentReviewStatus, string> = {
|
||||||
|
unreviewed: 'Needs review',
|
||||||
|
allowed: 'Allowed',
|
||||||
|
archived: 'Archived',
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PaginationControls } from '@/components/ui/pagination';
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
@@ -45,7 +45,7 @@ export function AuditLogsPage() {
|
|||||||
const from = (page - 1) * pageSize;
|
const from = (page - 1) * pageSize;
|
||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
const { data, error, count } = await supabase
|
const { data, error, count } = await adminData
|
||||||
.from('audit_logs')
|
.from('audit_logs')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||||
import { supabase } from '@/config';
|
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
|
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -43,6 +43,7 @@ function formatFeatureName(featureName: string) {
|
|||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||||
|
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
categories: 0,
|
categories: 0,
|
||||||
packs: 0,
|
packs: 0,
|
||||||
@@ -64,10 +65,12 @@ export function DashboardPage() {
|
|||||||
{ count: questionsCount },
|
{ count: questionsCount },
|
||||||
{ count: usersCount },
|
{ count: usersCount },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
adminData.from('categories').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
adminData.from('questions').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
canViewUsers
|
||||||
|
? adminData.from('profiles').select('*', { count: 'exact', head: true })
|
||||||
|
: Promise.resolve({ count: 0 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
@@ -85,31 +88,24 @@ export function DashboardPage() {
|
|||||||
{ data: featureInterests },
|
{ data: featureInterests },
|
||||||
{ data: recentInterests },
|
{ data: recentInterests },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase
|
canViewChats
|
||||||
.from('messages')
|
? adminData.from('messages').select('created_at').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||||
.select('created_at')
|
: Promise.resolve({ data: [] }),
|
||||||
.gte('created_at', startDate.toISOString())
|
canViewUsers
|
||||||
.lte('created_at', endDate.toISOString()),
|
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||||
supabase
|
: Promise.resolve({ data: [] }),
|
||||||
.from('feature_interests')
|
canViewUsers
|
||||||
.select('created_at, user_id')
|
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
||||||
.gte('created_at', startDate.toISOString())
|
: Promise.resolve({ data: [] }),
|
||||||
.lte('created_at', endDate.toISOString()),
|
|
||||||
supabase
|
|
||||||
.from('feature_interests')
|
|
||||||
.select('id, user_id, feature_name, created_at')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(10),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (canViewUsers) {
|
if (canViewUsers) {
|
||||||
const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts');
|
try {
|
||||||
|
const { counts } = await adminRequest<{ counts: FeatureInterestCountsRow[] }>('/v1/admin/feature-interest-counts');
|
||||||
if (countsError) {
|
setFeatureInterestCounts(counts);
|
||||||
|
} catch (countsError) {
|
||||||
console.error('Failed to fetch feature interest counts:', countsError);
|
console.error('Failed to fetch feature interest counts:', countsError);
|
||||||
setFeatureInterestCounts([]);
|
setFeatureInterestCounts([]);
|
||||||
} else {
|
|
||||||
setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setFeatureInterestCounts([]);
|
setFeatureInterestCounts([]);
|
||||||
@@ -172,7 +168,7 @@ export function DashboardPage() {
|
|||||||
if (canViewUsers && recentActivity.length > 0) {
|
if (canViewUsers && recentActivity.length > 0) {
|
||||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||||
|
|
||||||
const { data: profiles } = await supabase
|
const { data: profiles } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email, avatar_url')
|
.select('id, name, email, avatar_url')
|
||||||
.in('id', userIds);
|
.in('id', userIds);
|
||||||
@@ -193,11 +189,12 @@ export function DashboardPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canViewUsers]);
|
}, [canViewChats, canViewUsers]);
|
||||||
|
|
||||||
// Real-time subscriptions for dashboard stats (with debouncing)
|
// Real-time subscriptions for dashboard stats (with debouncing)
|
||||||
const { status: profilesStatus } = useRealtimeSubscription({
|
const { status: profilesStatus } = useRealtimeSubscription({
|
||||||
table: 'profiles',
|
table: 'profiles',
|
||||||
|
enabled: canViewUsers,
|
||||||
onInsert: fetchStats,
|
onInsert: fetchStats,
|
||||||
onDelete: fetchStats,
|
onDelete: fetchStats,
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
@@ -205,6 +202,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const { status: messagesStatus } = useRealtimeSubscription({
|
const { status: messagesStatus } = useRealtimeSubscription({
|
||||||
table: 'messages',
|
table: 'messages',
|
||||||
|
enabled: canViewChats,
|
||||||
onInsert: fetchStats,
|
onInsert: fetchStats,
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,146 +1,146 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PaginationControls } from '@/components/ui/pagination';
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
|
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
||||||
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
|
||||||
interface Feedback {
|
interface Feedback {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
type: FeedbackType;
|
type: FeedbackType;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
screenshot_url: string | null;
|
screenshot_url: string | null;
|
||||||
device_info: Record<string, unknown>;
|
device_info: Record<string, unknown>;
|
||||||
status: FeedbackStatus;
|
status: FeedbackStatus;
|
||||||
admin_notes: string | null;
|
admin_notes: string | null;
|
||||||
question_id: string | null;
|
question_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
profile?: {
|
profile?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
};
|
};
|
||||||
question?: {
|
question?: {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack?: {
|
pack?: {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
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' },
|
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' },
|
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' },
|
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
|
||||||
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
|
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||||
new: { label: 'New', variant: 'destructive' },
|
new: { label: 'New', variant: 'destructive' },
|
||||||
reviewed: { label: 'Reviewed', variant: 'secondary' },
|
reviewed: { label: 'Reviewed', variant: 'secondary' },
|
||||||
in_progress: { label: 'In Progress', variant: 'default' },
|
in_progress: { label: 'In Progress', variant: 'default' },
|
||||||
resolved: { label: 'Resolved', variant: 'outline' },
|
resolved: { label: 'Resolved', variant: 'outline' },
|
||||||
closed: { label: 'Closed', variant: 'outline' },
|
closed: { label: 'Closed', variant: 'outline' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FeedbackPage() {
|
export function FeedbackPage() {
|
||||||
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
|
|
||||||
// Detail Dialog State
|
// Detail Dialog State
|
||||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
||||||
const [editNotes, setEditNotes] = useState('');
|
const [editNotes, setEditNotes] = useState('');
|
||||||
|
|
||||||
const fetchFeedback = useCallback(async () => {
|
const fetchFeedback = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('feedback')
|
.from('feedback')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
profile:profiles!feedback_user_id_fkey(id, name, email),
|
profile:profiles!feedback_user_id_fkey(id, name, email),
|
||||||
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
|
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
|
||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setFeedback(data || []);
|
setFeedback(data || []);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to load feedback:', error);
|
console.error('Failed to load feedback:', error);
|
||||||
toast.error("Failed to load feedback");
|
toast.error("Failed to load feedback");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Real-time subscription for feedback table
|
// Real-time subscription for feedback table
|
||||||
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
|
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
|
||||||
table: 'feedback',
|
table: 'feedback',
|
||||||
onInsert: useCallback(() => {
|
onInsert: useCallback(() => {
|
||||||
// Refetch to get the full data with profile/question relations
|
// Refetch to get the full data with profile/question relations
|
||||||
fetchFeedback();
|
fetchFeedback();
|
||||||
}, [fetchFeedback]),
|
}, [fetchFeedback]),
|
||||||
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
|
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
|
||||||
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
|
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
|
||||||
}, []),
|
}, []),
|
||||||
onDelete: useCallback((deleted: Feedback) => {
|
onDelete: useCallback((deleted: Feedback) => {
|
||||||
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
|
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
|
||||||
}, []),
|
}, []),
|
||||||
insertToast: {
|
insertToast: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
|
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeedback();
|
fetchFeedback();
|
||||||
}, [fetchFeedback]);
|
}, [fetchFeedback]);
|
||||||
@@ -149,40 +149,40 @@ export function FeedbackPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [search, typeFilter, statusFilter]);
|
}, [search, typeFilter, statusFilter]);
|
||||||
|
|
||||||
|
|
||||||
const handleOpenDetail = (item: Feedback) => {
|
const handleOpenDetail = (item: Feedback) => {
|
||||||
setSelectedFeedback(item);
|
setSelectedFeedback(item);
|
||||||
setEditStatus(item.status);
|
setEditStatus(item.status);
|
||||||
setEditNotes(item.admin_notes || '');
|
setEditNotes(item.admin_notes || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateFeedback = async () => {
|
const handleUpdateFeedback = async () => {
|
||||||
if (!selectedFeedback) return;
|
if (!selectedFeedback) return;
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from('feedback')
|
.from('feedback')
|
||||||
.update({
|
.update({
|
||||||
status: editStatus,
|
status: editStatus,
|
||||||
admin_notes: editNotes.trim() || null,
|
admin_notes: editNotes.trim() || null,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', selectedFeedback.id);
|
.eq('id', selectedFeedback.id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success("Feedback updated");
|
toast.success("Feedback updated");
|
||||||
setSelectedFeedback(null);
|
setSelectedFeedback(null);
|
||||||
fetchFeedback();
|
fetchFeedback();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to update feedback:', error);
|
console.error('Failed to update feedback:', error);
|
||||||
toast.error("Failed to update feedback");
|
toast.error("Failed to update feedback");
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredFeedback = feedback.filter(item => {
|
const filteredFeedback = feedback.filter(item => {
|
||||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||||
@@ -210,35 +210,35 @@ export function FeedbackPage() {
|
|||||||
}
|
}
|
||||||
}, [page, pageSize, totalCount]);
|
}, [page, pageSize, totalCount]);
|
||||||
|
|
||||||
|
|
||||||
const getTypeBadge = (type: FeedbackType) => {
|
const getTypeBadge = (type: FeedbackType) => {
|
||||||
const config = feedbackTypeConfig[type];
|
const config = feedbackTypeConfig[type];
|
||||||
return (
|
return (
|
||||||
<Badge variant={config.variant} className="gap-1">
|
<Badge variant={config.variant} className="gap-1">
|
||||||
{config.icon}
|
{config.icon}
|
||||||
{config.label}
|
{config.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: FeedbackStatus) => {
|
const getStatusBadge = (status: FeedbackStatus) => {
|
||||||
const config = statusConfig[status];
|
const config = statusConfig[status];
|
||||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDeviceInfo = (info: Record<string, unknown>) => {
|
const formatDeviceInfo = (info: Record<string, unknown>) => {
|
||||||
if (!info || Object.keys(info).length === 0) return null;
|
if (!info || Object.keys(info).length === 0) return null;
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (info.platform) lines.push(`Platform: ${info.platform}`);
|
if (info.platform) lines.push(`Platform: ${info.platform}`);
|
||||||
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
|
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
|
||||||
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
|
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
|
||||||
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
|
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
|
||||||
|
|
||||||
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
|
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -246,13 +246,13 @@ export function FeedbackPage() {
|
|||||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Review and respond to user feedback submissions
|
Review and respond to user feedback submissions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
<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">
|
<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" />
|
<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]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Type" />
|
<SelectValue placeholder="Type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="bug">Bug</SelectItem>
|
<SelectItem value="bug">Bug</SelectItem>
|
||||||
<SelectItem value="feature_request">Feature Request</SelectItem>
|
<SelectItem value="feature_request">Feature Request</SelectItem>
|
||||||
<SelectItem value="general">General</SelectItem>
|
<SelectItem value="general">General</SelectItem>
|
||||||
<SelectItem value="question">Question</SelectItem>
|
<SelectItem value="question">Question</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-full md:w-[150px]">
|
<SelectTrigger className="w-full md:w-[150px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
<SelectItem value="new">New</SelectItem>
|
<SelectItem value="new">New</SelectItem>
|
||||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
<SelectItem value="resolved">Resolved</SelectItem>
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -373,131 +373,131 @@ export function FeedbackPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detail Dialog */}
|
{/* Detail Dialog */}
|
||||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
|
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
|
||||||
<span className="truncate">{selectedFeedback?.title}</span>
|
<span className="truncate">{selectedFeedback?.title}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
|
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{selectedFeedback && (
|
{selectedFeedback && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
<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" />
|
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
|
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
|
||||||
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
|
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
|
||||||
{selectedFeedback.profile?.id && (
|
{selectedFeedback.profile?.id && (
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${selectedFeedback.profile.id}`}
|
to={`/users/${selectedFeedback.profile.id}`}
|
||||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
|
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
View user profile <ExternalLink className="h-3 w-3" />
|
View user profile <ExternalLink className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium">Description</Label>
|
<Label className="text-sm font-medium">Description</Label>
|
||||||
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
|
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Question */}
|
{/* Related Question */}
|
||||||
{selectedFeedback.question && (
|
{selectedFeedback.question && (
|
||||||
<div className="p-3 bg-muted/50 rounded-lg">
|
<div className="p-3 bg-muted/50 rounded-lg">
|
||||||
<Label className="text-sm font-medium">Related Question</Label>
|
<Label className="text-sm font-medium">Related Question</Label>
|
||||||
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
|
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
|
||||||
{selectedFeedback.question.pack && (
|
{selectedFeedback.question.pack && (
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
From pack: {selectedFeedback.question.pack.name}
|
From pack: {selectedFeedback.question.pack.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Screenshot */}
|
{/* Screenshot */}
|
||||||
{selectedFeedback.screenshot_url && (
|
{selectedFeedback.screenshot_url && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-medium">Screenshot</Label>
|
<Label className="text-sm font-medium">Screenshot</Label>
|
||||||
<a
|
<a
|
||||||
href={selectedFeedback.screenshot_url}
|
href={selectedFeedback.screenshot_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-1 block"
|
className="mt-1 block"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={selectedFeedback.screenshot_url}
|
src={selectedFeedback.screenshot_url}
|
||||||
alt="Screenshot"
|
alt="Screenshot"
|
||||||
className="max-w-full max-h-[200px] rounded border object-contain"
|
className="max-w-full max-h-[200px] rounded border object-contain"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Device Info */}
|
{/* Device Info */}
|
||||||
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
|
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
|
||||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
<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" />
|
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Label className="text-sm font-medium">Device Info</Label>
|
<Label className="text-sm font-medium">Device Info</Label>
|
||||||
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||||
{formatDeviceInfo(selectedFeedback.device_info)}
|
{formatDeviceInfo(selectedFeedback.device_info)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status Update */}
|
{/* Status Update */}
|
||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
|
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="new">New</SelectItem>
|
<SelectItem value="new">New</SelectItem>
|
||||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
<SelectItem value="resolved">Resolved</SelectItem>
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Admin Notes</Label>
|
<Label>Admin Notes</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={editNotes}
|
value={editNotes}
|
||||||
onChange={(e) => setEditNotes(e.target.value)}
|
onChange={(e) => setEditNotes(e.target.value)}
|
||||||
placeholder="Add internal notes about this feedback..."
|
placeholder="Add internal notes about this feedback..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
|
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
|
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
|
||||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminBinaryRequest, adminData, adminRequest } from '@/lib/adminApi';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -95,7 +95,7 @@ export function FlaggedMessagesPage() {
|
|||||||
const from = (aiPage - 1) * aiPageSize;
|
const from = (aiPage - 1) * aiPageSize;
|
||||||
const to = from + aiPageSize - 1;
|
const to = from + aiPageSize - 1;
|
||||||
|
|
||||||
const { data, error, count } = await supabase
|
const { data, error, count } = await adminData
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||||
.eq('moderation_status', 'flagged')
|
.eq('moderation_status', 'flagged')
|
||||||
@@ -120,7 +120,7 @@ export function FlaggedMessagesPage() {
|
|||||||
const from = (reportsPage - 1) * reportsPageSize;
|
const from = (reportsPage - 1) * reportsPageSize;
|
||||||
const to = from + reportsPageSize - 1;
|
const to = from + reportsPageSize - 1;
|
||||||
|
|
||||||
const { data, error, count } = await supabase
|
const { data, error, count } = await adminData
|
||||||
.from('message_reports')
|
.from('message_reports')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
@@ -166,24 +166,11 @@ export function FlaggedMessagesPage() {
|
|||||||
}
|
}
|
||||||
}, [reportsPage, reportsPageSize, reportsTotal]);
|
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||||
|
|
||||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
|
||||||
const fetchDecryptedMedia = async (messageId: string) => {
|
const fetchDecryptedMedia = async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||||
if (!session) return null;
|
method: 'POST', body: { messageId },
|
||||||
|
|
||||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${session.access_token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ messageId })
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch media');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -199,8 +186,8 @@ export function FlaggedMessagesPage() {
|
|||||||
setImageDescription(null);
|
setImageDescription(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||||
body: { messageId: message.id }
|
method: 'POST', body: { messageId: message.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
let mediaUrl = null;
|
let mediaUrl = null;
|
||||||
@@ -209,7 +196,7 @@ export function FlaggedMessagesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDecryptedContent({
|
setDecryptedContent({
|
||||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
text: textData.content,
|
||||||
mediaUrl
|
mediaUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,8 +220,8 @@ export function FlaggedMessagesPage() {
|
|||||||
setImageDescription(null);
|
setImageDescription(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||||
body: { messageId: report.message.id }
|
method: 'POST', body: { messageId: report.message.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
let mediaUrl = null;
|
let mediaUrl = null;
|
||||||
@@ -243,7 +230,7 @@ export function FlaggedMessagesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDecryptedContent({
|
setDecryptedContent({
|
||||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
text: textData.content,
|
||||||
mediaUrl
|
mediaUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,27 +261,9 @@ export function FlaggedMessagesPage() {
|
|||||||
|
|
||||||
setDescribingInline(message.id);
|
setDescribingInline(message.id);
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||||
if (!session) {
|
method: 'POST', body: { messageId: message.id },
|
||||||
toast.error('Not authenticated');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${session.access_token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ messageId: message.id })
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error('Failed to decrypt media');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const base64 = await new Promise<string>((resolve, reject) => {
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
@@ -319,7 +288,7 @@ export function FlaggedMessagesPage() {
|
|||||||
const handleMarkSafe = async (messageId: string) => {
|
const handleMarkSafe = async (messageId: string) => {
|
||||||
setProcessing(messageId);
|
setProcessing(messageId);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.update({ moderation_status: 'safe' })
|
.update({ moderation_status: 'safe' })
|
||||||
.eq('id', messageId);
|
.eq('id', messageId);
|
||||||
@@ -339,7 +308,7 @@ export function FlaggedMessagesPage() {
|
|||||||
const handleDismissReport = async (reportId: string) => {
|
const handleDismissReport = async (reportId: string) => {
|
||||||
setProcessing(reportId);
|
setProcessing(reportId);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from('message_reports')
|
.from('message_reports')
|
||||||
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
||||||
.eq('id', reportId);
|
.eq('id', reportId);
|
||||||
@@ -359,7 +328,7 @@ export function FlaggedMessagesPage() {
|
|||||||
const handleReviewReport = async (reportId: string) => {
|
const handleReviewReport = async (reportId: string) => {
|
||||||
setProcessing(reportId);
|
setProcessing(reportId);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await adminData
|
||||||
.from('message_reports')
|
.from('message_reports')
|
||||||
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
||||||
.eq('id', reportId);
|
.eq('id', reportId);
|
||||||
|
|||||||
@@ -1,69 +1,69 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PaginationControls } from '@/components/ui/pagination';
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
|
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface RedemptionCode {
|
interface RedemptionCode {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
max_uses: number;
|
max_uses: number;
|
||||||
current_uses: number;
|
current_uses: number;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodeRedemption {
|
interface CodeRedemption {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
redeemed_at: string;
|
redeemed_at: string;
|
||||||
profile?: {
|
profile?: {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateCode(length: number = 8): string {
|
function generateCode(length: number = 8): string {
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RedemptionCodesPage() {
|
export function RedemptionCodesPage() {
|
||||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -71,18 +71,18 @@ export function RedemptionCodesPage() {
|
|||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
// Add Code State
|
// Add Code State
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
code: '',
|
code: '',
|
||||||
description: '',
|
description: '',
|
||||||
max_uses: 1,
|
max_uses: 1,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// View Redemptions State
|
// View Redemptions State
|
||||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||||
@@ -90,9 +90,9 @@ export function RedemptionCodesPage() {
|
|||||||
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
||||||
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const fetchCodes = useCallback(async () => {
|
const fetchCodes = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +100,7 @@ export function RedemptionCodesPage() {
|
|||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
const trimmedSearch = search.trim();
|
const trimmedSearch = search.trim();
|
||||||
|
|
||||||
let query = supabase
|
let query = adminData
|
||||||
.from('redemption_codes')
|
.from('redemption_codes')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
@@ -122,14 +122,14 @@ export function RedemptionCodesPage() {
|
|||||||
}
|
}
|
||||||
}, [page, pageSize, search]);
|
}, [page, pageSize, search]);
|
||||||
|
|
||||||
|
|
||||||
const fetchRedemptions = useCallback(async (codeId: string) => {
|
const fetchRedemptions = useCallback(async (codeId: string) => {
|
||||||
setLoadingRedemptions(true);
|
setLoadingRedemptions(true);
|
||||||
try {
|
try {
|
||||||
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||||
const to = from + redemptionsPageSize - 1;
|
const to = from + redemptionsPageSize - 1;
|
||||||
|
|
||||||
const { data: redemptionData, error: redemptionError, count } = await supabase
|
const { data: redemptionData, error: redemptionError, count } = await adminData
|
||||||
.from('code_redemptions')
|
.from('code_redemptions')
|
||||||
.select('id, user_id, redeemed_at', { count: 'exact' })
|
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||||
.eq('code_id', codeId)
|
.eq('code_id', codeId)
|
||||||
@@ -142,7 +142,7 @@ export function RedemptionCodesPage() {
|
|||||||
|
|
||||||
if (redemptionData && redemptionData.length > 0) {
|
if (redemptionData && redemptionData.length > 0) {
|
||||||
const userIds = redemptionData.map(r => r.user_id);
|
const userIds = redemptionData.map(r => r.user_id);
|
||||||
const { data: profileData } = await supabase
|
const { data: profileData } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email')
|
.select('id, name, email')
|
||||||
.in('id', userIds);
|
.in('id', userIds);
|
||||||
@@ -191,117 +191,117 @@ export function RedemptionCodesPage() {
|
|||||||
}
|
}
|
||||||
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddCode = async () => {
|
const handleAddCode = async () => {
|
||||||
if (!formData.code.trim()) {
|
if (!formData.code.trim()) {
|
||||||
toast.error("Please enter a code");
|
toast.error("Please enter a code");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.insert('redemption_codes', {
|
const { error } = await auditedAdminData.insert('redemption_codes', {
|
||||||
code: formData.code.toUpperCase().trim(),
|
code: formData.code.toUpperCase().trim(),
|
||||||
description: formData.description.trim() || null,
|
description: formData.description.trim() || null,
|
||||||
max_uses: formData.max_uses,
|
max_uses: formData.max_uses,
|
||||||
current_uses: 0,
|
current_uses: 0,
|
||||||
expires_at: formData.expires_at || null,
|
expires_at: formData.expires_at || null,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success("Redemption code created");
|
toast.success("Redemption code created");
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchCodes();
|
fetchCodes();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to create code:', error);
|
console.error('Failed to create code:', error);
|
||||||
if (error.message?.includes('duplicate') || error.code === '23505') {
|
if (error.message?.includes('duplicate') || error.code === '23505') {
|
||||||
toast.error("This code already exists");
|
toast.error("This code already exists");
|
||||||
} else {
|
} else {
|
||||||
toast.error(error.message || "Failed to create code");
|
toast.error(error.message || "Failed to create code");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleActive = async (code: RedemptionCode) => {
|
const handleToggleActive = async (code: RedemptionCode) => {
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.update('redemption_codes', code.id, {
|
const { error } = await auditedAdminData.update('redemption_codes', code.id, {
|
||||||
is_active: !code.is_active,
|
is_active: !code.is_active,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success(code.is_active ? "Code deactivated" : "Code activated");
|
toast.success(code.is_active ? "Code deactivated" : "Code activated");
|
||||||
fetchCodes();
|
fetchCodes();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error("Failed to update code");
|
toast.error("Failed to update code");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCode = async (code: RedemptionCode) => {
|
const handleDeleteCode = async (code: RedemptionCode) => {
|
||||||
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
|
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.delete('redemption_codes', code.id);
|
const { error } = await auditedAdminData.delete('redemption_codes', code.id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success("Code deleted");
|
toast.success("Code deleted");
|
||||||
fetchCodes();
|
fetchCodes();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error("Failed to delete code");
|
toast.error("Failed to delete code");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
toast.success("Copied to clipboard");
|
toast.success("Copied to clipboard");
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
code: '',
|
code: '',
|
||||||
description: '',
|
description: '',
|
||||||
max_uses: 1,
|
max_uses: 1,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenAddDialog = () => {
|
const handleOpenAddDialog = () => {
|
||||||
resetForm();
|
resetForm();
|
||||||
setFormData(prev => ({ ...prev, code: generateCode() }));
|
setFormData(prev => ({ ...prev, code: generateCode() }));
|
||||||
setIsAddDialogOpen(true);
|
setIsAddDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||||
setViewingCode(code);
|
setViewingCode(code);
|
||||||
setRedemptionsPage(1);
|
setRedemptionsPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const filteredCodes = codes;
|
const filteredCodes = codes;
|
||||||
|
|
||||||
|
|
||||||
const getStatusBadge = (code: RedemptionCode) => {
|
const getStatusBadge = (code: RedemptionCode) => {
|
||||||
if (!code.is_active) {
|
if (!code.is_active) {
|
||||||
return <Badge variant="secondary">Inactive</Badge>;
|
return <Badge variant="secondary">Inactive</Badge>;
|
||||||
}
|
}
|
||||||
if (code.expires_at && new Date(code.expires_at) < new Date()) {
|
if (code.expires_at && new Date(code.expires_at) < new Date()) {
|
||||||
return <Badge variant="destructive">Expired</Badge>;
|
return <Badge variant="destructive">Expired</Badge>;
|
||||||
}
|
}
|
||||||
if (code.current_uses >= code.max_uses) {
|
if (code.current_uses >= code.max_uses) {
|
||||||
return <Badge variant="outline">Exhausted</Badge>;
|
return <Badge variant="outline">Exhausted</Badge>;
|
||||||
}
|
}
|
||||||
return <Badge variant="default">Active</Badge>;
|
return <Badge variant="default">Active</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -317,9 +317,9 @@ export function RedemptionCodesPage() {
|
|||||||
placeholder="Search codes..."
|
placeholder="Search codes..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
|
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
|
||||||
@@ -327,14 +327,14 @@ export function RedemptionCodesPage() {
|
|||||||
Generate Code
|
Generate Code
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a new code that users can redeem for premium access.
|
Create a new code that users can redeem for premium access.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Code</Label>
|
<Label>Code</Label>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<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() }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||||
placeholder="e.g., PREMIUM2024"
|
placeholder="e.g., PREMIUM2024"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
||||||
title="Generate random code"
|
title="Generate random code"
|
||||||
>
|
>
|
||||||
<Ticket className="h-4 w-4" />
|
<Ticket className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Description (optional)</Label>
|
<Label>Description (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
placeholder="e.g., Early adopter reward"
|
placeholder="e.g., Early adopter reward"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Max Uses</Label>
|
<Label>Max Uses</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={formData.max_uses}
|
value={formData.max_uses}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
How many times this code can be redeemed
|
How many times this code can be redeemed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Expires At (optional)</Label>
|
<Label>Expires At (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={formData.expires_at}
|
value={formData.expires_at}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
||||||
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
|
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
|
||||||
{isSubmitting ? 'Creating...' : 'Create Code'}
|
{isSubmitting ? 'Creating...' : 'Create Code'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -545,18 +545,18 @@ export function RedemptionCodesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* View Redemptions Dialog */}
|
{/* View Redemptions Dialog */}
|
||||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
|
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{loadingRedemptions ? (
|
{loadingRedemptions ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
@@ -607,10 +607,10 @@ export function RedemptionCodesPage() {
|
|||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
||||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,386 +1,386 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
||||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
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';
|
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||||
|
|
||||||
interface UsageReasonStat {
|
interface UsageReasonStat {
|
||||||
reason: UsageReason | null;
|
reason: UsageReason | null;
|
||||||
count: number;
|
count: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenderStat {
|
interface GenderStat {
|
||||||
gender: string | null;
|
gender: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
|
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
|
||||||
improve_communication: {
|
improve_communication: {
|
||||||
label: 'Improve communication',
|
label: 'Improve communication',
|
||||||
icon: <MessageSquare className="h-5 w-5" />,
|
icon: <MessageSquare className="h-5 w-5" />,
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
},
|
},
|
||||||
spice_up_intimacy: {
|
spice_up_intimacy: {
|
||||||
label: 'Spice up intimacy',
|
label: 'Spice up intimacy',
|
||||||
icon: <Flame className="h-5 w-5" />,
|
icon: <Flame className="h-5 w-5" />,
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
},
|
},
|
||||||
deeper_connection: {
|
deeper_connection: {
|
||||||
label: 'Build deeper connection',
|
label: 'Build deeper connection',
|
||||||
icon: <Heart className="h-5 w-5" />,
|
icon: <Heart className="h-5 w-5" />,
|
||||||
color: '#ec4899',
|
color: '#ec4899',
|
||||||
},
|
},
|
||||||
have_fun: {
|
have_fun: {
|
||||||
label: 'Have fun together',
|
label: 'Have fun together',
|
||||||
icon: <Smile className="h-5 w-5" />,
|
icon: <Smile className="h-5 w-5" />,
|
||||||
color: '#f59e0b',
|
color: '#f59e0b',
|
||||||
},
|
},
|
||||||
strengthen_relationship: {
|
strengthen_relationship: {
|
||||||
label: 'Strengthen relationship',
|
label: 'Strengthen relationship',
|
||||||
icon: <Shield className="h-5 w-5" />,
|
icon: <Shield className="h-5 w-5" />,
|
||||||
color: '#10b981',
|
color: '#10b981',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const genderConfig: Record<string, { label: string; color: string }> = {
|
const genderConfig: Record<string, { label: string; color: string }> = {
|
||||||
male: { label: 'Male', color: '#3b82f6' },
|
male: { label: 'Male', color: '#3b82f6' },
|
||||||
female: { label: 'Female', color: '#ec4899' },
|
female: { label: 'Female', color: '#ec4899' },
|
||||||
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
|
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
|
||||||
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
|
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UsageInsightsPage() {
|
export function UsageInsightsPage() {
|
||||||
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
|
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
|
||||||
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
|
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
|
||||||
const [totalUsers, setTotalUsers] = useState(0);
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
const [onboardedUsers, setOnboardedUsers] = useState(0);
|
const [onboardedUsers, setOnboardedUsers] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
// Get total users count
|
// Get total users count
|
||||||
const { count: total } = await supabase
|
const { count: total } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*', { count: 'exact', head: true });
|
.select('*', { count: 'exact', head: true });
|
||||||
|
|
||||||
setTotalUsers(total || 0);
|
setTotalUsers(total || 0);
|
||||||
|
|
||||||
// Get onboarded users count
|
// Get onboarded users count
|
||||||
const { count: onboarded } = await supabase
|
const { count: onboarded } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.eq('onboarding_completed', true);
|
.eq('onboarding_completed', true);
|
||||||
|
|
||||||
setOnboardedUsers(onboarded || 0);
|
setOnboardedUsers(onboarded || 0);
|
||||||
|
|
||||||
// Get usage reason breakdown
|
// Get usage reason breakdown
|
||||||
const { data: usageData } = await supabase
|
const { data: usageData } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('usage_reason')
|
.select('usage_reason')
|
||||||
.not('usage_reason', 'is', null);
|
.not('usage_reason', 'is', null);
|
||||||
|
|
||||||
const usageCounts: Record<string, number> = {};
|
const usageCounts: Record<string, number> = {};
|
||||||
usageData?.forEach(profile => {
|
usageData?.forEach(profile => {
|
||||||
const reason = profile.usage_reason as string;
|
const reason = profile.usage_reason as string;
|
||||||
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
|
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
|
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
|
||||||
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
|
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
|
||||||
.map(([reason, count]) => ({
|
.map(([reason, count]) => ({
|
||||||
reason: reason as UsageReason,
|
reason: reason as UsageReason,
|
||||||
count,
|
count,
|
||||||
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
|
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
setUsageStats(usageStatsArray);
|
setUsageStats(usageStatsArray);
|
||||||
|
|
||||||
// Get gender breakdown
|
// Get gender breakdown
|
||||||
const { data: genderData } = await supabase
|
const { data: genderData } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('gender')
|
.select('gender')
|
||||||
.not('gender', 'is', null);
|
.not('gender', 'is', null);
|
||||||
|
|
||||||
const genderCounts: Record<string, number> = {};
|
const genderCounts: Record<string, number> = {};
|
||||||
genderData?.forEach(profile => {
|
genderData?.forEach(profile => {
|
||||||
const gender = profile.gender as string;
|
const gender = profile.gender as string;
|
||||||
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
|
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
||||||
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
|
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
|
||||||
.map(([gender, count]) => ({
|
.map(([gender, count]) => ({
|
||||||
gender,
|
gender,
|
||||||
count,
|
count,
|
||||||
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
|
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
setGenderStats(genderStatsArray);
|
setGenderStats(genderStatsArray);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch usage stats:', error);
|
console.error('Failed to fetch usage stats:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const chartData = usageStats.map(stat => ({
|
const chartData = usageStats.map(stat => ({
|
||||||
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
|
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
|
||||||
value: stat.count,
|
value: stat.count,
|
||||||
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
|
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pieData = genderStats.map(stat => ({
|
const pieData = genderStats.map(stat => ({
|
||||||
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
|
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
|
||||||
value: stat.count,
|
value: stat.count,
|
||||||
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
|
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<Target className="h-8 w-8 text-primary" />
|
<Target className="h-8 w-8 text-primary" />
|
||||||
Usage Insights
|
Usage Insights
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Understand why users are using Sauci based on onboarding responses
|
Understand why users are using Sauci based on onboarding responses
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
|
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
|
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
|
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
|
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
|
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
|
||||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
|
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
users provided usage reason
|
users provided usage reason
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* Usage Reasons Chart */}
|
{/* Usage Reasons Chart */}
|
||||||
<Card className="col-span-1">
|
<Card className="col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Target className="h-5 w-5 text-primary" />
|
<Target className="h-5 w-5 text-primary" />
|
||||||
Why Users Join Sauci
|
Why Users Join Sauci
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Distribution of user motivations from onboarding
|
Distribution of user motivations from onboarding
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-[300px] w-full" />
|
<Skeleton className="h-[300px] w-full" />
|
||||||
) : usageStats.length === 0 ? (
|
) : usageStats.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||||
No usage data available yet
|
No usage data available yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
|
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
|
||||||
<XAxis type="number" />
|
<XAxis type="number" />
|
||||||
<YAxis
|
<YAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
type="category"
|
type="category"
|
||||||
width={150}
|
width={150}
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||||
{chartData.map((entry, index) => (
|
{chartData.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Gender Distribution */}
|
{/* Gender Distribution */}
|
||||||
<Card className="col-span-1">
|
<Card className="col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<Users className="h-5 w-5 text-primary" />
|
||||||
Gender Distribution
|
Gender Distribution
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
User demographics from onboarding
|
User demographics from onboarding
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-[300px] w-full" />
|
<Skeleton className="h-[300px] w-full" />
|
||||||
) : genderStats.length === 0 ? (
|
) : genderStats.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||||
No gender data available yet
|
No gender data available yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center">
|
<div className="h-[300px] flex items-center justify-center">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<RechartsPieChart>
|
<RechartsPieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={pieData}
|
data={pieData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={60}
|
innerRadius={60}
|
||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
label={({ name, percent }) =>
|
label={({ name, percent }) =>
|
||||||
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{pieData.map((entry, index) => (
|
{pieData.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</RechartsPieChart>
|
</RechartsPieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Breakdown */}
|
{/* Detailed Breakdown */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Detailed Breakdown</CardTitle>
|
<CardTitle>Detailed Breakdown</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Individual statistics for each usage reason
|
Individual statistics for each usage reason
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3, 4, 5].map(i => (
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : usageStats.length === 0 ? (
|
) : usageStats.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
No usage data available yet
|
No usage data available yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{usageStats.map(stat => {
|
{usageStats.map(stat => {
|
||||||
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
|
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stat.reason}
|
key={stat.reason}
|
||||||
className="flex items-center gap-4 p-4 rounded-lg border"
|
className="flex items-center gap-4 p-4 rounded-lg border"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="p-3 rounded-lg"
|
className="p-3 rounded-lg"
|
||||||
style={{ backgroundColor: `${config?.color}20` }}
|
style={{ backgroundColor: `${config?.color}20` }}
|
||||||
>
|
>
|
||||||
<div style={{ color: config?.color }}>
|
<div style={{ color: config?.color }}>
|
||||||
{config?.icon}
|
{config?.icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium">{config?.label || stat.reason}</p>
|
<p className="font-medium">{config?.label || stat.reason}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all"
|
className="h-full rounded-full transition-all"
|
||||||
style={{
|
style={{
|
||||||
width: `${stat.percentage}%`,
|
width: `${stat.percentage}%`,
|
||||||
backgroundColor: config?.color,
|
backgroundColor: config?.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||||
{stat.percentage}%
|
{stat.percentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-2xl font-bold">{stat.count}</p>
|
<p className="text-2xl font-bold">{stat.count}</p>
|
||||||
<p className="text-xs text-muted-foreground">users</p>
|
<p className="text-xs text-muted-foreground">users</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
@@ -146,7 +146,7 @@ export function UserActivityPage() {
|
|||||||
const from = (responsesPage - 1) * responsesPageSize;
|
const from = (responsesPage - 1) * responsesPageSize;
|
||||||
const to = from + responsesPageSize - 1;
|
const to = from + responsesPageSize - 1;
|
||||||
|
|
||||||
const { data, error, count } = await supabase
|
const { data, error, count } = await adminData
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select(`
|
.select(`
|
||||||
id, user_id, answer, response_data, created_at,
|
id, user_id, answer, response_data, created_at,
|
||||||
@@ -163,7 +163,7 @@ export function UserActivityPage() {
|
|||||||
|
|
||||||
setResponsesTotal(count || 0);
|
setResponsesTotal(count || 0);
|
||||||
|
|
||||||
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
// Transform the API's nested relationship objects to the view model.
|
||||||
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
user_id: item.user_id,
|
user_id: item.user_id,
|
||||||
@@ -194,7 +194,7 @@ export function UserActivityPage() {
|
|||||||
const from = (matchesPage - 1) * matchesPageSize;
|
const from = (matchesPage - 1) * matchesPageSize;
|
||||||
const to = from + matchesPageSize - 1;
|
const to = from + matchesPageSize - 1;
|
||||||
|
|
||||||
const { data: matchesData, error: matchesError, count } = await supabase
|
const { data: matchesData, error: matchesError, count } = await adminData
|
||||||
.from('matches')
|
.from('matches')
|
||||||
.select(`
|
.select(`
|
||||||
id, couple_id, match_type, is_new, created_at,
|
id, couple_id, match_type, is_new, created_at,
|
||||||
@@ -210,7 +210,7 @@ export function UserActivityPage() {
|
|||||||
// Get unique couple IDs and fetch profiles
|
// Get unique couple IDs and fetch profiles
|
||||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||||
if (coupleIds.length > 0) {
|
if (coupleIds.length > 0) {
|
||||||
const { data: profiles, error: profilesError } = await supabase
|
const { data: profiles, error: profilesError } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email, couple_id')
|
.select('id, name, email, couple_id')
|
||||||
.in('couple_id', coupleIds);
|
.in('couple_id', coupleIds);
|
||||||
@@ -256,7 +256,7 @@ export function UserActivityPage() {
|
|||||||
const to = from + messagesPageSize - 1;
|
const to = from + messagesPageSize - 1;
|
||||||
|
|
||||||
// Fetch messages with match data
|
// Fetch messages with match data
|
||||||
const { data: messagesData, error: messagesError, count } = await supabase
|
const { data: messagesData, error: messagesError, count } = await adminData
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select(`
|
.select(`
|
||||||
id, user_id, content, media_path, media_type, created_at,
|
id, user_id, content, media_path, media_type, created_at,
|
||||||
@@ -277,7 +277,7 @@ export function UserActivityPage() {
|
|||||||
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
const { data: profiles, error: profilesError } = await supabase
|
const { data: profiles, error: profilesError } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email')
|
.select('id, name, email')
|
||||||
.in('id', userIds);
|
.in('id', userIds);
|
||||||
@@ -318,10 +318,7 @@ export function UserActivityPage() {
|
|||||||
setLoading(prev => ({ ...prev, signups: true }));
|
setLoading(prev => ({ ...prev, signups: true }));
|
||||||
try {
|
try {
|
||||||
// Use RPC to get profiles with auth info (includes email from auth.users)
|
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||||
const { data: allData, error } = await supabase
|
const { users: allData } = await adminRequest<{ users: SignupActivity[] }>('/v1/admin/users');
|
||||||
.rpc('get_profiles_with_auth_info');
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Sort by created_at descending and apply pagination
|
// Sort by created_at descending and apply pagination
|
||||||
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -82,7 +82,7 @@ export function AdminsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// First fetch admin users with permissions
|
// First fetch admin users with permissions
|
||||||
const { data: adminData, error: adminError } = await supabase
|
const { data: adminRows, error: adminError } = await adminData
|
||||||
.from('admin_users')
|
.from('admin_users')
|
||||||
.select('id, user_id, role, permissions, created_at')
|
.select('id, user_id, role, permissions, created_at')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
@@ -91,9 +91,9 @@ export function AdminsPage() {
|
|||||||
|
|
||||||
// Then fetch profiles for these users
|
// Then fetch profiles for these users
|
||||||
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
|
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
|
||||||
if (adminData && adminData.length > 0) {
|
if (adminRows && adminRows.length > 0) {
|
||||||
const userIds = adminData.map(a => a.user_id);
|
const userIds = adminRows.map(a => a.user_id);
|
||||||
const { data: profileData, error: profileError } = await supabase
|
const { data: profileData, error: profileError } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email, avatar_url')
|
.select('id, name, email, avatar_url')
|
||||||
.in('id', userIds);
|
.in('id', userIds);
|
||||||
@@ -101,7 +101,7 @@ export function AdminsPage() {
|
|||||||
if (profileError) throw profileError;
|
if (profileError) throw profileError;
|
||||||
|
|
||||||
// Merge data
|
// Merge data
|
||||||
const joinedAdmins = adminData.map(admin => ({
|
const joinedAdmins = adminRows.map(admin => ({
|
||||||
...admin,
|
...admin,
|
||||||
permissions: (admin.permissions as string[]) || [],
|
permissions: (admin.permissions as string[]) || [],
|
||||||
profile: profileData?.find(p => p.id === admin.user_id)
|
profile: profileData?.find(p => p.id === admin.user_id)
|
||||||
@@ -149,7 +149,7 @@ export function AdminsPage() {
|
|||||||
// Determine which users are already admins to exclude them
|
// Determine which users are already admins to exclude them
|
||||||
const adminUserIds = new Set(admins.map(a => a.user_id));
|
const adminUserIds = new Set(admins.map(a => a.user_id));
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, email, avatar_url')
|
.select('id, name, email, avatar_url')
|
||||||
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
|
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
|
||||||
@@ -179,7 +179,7 @@ export function AdminsPage() {
|
|||||||
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
|
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
|
||||||
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
|
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
|
||||||
|
|
||||||
const { error } = await auditedSupabase.insert('admin_users', {
|
const { error } = await auditedAdminData.insert('admin_users', {
|
||||||
user_id: selectedUser.id,
|
user_id: selectedUser.id,
|
||||||
role,
|
role,
|
||||||
permissions
|
permissions
|
||||||
@@ -219,7 +219,7 @@ export function AdminsPage() {
|
|||||||
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
|
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
|
||||||
const permissions = editIsSuperAdmin ? [] : editPermissions;
|
const permissions = editIsSuperAdmin ? [] : editPermissions;
|
||||||
|
|
||||||
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
|
const { error } = await auditedAdminData.update('admin_users', editingAdmin.id, {
|
||||||
role,
|
role,
|
||||||
permissions
|
permissions
|
||||||
});
|
});
|
||||||
@@ -261,7 +261,7 @@ export function AdminsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
|
const { error } = await auditedAdminData.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
rectSortingStrategy,
|
rectSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -56,6 +58,8 @@ interface Category {
|
|||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
|
content_status?: ContentReviewStatus | null;
|
||||||
|
content_review_reason?: string | null;
|
||||||
question_pack_count?: number;
|
question_pack_count?: number;
|
||||||
dare_pack_count?: number;
|
dare_pack_count?: number;
|
||||||
}
|
}
|
||||||
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
|
|||||||
onEdit: (category: Category) => void;
|
onEdit: (category: Category) => void;
|
||||||
onDelete: (category: Category) => void;
|
onDelete: (category: Category) => void;
|
||||||
onToggleVisibility: (category: Category) => void;
|
onToggleVisibility: (category: Category) => void;
|
||||||
|
onReviewChanged: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
|
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility, onReviewChanged }: SortableCategoryCardProps) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContentReviewControl
|
||||||
|
table="categories"
|
||||||
|
entityId={category.id}
|
||||||
|
entityLabel={`category “${category.name}”`}
|
||||||
|
status={category.content_status}
|
||||||
|
reason={category.content_review_reason}
|
||||||
|
onChanged={onReviewChanged}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Link to={`/categories/${category.id}/packs`}>
|
<Link to={`/categories/${category.id}/packs`}>
|
||||||
<Button variant="outline" className="w-full">
|
<Button variant="outline" className="w-full">
|
||||||
@@ -215,7 +230,7 @@ export function CategoriesPage() {
|
|||||||
const from = (page - 1) * pageSize;
|
const from = (page - 1) * pageSize;
|
||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
const { data: cats, error, count } = await supabase
|
const { data: cats, error, count } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
@@ -231,11 +246,11 @@ export function CategoriesPage() {
|
|||||||
|
|
||||||
if (categoryIds.length > 0) {
|
if (categoryIds.length > 0) {
|
||||||
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
||||||
supabase
|
adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('category_id')
|
.select('category_id')
|
||||||
.in('category_id', categoryIds),
|
.in('category_id', categoryIds),
|
||||||
supabase
|
adminData
|
||||||
.from('dare_packs')
|
.from('dare_packs')
|
||||||
.select('category_id')
|
.select('category_id')
|
||||||
.in('category_id', categoryIds),
|
.in('category_id', categoryIds),
|
||||||
@@ -310,11 +325,11 @@ export function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (form.editingItem) {
|
if (form.editingItem) {
|
||||||
const { error } = await auditedSupabase.update('categories', form.editingItem.id, data);
|
const { error } = await auditedAdminData.update('categories', form.editingItem.id, data);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Category updated');
|
toast.success('Category updated');
|
||||||
} else {
|
} else {
|
||||||
const { error } = await auditedSupabase.insert('categories', {
|
const { error } = await auditedAdminData.insert('categories', {
|
||||||
...data,
|
...data,
|
||||||
sort_order: totalCount,
|
sort_order: totalCount,
|
||||||
});
|
});
|
||||||
@@ -338,7 +353,7 @@ export function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
const { error } = await auditedAdminData.delete('categories', category.id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Category deleted');
|
toast.success('Category deleted');
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
@@ -355,7 +370,7 @@ export function CategoriesPage() {
|
|||||||
c.id === category.id ? { ...c, is_public: !c.is_public } : c
|
c.id === category.id ? { ...c, is_public: !c.is_public } : c
|
||||||
));
|
));
|
||||||
|
|
||||||
const { error } = await auditedSupabase.update('categories', category.id, {
|
const { error } = await auditedAdminData.update('categories', category.id, {
|
||||||
is_public: !category.is_public
|
is_public: !category.is_public
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -399,7 +414,7 @@ export function CategoriesPage() {
|
|||||||
// Persist new order to database
|
// Persist new order to database
|
||||||
try {
|
try {
|
||||||
const updates = newCategories.map((category, index) =>
|
const updates = newCategories.map((category, index) =>
|
||||||
auditedSupabase.update('categories', category.id, { sort_order: index })
|
auditedAdminData.update('categories', category.id, { sort_order: index })
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = await Promise.all(updates);
|
const results = await Promise.all(updates);
|
||||||
@@ -579,6 +594,7 @@ export function CategoriesPage() {
|
|||||||
onEdit={form.openEdit}
|
onEdit={form.openEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onToggleVisibility={handleToggleVisibility}
|
onToggleVisibility={handleToggleVisibility}
|
||||||
|
onReviewChanged={fetchCategories}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -54,6 +56,8 @@ interface DarePack {
|
|||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
dare_count?: number;
|
dare_count?: number;
|
||||||
|
content_status?: ContentReviewStatus | null;
|
||||||
|
content_review_reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DarePackFormData {
|
interface DarePackFormData {
|
||||||
@@ -116,7 +120,7 @@ export function DarePacksPage() {
|
|||||||
|
|
||||||
// Fetch category info
|
// Fetch category info
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const { data: categoryData } = await supabase
|
const { data: categoryData } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('id, name, icon')
|
.select('id, name, icon')
|
||||||
.eq('id', categoryId)
|
.eq('id', categoryId)
|
||||||
@@ -127,7 +131,7 @@ export function DarePacksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch dare packs
|
// Fetch dare packs
|
||||||
let query = supabase
|
let query = adminData
|
||||||
.from('dare_packs')
|
.from('dare_packs')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
@@ -148,7 +152,7 @@ export function DarePacksPage() {
|
|||||||
const dareCounts: Record<string, number> = {};
|
const dareCounts: Record<string, number> = {};
|
||||||
|
|
||||||
if (packIds.length > 0) {
|
if (packIds.length > 0) {
|
||||||
const { data: dares } = await supabase
|
const { data: dares } = await adminData
|
||||||
.from('dares')
|
.from('dares')
|
||||||
.select('pack_id')
|
.select('pack_id')
|
||||||
.in('pack_id', packIds);
|
.in('pack_id', packIds);
|
||||||
@@ -159,7 +163,7 @@ export function DarePacksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categoryData } = await supabase
|
const { data: categoryData } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.order('name');
|
.order('name');
|
||||||
@@ -208,7 +212,7 @@ export function DarePacksPage() {
|
|||||||
form.setSaving(true);
|
form.setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (form.editingItem) {
|
if (form.editingItem) {
|
||||||
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
|
const { error } = await auditedAdminData.update('dare_packs', form.editingItem.id, {
|
||||||
name: form.formData.name,
|
name: form.formData.name,
|
||||||
description: form.formData.description || null,
|
description: form.formData.description || null,
|
||||||
icon: form.formData.icon || null,
|
icon: form.formData.icon || null,
|
||||||
@@ -220,7 +224,7 @@ export function DarePacksPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Dare pack updated');
|
toast.success('Dare pack updated');
|
||||||
} else {
|
} else {
|
||||||
const { error } = await auditedSupabase.insert('dare_packs', {
|
const { error } = await auditedAdminData.insert('dare_packs', {
|
||||||
name: form.formData.name,
|
name: form.formData.name,
|
||||||
description: form.formData.description || null,
|
description: form.formData.description || null,
|
||||||
icon: form.formData.icon || null,
|
icon: form.formData.icon || null,
|
||||||
@@ -250,7 +254,7 @@ export function DarePacksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
|
const { error } = await auditedAdminData.delete('dare_packs', pack.id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Dare pack deleted');
|
toast.success('Dare pack deleted');
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -273,8 +277,8 @@ export function DarePacksPage() {
|
|||||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||||
|
|
||||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||||
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
auditedAdminData.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||||
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
auditedAdminData.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (currentUpdate.error || targetUpdate.error) {
|
if (currentUpdate.error || targetUpdate.error) {
|
||||||
@@ -511,6 +515,15 @@ export function DarePacksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<ContentReviewControl
|
||||||
|
table="dare_packs"
|
||||||
|
entityId={pack.id}
|
||||||
|
entityLabel={`dare pack “${pack.name}”`}
|
||||||
|
status={pack.content_status}
|
||||||
|
reason={pack.content_review_reason}
|
||||||
|
onChanged={fetchData}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
{pack.is_explicit && (
|
{pack.is_explicit && (
|
||||||
<Badge variant="destructive">
|
<Badge variant="destructive">
|
||||||
NSFW
|
NSFW
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -37,6 +37,8 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -56,6 +58,8 @@ interface Dare {
|
|||||||
intensity: number;
|
intensity: number;
|
||||||
suggested_duration_hours: number | null;
|
suggested_duration_hours: number | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
content_status?: ContentReviewStatus | null;
|
||||||
|
content_review_reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration options for dares
|
// Duration options for dares
|
||||||
@@ -110,7 +114,7 @@ export function DaresPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch pack info
|
// Fetch pack info
|
||||||
const { data: packData } = await supabase
|
const { data: packData } = await adminData
|
||||||
.from('dare_packs')
|
.from('dare_packs')
|
||||||
.select('id, name, description, icon')
|
.select('id, name, description, icon')
|
||||||
.eq('id', packId)
|
.eq('id', packId)
|
||||||
@@ -122,7 +126,7 @@ export function DaresPage() {
|
|||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch dares
|
// Fetch dares
|
||||||
const { data: dareData, error, count } = await supabase
|
const { data: dareData, error, count } = await adminData
|
||||||
.from('dares')
|
.from('dares')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -191,11 +195,11 @@ export function DaresPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (editingDare) {
|
if (editingDare) {
|
||||||
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
|
const { error } = await auditedAdminData.update('dares', editingDare.id, dareData);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Dare updated');
|
toast.success('Dare updated');
|
||||||
} else {
|
} else {
|
||||||
const { error } = await auditedSupabase.insert('dares', {
|
const { error } = await auditedAdminData.insert('dares', {
|
||||||
...dareData,
|
...dareData,
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
});
|
});
|
||||||
@@ -219,7 +223,7 @@ export function DaresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.delete('dares', dare.id);
|
const { error } = await auditedAdminData.delete('dares', dare.id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Dare deleted');
|
toast.success('Dare deleted');
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -259,7 +263,7 @@ export function DaresPage() {
|
|||||||
setBulkDeleting(true);
|
setBulkDeleting(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
|
Array.from(selectedIds).map(id => auditedAdminData.delete('dares', id))
|
||||||
);
|
);
|
||||||
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
|
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
@@ -439,6 +443,7 @@ export function DaresPage() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-12">#</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Dare</TableHead>
|
<TableHead>Dare</TableHead>
|
||||||
|
<TableHead className="w-40">Catalogue</TableHead>
|
||||||
<TableHead className="w-24">Duration</TableHead>
|
<TableHead className="w-24">Duration</TableHead>
|
||||||
<TableHead className="w-24">Actions</TableHead>
|
<TableHead className="w-24">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -462,6 +467,17 @@ export function DaresPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="line-clamp-2">{dare.text}</span>
|
<span className="line-clamp-2">{dare.text}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ContentReviewControl
|
||||||
|
table="dares"
|
||||||
|
entityId={dare.id}
|
||||||
|
entityLabel={`dare ${((page - 1) * pageSize) + index + 1}`}
|
||||||
|
status={dare.content_status}
|
||||||
|
reason={dare.content_review_reason}
|
||||||
|
onChanged={fetchData}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{dare.suggested_duration_hours ? (
|
{dare.suggested_duration_hours ? (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -14,6 +14,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
|||||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||||
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -43,6 +45,8 @@ interface QuestionPack {
|
|||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||||
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||||
|
content_status?: ContentReviewStatus | null;
|
||||||
|
content_review_reason?: string | null;
|
||||||
question_count?: number;
|
question_count?: number;
|
||||||
topics?: Topic[];
|
topics?: Topic[];
|
||||||
}
|
}
|
||||||
@@ -103,7 +107,7 @@ export function PacksPage() {
|
|||||||
try {
|
try {
|
||||||
// Fetch category info
|
// Fetch category info
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const { data: cat } = await supabase
|
const { data: cat } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('id, name, icon')
|
.select('id, name, icon')
|
||||||
.eq('id', categoryId)
|
.eq('id', categoryId)
|
||||||
@@ -111,7 +115,7 @@ export function PacksPage() {
|
|||||||
setCategory(cat);
|
setCategory(cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: categoriesData } = await supabase
|
const { data: categoriesData } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.order('name');
|
.order('name');
|
||||||
@@ -121,7 +125,7 @@ export function PacksPage() {
|
|||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch packs
|
// Fetch packs
|
||||||
let query = supabase
|
let query = adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.order('sort_order', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
@@ -141,7 +145,7 @@ export function PacksPage() {
|
|||||||
const questionCounts: Record<string, number> = {};
|
const questionCounts: Record<string, number> = {};
|
||||||
|
|
||||||
if (packIds.length > 0) {
|
if (packIds.length > 0) {
|
||||||
const { data: questions } = await supabase
|
const { data: questions } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('pack_id')
|
.select('pack_id')
|
||||||
.in('pack_id', packIds);
|
.in('pack_id', packIds);
|
||||||
@@ -152,7 +156,7 @@ export function PacksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all topics
|
// Fetch all topics
|
||||||
const { data: topicsData } = await supabase
|
const { data: topicsData } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.order('name');
|
.order('name');
|
||||||
@@ -161,7 +165,7 @@ export function PacksPage() {
|
|||||||
// Fetch pack_topics relationships
|
// Fetch pack_topics relationships
|
||||||
let packTopicsData: any[] = [];
|
let packTopicsData: any[] = [];
|
||||||
if (packIds.length > 0) {
|
if (packIds.length > 0) {
|
||||||
const { data } = await supabase
|
const { data } = await adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.select('pack_id, topic_id, topics(id, name)')
|
.select('pack_id, topic_id, topics(id, name)')
|
||||||
.in('pack_id', packIds);
|
.in('pack_id', packIds);
|
||||||
@@ -236,7 +240,7 @@ export function PacksPage() {
|
|||||||
? true
|
? true
|
||||||
: (form.formData.scheduled_release_at ? false : undefined);
|
: (form.formData.scheduled_release_at ? false : undefined);
|
||||||
|
|
||||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
const { error } = await auditedAdminData.update('question_packs', form.editingItem.id, {
|
||||||
name: form.formData.name,
|
name: form.formData.name,
|
||||||
description: form.formData.description || null,
|
description: form.formData.description || null,
|
||||||
icon: form.formData.icon || null,
|
icon: form.formData.icon || null,
|
||||||
@@ -254,7 +258,7 @@ export function PacksPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
packId = form.editingItem.id;
|
packId = form.editingItem.id;
|
||||||
} else {
|
} else {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.insert({
|
.insert({
|
||||||
name: form.formData.name,
|
name: form.formData.name,
|
||||||
@@ -279,10 +283,10 @@ export function PacksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update topics
|
// Update topics
|
||||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
await adminData.from('pack_topics').delete().eq('pack_id', packId);
|
||||||
|
|
||||||
if (selectedTopicIds.size > 0) {
|
if (selectedTopicIds.size > 0) {
|
||||||
const { error: topicsError } = await supabase
|
const { error: topicsError } = await adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.insert(
|
.insert(
|
||||||
Array.from(selectedTopicIds).map(topicId => ({
|
Array.from(selectedTopicIds).map(topicId => ({
|
||||||
@@ -310,7 +314,7 @@ export function PacksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
const { error } = await auditedAdminData.delete('question_packs', pack.id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Pack deleted');
|
toast.success('Pack deleted');
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -351,8 +355,8 @@ export function PacksPage() {
|
|||||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||||
|
|
||||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||||
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
|
auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||||
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
auditedAdminData.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (currentUpdate.error || targetUpdate.error) {
|
if (currentUpdate.error || targetUpdate.error) {
|
||||||
@@ -513,6 +517,15 @@ export function PacksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<ContentReviewControl
|
||||||
|
table="question_packs"
|
||||||
|
entityId={pack.id}
|
||||||
|
entityLabel={`pack “${pack.name}”`}
|
||||||
|
status={pack.content_status}
|
||||||
|
reason={pack.content_review_reason}
|
||||||
|
onChanged={fetchData}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
{pack.is_explicit && (
|
{pack.is_explicit && (
|
||||||
<Badge variant="destructive">
|
<Badge variant="destructive">
|
||||||
NSFW
|
NSFW
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -126,7 +126,7 @@ export function QuestionAnalyticsPage() {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const from = page * pageSize;
|
const from = page * pageSize;
|
||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
|
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
|
||||||
.range(from, to);
|
.range(from, to);
|
||||||
@@ -150,7 +150,7 @@ export function QuestionAnalyticsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Build pack query with optional is_public filter
|
// Build pack query with optional is_public filter
|
||||||
let packQuery = supabase
|
let packQuery = adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
|
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
|
||||||
|
|
||||||
@@ -162,11 +162,11 @@ export function QuestionAnalyticsPage() {
|
|||||||
|
|
||||||
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
|
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
|
||||||
packQuery,
|
packQuery,
|
||||||
supabase
|
adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.order('name'),
|
.order('name'),
|
||||||
supabase
|
adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.select('pack_id, topic_id, topics(id, name)'),
|
.select('pack_id, topic_id, topics(id, name)'),
|
||||||
fetchAllQuestions(),
|
fetchAllQuestions(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
|||||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||||
import { IconPreview } from '@/components/ui/icon-picker';
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
|
||||||
interface QuestionPack {
|
interface QuestionPack {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,6 +72,8 @@ interface Question {
|
|||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
question_type?: QuestionType | null;
|
question_type?: QuestionType | null;
|
||||||
config?: QuestionConfig | null;
|
config?: QuestionConfig | null;
|
||||||
|
content_status?: ContentReviewStatus | null;
|
||||||
|
content_review_reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AUDIO_DURATION = 60;
|
const DEFAULT_AUDIO_DURATION = 60;
|
||||||
@@ -150,12 +154,12 @@ export function QuestionsPage() {
|
|||||||
const from = (page - 1) * pageSize;
|
const from = (page - 1) * pageSize;
|
||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
const packPromise = supabase
|
const packPromise = adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('id, name, description, icon')
|
.select('id, name, description, icon')
|
||||||
.eq('id', packId)
|
.eq('id', packId)
|
||||||
.single();
|
.single();
|
||||||
let questionsQuery = supabase
|
let questionsQuery = adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -167,7 +171,7 @@ export function QuestionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const questionsPromise = questionsQuery;
|
const questionsPromise = questionsQuery;
|
||||||
const allPackPromise = supabase
|
const allPackPromise = adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, text, inverse_of')
|
.select('id, text, inverse_of')
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -250,7 +254,7 @@ export function QuestionsPage() {
|
|||||||
// Fetch all questions in pack for inverse_of dropdown
|
// Fetch all questions in pack for inverse_of dropdown
|
||||||
const fetchAllPackQuestions = useCallback(async () => {
|
const fetchAllPackQuestions = useCallback(async () => {
|
||||||
if (!packId) return;
|
if (!packId) return;
|
||||||
const { data } = await supabase
|
const { data } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, text, inverse_of')
|
.select('id, text, inverse_of')
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -317,7 +321,7 @@ export function QuestionsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editingQuestion) {
|
if (editingQuestion) {
|
||||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
const { error } = await auditedAdminData.update('questions', editingQuestion.id, {
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
partner_text: formData.partner_text || null,
|
partner_text: formData.partner_text || null,
|
||||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||||
@@ -331,7 +335,7 @@ export function QuestionsPage() {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Question updated');
|
toast.success('Question updated');
|
||||||
} else {
|
} else {
|
||||||
const { error } = await auditedSupabase.insert('questions', {
|
const { error } = await auditedAdminData.insert('questions', {
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: formData.text,
|
text: formData.text,
|
||||||
partner_text: formData.partner_text || null,
|
partner_text: formData.partner_text || null,
|
||||||
@@ -363,7 +367,7 @@ export function QuestionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await auditedSupabase.delete('questions', question.id);
|
const { error } = await auditedAdminData.delete('questions', question.id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
toast.success('Question deleted');
|
toast.success('Question deleted');
|
||||||
@@ -396,7 +400,7 @@ export function QuestionsPage() {
|
|||||||
|
|
||||||
// Insert standalone questions first (no inverse_of)
|
// Insert standalone questions first (no inverse_of)
|
||||||
if (standaloneQuestions.length > 0) {
|
if (standaloneQuestions.length > 0) {
|
||||||
const { error: standaloneError } = await auditedSupabase.insert(
|
const { error: standaloneError } = await auditedAdminData.insert(
|
||||||
'questions',
|
'questions',
|
||||||
standaloneQuestions.map(q => ({
|
standaloneQuestions.map(q => ({
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
@@ -415,7 +419,7 @@ export function QuestionsPage() {
|
|||||||
if (pair.length >= 2) {
|
if (pair.length >= 2) {
|
||||||
// First question is primary (inverse_of = NULL)
|
// First question is primary (inverse_of = NULL)
|
||||||
const primary = pair[0];
|
const primary = pair[0];
|
||||||
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
|
const { data: primaryData, error: primaryError } = await auditedAdminData.insert(
|
||||||
'questions',
|
'questions',
|
||||||
[{
|
[{
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
@@ -432,7 +436,7 @@ export function QuestionsPage() {
|
|||||||
const primaryId = primaryData?.[0]?.id;
|
const primaryId = primaryData?.[0]?.id;
|
||||||
if (primaryId) {
|
if (primaryId) {
|
||||||
const inverse = pair[1];
|
const inverse = pair[1];
|
||||||
const { error: inverseError } = await auditedSupabase.insert(
|
const { error: inverseError } = await auditedAdminData.insert(
|
||||||
'questions',
|
'questions',
|
||||||
[{
|
[{
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
@@ -449,7 +453,7 @@ export function QuestionsPage() {
|
|||||||
// Handle any additional questions in the pair (shouldn't happen, but just in case)
|
// Handle any additional questions in the pair (shouldn't happen, but just in case)
|
||||||
for (let i = 2; i < pair.length; i++) {
|
for (let i = 2; i < pair.length; i++) {
|
||||||
const extra = pair[i];
|
const extra = pair[i];
|
||||||
const { error: extraError } = await auditedSupabase.insert(
|
const { error: extraError } = await auditedAdminData.insert(
|
||||||
'questions',
|
'questions',
|
||||||
[{
|
[{
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
@@ -464,7 +468,7 @@ export function QuestionsPage() {
|
|||||||
}
|
}
|
||||||
} else if (pair.length === 1) {
|
} else if (pair.length === 1) {
|
||||||
// Single question with pair_id but no pair - treat as standalone
|
// Single question with pair_id but no pair - treat as standalone
|
||||||
const { error } = await auditedSupabase.insert(
|
const { error } = await auditedAdminData.insert(
|
||||||
'questions',
|
'questions',
|
||||||
[{
|
[{
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
@@ -497,7 +501,7 @@ export function QuestionsPage() {
|
|||||||
|
|
||||||
setReviewLoading(true);
|
setReviewLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
|
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -544,7 +548,7 @@ export function QuestionsPage() {
|
|||||||
setBulkDeleting(true);
|
setBulkDeleting(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(selectedIds).map(id => auditedSupabase.delete('questions', id))
|
Array.from(selectedIds).map(id => auditedAdminData.delete('questions', id))
|
||||||
);
|
);
|
||||||
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
|
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
@@ -982,6 +986,7 @@ export function QuestionsPage() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-12">#</TableHead>
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Question</TableHead>
|
<TableHead>Question</TableHead>
|
||||||
|
<TableHead className="w-40">Catalogue</TableHead>
|
||||||
<TableHead className="w-28">Type</TableHead>
|
<TableHead className="w-28">Type</TableHead>
|
||||||
<TableHead className="w-20">Inverse</TableHead>
|
<TableHead className="w-20">Inverse</TableHead>
|
||||||
<TableHead className="w-32">Partner Text</TableHead>
|
<TableHead className="w-32">Partner Text</TableHead>
|
||||||
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="line-clamp-2">{question.text}</span>
|
<span className="line-clamp-2">{question.text}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ContentReviewControl
|
||||||
|
table="questions"
|
||||||
|
entityId={question.id}
|
||||||
|
entityLabel={`question ${((page - 1) * pageSize) + index + 1}`}
|
||||||
|
status={question.content_status}
|
||||||
|
reason={question.content_review_reason}
|
||||||
|
onChanged={fetchData}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -64,9 +64,9 @@ export function TagsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
|
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
|
||||||
supabase.from('topics').select('id, name').order('name'),
|
adminData.from('topics').select('id, name').order('name'),
|
||||||
supabase.from('pack_topics').select('pack_id, topic_id'),
|
adminData.from('pack_topics').select('pack_id, topic_id'),
|
||||||
supabase
|
adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('id, name, description, is_public, category:categories(name)')
|
.select('id, name, description, is_public, category:categories(name)')
|
||||||
.order('name'),
|
.order('name'),
|
||||||
@@ -106,7 +106,7 @@ export function TagsPage() {
|
|||||||
const questionCounts: Record<string, number> = {};
|
const questionCounts: Record<string, number> = {};
|
||||||
|
|
||||||
if (untaggedPackIds.length > 0) {
|
if (untaggedPackIds.length > 0) {
|
||||||
const { data: questions, error: questionsError } = await supabase
|
const { data: questions, error: questionsError } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('pack_id')
|
.select('pack_id')
|
||||||
.in('pack_id', untaggedPackIds);
|
.in('pack_id', untaggedPackIds);
|
||||||
@@ -167,7 +167,7 @@ export function TagsPage() {
|
|||||||
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
|
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: topicsData, error: topicsError } = await supabase
|
const { data: topicsData, error: topicsError } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
.order('name');
|
.order('name');
|
||||||
@@ -188,7 +188,7 @@ export function TagsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: questions, error: questionsError } = await supabase
|
const { data: questions, error: questionsError } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('text')
|
.select('text')
|
||||||
.eq('pack_id', pack.id);
|
.eq('pack_id', pack.id);
|
||||||
@@ -237,7 +237,7 @@ export function TagsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (newTopics.length > 0) {
|
if (newTopics.length > 0) {
|
||||||
const { data: createdTopics, error: createError } = await supabase
|
const { data: createdTopics, error: createError } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.insert(newTopics)
|
.insert(newTopics)
|
||||||
.select('id, name');
|
.select('id, name');
|
||||||
@@ -250,14 +250,14 @@ export function TagsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
|
await adminData.from('pack_topics').delete().eq('pack_id', pack.id);
|
||||||
|
|
||||||
if (topicIds.length === 0) {
|
if (topicIds.length === 0) {
|
||||||
skippedCount += 1;
|
skippedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: linkError } = await supabase
|
const { error: linkError } = await adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.insert(topicIds.map((topicId) => ({
|
.insert(topicIds.map((topicId) => ({
|
||||||
pack_id: pack.id,
|
pack_id: pack.id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { supabase, supabaseConfig } from '@/config';
|
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -89,29 +89,9 @@ function ChatImage({ messageId }: { messageId: string }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setUrl(null);
|
setUrl(null);
|
||||||
|
|
||||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||||
method: 'POST',
|
method: 'POST', body: { messageId },
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
apikey: supabaseConfig.anonKey,
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ messageId }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const raw = await res.text().catch(() => '');
|
|
||||||
let message = raw;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
|
||||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
throw new Error(message || `Failed to load media (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -180,29 +160,9 @@ function ChatVideo({ messageId }: { messageId: string }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setUrl(null);
|
setUrl(null);
|
||||||
|
|
||||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||||
method: 'POST',
|
method: 'POST', body: { messageId },
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
apikey: supabaseConfig.anonKey,
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ messageId }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const raw = await res.text().catch(() => '');
|
|
||||||
let message = raw;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
|
||||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
throw new Error(message || `Failed to load media (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -289,18 +249,13 @@ function DecryptedMessageText({ message }: { message: Message }) {
|
|||||||
const decrypt = async () => {
|
const decrypt = async () => {
|
||||||
setIsDecrypting(true);
|
setIsDecrypting(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
|
const data = await adminRequest<{ content?: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||||
body: { messageId: message.id },
|
method: 'POST', body: { messageId: message.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (error) {
|
setText(data.content ?? null);
|
||||||
setText('[Unable to decrypt]');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setText((data as { content?: string | null } | null)?.content ?? null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setText('[Unable to decrypt]');
|
setText('[Unable to decrypt]');
|
||||||
@@ -344,7 +299,7 @@ export function MatchChatPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch user profile
|
// Fetch user profile
|
||||||
const { data: userData } = await supabase
|
const { data: userData } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, avatar_url, couple_id')
|
.select('id, name, avatar_url, couple_id')
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
@@ -354,7 +309,7 @@ export function MatchChatPage() {
|
|||||||
|
|
||||||
// Fetch partner
|
// Fetch partner
|
||||||
if (userData?.couple_id) {
|
if (userData?.couple_id) {
|
||||||
const { data: partnerData } = await supabase
|
const { data: partnerData } = await adminData
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, name, avatar_url')
|
.select('id, name, avatar_url')
|
||||||
.eq('couple_id', userData.couple_id)
|
.eq('couple_id', userData.couple_id)
|
||||||
@@ -367,7 +322,7 @@ export function MatchChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch match with question
|
// Fetch match with question
|
||||||
const { data: matchData } = await supabase
|
const { data: matchData } = await adminData
|
||||||
.from('matches')
|
.from('matches')
|
||||||
.select(`
|
.select(`
|
||||||
id, match_type,
|
id, match_type,
|
||||||
@@ -382,7 +337,7 @@ export function MatchChatPage() {
|
|||||||
const to = from + pageSize - 1;
|
const to = from + pageSize - 1;
|
||||||
|
|
||||||
// Fetch messages
|
// Fetch messages
|
||||||
const { data: messageData, error: messagesError, count } = await supabase
|
const { data: messageData, error: messagesError, count } = await adminData
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('match_id', matchId)
|
.eq('match_id', matchId)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { supabase, supabaseConfig } from '@/config';
|
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||||
@@ -193,16 +193,9 @@ function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: strin
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||||
method: 'POST',
|
method: 'POST', body: { messageId },
|
||||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
|
||||||
body: JSON.stringify({ messageId }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
const blob = await res.blob();
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
if (!cancelled) setUrl(objectUrl);
|
if (!cancelled) setUrl(objectUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -249,16 +242,9 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||||
method: 'POST',
|
method: 'POST', body: { messageId },
|
||||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
|
||||||
body: JSON.stringify({ messageId }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
const blob = await res.blob();
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
if (!cancelled) setUrl(objectUrl);
|
if (!cancelled) setUrl(objectUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -311,7 +297,7 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
|||||||
const fetchStrokes = async () => {
|
const fetchStrokes = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const { data, error: err } = await supabase
|
const { data, error: err } = await adminData
|
||||||
.from('live_draw_sessions')
|
.from('live_draw_sessions')
|
||||||
.select('strokes')
|
.select('strokes')
|
||||||
.eq('couple_id', coupleId)
|
.eq('couple_id', coupleId)
|
||||||
@@ -325,26 +311,14 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
|||||||
setStrokes((data?.strokes as StrokeSegment[]) || []);
|
setStrokes((data?.strokes as StrokeSegment[]) || []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
fetchStrokes();
|
void fetchStrokes();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
// Subscribe to real-time updates
|
if (document.visibilityState === 'visible') void fetchStrokes();
|
||||||
const channel = supabase
|
}, 5_000);
|
||||||
.channel(`admin-livedraw-${coupleId}`)
|
|
||||||
.on('postgres_changes', {
|
|
||||||
event: '*',
|
|
||||||
schema: 'public',
|
|
||||||
table: 'live_draw_sessions',
|
|
||||||
filter: `couple_id=eq.${coupleId}`,
|
|
||||||
}, (payload: any) => {
|
|
||||||
if (payload.new?.strokes) {
|
|
||||||
setStrokes(payload.new.strokes as StrokeSegment[]);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
supabase.removeChannel(channel);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [coupleId]);
|
}, [coupleId]);
|
||||||
|
|
||||||
@@ -485,13 +459,12 @@ export function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
expiresAt = date.toISOString();
|
expiresAt = date.toISOString();
|
||||||
}
|
}
|
||||||
// Use RPC function which handles permissions internally
|
const days = expiresAt === null ? null : Math.max(1, Math.ceil((new Date(expiresAt).getTime() - now.getTime()) / 86_400_000));
|
||||||
const { error } = await supabase.rpc('admin_gift_premium', {
|
await adminRequest(`/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, {
|
||||||
target_user_id: userId,
|
method: 'POST', body: { days, reason: 'Granted from admin user detail' },
|
||||||
expires_at_param: expiresAt,
|
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
|
||||||
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
const updatedProfile = users[0] ?? null;
|
||||||
if (updatedProfile) setProfile(updatedProfile);
|
if (updatedProfile) setProfile(updatedProfile);
|
||||||
setUpgradeOpen(false);
|
setUpgradeOpen(false);
|
||||||
alert('User upgraded to premium successfully!');
|
alert('User upgraded to premium successfully!');
|
||||||
@@ -507,11 +480,12 @@ export function UserDetailPage() {
|
|||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
|
||||||
|
const profileData = users[0] ?? null;
|
||||||
setProfile(profileData);
|
setProfile(profileData);
|
||||||
|
|
||||||
// Fetch feature interests for the user
|
// Fetch feature interests for the user
|
||||||
const { data: featureInterestsData } = await supabase
|
const { data: featureInterestsData } = await adminData
|
||||||
.from('feature_interests')
|
.from('feature_interests')
|
||||||
.select('id, feature_name, created_at')
|
.select('id, feature_name, created_at')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
@@ -519,11 +493,11 @@ export function UserDetailPage() {
|
|||||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
|
|
||||||
// Fetch streak data
|
// Fetch streak data
|
||||||
const { data: streakData } = await supabase
|
const { data: streakData } = await adminData
|
||||||
.from('couple_streaks')
|
.from('couple_streaks')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('couple_id', profileData.couple_id)
|
.eq('couple_id', profileData.couple_id)
|
||||||
@@ -537,7 +511,7 @@ export function UserDetailPage() {
|
|||||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
const responsesTo = responsesFrom + responsesPageSize - 1;
|
const responsesTo = responsesFrom + responsesPageSize - 1;
|
||||||
|
|
||||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
const { data: responseData, count: responsesCount, error: responsesError } = await adminData
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' })
|
.select('id, answer, response_data, created_at, question:questions(id, text, question_type, pack:question_packs(id, name))', { count: 'exact' })
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
@@ -553,7 +527,7 @@ export function UserDetailPage() {
|
|||||||
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||||
const matchesTo = matchesFrom + matchesPageSize - 1;
|
const matchesTo = matchesFrom + matchesPageSize - 1;
|
||||||
|
|
||||||
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
|
const { data: matchData, count: matchesCount, error: matchesError } = await adminData
|
||||||
.from('matches')
|
.from('matches')
|
||||||
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||||
.eq('couple_id', profileData.couple_id)
|
.eq('couple_id', profileData.couple_id)
|
||||||
@@ -566,7 +540,7 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
const matchIds = (matchData || []).map(m => m.id);
|
const matchIds = (matchData || []).map(m => m.id);
|
||||||
if (matchIds.length > 0) {
|
if (matchIds.length > 0) {
|
||||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
const { data: messages } = await adminData.from('messages').select('match_id').in('match_id', matchIds);
|
||||||
const messageCounts: Record<string, number> = {};
|
const messageCounts: Record<string, number> = {};
|
||||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||||
@@ -574,7 +548,7 @@ export function UserDetailPage() {
|
|||||||
setMatches([]);
|
setMatches([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: allMatchIdsData } = await supabase
|
const { data: allMatchIdsData } = await adminData
|
||||||
.from('matches')
|
.from('matches')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('couple_id', profileData.couple_id);
|
.eq('couple_id', profileData.couple_id);
|
||||||
@@ -584,7 +558,7 @@ export function UserDetailPage() {
|
|||||||
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||||
const mediaTo = mediaFrom + mediaPageSize - 1;
|
const mediaTo = mediaFrom + mediaPageSize - 1;
|
||||||
|
|
||||||
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
|
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await adminData
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||||
.in('match_id', allMatchIds)
|
.in('match_id', allMatchIds)
|
||||||
@@ -605,7 +579,7 @@ export function UserDetailPage() {
|
|||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
// Fetch enabled packs for the couple
|
// Fetch enabled packs for the couple
|
||||||
const { data: enabledPacksData, error: packsError } = await supabase
|
const { data: enabledPacksData, error: packsError } = await adminData
|
||||||
.from('couple_packs')
|
.from('couple_packs')
|
||||||
.select(`
|
.select(`
|
||||||
pack_id,
|
pack_id,
|
||||||
@@ -634,17 +608,17 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
// Fetch outstanding questions (partner answered, user hasn't)
|
// Fetch outstanding questions (partner answered, user hasn't)
|
||||||
// Use partner from state (set earlier in the first couple_id block)
|
// Use partner from state (set earlier in the first couple_id block)
|
||||||
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
const partnerProfile = partner || (await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
||||||
if (partnerProfile) {
|
if (partnerProfile) {
|
||||||
// Get question IDs user has already answered
|
// Get question IDs user has already answered
|
||||||
const { data: userResponseIds } = await supabase
|
const { data: userResponseIds } = await adminData
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select('question_id')
|
.select('question_id')
|
||||||
.eq('user_id', userId);
|
.eq('user_id', userId);
|
||||||
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
|
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
|
||||||
|
|
||||||
// Get all partner responses in this couple
|
// Get all partner responses in this couple
|
||||||
const { data: partnerResponses } = await supabase
|
const { data: partnerResponses } = await adminData
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
@@ -663,7 +637,7 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
if (unansweredQuestionIds.length > 0) {
|
if (unansweredQuestionIds.length > 0) {
|
||||||
// Fetch question details (no pack filter — admin sees all)
|
// Fetch question details (no pack filter — admin sees all)
|
||||||
const { data: questionDetails } = await supabase
|
const { data: questionDetails } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, text, intensity, question_type, pack:question_packs(id, name)')
|
.select('id, text, intensity, question_type, pack:question_packs(id, name)')
|
||||||
.in('id', unansweredQuestionIds);
|
.in('id', unansweredQuestionIds);
|
||||||
@@ -701,7 +675,7 @@ export function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate gap status
|
// Calculate gap status
|
||||||
const { data: appConfig } = await supabase
|
const { data: appConfig } = await adminData
|
||||||
.from('app_config')
|
.from('app_config')
|
||||||
.select('answer_gap_threshold')
|
.select('answer_gap_threshold')
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -713,7 +687,7 @@ export function UserDetailPage() {
|
|||||||
if (threshold > 0) {
|
if (threshold > 0) {
|
||||||
// Count questions user answered that partner hasn't
|
// Count questions user answered that partner hasn't
|
||||||
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
|
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
|
||||||
const { data: userResponses } = await supabase
|
const { data: userResponses } = await adminData
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select('question_id')
|
.select('question_id')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { supabase } from '@/config';
|
import { adminRequest } from '@/lib/adminApi';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -55,24 +55,7 @@ export function UsersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||||
const { data: profilesDataRaw, error } = await supabase
|
const { users: profilesData } = await adminRequest<{ users: Profile[] }>('/v1/admin/users');
|
||||||
.rpc('get_profiles_with_auth_info');
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
const profilesData = (profilesDataRaw || []) as Profile[];
|
|
||||||
|
|
||||||
// Fetch storage usage per user from storage.objects
|
|
||||||
const { data: storageData } = await supabase
|
|
||||||
.rpc('get_user_storage_usage');
|
|
||||||
|
|
||||||
// Create a map of user_id -> storage_bytes
|
|
||||||
const storageMap: Record<string, number> = {};
|
|
||||||
if (storageData) {
|
|
||||||
storageData.forEach((row: { owner: string; total_bytes: number }) => {
|
|
||||||
storageMap[row.owner] = row.total_bytes;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of couple_id -> users with premium in that couple
|
// Build a map of couple_id -> users with premium in that couple
|
||||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||||
@@ -100,7 +83,7 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
storage_bytes: storageMap[profile.id] || 0,
|
storage_bytes: Number(profile.storage_bytes || 0),
|
||||||
partner_is_premium: partnerIsPremium,
|
partner_is_premium: partnerIsPremium,
|
||||||
partner_count: partnerCount,
|
partner_count: partnerCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -32,7 +32,7 @@ export interface CategoryFormData {
|
|||||||
*/
|
*/
|
||||||
export async function fetchCategories(): Promise<Category[]> {
|
export async function fetchCategories(): Promise<Category[]> {
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categories, error: catError } = await supabase
|
const { data: categories, error: catError } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('sort_order', { ascending: true });
|
.order('sort_order', { ascending: true });
|
||||||
@@ -40,7 +40,7 @@ export async function fetchCategories(): Promise<Category[]> {
|
|||||||
if (catError) throw catError;
|
if (catError) throw catError;
|
||||||
|
|
||||||
// Fetch pack counts per category
|
// Fetch pack counts per category
|
||||||
const { data: packs } = await supabase
|
const { data: packs } = await adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('category_id');
|
.select('category_id');
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export async function fetchCategories(): Promise<Category[]> {
|
|||||||
* Fetch a single category by ID
|
* Fetch a single category by ID
|
||||||
*/
|
*/
|
||||||
export async function fetchCategoryById(id: string): Promise<Category | null> {
|
export async function fetchCategoryById(id: string): Promise<Category | null> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('categories')
|
.from('categories')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -82,7 +82,7 @@ export async function createCategory(
|
|||||||
data: CategoryFormData,
|
data: CategoryFormData,
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
): Promise<Category> {
|
): Promise<Category> {
|
||||||
const { data: created, error } = await auditedSupabase.insert('categories', {
|
const { data: created, error } = await auditedAdminData.insert('categories', {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
icon: data.icon || null,
|
icon: data.icon || null,
|
||||||
@@ -102,7 +102,7 @@ export async function updateCategory(
|
|||||||
id: string,
|
id: string,
|
||||||
data: Partial<CategoryFormData>
|
data: Partial<CategoryFormData>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await auditedSupabase.update('categories', id, {
|
const { error } = await auditedAdminData.update('categories', id, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
icon: data.icon || null,
|
icon: data.icon || null,
|
||||||
@@ -116,7 +116,7 @@ export async function updateCategory(
|
|||||||
* Delete a category
|
* Delete a category
|
||||||
*/
|
*/
|
||||||
export async function deleteCategory(id: string): Promise<void> {
|
export async function deleteCategory(id: string): Promise<void> {
|
||||||
const { error } = await auditedSupabase.delete('categories', id);
|
const { error } = await auditedAdminData.delete('categories', id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ export async function swapCategorySortOrder(
|
|||||||
category2Order: number
|
category2Order: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
auditedSupabase.update('categories', category1Id, { sort_order: category2Order }),
|
auditedAdminData.update('categories', category1Id, { sort_order: category2Order }),
|
||||||
auditedSupabase.update('categories', category2Id, { sort_order: category1Order }),
|
auditedAdminData.update('categories', category2Id, { sort_order: category1Order }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
||||||
|
|
||||||
|
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAuditedAdminData', () => ({
|
||||||
|
auditedAdminData: { update },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { updateContentReviewStatus } from './contentReviews';
|
||||||
|
|
||||||
|
describe('catalogue review labels', () => {
|
||||||
|
it('uses archive language for reversible removals', () => {
|
||||||
|
expect(CONTENT_STATUS_LABELS).toEqual({
|
||||||
|
unreviewed: 'Needs review',
|
||||||
|
allowed: 'Allowed',
|
||||||
|
archived: 'Archived',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
update.mockReset();
|
||||||
|
update.mockResolvedValue({ data: null, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a trimmed, audited review decision', async () => {
|
||||||
|
await updateContentReviewStatus(
|
||||||
|
'question_packs',
|
||||||
|
'pack-1',
|
||||||
|
'archived',
|
||||||
|
' Store compliance review ',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith('question_packs', 'pack-1', {
|
||||||
|
content_status: 'archived',
|
||||||
|
content_review_reason: 'Store compliance review',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty reason before writing', async () => {
|
||||||
|
await expect(
|
||||||
|
updateContentReviewStatus('questions', 'question-1', 'allowed', ' '),
|
||||||
|
).rejects.toThrow('A review reason is required');
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ContentReviewStatus } from '@sauci/shared';
|
||||||
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
|
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
|
||||||
|
|
||||||
|
export type ReviewableContentTable =
|
||||||
|
| 'categories'
|
||||||
|
| 'question_packs'
|
||||||
|
| 'questions'
|
||||||
|
| 'dare_packs'
|
||||||
|
| 'dares';
|
||||||
|
|
||||||
|
export async function updateContentReviewStatus(
|
||||||
|
table: ReviewableContentTable,
|
||||||
|
id: string,
|
||||||
|
status: ContentReviewStatus,
|
||||||
|
reason: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedReason = reason.trim();
|
||||||
|
if (!normalizedReason) {
|
||||||
|
throw new Error('A review reason is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await auditedAdminData.update(table, id, {
|
||||||
|
content_status: status,
|
||||||
|
content_review_reason: normalizedReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -42,7 +42,7 @@ export interface Topic {
|
|||||||
* Fetch all packs for a category with question counts
|
* Fetch all packs for a category with question counts
|
||||||
*/
|
*/
|
||||||
export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> {
|
export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> {
|
||||||
const { data: packs, error: packError } = await supabase
|
const { data: packs, error: packError } = await adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('*, category:categories(name)')
|
.select('*, category:categories(name)')
|
||||||
.eq('category_id', categoryId)
|
.eq('category_id', categoryId)
|
||||||
@@ -51,7 +51,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
|
|||||||
if (packError) throw packError;
|
if (packError) throw packError;
|
||||||
|
|
||||||
// Get question counts per pack
|
// Get question counts per pack
|
||||||
const { data: questions } = await supabase
|
const { data: questions } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('pack_id');
|
.select('pack_id');
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
|
|||||||
* Fetch a single pack by ID
|
* Fetch a single pack by ID
|
||||||
*/
|
*/
|
||||||
export async function fetchPackById(id: string): Promise<Pack | null> {
|
export async function fetchPackById(id: string): Promise<Pack | null> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('question_packs')
|
.from('question_packs')
|
||||||
.select('*, category:categories(name)')
|
.select('*, category:categories(name)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -86,7 +86,7 @@ export async function fetchPackById(id: string): Promise<Pack | null> {
|
|||||||
* Fetch all topics
|
* Fetch all topics
|
||||||
*/
|
*/
|
||||||
export async function fetchAllTopics(): Promise<Topic[]> {
|
export async function fetchAllTopics(): Promise<Topic[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('name');
|
.order('name');
|
||||||
@@ -99,7 +99,7 @@ export async function fetchAllTopics(): Promise<Topic[]> {
|
|||||||
* Fetch topic IDs for a specific pack
|
* Fetch topic IDs for a specific pack
|
||||||
*/
|
*/
|
||||||
export async function fetchPackTopicIds(packId: string): Promise<Set<string>> {
|
export async function fetchPackTopicIds(packId: string): Promise<Set<string>> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('pack_topics')
|
.from('pack_topics')
|
||||||
.select('topic_id')
|
.select('topic_id')
|
||||||
.eq('pack_id', packId);
|
.eq('pack_id', packId);
|
||||||
@@ -120,7 +120,7 @@ export async function createPack(
|
|||||||
data: PackFormData,
|
data: PackFormData,
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
): Promise<Pack> {
|
): Promise<Pack> {
|
||||||
const { data: created, error } = await auditedSupabase.insert('question_packs', {
|
const { data: created, error } = await auditedAdminData.insert('question_packs', {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
icon: data.icon || null,
|
icon: data.icon || null,
|
||||||
@@ -143,7 +143,7 @@ export async function updatePack(
|
|||||||
id: string,
|
id: string,
|
||||||
data: Partial<PackFormData>
|
data: Partial<PackFormData>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await auditedSupabase.update('question_packs', id, {
|
const { error } = await auditedAdminData.update('question_packs', id, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
icon: data.icon || null,
|
icon: data.icon || null,
|
||||||
@@ -159,7 +159,7 @@ export async function updatePack(
|
|||||||
* Delete a pack
|
* Delete a pack
|
||||||
*/
|
*/
|
||||||
export async function deletePack(id: string): Promise<void> {
|
export async function deletePack(id: string): Promise<void> {
|
||||||
const { error } = await auditedSupabase.delete('question_packs', id);
|
const { error } = await auditedAdminData.delete('question_packs', id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +173,8 @@ export async function swapPackSortOrder(
|
|||||||
pack2Order: number
|
pack2Order: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
auditedSupabase.update('question_packs', pack1Id, { sort_order: pack2Order }),
|
auditedAdminData.update('question_packs', pack1Id, { sort_order: pack2Order }),
|
||||||
auditedSupabase.update('question_packs', pack2Id, { sort_order: pack1Order }),
|
auditedAdminData.update('question_packs', pack2Id, { sort_order: pack1Order }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,11 +190,11 @@ export async function updatePackTopics(
|
|||||||
topicIds: string[]
|
topicIds: string[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Delete existing topics
|
// Delete existing topics
|
||||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
await adminData.from('pack_topics').delete().eq('pack_id', packId);
|
||||||
|
|
||||||
// Insert new topics
|
// Insert new topics
|
||||||
if (topicIds.length > 0) {
|
if (topicIds.length > 0) {
|
||||||
const { error } = await supabase.from('pack_topics').insert(
|
const { error } = await adminData.from('pack_topics').insert(
|
||||||
topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId }))
|
topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId }))
|
||||||
);
|
);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -205,7 +205,7 @@ export async function updatePackTopics(
|
|||||||
* Create a new topic
|
* Create a new topic
|
||||||
*/
|
*/
|
||||||
export async function createTopic(name: string): Promise<Topic> {
|
export async function createTopic(name: string): Promise<Topic> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('topics')
|
.from('topics')
|
||||||
.insert({ name })
|
.insert({ name })
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -29,7 +29,7 @@ export interface QuestionFormData {
|
|||||||
* Fetch all questions for a pack (excludes soft-deleted by default)
|
* Fetch all questions for a pack (excludes soft-deleted by default)
|
||||||
*/
|
*/
|
||||||
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
|
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
|
||||||
let query = supabase
|
let query = adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -49,7 +49,7 @@ export async function fetchQuestionsForPack(packId: string, includeDeleted = fal
|
|||||||
* Fetch a single question by ID
|
* Fetch a single question by ID
|
||||||
*/
|
*/
|
||||||
export async function fetchQuestionById(id: string): Promise<Question | null> {
|
export async function fetchQuestionById(id: string): Promise<Question | null> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -63,7 +63,7 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
|
|||||||
* Fetch question count for a pack (excludes soft-deleted)
|
* Fetch question count for a pack (excludes soft-deleted)
|
||||||
*/
|
*/
|
||||||
export async function fetchQuestionCount(packId: string): Promise<number> {
|
export async function fetchQuestionCount(packId: string): Promise<number> {
|
||||||
const { count, error } = await supabase
|
const { count, error } = await adminData
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.eq('pack_id', packId)
|
.eq('pack_id', packId)
|
||||||
@@ -84,7 +84,7 @@ export async function createQuestion(
|
|||||||
packId: string,
|
packId: string,
|
||||||
data: QuestionFormData
|
data: QuestionFormData
|
||||||
): Promise<Question> {
|
): Promise<Question> {
|
||||||
const { data: created, error } = await auditedSupabase.insert('questions', {
|
const { data: created, error } = await auditedAdminData.insert('questions', {
|
||||||
text: data.text,
|
text: data.text,
|
||||||
partner_text: data.partner_text || null,
|
partner_text: data.partner_text || null,
|
||||||
intensity: data.intensity,
|
intensity: data.intensity,
|
||||||
@@ -103,7 +103,7 @@ export async function updateQuestion(
|
|||||||
id: string,
|
id: string,
|
||||||
data: Partial<QuestionFormData>
|
data: Partial<QuestionFormData>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { error } = await auditedSupabase.update('questions', id, {
|
const { error } = await auditedAdminData.update('questions', id, {
|
||||||
text: data.text,
|
text: data.text,
|
||||||
partner_text: data.partner_text || null,
|
partner_text: data.partner_text || null,
|
||||||
intensity: data.intensity,
|
intensity: data.intensity,
|
||||||
@@ -116,7 +116,7 @@ export async function updateQuestion(
|
|||||||
* Soft delete a question (sets deleted_at timestamp)
|
* Soft delete a question (sets deleted_at timestamp)
|
||||||
*/
|
*/
|
||||||
export async function deleteQuestion(id: string): Promise<void> {
|
export async function deleteQuestion(id: string): Promise<void> {
|
||||||
const { error } = await auditedSupabase.update('questions', id, {
|
const { error } = await auditedAdminData.update('questions', id, {
|
||||||
deleted_at: new Date().toISOString(),
|
deleted_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -127,7 +127,7 @@ export async function deleteQuestion(id: string): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function deleteQuestions(ids: string[]): Promise<void> {
|
export async function deleteQuestions(ids: string[]): Promise<void> {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map(id => auditedSupabase.update('questions', id, {
|
ids.map(id => auditedAdminData.update('questions', id, {
|
||||||
deleted_at: new Date().toISOString(),
|
deleted_at: new Date().toISOString(),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -137,7 +137,7 @@ export async function deleteQuestions(ids: string[]): Promise<void> {
|
|||||||
* Restore a soft-deleted question
|
* Restore a soft-deleted question
|
||||||
*/
|
*/
|
||||||
export async function restoreQuestion(id: string): Promise<void> {
|
export async function restoreQuestion(id: string): Promise<void> {
|
||||||
const { error } = await auditedSupabase.update('questions', id, {
|
const { error } = await auditedAdminData.update('questions', id, {
|
||||||
deleted_at: null,
|
deleted_at: null,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@@ -169,7 +169,7 @@ export async function bulkCreateQuestions(
|
|||||||
|
|
||||||
// Insert standalone questions first
|
// Insert standalone questions first
|
||||||
for (const q of standaloneQuestions) {
|
for (const q of standaloneQuestions) {
|
||||||
const { data, error } = await auditedSupabase.insert('questions', {
|
const { data, error } = await auditedAdminData.insert('questions', {
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: q.text,
|
text: q.text,
|
||||||
partner_text: q.partner_text || null,
|
partner_text: q.partner_text || null,
|
||||||
@@ -186,7 +186,7 @@ export async function bulkCreateQuestions(
|
|||||||
if (pair.length >= 2) {
|
if (pair.length >= 2) {
|
||||||
// First question is primary
|
// First question is primary
|
||||||
const primary = pair[0];
|
const primary = pair[0];
|
||||||
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', {
|
const { data: primaryData, error: primaryError } = await auditedAdminData.insert('questions', {
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: primary.text,
|
text: primary.text,
|
||||||
partner_text: primary.partner_text || null,
|
partner_text: primary.partner_text || null,
|
||||||
@@ -201,7 +201,7 @@ export async function bulkCreateQuestions(
|
|||||||
|
|
||||||
// Second question is inverse
|
// Second question is inverse
|
||||||
const inverse = pair[1];
|
const inverse = pair[1];
|
||||||
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', {
|
const { data: inverseData, error: inverseError } = await auditedAdminData.insert('questions', {
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: inverse.text,
|
text: inverse.text,
|
||||||
partner_text: inverse.partner_text || null,
|
partner_text: inverse.partner_text || null,
|
||||||
@@ -216,7 +216,7 @@ export async function bulkCreateQuestions(
|
|||||||
// Handle any additional questions in the pair
|
// Handle any additional questions in the pair
|
||||||
for (let i = 2; i < pair.length; i++) {
|
for (let i = 2; i < pair.length; i++) {
|
||||||
const extra = pair[i];
|
const extra = pair[i];
|
||||||
const { data, error } = await auditedSupabase.insert('questions', {
|
const { data, error } = await auditedAdminData.insert('questions', {
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: extra.text,
|
text: extra.text,
|
||||||
partner_text: extra.partner_text || null,
|
partner_text: extra.partner_text || null,
|
||||||
@@ -228,7 +228,7 @@ export async function bulkCreateQuestions(
|
|||||||
}
|
}
|
||||||
} else if (pair.length === 1) {
|
} else if (pair.length === 1) {
|
||||||
// Single question with pair_id but no pair
|
// Single question with pair_id but no pair
|
||||||
const { data, error } = await auditedSupabase.insert('questions', {
|
const { data, error } = await auditedAdminData.insert('questions', {
|
||||||
pack_id: packId,
|
pack_id: packId,
|
||||||
text: pair[0].text,
|
text: pair[0].text,
|
||||||
partner_text: pair[0].partner_text || null,
|
partner_text: pair[0].partner_text || null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { supabase } from '@/config';
|
import { adminData } from '@/lib/adminApi';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -25,10 +25,10 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
|
|||||||
{ count: questions },
|
{ count: questions },
|
||||||
{ count: users },
|
{ count: users },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
adminData.from('categories').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
adminData.from('questions').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
adminData.from('profiles').select('*', { count: 'exact', head: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -43,7 +43,7 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
|
|||||||
* Fetch count for a specific table
|
* Fetch count for a specific table
|
||||||
*/
|
*/
|
||||||
export async function fetchTableCount(tableName: string): Promise<number> {
|
export async function fetchTableCount(tableName: string): Promise<number> {
|
||||||
const { count, error } = await supabase
|
const { count, error } = await adminData
|
||||||
.from(tableName)
|
.from(tableName)
|
||||||
.select('*', { count: 'exact', head: true });
|
.select('*', { count: 'exact', head: true });
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -3,6 +3,7 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_SUPABASE_URL: string;
|
readonly VITE_SUPABASE_URL: string;
|
||||||
readonly VITE_SUPABASE_ANON_KEY: string;
|
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
readonly VITE_OPENAI_API_KEY: string;
|
readonly VITE_OPENAI_API_KEY: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
coverage
|
||||||
|
test
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
PORT=3003
|
||||||
|
DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
||||||
|
SUPABASE_AUTH_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_AUTH_AUDIENCE=authenticated
|
||||||
|
|
||||||
|
# Server-only hosted Auth Admin credential. Used for account deletion and for
|
||||||
|
# enriching admin user lists; it is never exposed to the browser or used as a
|
||||||
|
# Supabase database client.
|
||||||
|
# SUPABASE_AUTH_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# Optional overrides. The issuer defaults to <SUPABASE_AUTH_URL>/auth/v1 and
|
||||||
|
# the JWKS endpoint defaults to <SUPABASE_AUTH_URL>/auth/v1/.well-known/jwks.json.
|
||||||
|
# SUPABASE_AUTH_ISSUER=
|
||||||
|
# SUPABASE_AUTH_JWKS_URL=
|
||||||
|
|
||||||
|
# Test-only. Startup fails if this is set outside NODE_ENV=test.
|
||||||
|
# AUTH_TEST_JWKS={"keys":[]}
|
||||||
|
|
||||||
|
# Optional stable credential for non-browser admin clients such as MCP. MCP
|
||||||
|
# receives the token as SAUCI_ADMIN_API_TOKEN; it does not receive the user ID.
|
||||||
|
# Both values are required together. The user ID must belong to an active,
|
||||||
|
# least-privilege admin_users row; generate a random token of at least 32 chars.
|
||||||
|
# ADMIN_API_SERVICE_TOKEN=
|
||||||
|
# ADMIN_API_SERVICE_USER_ID=00000000-0000-4000-8000-000000000002
|
||||||
|
|
||||||
|
# Optional admin review decryption. Keep the private JWK server-side only.
|
||||||
|
# ADMIN_PRIVATE_KEY_JWK=
|
||||||
|
|
||||||
|
# Optional local fixture identity used by `npm run db:seed -w @sauci/api`.
|
||||||
|
# SEED_USER_ID=00000000-0000-4000-8000-000000000001
|
||||||
|
# SEED_USER_EMAIL=local@sauci.test
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:20-bookworm-slim AS build
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/api/package.json ./apps/api/package.json
|
||||||
|
COPY packages/shared/package.json ./packages/shared/package.json
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --workspace @sauci/api --include-workspace-root=false --ignore-scripts
|
||||||
|
COPY apps/api ./apps/api
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
RUN npm run build --workspace @sauci/api
|
||||||
|
RUN npm prune --omit=dev --workspace @sauci/api --include-workspace-root=false --ignore-scripts
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
ENV NODE_ENV=production PORT=3003
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /data/media && chown node:node /data/media
|
||||||
|
COPY --from=build /workspace/node_modules ./node_modules
|
||||||
|
# npm may place 0.x dependency upgrades in the workspace-local tree. Overlay
|
||||||
|
# that pruned tree so runtime resolution matches the workspace lockfile.
|
||||||
|
COPY --from=build /workspace/apps/api/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /workspace/apps/api/package.json ./package.json
|
||||||
|
COPY --from=build --chown=node:node /workspace/apps/api/dist ./dist
|
||||||
|
COPY --from=build --chown=node:node /workspace/apps/api/drizzle ./drizzle
|
||||||
|
USER node
|
||||||
|
EXPOSE 3003
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:3003/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error('DATABASE_URL is required for database migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/db/all-schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL },
|
||||||
|
strict: true,
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE "profiles" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"email" text,
|
||||||
|
"avatar_url" text,
|
||||||
|
"push_token" text,
|
||||||
|
"is_premium" boolean DEFAULT false NOT NULL,
|
||||||
|
"couple_id" uuid,
|
||||||
|
"gender" text,
|
||||||
|
"show_explicit_content" boolean DEFAULT true NOT NULL,
|
||||||
|
"max_intensity" integer DEFAULT 5 NOT NULL,
|
||||||
|
"public_key_jwk" jsonb,
|
||||||
|
"hide_nsfw" boolean DEFAULT false NOT NULL,
|
||||||
|
"onboarding_completed" boolean DEFAULT false NOT NULL,
|
||||||
|
"onboarding_version" integer DEFAULT 0 NOT NULL,
|
||||||
|
"auth_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "profiles_gender_check" CHECK ("gender" IS NULL OR "gender" IN ('male', 'female', 'non-binary', 'prefer-not-to-say')),
|
||||||
|
CONSTRAINT "profiles_max_intensity_check" CHECK ("max_intensity" BETWEEN 1 AND 5)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "profiles_couple_id_idx" ON "profiles" USING btree ("couple_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "feature_interests" (
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"feature" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "feature_interests_user_id_feature_pk" PRIMARY KEY("user_id", "feature")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "feature_interests" ADD CONSTRAINT "feature_interests_user_id_profiles_id_fk"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "profiles"("id") ON DELETE cascade;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "feature_interests_feature_idx" ON "feature_interests" USING btree ("feature");
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "couples" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"invite_code" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "couples_invite_code_unique" UNIQUE("invite_code"),
|
||||||
|
CONSTRAINT "couples_invite_code_format" CHECK ("invite_code" ~ '^[A-Z0-9]{8}$')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_couple_id_couples_id_fk"
|
||||||
|
FOREIGN KEY ("couple_id") REFERENCES "couples"("id") ON DELETE SET NULL;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "categories" (
|
||||||
|
"id" uuid PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"icon" text,
|
||||||
|
"color" text,
|
||||||
|
"sort_order" integer NOT NULL DEFAULT 0,
|
||||||
|
"is_public" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "question_packs" (
|
||||||
|
"id" uuid PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"icon" text,
|
||||||
|
"is_premium" boolean NOT NULL DEFAULT false,
|
||||||
|
"is_public" boolean NOT NULL DEFAULT true,
|
||||||
|
"is_explicit" boolean NOT NULL DEFAULT false,
|
||||||
|
"min_intensity" integer,
|
||||||
|
"max_intensity" integer,
|
||||||
|
"avg_intensity" integer,
|
||||||
|
"sort_order" integer NOT NULL DEFAULT 0,
|
||||||
|
"category_id" uuid REFERENCES "categories"("id") ON DELETE SET NULL,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "question_packs_category_id_idx" ON "question_packs" ("category_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "questions" (
|
||||||
|
"id" uuid PRIMARY KEY,
|
||||||
|
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"partner_text" text,
|
||||||
|
"intensity" integer NOT NULL DEFAULT 1 CHECK ("intensity" BETWEEN 1 AND 5),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "questions_pack_id_idx" ON "questions" ("pack_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "responses" (
|
||||||
|
"id" uuid PRIMARY KEY,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
|
||||||
|
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"answer" text NOT NULL CHECK ("answer" IN ('yes', 'no', 'maybe')),
|
||||||
|
"response_data" jsonb,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE ("user_id", "question_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "responses_user_id_idx" ON "responses" ("user_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "couple_packs" (
|
||||||
|
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
|
||||||
|
"enabled" boolean NOT NULL DEFAULT true,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("couple_id", "pack_id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
ALTER TABLE "questions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "allowed_couple_genders" text[],
|
||||||
|
ADD COLUMN IF NOT EXISTS "target_user_genders" text[],
|
||||||
|
ADD COLUMN IF NOT EXISTS "question_type" text NOT NULL DEFAULT 'swipe',
|
||||||
|
ADD COLUMN IF NOT EXISTS "config" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "questions" ADD CONSTRAINT "questions_type_check"
|
||||||
|
CHECK ("question_type" IN ('swipe', 'text_answer', 'audio', 'photo', 'who_likely'));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "app_config" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"answer_gap_threshold" integer NOT NULL DEFAULT 10 CHECK ("answer_gap_threshold" >= 0),
|
||||||
|
"daily_response_limit" integer NOT NULL DEFAULT 0 CHECK ("daily_response_limit" >= 0),
|
||||||
|
"couple_intensity_gate_enabled" boolean NOT NULL DEFAULT false,
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "app_config_singleton_idx" ON "app_config" ((true));
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "app_config" ("id") VALUES (gen_random_uuid()) ON CONFLICT DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "matches" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
|
||||||
|
"match_type" text NOT NULL CHECK ("match_type" IN ('yes_yes', 'yes_maybe', 'maybe_maybe', 'both_answered')),
|
||||||
|
"is_new" boolean NOT NULL DEFAULT true,
|
||||||
|
"response_summary" jsonb,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE ("couple_id", "question_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "matches_couple_created_idx" ON "matches" ("couple_id", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"content" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"read_at" timestamptz
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "match_archives" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"archived_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE ("match_id", "user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "match_archives_user_idx" ON "match_archives" ("user_id", "match_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "couple_streaks" (
|
||||||
|
"couple_id" uuid PRIMARY KEY REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||||
|
"current_streak" integer NOT NULL DEFAULT 0,
|
||||||
|
"longest_streak" integer NOT NULL DEFAULT 0,
|
||||||
|
"last_active_date" date,
|
||||||
|
"last_completed_date" date,
|
||||||
|
"user1_answered_today" boolean NOT NULL DEFAULT false,
|
||||||
|
"user2_answered_today" boolean NOT NULL DEFAULT false,
|
||||||
|
"streak_celebrated_at" integer NOT NULL DEFAULT 0,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "responses_couple_question_idx" ON "responses" ("couple_id", "question_id");
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"content" text,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"read_at" timestamptz,
|
||||||
|
"delivered_at" timestamptz,
|
||||||
|
"deleted_at" timestamptz,
|
||||||
|
"media_path" text,
|
||||||
|
"media_type" text CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video')),
|
||||||
|
"media_expires_at" timestamptz,
|
||||||
|
"media_expired" boolean NOT NULL DEFAULT false,
|
||||||
|
"media_viewed_at" timestamptz,
|
||||||
|
"version" integer NOT NULL DEFAULT 1,
|
||||||
|
"encrypted_content" text,
|
||||||
|
"encryption_iv" text,
|
||||||
|
"keys_metadata" jsonb,
|
||||||
|
"moderation_status" text,
|
||||||
|
"flag_reason" text,
|
||||||
|
"category" text,
|
||||||
|
CONSTRAINT "messages_content_or_media" CHECK (
|
||||||
|
"content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "messages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "delivered_at" timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS "media_path" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "media_type" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "media_expires_at" timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS "media_expired" boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS "media_viewed_at" timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS "encrypted_content" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "encryption_iv" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "keys_metadata" jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS "moderation_status" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "flag_reason" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "category" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_media_type_check"
|
||||||
|
CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video'));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "messages" ADD CONSTRAINT "messages_content_or_media"
|
||||||
|
CHECK ("content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "messages_match_created_idx" ON "messages" ("match_id", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "messages_unread_idx" ON "messages" ("match_id", "user_id") WHERE "read_at" IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "message_deletions" (
|
||||||
|
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"deleted_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("message_id", "user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "message_reports" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||||
|
"reporter_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"reason" text NOT NULL CHECK ("reason" IN ('harassment', 'spam', 'inappropriate_content', 'other')),
|
||||||
|
"status" text NOT NULL DEFAULT 'pending' CHECK ("status" IN ('pending', 'reviewed', 'dismissed')),
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE ("message_id", "reporter_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "message_reports_status_idx" ON "message_reports" ("status", "created_at" DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "chat_typing_states" (
|
||||||
|
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||||
|
"expires_at" timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY ("match_id", "user_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "chat_typing_expires_idx" ON "chat_typing_states" ("expires_at");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS usage_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_active_at timestamptz;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
matches_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
messages_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
partner_activity_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
nudges_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
pack_changes_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
new_packs_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
streak_milestones_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
weekly_summary_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
unpaired_reminders_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
catchup_reminders_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
type text NOT NULL CHECK (type IN ('bug', 'feature_request', 'general', 'question')),
|
||||||
|
title text NOT NULL,
|
||||||
|
description text NOT NULL,
|
||||||
|
device_info jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status text NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'in_progress', 'resolved', 'closed')),
|
||||||
|
question_id uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS feedback_user_id_idx ON feedback(user_id);
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS media_objects (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
owner_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
couple_id uuid REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('avatar', 'response', 'chat', 'feedback')),
|
||||||
|
storage_key text NOT NULL UNIQUE,
|
||||||
|
mime_type text NOT NULL,
|
||||||
|
byte_size integer NOT NULL CHECK (byte_size > 0),
|
||||||
|
question_id uuid REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
match_id uuid REFERENCES matches(id) ON DELETE CASCADE,
|
||||||
|
expires_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS media_objects_owner_idx ON media_objects(owner_id, created_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS media_objects_couple_idx ON media_objects(couple_id) WHERE couple_id IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS media_deletion_queue (
|
||||||
|
storage_key text PRIMARY KEY,
|
||||||
|
queued_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION queue_media_blob_deletion() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO media_deletion_queue(storage_key) VALUES(OLD.storage_key) ON CONFLICT DO NOTHING;
|
||||||
|
RETURN OLD;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS media_objects_queue_blob_deletion ON media_objects;
|
||||||
|
CREATE TRIGGER media_objects_queue_blob_deletion BEFORE DELETE ON media_objects
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION queue_media_blob_deletion();
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS screenshot_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
revenuecat_app_user_id text NOT NULL,
|
||||||
|
product_id text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active'
|
||||||
|
CHECK (status IN ('active', 'cancelled', 'expired', 'billing_issue', 'paused')),
|
||||||
|
entitlement_ids text[] NOT NULL DEFAULT '{}',
|
||||||
|
purchased_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz,
|
||||||
|
original_transaction_id text,
|
||||||
|
store text NOT NULL DEFAULT 'APP_STORE',
|
||||||
|
is_sandbox boolean NOT NULL DEFAULT false,
|
||||||
|
cancel_reason text,
|
||||||
|
grace_period_expires_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (user_id, original_transaction_id)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS subscriptions_user_id_idx ON subscriptions(user_id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS subscriptions_status_idx ON subscriptions(status);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS revenuecat_webhook_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id text NOT NULL UNIQUE,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
app_user_id text NOT NULL,
|
||||||
|
processed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
payload jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS redemption_codes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code text NOT NULL,
|
||||||
|
description text,
|
||||||
|
max_uses integer DEFAULT 1 CHECK (max_uses IS NULL OR max_uses >= 0),
|
||||||
|
current_uses integer NOT NULL DEFAULT 0 CHECK (current_uses >= 0),
|
||||||
|
expires_at timestamptz,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS redemption_codes_upper_code_idx ON redemption_codes(upper(code));
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS code_redemptions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code_id uuid NOT NULL REFERENCES redemption_codes(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
redeemed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (code_id, user_id)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS code_redemptions_user_id_idx ON code_redemptions(user_id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS redemption_rate_limits (
|
||||||
|
bucket timestamptz PRIMARY KEY,
|
||||||
|
attempts integer NOT NULL CHECK (attempts >= 0)
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS last_nudge_sent_at timestamptz;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS live_draw_sessions (
|
||||||
|
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
strokes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
revision bigint NOT NULL DEFAULT 1,
|
||||||
|
updated_by uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT live_draw_sessions_strokes_array CHECK (jsonb_typeof(strokes) = 'array')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS live_draw_sessions_updated_at_idx
|
||||||
|
ON live_draw_sessions(updated_at DESC);
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
ALTER TABLE question_packs
|
||||||
|
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at timestamptz;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_match_notifications (
|
||||||
|
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
active_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
match_count integer NOT NULL DEFAULT 0,
|
||||||
|
response_count integer NOT NULL DEFAULT 1,
|
||||||
|
latest_match_id uuid REFERENCES matches(id) ON DELETE SET NULL,
|
||||||
|
notify_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS pending_match_notifications_due_idx ON pending_match_notifications(notify_at);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_pack_notifications (
|
||||||
|
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
changed_by_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
notify_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS pending_pack_notifications_due_idx ON pending_pack_notifications(notify_at);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
pending_since timestamptz,
|
||||||
|
last_reminder_sent_at timestamptz,
|
||||||
|
reminder_count integer NOT NULL DEFAULT 0 CHECK (reminder_count >= 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS operations_outbox (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kind text NOT NULL CHECK (kind IN ('expo', 'discord', 'classify')),
|
||||||
|
dedupe_key text NOT NULL UNIQUE,
|
||||||
|
recipient_id uuid REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
available_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||||
|
locked_at timestamptz,
|
||||||
|
sent_at timestamptz,
|
||||||
|
last_error text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS operations_outbox_due_idx
|
||||||
|
ON operations_outbox(available_at, created_at) WHERE sent_at IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||||
|
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
|
||||||
|
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
|
||||||
|
ON CONFLICT(couple_id) DO UPDATE SET
|
||||||
|
active_user_id=excluded.active_user_id,
|
||||||
|
response_count=pending_match_notifications.response_count+1,
|
||||||
|
notify_at=excluded.notify_at,
|
||||||
|
updated_at=now();
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS operations_response_digest ON responses;
|
||||||
|
CREATE TRIGGER operations_response_digest AFTER INSERT ON responses
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION queue_response_digest();
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION count_digest_match() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||||
|
UPDATE pending_match_notifications SET match_count=match_count+1,
|
||||||
|
latest_match_id=NEW.id, updated_at=now() WHERE couple_id=NEW.couple_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS operations_match_digest ON matches;
|
||||||
|
CREATE TRIGGER operations_match_digest AFTER INSERT ON matches
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION count_digest_match();
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION queue_message_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE v_couple_id uuid; v_recipient record;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||||
|
SELECT couple_id INTO v_couple_id FROM matches WHERE id=NEW.match_id;
|
||||||
|
FOR v_recipient IN
|
||||||
|
SELECT p.id FROM profiles p
|
||||||
|
LEFT JOIN notification_preferences np ON np.user_id=p.id
|
||||||
|
WHERE p.couple_id=v_couple_id AND p.id<>NEW.user_id
|
||||||
|
AND COALESCE(np.messages_enabled,true)=true
|
||||||
|
LOOP
|
||||||
|
INSERT INTO operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||||
|
VALUES('expo','message:'||NEW.id||':'||v_recipient.id,v_recipient.id,
|
||||||
|
jsonb_build_object('title','New message','body','Your partner sent you a message',
|
||||||
|
'data',jsonb_build_object('type','message','match_id',NEW.match_id,'message_id',NEW.id)))
|
||||||
|
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||||
|
VALUES('classify','classify:'||NEW.id,jsonb_build_object('message_id',NEW.id))
|
||||||
|
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS operations_message_created ON messages;
|
||||||
|
CREATE TRIGGER operations_message_created AFTER INSERT ON messages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION queue_message_operations();
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION queue_pack_change(p_couple_id uuid, p_user_id uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS(SELECT 1 FROM profiles WHERE id=p_user_id AND couple_id=p_couple_id) THEN
|
||||||
|
RAISE EXCEPTION 'user is not a member of couple';
|
||||||
|
END IF;
|
||||||
|
INSERT INTO pending_pack_notifications(couple_id,changed_by_user_id,notify_at)
|
||||||
|
VALUES(p_couple_id,p_user_id,now()+interval '30 minutes')
|
||||||
|
ON CONFLICT(couple_id) DO UPDATE SET changed_by_user_id=excluded.changed_by_user_id,
|
||||||
|
notify_at=excluded.notify_at,updated_at=now();
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION queue_discord_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||||
|
IF TG_TABLE_NAME='profiles' AND NEW.onboarding_completed=true
|
||||||
|
AND (TG_OP='INSERT' OR OLD.onboarding_completed=false) THEN
|
||||||
|
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||||
|
VALUES('discord','discord:new_user:'||NEW.id,
|
||||||
|
jsonb_build_object('event','new_user','user_id',NEW.id,'name',NEW.name,'email',NEW.email,'created_at',NEW.created_at))
|
||||||
|
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
IF TG_TABLE_NAME='profiles' AND NEW.couple_id IS NOT NULL
|
||||||
|
AND (TG_OP='INSERT' OR OLD.couple_id IS DISTINCT FROM NEW.couple_id)
|
||||||
|
AND EXISTS(SELECT 1 FROM profiles partner WHERE partner.couple_id=NEW.couple_id AND partner.id<>NEW.id) THEN
|
||||||
|
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||||
|
VALUES('discord','discord:couple_paired:'||NEW.couple_id,
|
||||||
|
jsonb_build_object('event','couple_paired','couple_id',NEW.couple_id,'paired_at',now()))
|
||||||
|
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS operations_profile_discord ON profiles;
|
||||||
|
CREATE TRIGGER operations_profile_discord AFTER INSERT OR UPDATE OF onboarding_completed,couple_id ON profiles
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION queue_discord_operations();
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE OR REPLACE FUNCTION queue_feedback_discord() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||||
|
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||||
|
VALUES('discord','discord:feedback:'||NEW.id,
|
||||||
|
jsonb_build_object('event','feedback_submitted','feedback_id',NEW.id,'user_id',NEW.user_id,
|
||||||
|
'type',NEW.type,'status',NEW.status,'title',NEW.title,'description',NEW.description,
|
||||||
|
'question_id',NEW.question_id,'created_at',NEW.created_at))
|
||||||
|
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TRIGGER IF EXISTS operations_feedback_discord ON feedback;
|
||||||
|
CREATE TRIGGER operations_feedback_discord AFTER INSERT ON feedback
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION queue_feedback_discord();
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL UNIQUE REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
role text NOT NULL CHECK (role IN ('pack_creator', 'super_admin')),
|
||||||
|
permissions text[] NOT NULL DEFAULT '{}',
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS admin_users_active_user_idx ON admin_users(user_id) WHERE is_active = true;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
admin_user_id uuid REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||||
|
actor_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
|
||||||
|
table_name text NOT NULL,
|
||||||
|
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE', 'ACTION')),
|
||||||
|
record_id text,
|
||||||
|
old_values jsonb,
|
||||||
|
new_values jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs(created_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_logs_actor_idx ON audit_logs(actor_user_id, created_at DESC);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS topics (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS pack_topics (
|
||||||
|
pack_id uuid NOT NULL REFERENCES question_packs(id) ON DELETE CASCADE,
|
||||||
|
topic_id uuid NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (pack_id, topic_id)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE categories ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE question_packs ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE questions ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE question_packs
|
||||||
|
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD COLUMN IF NOT EXISTS inverse_of uuid REFERENCES questions(id) ON DELETE SET NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE messages
|
||||||
|
ADD COLUMN IF NOT EXISTS moderation_status text DEFAULT 'unmoderated',
|
||||||
|
ADD COLUMN IF NOT EXISTS flag_reason text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE messages ADD CONSTRAINT messages_moderation_status_check
|
||||||
|
CHECK (moderation_status IS NULL OR moderation_status IN ('safe', 'flagged', 'unmoderated'));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE message_reports
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE feedback
|
||||||
|
ADD COLUMN IF NOT EXISTS admin_notes text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_config (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
openrouter_api_key text,
|
||||||
|
default_model text DEFAULT 'openai/gpt-4o-mini',
|
||||||
|
default_temperature double precision,
|
||||||
|
model_generate text,
|
||||||
|
temperature_generate double precision,
|
||||||
|
model_fix text,
|
||||||
|
temperature_fix double precision,
|
||||||
|
model_polish text,
|
||||||
|
temperature_polish double precision,
|
||||||
|
council_enabled boolean NOT NULL DEFAULT false,
|
||||||
|
council_generator_model text DEFAULT 'anthropic/claude-3.5-sonnet',
|
||||||
|
council_generators jsonb DEFAULT '[{"model":"anthropic/claude-3.5-sonnet"}]'::jsonb,
|
||||||
|
council_reviewer_model text DEFAULT 'google/gemini-pro-1.5',
|
||||||
|
council_reviewer_temperature double precision,
|
||||||
|
council_selection_mode text DEFAULT 'whole_set' CHECK (
|
||||||
|
council_selection_mode IS NULL OR council_selection_mode IN ('whole_set', 'cherry_pick')
|
||||||
|
),
|
||||||
|
cherry_pick_ensure_intensity_distribution boolean DEFAULT true,
|
||||||
|
classifier_enabled boolean DEFAULT true,
|
||||||
|
classifier_model text DEFAULT 'openai/gpt-4o',
|
||||||
|
classifier_temperature double precision,
|
||||||
|
classifier_prompt text,
|
||||||
|
heuristics_enabled boolean NOT NULL DEFAULT false,
|
||||||
|
heuristic_min_text_length integer NOT NULL DEFAULT 12,
|
||||||
|
heuristic_whitelist_max_length integer NOT NULL DEFAULT 30,
|
||||||
|
heuristic_skip_if_no_alnum boolean NOT NULL DEFAULT true,
|
||||||
|
heuristic_skip_media_without_text boolean NOT NULL DEFAULT false,
|
||||||
|
heuristic_record_reason boolean NOT NULL DEFAULT false,
|
||||||
|
heuristic_use_default_whitelist boolean NOT NULL DEFAULT true,
|
||||||
|
heuristic_use_default_keywords boolean NOT NULL DEFAULT true,
|
||||||
|
heuristic_whitelist text,
|
||||||
|
heuristic_keyword_triggers text,
|
||||||
|
updated_at timestamptz DEFAULT now(),
|
||||||
|
updated_by uuid REFERENCES profiles(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton_idx ON ai_config ((true));
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS dare_packs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, description text, icon text,
|
||||||
|
is_premium boolean NOT NULL DEFAULT false, is_public boolean NOT NULL DEFAULT true,
|
||||||
|
is_explicit boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
category_id uuid REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
min_intensity integer, max_intensity integer, avg_intensity numeric(3,2),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS dares (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pack_id uuid NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
|
||||||
|
text text NOT NULL, intensity integer NOT NULL DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
|
||||||
|
suggested_duration_hours integer, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS dares_pack_idx ON dares(pack_id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS master_keys (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
key_name text NOT NULL UNIQUE,
|
||||||
|
public_key_jwk jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
rotated_at timestamptz,
|
||||||
|
is_active boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_dares (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
couple_id uuid NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
dare_id uuid REFERENCES dares(id) ON DELETE SET NULL,
|
||||||
|
custom_dare_text text,
|
||||||
|
custom_dare_intensity integer CHECK (
|
||||||
|
custom_dare_intensity IS NULL OR custom_dare_intensity BETWEEN 1 AND 5
|
||||||
|
),
|
||||||
|
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
recipient_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
status text NOT NULL DEFAULT 'pending' CHECK (
|
||||||
|
status IN ('pending', 'active', 'completed', 'expired', 'declined', 'cancelled')
|
||||||
|
),
|
||||||
|
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
accepted_at timestamptz,
|
||||||
|
expires_at timestamptz,
|
||||||
|
completed_at timestamptz,
|
||||||
|
sender_notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT sent_dares_dare_or_custom_check CHECK (
|
||||||
|
dare_id IS NOT NULL OR custom_dare_text IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_couple_idx ON sent_dares(couple_id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS sent_dares_dare_idx ON sent_dares(dare_id);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS dare_messages (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sent_dare_id uuid NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
|
||||||
|
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
content text NOT NULL,
|
||||||
|
read_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS dare_messages_sent_dare_created_idx
|
||||||
|
ON dare_messages(sent_dare_id, created_at);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS dare_messages_sender_idx ON dare_messages(sender_id);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "question_packs"
|
||||||
|
ALTER COLUMN "avg_intensity" TYPE numeric(3,2)
|
||||||
|
USING "avg_intensity"::numeric(3,2);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "topics"
|
||||||
|
ADD COLUMN IF NOT EXISTS "description" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "icon" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "sort_order" integer NOT NULL DEFAULT 0;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "questions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "required_props" text[];
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787853600000,
|
||||||
|
"tag": "0000_identity_and_feature_interests",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787864000000,
|
||||||
|
"tag": "0001_couples",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787864744000,
|
||||||
|
"tag": "0002_packs_catalog_progress",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865000000,
|
||||||
|
"tag": "0003_answers_matches",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865100000,
|
||||||
|
"tag": "0004_chat",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865200000,
|
||||||
|
"tag": "0005_profile_settings",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865300000,
|
||||||
|
"tag": "0006_media_storage",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865400000,
|
||||||
|
"tag": "0007_billing_redemption",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865500000,
|
||||||
|
"tag": "0008_residual_realtime",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865600000,
|
||||||
|
"tag": "0009_operations_workers",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787865700000,
|
||||||
|
"tag": "0010_admin",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787907600000,
|
||||||
|
"tag": "0011_question_pack_average_precision",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787908500000,
|
||||||
|
"tag": "0012_catalogue_field_parity",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@sauci/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20 <21"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"dev:worker": "tsx watch src/workers/index.ts",
|
||||||
|
"build": "tsup src/index.ts src/db/migrate.ts src/migration/run.ts src/workers/index.ts --format esm --clean",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"start:worker": "node dist/workers/index.js",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
|
"db:migrate:prod": "node dist/db/migrate.js",
|
||||||
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
|
"migration:cutover": "tsx src/migration/run.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.13.7",
|
||||||
|
"@sauci/shared": "*",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"hono": "^4.6.0",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"pg": "^8.17.2",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"tsup": "^8.2.4",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
||||||
|
import type { ApiRepository } from './db/repository.js';
|
||||||
|
import type { AppDataRepository } from './domains/app-data/repository.js';
|
||||||
|
import { registerAppDataRoutes } from './domains/app-data/routes.js';
|
||||||
|
import type { BillingService } from './domains/billing/service.js';
|
||||||
|
import { registerBillingRoutes } from './domains/billing/routes.js';
|
||||||
|
import type { AccountOperationsService } from './domains/account-operations/service.js';
|
||||||
|
import { registerAccountOperationRoutes } from './domains/account-operations/routes.js';
|
||||||
|
import type { AnswersRepository } from './domains/answers/repository.js';
|
||||||
|
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||||
|
import type { ChatRepository } from './domains/chat/repository.js';
|
||||||
|
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||||
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||||
|
import type { CoupleService } from './domains/couples/service.js';
|
||||||
|
import type { PacksRepository } from './domains/packs/repository.js';
|
||||||
|
import { registerPackRoutes } from './domains/packs/routes.js';
|
||||||
|
import type { ProfileSettingsRepository } from './domains/profile-settings/repository.js';
|
||||||
|
import { registerProfileSettingsRoutes } from './domains/profile-settings/routes.js';
|
||||||
|
import type { MediaRepository } from './domains/media/repository.js';
|
||||||
|
import { registerMediaRoutes } from './domains/media/routes.js';
|
||||||
|
import type { FilesystemMediaStorage } from './domains/media/storage.js';
|
||||||
|
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
||||||
|
import type { AdminRepository } from './domains/admin/repository.js';
|
||||||
|
import { registerAdminRoutes } from './domains/admin/routes.js';
|
||||||
|
|
||||||
|
type Variables = { identity: AuthIdentity };
|
||||||
|
|
||||||
|
export interface AppDependencies {
|
||||||
|
auth: AuthVerifier;
|
||||||
|
repository: ApiRepository;
|
||||||
|
coupleService?: CoupleService;
|
||||||
|
packsRepository?: PacksRepository;
|
||||||
|
answersRepository?: AnswersRepository;
|
||||||
|
chatRepository?: ChatRepository;
|
||||||
|
profileSettingsRepository?: ProfileSettingsRepository;
|
||||||
|
accountOperationsService?: AccountOperationsService;
|
||||||
|
mediaRepository?: MediaRepository;
|
||||||
|
mediaStorage?: FilesystemMediaStorage;
|
||||||
|
appDataRepository?: AppDataRepository;
|
||||||
|
billingService?: BillingService;
|
||||||
|
adminRepository?: AdminRepository;
|
||||||
|
adminAuth?: AdminRequestAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureSchema = z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||||
|
|
||||||
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
|
return { error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
||||||
|
const app = new Hono<{ Variables: Variables }>();
|
||||||
|
|
||||||
|
app.get('/health/live', (c) => c.json({ status: 'ok' }));
|
||||||
|
app.get('/health/ready', async (c) => {
|
||||||
|
try {
|
||||||
|
await deps.repository.ready();
|
||||||
|
return c.json({ status: 'ready' });
|
||||||
|
} catch {
|
||||||
|
return c.json(error('not_ready', 'Database is unavailable'), 503);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/v1/*', async (c, next) => {
|
||||||
|
// Admin routes accept either a hosted user token or the separately scoped
|
||||||
|
// MCP service credential and therefore own their authentication middleware.
|
||||||
|
if (c.req.path.startsWith('/v1/admin/')) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = bearerToken(c.req.header('authorization'));
|
||||||
|
if (!token) return c.json(error('unauthorized', 'A bearer token is required'), 401);
|
||||||
|
try {
|
||||||
|
c.set('identity', await deps.auth.verify(token));
|
||||||
|
} catch {
|
||||||
|
return c.json(error('unauthorized', 'The bearer token is invalid'), 401);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/me', async (c) => {
|
||||||
|
const profile = await deps.repository.upsertProfile(c.get('identity'));
|
||||||
|
return c.json<MeResponse>({ profile });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v1/me/feature-interests/:feature', async (c) => {
|
||||||
|
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||||
|
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||||
|
return c.json(await deps.repository.getFeatureInterest(c.get('identity').id, parsed.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/v1/me/feature-interests/:feature', async (c) => {
|
||||||
|
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||||
|
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||||
|
await deps.repository.upsertProfile(c.get('identity'));
|
||||||
|
return c.json(await deps.repository.putFeatureInterest(c.get('identity').id, parsed.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/v1/me/feature-interests/:feature', async (c) => {
|
||||||
|
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||||
|
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||||
|
return c.json(await deps.repository.deleteFeatureInterest(c.get('identity').id, parsed.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deps.coupleService) registerCoupleRoutes(app, deps.coupleService);
|
||||||
|
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||||
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||||
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||||
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||||
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||||
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||||
|
if (deps.appDataRepository) registerAppDataRoutes(app, deps.appDataRepository);
|
||||||
|
if (deps.billingService) registerBillingRoutes(app, deps.billingService);
|
||||||
|
if (deps.adminRepository && deps.adminAuth) {
|
||||||
|
registerAdminRoutes(app, deps.adminRepository, deps.adminAuth, deps.mediaStorage);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.notFound((c) => c.json(error('not_found', 'Route not found'), 404));
|
||||||
|
app.onError((cause, c) => {
|
||||||
|
console.error(cause);
|
||||||
|
return c.json(error('internal_error', 'An unexpected error occurred'), 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
createLocalJWKSet,
|
||||||
|
createRemoteJWKSet,
|
||||||
|
jwtVerify,
|
||||||
|
type JSONWebKeySet,
|
||||||
|
type JWTVerifyGetKey,
|
||||||
|
type JWTPayload,
|
||||||
|
} from 'jose';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AppConfig } from './config.js';
|
||||||
|
|
||||||
|
const uuidSchema = z.string().uuid();
|
||||||
|
|
||||||
|
export interface AuthIdentity {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthVerifier {
|
||||||
|
verify(token: string): Promise<AuthIdentity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityFromClaims(claims: JWTPayload): AuthIdentity {
|
||||||
|
const id = uuidSchema.parse(claims.sub);
|
||||||
|
const metadata = claims.user_metadata && typeof claims.user_metadata === 'object'
|
||||||
|
? claims.user_metadata as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
email: optionalString(claims.email),
|
||||||
|
name: optionalString(metadata.full_name) ?? optionalString(metadata.name),
|
||||||
|
avatarUrl: optionalString(metadata.avatar_url) ?? optionalString(metadata.picture),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthVerifier(config: AppConfig): AuthVerifier {
|
||||||
|
let keySet: JWTVerifyGetKey;
|
||||||
|
if (config.authTestJwks) {
|
||||||
|
if (config.nodeEnv !== 'test') {
|
||||||
|
throw new Error('Local JWKS verification is forbidden outside test');
|
||||||
|
}
|
||||||
|
const jwks = JSON.parse(config.authTestJwks) as JSONWebKeySet;
|
||||||
|
keySet = createLocalJWKSet(jwks);
|
||||||
|
} else {
|
||||||
|
if (!config.authJwksUrl) {
|
||||||
|
throw new Error('A remote Supabase Auth JWKS URL is required');
|
||||||
|
}
|
||||||
|
keySet = createRemoteJWKSet(new URL(config.authJwksUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async verify(token: string): Promise<AuthIdentity> {
|
||||||
|
const { payload } = await jwtVerify(token, keySet, {
|
||||||
|
issuer: config.authIssuer,
|
||||||
|
audience: config.authAudience,
|
||||||
|
algorithms: ['ES256', 'RS256'],
|
||||||
|
});
|
||||||
|
return identityFromClaims(payload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bearerToken(header: string | undefined): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const optionalString = z.preprocess((value) => value === '' ? undefined : value, z.string().min(1).optional());
|
||||||
|
const optionalUuid = z.preprocess((value) => value === '' ? undefined : value, z.string().uuid().optional());
|
||||||
|
|
||||||
|
const baseSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
SUPABASE_AUTH_URL: z.string().url().optional(),
|
||||||
|
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
||||||
|
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
||||||
|
SUPABASE_AUTH_AUDIENCE: z.string().min(1).default('authenticated'),
|
||||||
|
AUTH_TEST_JWKS: z.string().optional(),
|
||||||
|
MEDIA_ROOT: z.string().min(1).default('/data/media'),
|
||||||
|
MEDIA_SIGNING_SECRET: z.string().min(32).optional(),
|
||||||
|
MEDIA_PUBLIC_BASE_URL: z.string().url().optional(),
|
||||||
|
SUPABASE_AUTH_SERVICE_ROLE_KEY: optionalString,
|
||||||
|
REVENUECAT_API_KEY: optionalString,
|
||||||
|
REVENUECAT_ENTITLEMENT_ID: z.string().min(1).default('Sauci Pro'),
|
||||||
|
REVENUECAT_WEBHOOK_SECRET: optionalString,
|
||||||
|
ADMIN_API_SERVICE_TOKEN: z.preprocess((value) => value === '' ? undefined : value, z.string().min(32).optional()),
|
||||||
|
ADMIN_API_SERVICE_USER_ID: optionalUuid,
|
||||||
|
ADMIN_PRIVATE_KEY_JWK: optionalString,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
|
port: number;
|
||||||
|
databaseUrl: string;
|
||||||
|
authIssuer: string;
|
||||||
|
authJwksUrl?: string;
|
||||||
|
authAudience: string;
|
||||||
|
authTestJwks?: string;
|
||||||
|
mediaRoot: string;
|
||||||
|
mediaSigningSecret: string;
|
||||||
|
mediaPublicBaseUrl: string;
|
||||||
|
supabaseAuthServiceRoleKey?: string;
|
||||||
|
revenueCatApiKey?: string;
|
||||||
|
revenueCatEntitlementId: string;
|
||||||
|
revenueCatWebhookSecret?: string;
|
||||||
|
adminApiServiceToken?: string;
|
||||||
|
adminApiServiceUserId?: string;
|
||||||
|
adminPrivateKeyJwk?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
|
const parsed = baseSchema.parse(env);
|
||||||
|
if (Boolean(parsed.ADMIN_API_SERVICE_TOKEN) !== Boolean(parsed.ADMIN_API_SERVICE_USER_ID)) {
|
||||||
|
throw new Error('ADMIN_API_SERVICE_TOKEN and ADMIN_API_SERVICE_USER_ID must be configured together');
|
||||||
|
}
|
||||||
|
if (parsed.ADMIN_PRIVATE_KEY_JWK) {
|
||||||
|
try { JSON.parse(parsed.ADMIN_PRIVATE_KEY_JWK); } catch { throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON'); }
|
||||||
|
}
|
||||||
|
if (parsed.AUTH_TEST_JWKS && parsed.NODE_ENV !== 'test') {
|
||||||
|
throw new Error('AUTH_TEST_JWKS is forbidden outside NODE_ENV=test');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUrl = parsed.SUPABASE_AUTH_URL?.replace(/\/$/, '');
|
||||||
|
const authIssuer = parsed.SUPABASE_AUTH_ISSUER ?? (authUrl ? `${authUrl}/auth/v1` : undefined);
|
||||||
|
const authJwksUrl = parsed.SUPABASE_AUTH_JWKS_URL ??
|
||||||
|
(authUrl ? `${authUrl}/auth/v1/.well-known/jwks.json` : undefined);
|
||||||
|
|
||||||
|
if (!authIssuer || (!authJwksUrl && !parsed.AUTH_TEST_JWKS)) {
|
||||||
|
throw new Error('Supabase Auth issuer and JWKS configuration are required');
|
||||||
|
}
|
||||||
|
if (parsed.NODE_ENV === 'production' &&
|
||||||
|
(new URL(authIssuer).protocol !== 'https:' ||
|
||||||
|
(authJwksUrl && new URL(authJwksUrl).protocol !== 'https:'))) {
|
||||||
|
throw new Error('Supabase Auth issuer and JWKS URLs must use HTTPS in production');
|
||||||
|
}
|
||||||
|
if (parsed.NODE_ENV === 'production' && !parsed.MEDIA_SIGNING_SECRET) {
|
||||||
|
throw new Error('MEDIA_SIGNING_SECRET is required in production');
|
||||||
|
}
|
||||||
|
const mediaPublicBaseUrl = parsed.MEDIA_PUBLIC_BASE_URL ?? `http://127.0.0.1:${parsed.PORT}`;
|
||||||
|
if (parsed.NODE_ENV === 'production' && new URL(mediaPublicBaseUrl).protocol !== 'https:') {
|
||||||
|
throw new Error('MEDIA_PUBLIC_BASE_URL must use HTTPS in production');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeEnv: parsed.NODE_ENV,
|
||||||
|
port: parsed.PORT,
|
||||||
|
databaseUrl: parsed.DATABASE_URL,
|
||||||
|
authIssuer,
|
||||||
|
authJwksUrl,
|
||||||
|
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
||||||
|
authTestJwks: parsed.AUTH_TEST_JWKS,
|
||||||
|
mediaRoot: parsed.MEDIA_ROOT,
|
||||||
|
mediaSigningSecret: parsed.MEDIA_SIGNING_SECRET ?? 'local-media-signing-secret-change-me-123456',
|
||||||
|
mediaPublicBaseUrl: mediaPublicBaseUrl.replace(/\/$/, ''),
|
||||||
|
supabaseAuthServiceRoleKey: parsed.SUPABASE_AUTH_SERVICE_ROLE_KEY,
|
||||||
|
revenueCatApiKey: parsed.REVENUECAT_API_KEY,
|
||||||
|
revenueCatEntitlementId: parsed.REVENUECAT_ENTITLEMENT_ID,
|
||||||
|
revenueCatWebhookSecret: parsed.REVENUECAT_WEBHOOK_SECRET,
|
||||||
|
adminApiServiceToken: parsed.ADMIN_API_SERVICE_TOKEN,
|
||||||
|
adminApiServiceUserId: parsed.ADMIN_API_SERVICE_USER_ID,
|
||||||
|
adminPrivateKeyJwk: parsed.ADMIN_PRIVATE_KEY_JWK,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './schema.js';
|
||||||
|
export * from '../domains/couples/schema.js';
|
||||||
|
export * from '../domains/packs/schema.js';
|
||||||
|
export * from '../domains/answers/schema.js';
|
||||||
|
export * from '../domains/chat/schema.js';
|
||||||
|
export * from '../domains/app-data/schema.js';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is required for database migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsFolder = resolve(process.env.MIGRATIONS_DIR ?? 'drizzle');
|
||||||
|
const pool = new Pool({ connectionString: databaseUrl, max: 2 });
|
||||||
|
const lockClient = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Serializes migration containers during rolling/retried deployments.
|
||||||
|
await lockClient.query('select pg_advisory_lock($1)', [2_024_082_701]);
|
||||||
|
await migrate(drizzle(pool), { migrationsFolder });
|
||||||
|
console.log(`Database migrations completed from ${migrationsFolder}`);
|
||||||
|
} finally {
|
||||||
|
await lockClient.query('select pg_advisory_unlock($1)', [2_024_082_701]).catch(() => undefined);
|
||||||
|
lockClient.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { FeatureInterestResponse, Profile } from '@sauci/shared';
|
||||||
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import type { AuthIdentity } from '../auth.js';
|
||||||
|
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
||||||
|
|
||||||
|
export type MobileCompatibleProfile = Profile & {
|
||||||
|
email: string | null;
|
||||||
|
gender: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
|
||||||
|
show_explicit_content: boolean;
|
||||||
|
max_intensity: 1 | 2 | 3 | 4 | 5;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ApiRepository {
|
||||||
|
ready(): Promise<void>;
|
||||||
|
upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile>;
|
||||||
|
getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||||
|
putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||||
|
deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
avatar_url: row.avatarUrl,
|
||||||
|
push_token: row.pushToken,
|
||||||
|
is_premium: row.isPremium,
|
||||||
|
couple_id: row.coupleId,
|
||||||
|
gender: row.gender,
|
||||||
|
show_explicit_content: row.showExplicitContent,
|
||||||
|
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
updated_at: row.updatedAt.toISOString(),
|
||||||
|
public_key_jwk: row.publicKeyJwk,
|
||||||
|
hide_nsfw: row.hideNsfw,
|
||||||
|
onboarding_completed: row.onboardingCompleted,
|
||||||
|
onboarding_version: row.onboardingVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresRepository implements ApiRepository {
|
||||||
|
private readonly pool: Pool;
|
||||||
|
private readonly db: NodePgDatabase;
|
||||||
|
|
||||||
|
constructor(databaseUrl: string) {
|
||||||
|
this.pool = new Pool({ connectionString: databaseUrl });
|
||||||
|
this.db = drizzle(this.pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ready(): Promise<void> {
|
||||||
|
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
||||||
|
const [row] = await this.db.insert(profiles).values({
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.name,
|
||||||
|
avatarUrl: identity.avatarUrl,
|
||||||
|
email: identity.email,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: profiles.id,
|
||||||
|
set: {
|
||||||
|
email: sql`coalesce(excluded.email, ${profiles.email})`,
|
||||||
|
authSyncedAt: new Date(),
|
||||||
|
},
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!row) throw new Error('Profile upsert returned no row');
|
||||||
|
return toProfile(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||||
|
const [row] = await this.db.select({ feature: featureInterests.feature })
|
||||||
|
.from(featureInterests)
|
||||||
|
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)))
|
||||||
|
.limit(1);
|
||||||
|
return { feature, interested: Boolean(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||||
|
await this.db.insert(featureInterests).values({ userId, feature }).onConflictDoNothing();
|
||||||
|
return { feature, interested: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||||
|
await this.db.delete(featureInterests)
|
||||||
|
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)));
|
||||||
|
return { feature, interested: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const profiles = pgTable('profiles', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
name: text('name'),
|
||||||
|
email: text('email'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
pushToken: text('push_token'),
|
||||||
|
isPremium: boolean('is_premium').notNull().default(false),
|
||||||
|
coupleId: uuid('couple_id'),
|
||||||
|
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
||||||
|
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
|
||||||
|
maxIntensity: integer('max_intensity').notNull().default(5),
|
||||||
|
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
||||||
|
hideNsfw: boolean('hide_nsfw').notNull().default(false),
|
||||||
|
onboardingCompleted: boolean('onboarding_completed').notNull().default(false),
|
||||||
|
onboardingVersion: integer('onboarding_version').notNull().default(0),
|
||||||
|
authSyncedAt: timestamp('auth_synced_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [index('profiles_couple_id_idx').on(table.coupleId)]);
|
||||||
|
|
||||||
|
export const featureInterests = pgTable('feature_interests', {
|
||||||
|
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
|
feature: text('feature').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
primaryKey({ columns: [table.userId, table.feature] }),
|
||||||
|
index('feature_interests_feature_idx').on(table.feature),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ProfileRow = typeof profiles.$inferSelect;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { PostgresRepository } from './repository.js';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
SEED_USER_ID: z.string().uuid().optional(),
|
||||||
|
SEED_USER_EMAIL: z.string().email().default('local@sauci.test'),
|
||||||
|
SEED_ADMIN_USER_ID: z.string().uuid().default('00000000-0000-4000-8000-000000000002'),
|
||||||
|
SEED_ADMIN_USER_EMAIL: z.string().email().default('local-admin@sauci.test'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = schema.parse(process.env);
|
||||||
|
const databaseUrl = new URL(env.DATABASE_URL);
|
||||||
|
if (!['127.0.0.1', 'localhost', '::1'].includes(databaseUrl.hostname)) {
|
||||||
|
throw new Error('db:seed only permits a localhost DATABASE_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = new PostgresRepository(env.DATABASE_URL);
|
||||||
|
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||||
|
try {
|
||||||
|
if (env.SEED_USER_ID) await repository.upsertProfile({
|
||||||
|
id: env.SEED_USER_ID, email: env.SEED_USER_EMAIL, name: 'Local Sauci User', avatarUrl: null,
|
||||||
|
});
|
||||||
|
await repository.upsertProfile({
|
||||||
|
id: env.SEED_ADMIN_USER_ID,
|
||||||
|
email: env.SEED_ADMIN_USER_EMAIL,
|
||||||
|
name: 'Local Sauci Admin Service',
|
||||||
|
avatarUrl: null,
|
||||||
|
});
|
||||||
|
await pool.query(`
|
||||||
|
insert into admin_users(user_id,role,permissions,is_active)
|
||||||
|
values($1,'super_admin','{}',true)
|
||||||
|
on conflict(user_id) do update set role='super_admin',is_active=true,updated_at=now()
|
||||||
|
`, [env.SEED_ADMIN_USER_ID]);
|
||||||
|
console.log(`Seeded local admin service profile ${env.SEED_ADMIN_USER_ID}`);
|
||||||
|
if (env.SEED_USER_ID) console.log(`Seeded local profile ${env.SEED_USER_ID}`);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([repository.close(), pool.end()]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Account operations and billing
|
||||||
|
|
||||||
|
This domain replaces the `delete-relationship`, `reset-couple-progress`,
|
||||||
|
`delete-account`, and `sync-subscription` Supabase Edge Functions. The request
|
||||||
|
identity always comes from the verified bearer token; no route accepts a user or
|
||||||
|
couple identifier from the client.
|
||||||
|
|
||||||
|
## Server-only configuration
|
||||||
|
|
||||||
|
- `SUPABASE_AUTH_URL`: hosted Supabase project URL (or its `/auth/v1`
|
||||||
|
endpoint), used only for Auth.
|
||||||
|
- `SUPABASE_AUTH_SERVICE_ROLE_KEY`: server-side Auth Admin credential required
|
||||||
|
for account deletion. Never expose this as an `EXPO_PUBLIC_*` value.
|
||||||
|
- `REVENUECAT_API_KEY`: secret RevenueCat REST API key used to verify the token
|
||||||
|
owner's entitlement.
|
||||||
|
- `REVENUECAT_ENTITLEMENT_ID`: entitlement checked by sync; defaults to
|
||||||
|
`Sauci Pro` to preserve the previous function behavior.
|
||||||
|
|
||||||
|
Missing provider secrets fail the affected operation with `503` before any
|
||||||
|
durable local change. Account deletion holds its local transaction open until
|
||||||
|
hosted Auth confirms deletion, and rolls the transaction back on Auth failure.
|
||||||
|
Provider error bodies are not returned to the mobile app.
|
||||||
|
|
||||||
|
## Application wiring
|
||||||
|
|
||||||
|
Create one `PostgresAccountOperationsRepository`, then construct:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const accountOperationsService = new AccountOperationsService(
|
||||||
|
accountOperationsRepository,
|
||||||
|
new SupabaseAuthAdminClient(process.env.SUPABASE_AUTH_URL, process.env.SUPABASE_AUTH_SERVICE_ROLE_KEY),
|
||||||
|
new HttpRevenueCatClient(process.env.REVENUECAT_API_KEY, process.env.REVENUECAT_ENTITLEMENT_ID),
|
||||||
|
new ExpoPartnerNotifier(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass it to the app and call `registerAccountOperationRoutes`. Close the
|
||||||
|
repository during graceful shutdown. No schema migration is required for these
|
||||||
|
four operations: they use `profiles`, `couples`, `responses`, and `matches`.
|
||||||
|
|
||||||
|
Blob deletion must be handled by the self-hosted media/storage lifecycle when
|
||||||
|
that vertical is wired. These operations intentionally never call the hosted
|
||||||
|
Supabase database, Storage API, or Edge Functions.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { AccountOperationError } from './types.js';
|
||||||
|
|
||||||
|
export interface AuthAdminClient {
|
||||||
|
deleteUser(userId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueCatClient {
|
||||||
|
isEntitled(userId: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerNotifier {
|
||||||
|
relationshipDeleted(pushToken: string): Promise<void>;
|
||||||
|
progressReset(pushToken: string): Promise<void>;
|
||||||
|
partnerAccountDeleted(pushToken: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fetch = typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const revenueCatResponse = z.object({
|
||||||
|
subscriber: z.object({
|
||||||
|
entitlements: z.record(z.object({ expires_date: z.string().datetime().nullable() }).passthrough()),
|
||||||
|
}).passthrough(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export class SupabaseAuthAdminClient implements AuthAdminClient {
|
||||||
|
constructor(
|
||||||
|
private readonly authUrl: string | undefined,
|
||||||
|
private readonly serviceRoleKey: string | undefined,
|
||||||
|
private readonly request: Fetch = globalThis.fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async deleteUser(userId: string): Promise<void> {
|
||||||
|
if (!this.authUrl || !this.serviceRoleKey) {
|
||||||
|
throw new AccountOperationError(
|
||||||
|
'account_deletion_unavailable',
|
||||||
|
'Account deletion is temporarily unavailable',
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredUrl = this.authUrl.replace(/\/$/, '');
|
||||||
|
const authApiUrl = configuredUrl.endsWith('/auth/v1') ? configuredUrl : `${configuredUrl}/auth/v1`;
|
||||||
|
const response = await this.request(
|
||||||
|
`${authApiUrl}/admin/users/${encodeURIComponent(userId)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
apikey: this.serviceRoleKey,
|
||||||
|
authorization: `Bearer ${this.serviceRoleKey}`,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
).catch(() => {
|
||||||
|
throw new AccountOperationError('auth_provider_unavailable', 'Authentication provider is unavailable', 502);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpRevenueCatClient implements RevenueCatClient {
|
||||||
|
constructor(
|
||||||
|
private readonly apiKey: string | undefined,
|
||||||
|
private readonly entitlementId = 'Sauci Pro',
|
||||||
|
private readonly request: Fetch = globalThis.fetch,
|
||||||
|
private readonly now: () => Date = () => new Date(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async isEntitled(userId: string): Promise<boolean> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
throw new AccountOperationError(
|
||||||
|
'subscription_sync_unavailable',
|
||||||
|
'Subscription verification is temporarily unavailable',
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request(
|
||||||
|
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(userId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.apiKey}`,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
},
|
||||||
|
).catch(() => {
|
||||||
|
throw new AccountOperationError('revenuecat_unavailable', 'Subscription provider is unavailable', 502);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AccountOperationError('subscription_verification_failed', 'Failed to verify subscription', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = revenueCatResponse.safeParse(await response.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new AccountOperationError('invalid_subscription_response', 'Subscription provider returned invalid data', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitlement = parsed.data.subscriber.entitlements[this.entitlementId];
|
||||||
|
if (!entitlement) return false;
|
||||||
|
return entitlement.expires_date === null || new Date(entitlement.expires_date) > this.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpoPartnerNotifier implements PartnerNotifier {
|
||||||
|
constructor(private readonly request: Fetch = globalThis.fetch) {}
|
||||||
|
|
||||||
|
relationshipDeleted(pushToken: string): Promise<void> {
|
||||||
|
return this.send(pushToken, 'Relationship Deleted',
|
||||||
|
'Your partner has deleted all shared data. You can start fresh with a new partner anytime.',
|
||||||
|
'relationship_deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
progressReset(pushToken: string): Promise<void> {
|
||||||
|
return this.send(pushToken, 'Progress Reset',
|
||||||
|
'Your partner has reset all progress. Your matches and chats have been cleared to start fresh.',
|
||||||
|
'progress_reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
partnerAccountDeleted(pushToken: string): Promise<void> {
|
||||||
|
return this.send(pushToken, 'Partner Left',
|
||||||
|
'Your partner has deleted their account. You can start fresh with a new partner anytime.',
|
||||||
|
'partner_account_deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(to: string, title: string, body: string, type: string): Promise<void> {
|
||||||
|
const response = await this.request('https://exp.host/--/api/v2/push/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ to, title, body, sound: 'default', data: { type } }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Expo push failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './clients.js';
|
||||||
|
export * from './repository.js';
|
||||||
|
export * from './routes.js';
|
||||||
|
export * from './service.js';
|
||||||
|
export * from './types.js';
|
||||||
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { AccountOperationError } from './types.js';
|
||||||
|
|
||||||
|
interface ProfileOperationRecord extends QueryResultRow {
|
||||||
|
couple_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartnerRecord extends QueryResultRow {
|
||||||
|
push_token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DestructiveOperationResult {
|
||||||
|
partnerPushToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountOperationsRepository {
|
||||||
|
deleteRelationship(userId: string): Promise<DestructiveOperationResult>;
|
||||||
|
resetProgress(userId: string): Promise<DestructiveOperationResult>;
|
||||||
|
deleteAccount(userId: string, deleteAuthUser: () => Promise<void>): Promise<DestructiveOperationResult>;
|
||||||
|
setPremium(userId: string, isPremium: boolean): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const result = await operation(client);
|
||||||
|
await client.query('commit');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockedProfile(client: PoolClient, userId: string): Promise<ProfileOperationRecord> {
|
||||||
|
const result = await client.query<ProfileOperationRecord>(
|
||||||
|
'select couple_id from profiles where id = $1 for update',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const profile = result.rows[0];
|
||||||
|
if (!profile) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockCoupleAndPartner(
|
||||||
|
client: PoolClient,
|
||||||
|
userId: string,
|
||||||
|
coupleId: string,
|
||||||
|
): Promise<PartnerRecord | undefined> {
|
||||||
|
await client.query('select id from couples where id = $1 for update', [coupleId]);
|
||||||
|
const partner = await client.query<PartnerRecord>(
|
||||||
|
`select push_token from profiles
|
||||||
|
where couple_id = $1 and id <> $2
|
||||||
|
for update`,
|
||||||
|
[coupleId, userId],
|
||||||
|
);
|
||||||
|
return partner.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||||
|
private readonly pool: Pool;
|
||||||
|
|
||||||
|
constructor(databaseUrl: string) {
|
||||||
|
this.pool = new Pool({ connectionString: databaseUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const profile = await lockedProfile(client, userId);
|
||||||
|
if (!profile.couple_id) {
|
||||||
|
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
|
||||||
|
}
|
||||||
|
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||||
|
await client.query(
|
||||||
|
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
|
||||||
|
[profile.couple_id],
|
||||||
|
);
|
||||||
|
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||||
|
return { partnerPushToken: partner?.push_token ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetProgress(userId: string): Promise<DestructiveOperationResult> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const profile = await lockedProfile(client, userId);
|
||||||
|
if (!profile.couple_id) {
|
||||||
|
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
|
||||||
|
}
|
||||||
|
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||||
|
// Matches cascade to messages and per-user match archives. Responses are separate.
|
||||||
|
await client.query('delete from matches where couple_id = $1', [profile.couple_id]);
|
||||||
|
await client.query('delete from responses where couple_id = $1', [profile.couple_id]);
|
||||||
|
return { partnerPushToken: partner?.push_token ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(
|
||||||
|
userId: string,
|
||||||
|
deleteAuthUser: () => Promise<void>,
|
||||||
|
): Promise<DestructiveOperationResult> {
|
||||||
|
return transaction(this.pool, async (client) => {
|
||||||
|
const profileResult = await client.query<ProfileOperationRecord>(
|
||||||
|
'select couple_id from profiles where id = $1 for update',
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
const profile = profileResult.rows[0];
|
||||||
|
let partner: PartnerRecord | undefined;
|
||||||
|
if (profile?.couple_id) {
|
||||||
|
partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||||
|
await client.query(
|
||||||
|
'update profiles set couple_id = null, updated_at = now() where couple_id = $1 and id <> $2',
|
||||||
|
[profile.couple_id, userId],
|
||||||
|
);
|
||||||
|
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||||
|
}
|
||||||
|
if (profile) await client.query('delete from profiles where id = $1', [userId]);
|
||||||
|
|
||||||
|
// Keep local deletion uncommitted until hosted Auth confirms deletion. A provider
|
||||||
|
// failure therefore rolls the entire local transaction back for a safe retry.
|
||||||
|
await deleteAuthUser();
|
||||||
|
return { partnerPushToken: partner?.push_token ?? null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPremium(userId: string, isPremium: boolean): Promise<void> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'update profiles set is_premium = $2, updated_at = now() where id = $1',
|
||||||
|
[userId, isPremium],
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ApiErrorResponse } from '@sauci/shared';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
|
import type { AccountOperationsService } from './service.js';
|
||||||
|
import { AccountOperationError } from './types.js';
|
||||||
|
|
||||||
|
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
|
|
||||||
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
|
return { error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(operation: () => Promise<T>): Promise<
|
||||||
|
{ ok: true; value: T } |
|
||||||
|
{ ok: false; body: ApiErrorResponse; status: 400 | 404 | 502 | 503 }
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: await operation() };
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof AccountOperationError) {
|
||||||
|
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAccountOperationRoutes(app: AuthenticatedApp, service: AccountOperationsService): void {
|
||||||
|
app.delete('/v1/couple/data', async (c) => {
|
||||||
|
const result = await run(() => service.deleteRelationship(c.get('identity').id));
|
||||||
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/v1/couple/progress', async (c) => {
|
||||||
|
const result = await run(() => service.resetProgress(c.get('identity').id));
|
||||||
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/v1/me', async (c) => {
|
||||||
|
const result = await run(() => service.deleteAccount(c.get('identity').id));
|
||||||
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/me/subscription/sync', async (c) => {
|
||||||
|
const result = await run(() => service.syncSubscription(c.get('identity').id));
|
||||||
|
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from './clients.js';
|
||||||
|
import type { AccountOperationsRepository } from './repository.js';
|
||||||
|
import type { OperationResult, SubscriptionSyncResult } from './types.js';
|
||||||
|
|
||||||
|
export class AccountOperationsService {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: AccountOperationsRepository,
|
||||||
|
private readonly authAdmin: AuthAdminClient,
|
||||||
|
private readonly revenueCat: RevenueCatClient,
|
||||||
|
private readonly notifier: PartnerNotifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async deleteRelationship(userId: string): Promise<OperationResult> {
|
||||||
|
const result = await this.repository.deleteRelationship(userId);
|
||||||
|
if (result.partnerPushToken) {
|
||||||
|
await this.notify(() => this.notifier.relationshipDeleted(result.partnerPushToken!));
|
||||||
|
}
|
||||||
|
return { success: true, message: 'Relationship data deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetProgress(userId: string): Promise<OperationResult> {
|
||||||
|
const result = await this.repository.resetProgress(userId);
|
||||||
|
if (result.partnerPushToken) {
|
||||||
|
await this.notify(() => this.notifier.progressReset(result.partnerPushToken!));
|
||||||
|
}
|
||||||
|
return { success: true, message: 'Progress reset successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(userId: string): Promise<OperationResult> {
|
||||||
|
const result = await this.repository.deleteAccount(userId, () => this.authAdmin.deleteUser(userId));
|
||||||
|
if (result.partnerPushToken) {
|
||||||
|
await this.notify(() => this.notifier.partnerAccountDeleted(result.partnerPushToken!));
|
||||||
|
}
|
||||||
|
return { success: true, message: 'Account deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncSubscription(userId: string): Promise<SubscriptionSyncResult> {
|
||||||
|
const isPremium = await this.revenueCat.isEntitled(userId);
|
||||||
|
await this.repository.setPremium(userId, isPremium);
|
||||||
|
return { success: true, is_premium: isPremium };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notify(operation: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (error) {
|
||||||
|
// Destructive data changes have already committed. Notification delivery is
|
||||||
|
// deliberately best-effort, matching the previous Edge Function behavior.
|
||||||
|
console.error('Partner notification failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export class AccountOperationError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
readonly status: 400 | 404 | 502 | 503,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AccountOperationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationResult {
|
||||||
|
success: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionSyncResult {
|
||||||
|
success: true;
|
||||||
|
is_premium: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Admin API
|
||||||
|
|
||||||
|
Browser administrators authenticate with hosted Supabase Auth. The API then
|
||||||
|
loads the active `admin_users` row for the token subject and enforces each role
|
||||||
|
permission again server-side. UI role state is never trusted for authorization.
|
||||||
|
|
||||||
|
Non-browser clients can use `ADMIN_API_SERVICE_TOKEN` with
|
||||||
|
`ADMIN_API_SERVICE_USER_ID`. The opaque token is compared in constant time and
|
||||||
|
maps to an explicit active `admin_users` record, so its permissions and audit
|
||||||
|
actor are identical to a browser administrator. It does not grant Supabase
|
||||||
|
service-role access.
|
||||||
|
|
||||||
|
Every generic mutation is transacted with an `audit_logs` write. Sensitive
|
||||||
|
fields are redacted from audit payloads. Admin data routes are resource-
|
||||||
|
whitelisted; arbitrary table names are rejected.
|
||||||
|
|
||||||
|
Central application wiring must exempt `/v1/admin/*` from the general hosted
|
||||||
|
Auth middleware and pass `AdminRequestAuth` to `registerAdminRoutes`, which
|
||||||
|
accepts either hosted JWTs or the configured service token.
|
||||||
|
|
||||||
|
`SupabaseAdminAuthDirectory` may use the existing server-side Auth Admin
|
||||||
|
credential to enrich `/v1/admin/users` with email confirmation and last sign-in
|
||||||
|
timestamps. It talks only to hosted Auth, never the Supabase data plane, and
|
||||||
|
degrades to explicit null metadata when the credential or provider is absent.
|
||||||
|
|
||||||
|
`GET /v1/admin/media/:mediaId/url` requires `view_media` and issues a five-minute
|
||||||
|
signed URL with `private, no-store` response caching. The object must exist and
|
||||||
|
not be marked deleted. The opaque service credential can therefore request a
|
||||||
|
URL without being accepted by customer media authorization.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type Fetch = typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const authUser = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
email: z.string().nullable().optional(),
|
||||||
|
last_sign_in_at: z.string().nullable().optional(),
|
||||||
|
email_confirmed_at: z.string().nullable().optional(),
|
||||||
|
confirmed_at: z.string().nullable().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export type AdminAuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
last_sign_in_at: string | null;
|
||||||
|
email_confirmed_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AdminAuthDirectory {
|
||||||
|
users(userId?: string): Promise<AdminAuthUser[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SupabaseAdminAuthDirectory implements AdminAuthDirectory {
|
||||||
|
constructor(
|
||||||
|
private readonly authUrl: string | undefined,
|
||||||
|
private readonly serviceRoleKey: string | undefined,
|
||||||
|
private readonly request: Fetch = globalThis.fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async users(userId?: string): Promise<AdminAuthUser[]> {
|
||||||
|
if (!this.authUrl || !this.serviceRoleKey) return [];
|
||||||
|
const base = this.authUrl.replace(/\/$/, '').replace(/\/auth\/v1$/, '');
|
||||||
|
const url = userId
|
||||||
|
? `${base}/auth/v1/admin/users/${encodeURIComponent(userId)}`
|
||||||
|
: `${base}/auth/v1/admin/users?page=1&per_page=1000`;
|
||||||
|
const response = await this.request(url, {
|
||||||
|
headers: { apikey: this.serviceRoleKey, authorization: `Bearer ${this.serviceRoleKey}` },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!response?.ok) return [];
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
const candidates = userId ? [payload] : z.object({ users: z.array(z.unknown()) }).safeParse(payload).data?.users ?? [];
|
||||||
|
return candidates.flatMap((candidate) => {
|
||||||
|
const parsed = authUser.safeParse(candidate); if (!parsed.success) return [];
|
||||||
|
return [{
|
||||||
|
id: parsed.data.id,
|
||||||
|
email: parsed.data.email ?? null,
|
||||||
|
last_sign_in_at: parsed.data.last_sign_in_at ?? null,
|
||||||
|
email_confirmed_at: parsed.data.email_confirmed_at ?? parsed.data.confirmed_at ?? null,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 345 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user